fix(parsing): apply the YAML 1.2 flow ':' rule with JSON-key adjacency

libyaml makes every ':' in a flow collection a value indicator, so it
mis-scans ':x', 'http://foo.com', '::vector', and 'omitted value:,', and
drops the value after an adjacent quoted or collection key. Per the spec a
':' only ends a node when followed by whitespace, end of input, or a flow
indicator, except an adjacent ':' after a JSON key (a quoted flow scalar
or a flow collection used as the key).

Gate the ':' value-indicator dispatch on that rule, tracking on the
pending simple key whether it is a JSON node (set for quoted flow scalars
and flow-collection starts). End the plain scalar at a ':' before a flow
indicator instead of rejecting it as unexpected.

Surpasses libyaml on 4ABK, 58MP, 5T43, DBG4, FRK4, HM87/00, with 9MMW
(mixing adjacent and separated keys) still correct. Port score 292 -> 298.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 19:02:43 +02:00
parent c3a1e6aaa4
commit ff199516ac
5 changed files with 50 additions and 7 deletions

View File

@ -22,6 +22,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Apply the YAML 1.2 flow `:` rule instead of libyaml's "always a value
indicator in flow". In a flow collection a `:` only ends a node when it is
followed by whitespace, the end of input, or a flow indicator, or when it is
adjacent to a JSON key (a quoted scalar or a flow collection used as the key).
Otherwise the `:` is plain-scalar content. So `[:x]` and `{x: :x}` keep the
scalar `:x`, `http://foo.com` and `::vector` stay single scalars, `"key":value`
and `{JSON: like}:adjacent` bind the value across the adjacent `:`, and
`omitted value:,` is a key with an empty value rather than a scan error. The
pending simple key now records whether it is a JSON node, and the plain scalar
scanner ends (rather than rejecting) at a `:` before a flow indicator. Surpasses
libyaml on 4ABK, 58MP, 5T43, DBG4, FRK4, and HM87/00, with 9MMW (mixed
adjacent/separate keys) still correct.
- Treat `?` in a flow collection as plain-scalar content unless it is followed by
whitespace, the end of input, or a flow indicator. libyaml makes every `?` in
flow context a mapping-key indicator, dropping the `?` from `?foo`, but

View File

@ -111,7 +111,12 @@ extension PureYAML.Parsing.LibYAML.Scanner {
// `ns-plain-first` a `?` followed by a flow-safe character (anything but
// whitespace, end, or a flow indicator) begins a plain scalar (`?foo`).
fetchKey()
case ":" where flowLevel > 0 || Classify.isBlankZ(reader.peek(1)):
case ":" where Classify.isBlankZ(reader.peek(1))
|| (flowLevel > 0 && (Classify.isFlowIndicator(reader.peek(1)) || flowKeyIsJSON)):
// In flow context libyaml makes every `:` a value indicator. Per the
// spec a `:` only ends a node when followed by whitespace, end of
// input, or a flow indicator, except an adjacent `:` after a JSON
// (quoted) key. Otherwise the `:` is plain-scalar content (`:x`).
try fetchValue()
default:
// A plain scalar may begin with any non-blank character; a blank (a
@ -128,6 +133,13 @@ extension PureYAML.Parsing.LibYAML.Scanner {
}
}
/// Whether this flow level's pending simple key is a JSON (quoted) node, which
/// lets an adjacent `:` open its value without a separating space.
private var flowKeyIsJSON: Bool {
let key = simpleKeys[simpleKeys.count - 1]
return key.possible && key.isJSON
}
/// Fetch a flow-collection indicator (`[` `]` `{` `}` `,`); returns false
/// otherwise.
private mutating func fetchFlowToken(_ character: Unicode.Scalar) throws -> Bool {
@ -178,7 +190,13 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Port of `yaml_parser_fetch_flow_scalar`.
private mutating func fetchFlowScalar(single: Bool) throws {
mark = reader.mark
let saved = simpleKeyAllowed
try saveSimpleKey()
// A quoted scalar that became this level's simple key is a JSON node, so a
// later adjacent `:` (no space) still opens its value.
if saved {
simpleKeys[simpleKeys.count - 1].isJSON = true
}
simpleKeyAllowed = false
let token = try scanFlowScalar(single: single)
indentEngine.enqueue(token)
@ -196,7 +214,13 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Port of `yaml_parser_fetch_flow_collection_start`.
private mutating func fetchFlowCollectionStart(_ type: PureYAML.Parsing.LibYAML.TokenType) {
// saveSimpleKey cannot throw in flow: a required key needs block context.
let saved = simpleKeyAllowed
try? saveSimpleKey()
// A flow collection is a JSON node, so when it is this level's simple key
// an adjacent `:` after its closing `]`/`}` still opens its value.
if saved {
simpleKeys[simpleKeys.count - 1].isJSON = true
}
increaseFlowLevel()
simpleKeyAllowed = true
let startMark = reader.mark

View File

@ -52,13 +52,16 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Consume the run of non-blank characters, folding any pending breaks or
/// whitespace ahead of each one.
private mutating func consumePlainContent(_ state: inout PlainState, startMark: PureYAML.Parsing.Mark) throws {
private mutating func consumePlainContent(_ state: inout PlainState, startMark _: PureYAML.Parsing.Mark) throws {
while let character = reader.peek(), !Classify.isBlankZ(character) {
if character == ":", Classify.isBlankZ(reader.peek(1)) {
break
}
if flowLevel > 0, character == ":", Classify.isFlowIndicator(reader.peek(1)) {
throw scannerError("found unexpected ':'", at: startMark)
// A `:` before a flow indicator ends the key; the value is empty.
// libyaml rejects this as an unexpected ':', but it is a valid
// value indicator in flow context.
break
}
if flowLevel > 0, Classify.isFlowIndicator(character) {
break

View File

@ -11,6 +11,10 @@ extension PureYAML.Parsing.LibYAML {
var tokenNumber: Int
/// The position the potential key started at.
var mark: PureYAML.Parsing.Mark
/// Whether this key is a JSON-style node (a quoted flow scalar). A `:`
/// adjacent to such a key is a value indicator even without a following
/// space, per the YAML flow "adjacent value" rule.
var isJSON = false
/// The empty entry pushed for a new flow level (`{ 0, 0, 0, {0,0,0} }`).
static var empty: Self {

View File

@ -12,7 +12,7 @@ struct CanonicalEventConformanceTests {
/// Valid suite cases whose event stream PureYAML does NOT yet emit exactly.
/// This is the authoritative gap set: a fix removes an id (and the test fails
/// until it is removed), a regression adds one (and the test fails). Of these,
/// 14 currently throw on valid input and 2 emit a wrong event stream.
/// 8 currently throw on valid input and 2 emit a wrong event stream.
static let knownEventGaps: Set<String> = [
// Gap set for the ported libyaml engine. Verified against the libyaml
// reference dumper: on all but four of these the port matches libyaml
@ -21,9 +21,9 @@ struct CanonicalEventConformanceTests {
// itself handles.
//
// false-rejects (parseEvents throws on valid input, as libyaml does)
"4ABK", "58MP", "5T43", "6BCT", "6CA3",
"A2M4", "CFD4", "DBG4", "DK95/00",
"DK95/04", "FRK4", "HM87/00",
"6BCT", "6CA3",
"A2M4", "CFD4", "DK95/00",
"DK95/04",
"Q5MG",
"Y79Y/010",
// wrong event stream: the port matches libyaml but both deviate from the