extension PureYAML.Parsing.TokenEventParser { mutating func parseBlockSequence( properties: PureYAML.Parsing.NodeProperties, allowMappingKeyTerminator: Bool = false, ) throws -> PureYAML.Parsing.NodeResult { let start = try expect("block sequence entry") { $0.isBlockEntry } try enforceBlockSequenceNotInlineAfterProperty(properties, firstEntry: start) // A compact nested sequence (`- - x`) carries no indent/dedent tokens, only block entries at // their columns, so the sibling-entry loop must stay at this sequence's own column: a later // entry at a smaller column belongs to an enclosing sequence and ends this one. let sequenceColumn = start.mark.column let startMark = properties.mark ?? start.mark var endMark = start.endMark try emit(.sequenceStart( anchor: properties.anchor, tag: properties.tag, style: .block, mark: startMark, )) try parseBlockSequenceItem( after: start, endMark: &endMark, allowMappingKeyTerminator: allowMappingKeyTerminator, ) try consumeSequenceItemBoundaryDedent(sequenceIndentWidth: start.mark.column - 1) try consumeSequenceItemBoundaryIndent(sequenceIndentWidth: start.mark.column - 1) while cursor.current?.kind.isBlockEntry == true, cursor.current?.mark.column == sequenceColumn { let entry = try expect("block sequence entry") { $0.isBlockEntry } try parseBlockSequenceItem( after: entry, endMark: &endMark, allowMappingKeyTerminator: allowMappingKeyTerminator, ) try consumeSequenceItemBoundaryDedent(sequenceIndentWidth: entry.mark.column - 1) try consumeSequenceItemBoundaryIndent(sequenceIndentWidth: entry.mark.column - 1) } if let current = cursor.current, current.kind.isMappingKey, !allowMappingKeyTerminator { throw PureYAML.Parsing.ParseError.mixedCollectionStyles(line: current.mark.line) } try emit(.sequenceEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .sequence, endMark: endMark) } /// A block collection must begin on the line after its node properties, so an anchor or tag on /// the same line as the sequence's first entry (`&a - x`) is malformed. func enforceBlockSequenceNotInlineAfterProperty( _ properties: PureYAML.Parsing.NodeProperties, firstEntry: PureYAML.Parsing.Token, ) throws { guard properties.anchor != nil || properties.tag != nil, let propertyMark = properties.mark, propertyMark.line == firstEntry.mark.line else { return } throw PureYAML.Parsing.ParseError.inlineBlockSequenceAfterProperty( line: propertyMark.line, column: propertyMark.column, ) } mutating func consumeSequenceItemBoundaryDedent(sequenceIndentWidth: Int) throws { guard dedentWidth(cursor.current) == sequenceIndentWidth, try cursor.peek()?.kind.isBlockEntry == true else { return } _ = try expect("dedent") { $0.isDedent } } mutating func consumeSequenceItemBoundaryIndent(sequenceIndentWidth: Int) throws { guard indentWidth(cursor.current) == sequenceIndentWidth, try cursor.peek()?.kind.isBlockEntry == true else { return } _ = try expect("indent") { $0.isIndent } } mutating func parseBlockSequenceItem( after entry: PureYAML.Parsing.Token, endMark: inout PureYAML.Parsing.Mark, allowMappingKeyTerminator: Bool, ) throws { if hasNodeOnSameLine(after: entry) { let properties = try consumeProperties() if cursor.current?.kind.isIndent == true { _ = try expect("indent") { $0.isIndent } endMark = try parseNode(properties: properties).endMark _ = try expect("dedent") { $0.isDedent } return } if cursor.current?.kind.isMappingKey == true { endMark = try parseBlockMapping( properties: properties, includeIndentedContinuation: true, minimumColumn: entry.mark.column + 1, ).endMark } else if cursor.current?.kind.isBlockEntry == true { endMark = try parseBlockSequence( properties: properties, allowMappingKeyTerminator: allowMappingKeyTerminator, ).endMark } else { endMark = try parseNode(properties: properties).endMark } } else if cursor.current?.kind.isIndent == true { _ = try expect("indent") { $0.isIndent } endMark = try parseNode().endMark _ = try expect("dedent") { $0.isDedent } } else { let mark = entry.endMark try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) endMark = mark } } mutating func parseBlockMapping( properties: PureYAML.Parsing.NodeProperties, includeIndentedContinuation: Bool, minimumColumn: Int? = nil, ) throws -> PureYAML.Parsing.NodeResult { let startMark = properties.mark ?? cursor.current?.mark ?? .start let tokenColumn = cursor.current?.mark.column ?? startMark.column let contentColumn = min(startMark.column, tokenColumn) let boundaryColumn = minimumColumn ?? contentColumn var endMark = startMark // A property written inline before the first key (`&a key: v`) binds to that key, not to the // mapping: the spec requires a block collection's own properties to be followed by a line // break (`s-l+block-collection`), so a property on the key's line is the key's. This matters // for anchors, which must register with the key (early enough for an alias inside a value to // resolve) rather than with the mapping, which registers only at its end. A property on its // own line above the first key still binds to the mapping. let firstKeyProperties: PureYAML.Parsing.NodeProperties let mappingProperties: PureYAML.Parsing.NodeProperties if properties != .none, properties.mark?.line == cursor.current?.mark.line { firstKeyProperties = properties mappingProperties = .none } else { firstKeyProperties = .none mappingProperties = properties } try emit(.mappingStart( anchor: mappingProperties.anchor, tag: mappingProperties.tag, style: .block, mark: startMark, )) try parseBlockMappingPairs( boundaryColumn: boundaryColumn, includeIndentedContinuation: includeIndentedContinuation, firstKeyProperties: firstKeyProperties, endMark: &endMark, ) if let current = cursor.current, !canEndBlockMapping(at: current, minimumColumn: boundaryColumn) { throw PureYAML.Parsing.ParseError.unexpectedToken( expected: "mapping key", actual: current.kind.description, line: current.mark.line, column: current.mark.column, ) } try emit(.mappingEnd(mark: endMark)) return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark) } mutating func parseBlockMappingPairs( boundaryColumn: Int, includeIndentedContinuation: Bool, firstKeyProperties: PureYAML.Parsing.NodeProperties = .none, endMark: inout PureYAML.Parsing.Mark, ) throws { var pendingKeyProperties = firstKeyProperties while canContinueBlockMapping(minimumColumn: boundaryColumn) { endMark = try parseMappingPairAndBoundaries( boundaryColumn: boundaryColumn, includeIndentedContinuation: includeIndentedContinuation, firstKeyProperties: pendingKeyProperties, ).endMark pendingKeyProperties = .none } while includeIndentedContinuation, cursor.current?.kind.isIndent == true { endMark = try parseIndentedBlockMappingPairs(boundaryColumn: boundaryColumn).endMark } } mutating func parseIndentedBlockMappingPairs( boundaryColumn: Int, ) throws -> PureYAML.Parsing.NodeResult { _ = try expect("indent") { $0.isIndent } guard cursor.current?.kind.isMappingKey == true else { throw unexpectedToken(expected: "mapping key") } var endMark = cursor.current?.mark ?? .start while canContinueBlockMapping(minimumColumn: boundaryColumn) { endMark = try parseMappingPairAndBoundaries( boundaryColumn: boundaryColumn, includeIndentedContinuation: true, ).endMark } if !canEndBlockMapping(at: cursor.current, minimumColumn: boundaryColumn) { _ = try expect("dedent") { $0.isDedent } } return PureYAML.Parsing.NodeResult(kind: .mapping, endMark: endMark) } mutating func parseMappingPairAndBoundaries( boundaryColumn: Int, includeIndentedContinuation: Bool, firstKeyProperties: PureYAML.Parsing.NodeProperties = .none, ) throws -> PureYAML.Parsing.NodeResult { let result = try parseMappingPair(firstKeyProperties: firstKeyProperties) try consumeMappingSiblingBoundaryDedents(minimumColumn: boundaryColumn) try consumeMappingSiblingBoundaryReindent(minimumColumn: boundaryColumn) if includeIndentedContinuation { try consumeDedents(atOrAbove: boundaryColumn) try consumeMappingSiblingBoundaryDedents(minimumColumn: boundaryColumn) try consumeMappingSiblingBoundaryReindent(minimumColumn: boundaryColumn) } return result } mutating func parseBlockScalar( properties: PureYAML.Parsing.NodeProperties, ) throws -> PureYAML.Parsing.NodeResult { let header = try expect("block scalar header") { kind in if case .blockScalarHeader = kind { return true } return false } var lines: [String] = [] var endMark = header.endMark if cursor.current?.kind.isIndent == true { _ = try expect("indent") { $0.isIndent } while cursor.current != nil, cursor.current?.kind.isDedent != true { guard case let .scalar(value, style) = cursor.current?.kind else { throw unexpectedToken(expected: "block scalar content") } let token = try cursor.advance() ?? header try lines.append(decodeScalar(value, style: style, line: token.mark.line)) endMark = token.endMark } _ = try expect("dedent") { $0.isDedent } } let value = blockScalarValue(lines: lines, header: header.kind) let mark = properties.mark ?? header.mark try emit(.scalar( value: value, anchor: properties.anchor, tag: properties.tag, style: scalarStyle(for: header.kind), mark: mark, )) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark) } func scalarStyle(for kind: PureYAML.Parsing.TokenKind) -> PureYAML.Parsing.ScalarStyle { switch kind { case .blockScalarHeader(.folded, _): .folded case .blockScalarHeader(.literal, _): .literal default: .plain } } func blockScalarValue( lines: [String], header: PureYAML.Parsing.TokenKind, ) -> String { let (style, chomping): (PureYAML.Parsing.BlockScalarStyle, PureYAML.Parsing.BlockScalarChomping) switch header { case let .blockScalarHeader(headerStyle, headerChomping): style = headerStyle chomping = headerChomping default: style = .literal chomping = .clip } // Each content line carries its own line break. The folded style folds a // single break between two non-empty lines into a space, while blank // lines stay as line breaks. Chomping then trims the trailing breaks. // For content with no blank lines (the only shape the scanner captures // today) this is identical to the previous join-and-append behavior. let body: String = switch style { case .literal: lines.map { $0 + "\n" }.joined() case .folded: foldedBlockBody(lines) } return chompedBlockScalar(body, chomping: chomping) } func foldedBlockBody(_ lines: [String]) -> String { guard let firstContent = lines.firstIndex(where: { !$0.isEmpty }) else { // The block is entirely blank lines: one break each, no folding. return String(repeating: "\n", count: lines.count) } // Leading blank lines each contribute a line break before the content. var result = String(repeating: "\n", count: firstContent) + lines[firstContent] var index = firstContent + 1 while index < lines.count { if lines[index].isEmpty { // Between two non-more-indented lines a blank run of N folds to N line breaks: the // first break is the absorbed fold, the rest are literal. Next to a more-indented line // nothing folds, so the absorbed break survives too and the run yields N+1 breaks. let beforeMoreIndented = isMoreIndentedBlockLine(lines[index - 1]) var blankCount = 0 while index < lines.count, lines[index].isEmpty { blankCount += 1 index += 1 } let afterMoreIndented = index < lines.count && isMoreIndentedBlockLine(lines[index]) let breaks = beforeMoreIndented || afterMoreIndented ? blankCount + 1 : blankCount result += String(repeating: "\n", count: breaks) if index < lines.count { result += lines[index] index += 1 } } else if isMoreIndentedBlockLine(lines[index]) || isMoreIndentedBlockLine(lines[index - 1]) { // A line break adjacent to a more-indented line is kept literal, // so the more-indented line and its breaks survive folding. result += "\n" + lines[index] index += 1 } else { result += " " + lines[index] index += 1 } } return result + "\n" } func isMoreIndentedBlockLine(_ line: String) -> Bool { // In a block scalar the scanner strips content lines to the block's // indentation, so only a more-indented line retains leading whitespace. line.first == " " || line.first == "\t" } func chompedBlockScalar( _ body: String, chomping: PureYAML.Parsing.BlockScalarChomping, ) -> String { switch chomping { case .keep: return body case .strip: return blockScalarTrimmingTrailingNewlines(body) case .clip: let trimmed = blockScalarTrimmingTrailingNewlines(body) return trimmed.isEmpty ? "" : trimmed + "\n" } } func blockScalarTrimmingTrailingNewlines(_ value: String) -> String { var result = value while result.hasSuffix("\n") { result.removeLast() } return result } }