diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c3100..a5999a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A flow collection followed by a `: ` indicator on the same line is now parsed as an implicit block + mapping key, e.g. `[a, b]: value` or `{a: 1}: value`. The scanner looks ahead from the flow opener + to its matching close (skipping quoted scalars so their brackets and colons do not miscount) and, + when a same-line `:` follows, emits the implicit-key marker a scalar key would get, so the flow + node is parsed as the key through the ordinary mapping path. A block implicit key must lie on one + line, so a flow collection spanning a line break before its `:` (`[23 / ]: 42`) stays an error, and + a flow node that no `:` follows, or that is a mapping value, is unaffected. Raises yaml-test-suite + parse-level conformance to 294/333 (88.3%) with no change to the should-error rejection rate. + - A block scalar that is the document's own root node now folds or keeps content that begins at column 0, e.g. `>` or `--- |` followed by unindented lines. The document node's indentation is -1, so its content at column 0 is still more indented than its parent, but the scanner read the base diff --git a/Sources/Parsing/Scanner.FlowKey.swift b/Sources/Parsing/Scanner.FlowKey.swift new file mode 100644 index 0000000..f49b0d1 --- /dev/null +++ b/Sources/Parsing/Scanner.FlowKey.swift @@ -0,0 +1,89 @@ +extension PureYAML.Parsing.Scanner { + /// Emits the implicit-key marker before a block-level flow collection that a `: ` follows, so the + /// flow node is parsed as a mapping key (e.g. `[a, b]: value`) through the same path a scalar key + /// takes. Skipped inside a flow context, after a mapping value already on the line (block style + /// allows one pair per line), and under a pending explicit `?` key, which marks its own key. + func appendFlowImplicitKeyMarkerIfNeeded(state: inout State) { + guard state.flowDepth == 0, !state.sawMappingValueOnLine, !state.pendingExplicitKey, + flowCollectionIsImplicitKey(state: state) + else { + return + } + let mark = state.reader.mark + state.append(.mappingKey, mark: mark, endMark: mark) + } + + /// Looks ahead from a block-level flow collection opener (`[` or `{`) to decide whether the whole + /// collection is an implicit mapping key: a flow node immediately followed by a `: ` mapping-value + /// indicator, e.g. `[a, b]: value` or `{a: 1}: value`. Quoted scalars are skipped so brackets and + /// colons inside them do not affect the nesting count, and the `:` must sit on the same line as + /// the close (only spaces between), since a block implicit key carries its `:` on its own line. + /// Returns false if the brackets do not balance before end of input. + func flowCollectionIsImplicitKey(state: State) -> Bool { + var depth = 0 + var offset = 0 + while let character = state.reader.peek(offset: offset) { + // A block implicit key must lie on a single line, so a flow collection that spans a line + // break (outside any quoted scalar) before its `:` cannot be one: `[23\n]: 42` is an error. + if isLineBreak(character) { + return false + } + switch character { + case "'": + offset = skipSingleQuotedLookahead(after: offset, state: state) + case "\"": + offset = skipDoubleQuotedLookahead(after: offset, state: state) + case "[", "{": + depth += 1 + offset += 1 + case "]", "}": + depth -= 1 + offset += 1 + guard depth > 0 else { + while state.reader.peek(offset: offset) == " " { + offset += 1 + } + return state.reader.peek(offset: offset) == ":" + && isBlockMappingValueBoundary(state.reader.peek(offset: offset + 1)) + } + default: + offset += 1 + } + } + return false + } + + /// Returns the offset just past the closing `'` of a single-quoted scalar that opened at `open`, + /// treating a doubled `''` as an escaped quote rather than a close. + private func skipSingleQuotedLookahead(after open: Int, state: State) -> Int { + var offset = open + 1 + while let character = state.reader.peek(offset: offset) { + if character == "'" { + if state.reader.peek(offset: offset + 1) == "'" { + offset += 2 + continue + } + return offset + 1 + } + offset += 1 + } + return offset + } + + /// Returns the offset just past the closing `"` of a double-quoted scalar that opened at `open`, + /// skipping `\`-escaped characters so an escaped quote does not end the scan early. + private func skipDoubleQuotedLookahead(after open: Int, state: State) -> Int { + var offset = open + 1 + while let character = state.reader.peek(offset: offset) { + if character == "\\" { + offset += 2 + continue + } + if character == "\"" { + return offset + 1 + } + offset += 1 + } + return offset + } +} diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 5998d55..d161cac 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -253,6 +253,7 @@ extension PureYAML.Parsing.Scanner { ) -> Bool { switch character { case "[": + appendFlowImplicitKeyMarkerIfNeeded(state: &state) state.flowDepth += 1 state.flowContexts.append(.init(kind: .sequence, isExpectingKey: false)) scanFixedToken(.flowSequenceStart, state: &state) @@ -266,6 +267,7 @@ extension PureYAML.Parsing.Scanner { } return true case "{": + appendFlowImplicitKeyMarkerIfNeeded(state: &state) state.flowDepth += 1 state.flowContexts.append(.init(kind: .mapping, isExpectingKey: true)) scanFixedToken(.flowMappingStart, state: &state) diff --git a/Tests/FlowCollectionAsMappingKeyTests.swift b/Tests/FlowCollectionAsMappingKeyTests.swift new file mode 100644 index 0000000..455b5c8 --- /dev/null +++ b/Tests/FlowCollectionAsMappingKeyTests.swift @@ -0,0 +1,54 @@ +@testable import PureYAML +import Testing + +/// A flow collection followed by a `: ` indicator on the same line is an implicit block mapping key, +/// e.g. `[a, b]: value` or `{a: 1}: value`. The scanner detects this by looking ahead from the flow +/// opener to the matching close and emits the same implicit-key marker a scalar key gets, so the flow +/// node is parsed as the key through the ordinary mapping path. A block implicit key must lie on one +/// line, so a flow collection spanning a line break before its `:` is not a key and stays an error. +@Suite("Flow collection as mapping key") +struct FlowCollectionAsMappingKeyTests { + @Test("A flow sequence before a colon is a mapping key") + func test_flowSequenceKey() throws { + #expect(try PureYAML.parse("[a, b]: c\n") == .mapping(.init([ + .init(keyNode: .sequence([.string("a"), .string("b")]), value: .string("c")), + ]))) + } + + @Test("A flow mapping before a colon is a mapping key") + func test_flowMappingKey() throws { + #expect(try PureYAML.parse("{a: 1}: b\n") == .mapping(.init([ + .init(keyNode: .mapping(.init([.init(key: "a", value: .int(1))])), value: .string("b")), + ]))) + } + + @Test("A colon inside a quoted scalar in the flow key does not end the lookahead early") + func test_quotedColonInsideKey() throws { + #expect(try PureYAML.parse("['x: y']: z\n") == .mapping(.init([ + .init(keyNode: .sequence([.string("x: y")]), value: .string("z")), + ]))) + } + + @Test("A flow node not followed by a colon stays an ordinary node, not a key") + func test_flowNodeWithoutColonIsNotAKey() throws { + #expect(try PureYAML.parse("[a, b, c]\n") == .sequence([.string("a"), .string("b"), .string("c")])) + #expect(try PureYAML.parse("key: [a, b]\n") == .mapping(.init([ + .init(key: "key", value: .sequence([.string("a"), .string("b")])), + ]))) + } + + @Test("A flow collection spanning a line break before its colon is not a valid key") + func test_multilineFlowKeyRejected() { + // The flow sequence runs across two lines, so it cannot be a single-line implicit key. + #expect(throws: PureYAML.Parsing.ParseError.self) { + try PureYAML.parse("[23\n]: 42\n") + } + } + + @Test("A colon without a following space is not a mapping value, so the flow node is not a key") + func test_colonWithoutSpaceIsNotAKey() { + #expect(throws: PureYAML.Parsing.ParseError.self) { + try PureYAML.parse("[a]:b\n") + } + } +}