Commit Graph

268 Commits

Author SHA1 Message Date
20f080a9f7 fix(streaming): replace the token-kind trap with an exhaustive switch
The 22-case TokenType mapping was split across three initializers to
stay under the complexity lint, leaving a preconditionFailure default
the types could not prove unreachable, which also tripped the
forbidden-patterns gate on every push. One exhaustive switch makes a
new TokenType case a compile error instead of a runtime trap; the lint
now ignores case statements when counting complexity, since exhaustive
closed-enum switches are the point, not a smell.
2026-07-12 13:26:06 +02:00
b48a30f978 chore(validation): classify new public types in coverage ledger
Tagged.Dumper, Tagged.ScalarStyle, and Tagged.CollectionStyle from the
issue #9 work, plus Streaming.Token.Kind and Streaming.Token.ScalarStyle
which predate it but were never entered and block every push.
2026-07-12 13:23:24 +02:00
2a84d6616f feat(tagged): preserve anchors, aliases, and per-node styles end to end
Tagged.Scalar/Sequence/Mapping gain anchor and style fields
(Tagged.ScalarStyle, Tagged.CollectionStyle), Tagged.Node gains an
alias(anchor:node:) case carrying both the reference and its resolution,
and parseTagged populates all of it from the parse events instead of
discarding anchors and expanding aliases into copies.

PureYAML.dump(_ node: Tagged.Node, options:) re-emits anchors, alias
references, tags, and per-node styles over the document-wide policy.
Plain scalars re-emit their source spelling (NO stays NO, an empty null
stays empty); literal blocks cover keep chomping, single-line strip
chomping, leading blank lines, and whitespace-led first lines via an
explicit indentation indicator; folded scalars re-emit as > when the
folding rules invert exactly and degrade to a literal block otherwise.

The round-trip law parseTagged(dump(parseTagged(d))) == parseTagged(d)
is corpus-tested over every real-world fixture, with one documented
degradation: single-quoted content containing line breaks re-emits
double-quoted, value unchanged.

Closes #9
2026-07-12 13:20:12 +02:00
ec3642bacc feat(emitting): add indentWidth and sortKeys emitter options
Closes two of the four emitter gaps a feature-parity audit against Yams
found (PureYAMLYamsComparison's FEATURE-PARITY.md): no configurable
indentation and no sorted-key output.

indentWidth replaces every hardcoded indent + 2 in Dumper.swift/
Dumper.Mapping.swift, verified with round-trip tests since indentation is
purely a rendering choice and must never change what a document means.

sortKeys sorts a mapping's pairs by their string key in both block and flow
style, but only when every key in that mapping is a plain string; a mapping
with any complex (mapping/sequence) key stays in insertion order, since
there is no well-defined ordering across mixed key kinds. Off by default,
since insertion order is what makes a duplicate-key-preserving Mapping
round-trip meaningfully in the first place.

width-based line wrapping and canonical mode are each a correctness surface
of their own and are deliberately left for later, not folded into this.
2026-07-10 17:49:44 +02:00
f9fe2325f7 fix(parsing): reject raw control characters in parse(), matching libyaml
PureYAML.parse() silently accepted raw C0 control characters anywhere in
the input; Yams's underlying libyaml rejects them unconditionally at the
reader level. Found by comparing parse() directly against Yams on a
real-world OpenAPI corpus. The printable-character predicate this needed
already existed (CharacterClass.isPrintable, matching libyaml's
c-printable set) but was only ever wired into anchor-name validation, and
the separate PreflightScanner control-character check is opt-in via
validate(), not part of what parse() itself runs.

Reader now records the index of the first non-printable scalar as it
buffers input (covering both batch and streaming), and fetchNextToken()
throws once scanning has actually reached that position, not merely once
one exists, so an earlier real syntax error is not hidden behind a later
control character.

Verified with 5 new tests and a full re-run of the official yaml-test-suite
conformance (333/333 parse-level, 208/208 value-level, unchanged) and the
whole-suite streaming differential (still clean), confirming no regression.
2026-07-10 11:03:27 +02:00
41ca600d93 feat(parsing): persist mid-token suspension and expose it as a public streaming API
Plain scalars now suspend and resume mid-token instead of rolling back to a
checkpoint and rescanning from the token's start on every chunk boundary
inside them: scanPlainScalar's loop is an explicit PlainScalarPhase state
machine that throws PlainScalarSuspended the moment a lookahead runs dry, and
scanStep() resumes it directly from Scanner.suspendedPlainScalar. Getting
this right took four rounds of bugs, each caught by the existing whole-suite
streaming differential test: checking ranDry only once at the loop's end
instead of after every peek-dependent decision, resuming a dry-interrupted
content or break run through a pre-check meant only for a fresh entry point,
trusting dispatchToken's own dry-fooled document-marker check, and a nested
blankLineAhead() lookahead independently going dry before an outer check
could catch it.

A synthetic long-scalar test (needed since the real-world benchmark fixture's
scalars are too short to exercise this) then caught a fifth, genuine bug: the
first working resumePlainScalar took the whole suspended value by parameter,
which kept a reference to its accumulated string alive for the entire resume
call and defeated copy-on-write, copying the whole scalar on every resume.
That is O(N^2) in the scalar's length, and measured 29x slower than not
suspending at all at small chunk sizes. Passing state inout directly, with
the alias broken at the call site before the call, fixed it to 3-4x, verified
genuinely linear (not just a smaller quadratic constant) by a permanent test
scaling scalar length 16x at a fixed chunk size.

Also exposes this scanner as public API for the first time: scanStep() and
everything around it were internal-only, so no consumer outside this module,
including the separate PureYAMLStreaming package that actually wants a
resumable token scanner, could call it. PureYAML.Streaming.Scanner wraps
scanStep() directly with no independent logic, translating its tokens into
public Streaming.Token/Kind/ScalarStyle types that don't leak the libyaml
port's internal naming. Proven separately from the internal scanner with its
own differential test, since a wrong case mapping in the translation layer
wouldn't be caught by a proof that never looks at the public types.

Stops short of rewiring PureYAMLStreaming's own document-boundary scanner
onto this; that is separate, cross-repo work with its own build and CI cycle.
2026-07-10 09:10:56 +02:00
6d3f3009d2 perf(parsing): correct the scanStep() floor to a checkpoint cost, not rescanning
The previous commit concluded the ~1.75x floor needed persisted mid-token
suspension to close, since that removes the need for a checkpoint. One more
measurement shows that conclusion was wrong for the floor itself: feeding the
entire 9.5MB file as a single chunk, so isFinal stays false until the true
end and essentially no real rescanning can occur, still measures 1.75x,
identical to 1,000,000-scalar and 65536-scalar chunk sizes.

The overhead is actually two independent costs. A fixed floor (~1.75x),
present even with zero rescanning, from copying the whole Scanner on every
fetchNextToken() call whenever isFinal is false, whether or not that copy is
ever used to roll back; since isFinal only becomes true once, on the last
chunk, that copy is paid for virtually every token in a real streaming
session. And a rescan-driven tail that only appears below roughly
4096-scalar chunks (up to 11.54x at 16 scalars).

Mid-token suspension would eliminate the rescan tail, not the floor. A fix
for the floor is not yet designed: every field the checkpoint covers can be
mutated before a dry point is reached within a single fetch, so no smaller
subset is obviously safe to skip, and isFinal: true remains the only
provably safe condition for skipping it entirely.
2026-07-10 01:23:39 +02:00
c6740ca299 perf(parsing): correct the scanStep() finding to release-mode numbers
The prior commit's ~1.5x "unexplained floor" was measured in debug mode,
because release-mode test builds hit an unrelated PureYAMLJSON test-target
issue. sample(1) profiling of that debug build pointed at consumePlainContent,
a mutating throws method, which is a known trigger for defensive retain/copy
traffic under -Onone that the optimizer removes in release.

Worked around the release-test-build issue with -Xswiftc -enable-testing and
re-measured: the isFinal: true control now shows 1.04x, essentially batch
parity, so the checkpoint-skip fix was a full fix for that case, not the
small, unexplained one debug mode suggested.

What remains is now correctly explained rather than guessed at: a chunked
run's isFinal only becomes true once, on the last chunk, so for any input
that arrives incrementally, the checkpoint stays live for virtually the
entire scan regardless of chunk size (release-mode ratios: 11.70x at 16
scalars down to 1.75x at 1,000,000, never reaching the 1.04x floor). Closing
that needs the deferred persisted mid-token suspension, which removes the
need for a checkpoint rather than making it cheaper; isFinal: true is the
only condition under which skipping it is provably safe, since a single
scalar can span past any finite "probably buffered enough" margin.

Removed the temporary sample(1) profiling scratch test and documented how to
re-run the measurement in release mode, which the doc comment previously
did not call out as necessary.
2026-07-10 00:36:45 +02:00
7add6f0087 perf(parsing): measure scanStep() overhead, fix a real but partial cause
Add StreamingScannerPerformanceTests (issue #4), gated behind
PUREYAML_STREAMING_PERF_MEASURE=1, measuring scanStep() against scanAll() on
the largest real-world fixture (9.5MB) across a spread of chunk sizes.

Small chunk sizes (16-256 scalars) show a real, chunk-size-dependent rescan
cost, up to 6x at 16, consistent with the checkpoint-and-retry design
rescanning a fetch attempt once per chunk it spans. A control run with
isFinal: true from construction, where ranDry can never be set and no
rollback ever happens, still cost ~1.5x over scanAll(), proving a separate,
chunk-size-independent cost dominates at any realistic chunk size (4KB+): the
checkpoint was being copied on every fetch even when it could never be used.

Fixed by skipping the checkpoint entirely when reader.isFinal is true.
Re-measured rather than assumed fixed: the isolated control improved from
1.49x to 1.38x, a real but modest gain, while the large-chunk-size ratios
were unchanged within measurement noise. The remaining floor's cause is not
identified and is left open on issue #4 rather than patched further on
speculation; closing it needs a profiler, not another guess.

Whole-suite conformance (402/402) and the chunk-size differential proof both
still pass unchanged.
2026-07-10 00:14:19 +02:00
18fdf9b69a feat(parsing): add a resumable scanner increment and broaden the mutation suite
Add Scanner.scanStep(), the first increment of true chunked input streaming
(#4): Reader gains isFinal/append(_:)/markFinal(), and peek(_:) flags ranDry
instead of asserting end of input when the buffer runs out mid-fetch and more
chunks may still arrive. scanStep() snapshots the scanner before each
fetchNextToken() and restores it whenever a fetch runs dry, so a construct
spanning a chunk boundary is retried after more input lands rather than
misread as an early end of input. Every existing call site keeps the default
isFinal: true, so scanAll() and batch parsing are unchanged; ranDry can never
be set when isFinal is true, by construction. A token already queued is not
always safe to release, since libyaml's simple-key mechanism can still splice
a KEY token in front of it, so scanStep() ports libyaml's
yaml_parser_fetch_more_tokens release condition.

Proof: StreamingScannerDifferentialTests.swift drives scanStep() at several
chunk sizes over representative cases always, and over the entire
yaml-test-suite manifest (402 cases) behind PUREYAML_RUN_YAML_TEST_SUITE=1,
asserting the token sequence matches scanAll() exactly. That proof caught a
real bug: lastTokenForbidsInlineSequence() read indentEngine.tokens.last,
which goes empty once tokens are released incrementally, silently breaking
the key: - a rejection. Fixed by tracking IndentEngine.lastToken explicitly,
kept in sync with tokens.last so batch behavior (which never dequeues) is
unchanged.

Not yet wired into PureYAMLStreaming; scanStep() and its supporting types stay
internal until that integration is designed.

Also broaden the real-seed mutation property suite (#6) with the three
mutation classes it named but didn't yet cover: invalidControlCharacter,
mixedTabSpaceIndentation, and truncatedFlowCollection. The test now asserts
diagnostic quality, not just pass/fail: line must fall within the mutated
document, and column, when present, must be 1-based (some ParseError cases,
like tabIndentation, are line-only by design).
2026-07-09 23:38:39 +02:00
1dc986f146 docs(readme): retire the stale Mermaid roadmap and its dead CI gate
The roadmap diagrams referenced issue numbers (#1, #8-#13, #43-#55) that no
longer exist on Codeberg: the GitHub to Codeberg migration reset this repo's
issue tracker, and those numbers were never re-created. scripts/check-roadmap.sh
existed only to keep that roadmap in sync with GitHub epic-labeled issues via
gh, which can't resolve a Codeberg/Forgejo repo anyway, so the gate had been a
silent no-op since the move. Removed the roadmap section, the gate script, its
two call sites, and its now-pointless rule doc, and pointed the remaining
work at the real, live issues (#4, #5, #6, #7, tracked under epic #8). Also
fixed two leftover Mihaela/PureYAML references in docs/RELEASE.md.
2026-07-09 23:38:23 +02:00
8031568858 docs(codeberg): update links for NodetteHQ 2026-07-05 12:59:00 +02:00
630849e67a chore(release): stamp 0.2.0
Section the changelog, add the release notes with the Windows
verification disclosure, update the current-release stamps, and repoint
the release process from the retired GitHub commands to Codeberg.
2026-07-05 00:41:02 +02:00
26bf4a0e00 chore(rules): track shared-rules baseline v1.1.2
The v1.1.1 and v1.1.2 canon changes touch only the canon's leak-gate
script, which this repo does not vendor, so the stamp is the whole fold.
2026-07-05 00:27:44 +02:00
a8950e118c Merge conformance/yaml-test-suite-gaps: the libyaml port becomes the engine on main
The ported engine wins every conflict: main's post-fork patches to the
hand-rolled Scanner and TokenEventParser land on deleted files and are
dropped; their behaviors are carried by the port and guarded by the
whole-suite conformance gate (402/402 passing).

Merge adaptations, all gates green (style, namespacing, forbidden
patterns, changelog, roadmap, swiftformat, swiftlint strict, build,
full test suite, whole-suite conformance sweep):

- PureYAMLJSON representability rules move onto the Check algebra,
  replacing the retired legacy Rule type.
- The permissive tag-handle pre-pass is removed; the port rejects an
  undefined handle at parse time (suite case QLJ7) and the validation
  report carries it as a parse diagnostic.
- EventStreamComposer's nested types split one-per-file per the
  namespacing gate.
- Tests pinned to old-engine behavior were re-verified against the
  reference and updated: libyaml rejects an explicit-key continuation
  ending at a value colon, and suite case QB6E requires rejecting a
  quoted continuation at column 0.
- CHANGELOG unreleased entries from both lines are preserved in two
  groups with a provenance note.

Closes #1, closes #3
2026-07-05 00:23:48 +02:00
e76472da11 docs(conformance): align the suite narrative with the 402/402 manifest
The doc still narrated 329 passing with 73 gaps and listed those gaps as
open, contradicting the manifest. Update the headline to 402/0, name the
manifest as the source of truth over the prose, retire the remaining-gaps
narrative, mark bucket 6 done, and add a closing section recording the
libyaml-port cutover and the final fix series (the hand-rolled engine
files cited in buckets 1-8 are historical; the port is the sole engine).
2026-07-05 00:02:43 +02:00
9b60f415ac chore: repoint github.com/mihaelamj references to codeberg.org/Mihaela
Swap issue-template, usage, and release-note links to Codeberg and
replace the four shields.io GitHub badges with a single Forgejo
Actions badge for ci.yml.
2026-07-04 23:02:59 +02:00
Mihaela
f5fefc7d21 chore(rules): track shared-rules baseline v1.1.0 2026-07-02 21:40:13 +02:00
ebf2e2477c fix(parsing): keep tag-handle diagnostics across a scan-aborting error
The diagnostic accessor ran the scan through `try?`, which discarded the whole
scanner state, and the tag-handle issues collected on it, on any error: the
comment promised the issues found before a scan-aborting error were returned,
but the code returned none. Split the run into a state-capturing `collect` that
returns the partial state with the aborting error, so `scan` still throws while
`tagHandleIssues` reports the diagnostics gathered before the abort. A
regression test pins an undeclared handle followed by an unterminated quote.
2026-06-29 21:43:25 +02:00
e64bff7cdf Merge multi-line flow value fix onto main
A flow mapping value on the line after the colon is now parsed (flow is line
insensitive); fixes all 27 JSON Schema Test Suite files that failed to parse.
2026-06-29 21:41:15 +02:00
d1d5b1a37c fix(parsing): parse a flow mapping value on the next line
In a flow mapping the value is a flow node and may sit on any line; flow is line
insensitive. parseMappingPair gated value parsing on hasNodeOnSameLine, a
block-only rule, so a value after `:` on the next line was treated as null and
left unconsumed, failing the whole flow mapping. Thread an inFlow flag and, in
flow, parse the value as a flow node line-insensitively (only a flow terminator
leaves it null), bypassing the block-only same-line and indentation branches.

Pretty-printed JSON (a YAML 1.2 superset) relies on this. Against the JSON Schema
Test Suite corpus this fixes all 27 files that previously failed to parse
(432/432 now); the YAML test-suite (318/333, value-level 206/206) and the full
suite stay green.
2026-06-29 21:40:12 +02:00
a8c5de540a fix(tests): compile conformance on Linux without CoreFoundation
YAMLTestSuiteConformanceTests resolved a JSON bool vs number with CFGetTypeID and
CFBooleanGetTypeID, CoreFoundation functions absent on Linux and Windows, so the
test target failed to compile there and the Linux and Windows CI gates were red.
Make JSONNode Decodable and read the expected in.json with JSONDecoder, which
distinguishes a bool from a number natively and reads top-level fragments via a
single-value container. Value-level conformance is unchanged at 206/206 on
macOS, and the parse no longer touches CoreFoundation.
2026-06-29 13:31:54 +02:00
5e5e568e2c docs(changelog): record the converged located-parse and conformance work
The located-parse port, per-document %TAG scoping with tag-handle diagnostics,
and the parse-gap fixes landed on main without CHANGELOG entries, and the new
canonical event-stream conformance gate was undocumented. Record them under
Unreleased so the changelog gate passes and the release notes are complete.
2026-06-29 12:21:20 +02:00
b35e2a04f0 build: classify the located-parse public types for coverage
The located-parse port added four public Model types (LocatedValue, LocatedPair,
SourceRange, SourcePosition) that the validation-coverage gate flagged as
unclassified, blocking the push. They are positional projections and pure
position data with no validation invariant of their own: LocatedValue and
LocatedPair wrap an already-validated Model.Value, and SourceRange and
SourcePosition are data-transfer. Classify all four as excluded with reasons.
2026-06-29 12:19:17 +02:00
c1bb5aa8d6 ci: gate conformance on every push, fetching the test-suite
The parse, value, and canonical event-stream conformance suites were opt-in
(PUREYAML_RUN_YAML_TEST_SUITE=1) and so never ran in CI: an oracle that only
runs when asked is not a gate. Add a Conformance workflow that fetches the
canonical yaml-test-suite and runs all three suites, so the event gate's
recorded-gap set fails the build on any regression or silently-closed gap, and
add its README badge alongside the other quality gates.
2026-06-29 12:06:58 +02:00
93621b8c84 test(conformance): add the canonical event-stream conformance gate
Serialize parse events to the official yaml-test-suite yeast format and compare
exactly to each case's test.event: the strongest oracle for the parser, checking
anchors, tags, scalar styles, flow vs block, and aliases for every valid case,
including those without an in.json that the JSON-projection oracle cannot judge.

It runs opt-in (PUREYAML_RUN_YAML_TEST_SUITE=1) against the fetched suite and is
a gate, not a measurement: knownEventGaps records the 31 valid cases whose event
stream is not yet exact, so closing one must remove its id and a regression adds
one. No case is silently skipped. One documented normalization: the event API
does not carry the explicit document-marker flag, so +DOC/-DOC are compared
without their ---/... suffix.

The first run exposed real deviations the parse+value metric missed: 4ABK
truncates http://foo.com at the colon, FH7J and PW8X bind a property to the
collection instead of the empty scalar, 6BFJ binds the wrong anchor.
2026-06-29 10:13:45 +02:00
af4a00f229 Merge parse-gap fixes onto main
Fix three should-parse conformance gaps: a repeated document-end marker (M7A3),
a root block scalar ended by a document marker (W4TN), and an empty explicit key
in a flow mapping (DFF7). Threaded the located parse end mark through the new
explicit-key emit. Parse-level suite conformance reaches 318/333.
2026-06-29 09:15:50 +02:00
ba000d90fa Merge tag-handle diagnostics onto main
Per-document %TAG scoping and an undefinedTagHandle diagnostic via
validationReport, with Scanner.State and the tag helpers in their own files.
Resolved against main's Scanner.State by carrying its sawNonPropertyContentOnLine
field into the extracted State.
2026-06-29 09:14:21 +02:00
9620122759 Merge located parse onto main
Opt-in located parse (parseLocated / parseStreamLocated) on the hand-rolled
engine, with exact scalar end positions. Resolved against main's extracted
parseScalarNode by threading the token end mark through it.
2026-06-29 09:11:07 +02:00
990a2d2e42 fix(parsing): parse an empty explicit key in a flow mapping
An explicit key (`?`) may have no key node: `? : v`, a trailing `? }` / `? ]` /
`? ,`, or `?` at the end of a flow mapping all denote a null key. The parser now
emits an empty key in those positions instead of failing to find a node before
the value indicator or terminator.

Fixes suite case DFF7 (parse-level conformance 316 -> 317); adds tests.
2026-06-29 09:04:29 +02:00
8112954f4c fix(parsing): end a root block scalar at a document marker
A `---` or `...` at column 0 ends an active block scalar rather than being
consumed as its content. The scanner now closes the scalar's indentation at the
marker, dedenting back to the document level, so the token stream stays balanced:
the dedent precedes the documentEnd. Previously the scalar's indent lingered past
the marker and surfaced as a stray late dedent plus a phantom empty document, and
a following directive was misread as a scalar.

Fixes suite case W4TN (parse-level conformance 315 -> 316); adds tests.
2026-06-29 08:57:39 +02:00
ae29059530 fix(parsing): parse a property-only explicit mapping key as an empty key
An explicit key that is only a node property (`? &e` then `: &a`) parsed to an
`expectedNode` error. Unlike the implicit form (`&a : v`), the scanner emits no
synthesized empty scalar for an explicit key, so the block parser read the key
via parseNode, consumed the property, then hit the `:` value indicator with no
node and threw.

parseNode now treats a `:` in node position as an empty key carrying any
properties, reusing emitEmptyNodeOrThrow (the node it already produced for a
property at end of input) without consuming the `:`, so the mapping machinery
reads the `:` as the value indicator. To stay within the cyclomatic-complexity
limit, the scalar case is extracted to parseScalarNode.

yaml-test-suite PW8X now passes (parse-level 314/333 to 315/333). should-error
rejections and value-level matches are unchanged, and a value-level test pins
the produced tree for the anchored and tagged property-only explicit keys.
2026-06-29 08:56:58 +02:00
056095ca3d fix(parsing): allow a repeated document-end marker
A `...` after a document is already closed (only comments or blank lines since
the last `...`) ends nothing: a repeated document-end marker is valid YAML, so
consume it as a no-op rather than rejecting the stream as an unsupported
multi-document case. A second documentEnd is not emitted, so no phantom null
document is composed, and real content after the marker stays an error.

Fixes suite case M7A3 (parse-level conformance 313 -> 315); adds focused tests.
2026-06-29 08:32:26 +02:00
8d245ae335 fix(parsing): parse a property-only block mapping key as an empty key
A block key that is only a node property (`&a : v`, `!!str : v`) parsed to an
`expectedNode` error. The scanner synthesized an empty mapping key for a bare
`: v` line but not when the line's sole content before the `:` was an anchor or
tag, so no key marker was emitted and the parser rejected the `:`.

Track non-property content per line (a leading `&`/`!` does not count, and a
block entry opens a fresh node position) and synthesize the empty key whenever
only properties precede the `:`. The property tokens already bind to the
synthesized empty scalar through the existing mapping machinery, so no parser
change is needed. Relocate the synthesis helper to Scanner.Helpers to keep
Scanner under the file-length limit.

yaml-test-suite FH7J now passes (parse-level 313/333 to 314/333); PW8X still
needs the empty-anchored explicit-value form. should-error rejections and
value-level matches are unchanged, and a value-level regression test pins the
produced tree for each property-only-key shape.
2026-06-29 08:26:50 +02:00
df3d43804c feat(parsing): scope %TAG per document, diagnose undefined handles
A tag shorthand using a named handle (`!handle!suffix`) must be declared by a
`%TAG` directive in the document that uses it; the handle does not carry across
documents (YAML 1.2). The parser was permissive but silent here, so an
undeclared handle passed unnoticed.

- Scope tag handles per document: a `%TAG` handle now applies only to the
  document it precedes (merged onto the defaults at the document start, cleared
  at the boundary) instead of accumulating across the whole stream. A directive
  repeated for each document keeps working, as the suite requires.
- Detect the named-handle form before the primary-handle fallback (which
  previously masked `!prefix!x` as a local tag) and record an undeclared handle.
  The parse stays permissive: it still yields a value, and
  `PureYAML.validationReport` surfaces the issue as an `undefinedTagHandle`
  error. The check reads the scanner's tokens, so it is exact: a `!x!` inside a
  scalar or comment is never flagged.

Extracts `Scanner.State` and the tag helpers into their own files to keep each
source file within the 400-line limit. Adds focused tests; the full suite and
strict lint stay green.
2026-06-29 07:54:55 +02:00
941c3f948a test(parsing): assert an explicit-key compact sequence's value
The explicit-key compact block-sequence case (`? k\n: - a\n  - b\n`) was only
asserted to parse without error, with a comment deferring its value because the
two entries used to fold into a single `"a - b"` scalar. That defect is fixed:
it now parses to `{k: ["a", "b"]}`. Strengthen the test to a value-level
assertion so the correct tree is locked against regression, and drop the
obsolete carve-out comment.
2026-06-29 07:23:57 +02:00
d4ba594012 feat(parsing): opt-in located parse on the hand-rolled engine
Port the located value tree onto the hand-rolled engine, reusing the
engine-agnostic located layer (Model.LocatedValue, LocatedPair,
SourcePosition, SourceRange, and the located composer helper) authored
on the conformance branch. The streaming composer now builds a parallel
located tree behind an opt-in tracksLocations flag, exposed as
parseLocated / parseStreamLocated; the plain Value path stays
byte-identical, gated behind the flag.

The scalar event carries the scanner token's end mark, so a located
scalar spans the whole token (end exclusive, one past the last
character) rather than being start-anchored. The hand-rolled scanner
already tracked token end marks, so this surfaces information the engine
was already computing.

Also stop emitting a spurious null document for a comment-only or empty
stream: YAML 1.2 gives such a stream zero documents, while the
single-document value API still maps it to null, so parse("") is
unchanged.

A whole-suite test proves the located projection equals a plain parse
for every passing case; the value, event-dump, and conformance suites
stay green (507 tests).
2026-06-29 07:14:42 +02:00
5a3bf6518c build(verify): make the local Linux relay a skippable fallback gate
The pre-push gate ran the self-hosted claw relay (ssh + Lima Debian) as a
hard stage, so any time the host was unreachable the whole hook failed and
the only way past it was a blanket --no-verify, which also discarded the
local macOS Swift gate. The authoritative Linux gate is the GitHub-hosted
workflow (ubuntu-latest + swift:6.2); the relay is a convenience fallback.

check-linux.sh now separates connectivity and provisioning (Stage A, exit 77
to signal skip when the relay is unavailable) from the relayed build and test
(Stage B, exit 1 on a genuine Linux failure). check-all.sh treats 77 as a
non-fatal skip and still aborts on any other non-zero, so a runner outage no
longer blocks a green push and never masks a real failure.
2026-06-29 06:55:36 +02:00
942a7ff2bc fix(parsing): keep a leading percent in a block scalar literal
A zero-indent literal or folded block scalar sits at column 0, where the
directive scan runs, so a content line beginning with `%` (`%!PS-Adobe-2.0`)
was misread as a directive and rejected as unsupported.

The directive scan now runs only outside a block scalar, so a leading `%` in
block scalar content is literal text. A real `%YAML` or `%TAG` directive still
parses, and a stray `%` in other content is still rejected rather than silently
accepted, so the should-error behaviour is unchanged.

This is groundwork for yaml-test-suite W4TN, which additionally needs a block
scalar to be terminated by `...` and per-document directives across a multi-
document stream, so conformance counts are unchanged this step.
2026-06-29 05:04:32 +02:00
5ea6fa668b fix(parsing): end a tagged zero-indent sequence at a sibling key
A block sequence value carrying an inline property (`sequence: !!seq` then
`- entry` at the key's column) is a zero-indent sequence: its entries sit at the
mapping key's own indentation, so a following sibling key ends it.

The untagged form was already parsed as a zero-indent sequence that stops at a
sibling mapping key, but the property-bearing form fell through to the generic
node path, which parsed the sequence without that terminator and read the
sibling key as a mixed sequence-and-mapping entry. The zero-indent sequence is
now handled before the generic path, carrying any property to the sequence.

Fixes yaml-test-suite 57H4; raises parse-level conformance to 313/333 (94.0%)
and value-level to 206/206 (100.0%), with the should-error rate unchanged.
2026-06-29 04:48:47 +02:00
4dcedbd6aa fix(parsing): carry spanned properties to a deeper indented node
An inline anchor with a tag on the next indented line, above a mapping indented
deeper still (`key: &anchor` / ` !!map` / `  a: b`), left the node introduced by
a further indent token after the properties were consumed. The parser could not
read a node from that indent and rejected it as a missing node.

The indented mapping value now descends through the deeper indent, carrying the
accumulated anchor and tag to the node it introduces, so the properties attach
to the mapping rather than to an empty value.

Fixes yaml-test-suite BU8L; raises parse-level conformance to 312/333 (93.7%)
and value-level to 205/205 (100.0%), with the should-error rate unchanged.
2026-06-29 04:05:04 +02:00
5528ce9aa4 fix(parsing): bind an indented property to a zero-indent sequence
A node property written on its own indented line can sit above a block sequence
whose entries return to the mapping key's own indentation (`seq:` then
` &anchor` then `- a` at the key's column). The grammar allows a block-in-block
sequence at its parent's indent n while the property sits at n+1, so the
indent's matching dedent falls between the property and the entries.

The parser consumed the property as an empty anchored value and then rejected
the entries as an unexpected block entry. It now recognises the property-then-
dedent-then-entry shape, consumes that dedent, and binds the property to the
block sequence that follows.

Fixes yaml-test-suite SKE5; raises parse-level conformance to 311/333 (93.4%)
and value-level to 204/204 (100.0%), with the should-error rate unchanged.
2026-06-29 03:44:53 +02:00
b45b7a4b7d fix(parsing): accept an alias node as an implicit mapping key
An alias may stand where a mapping key is expected (`*a : value`). The scanner
emits the implicit-key indicator before a scalar key that a `:` follows, but
not before an alias, so the parser, which already accepts an alias node in key
position, never entered the pair and rejected the alias as an unexpected token.

The scanner now emits the same indicator before an alias that a `:` (after
optional spaces) follows, in block context. A `:` written directly against the
alias stays part of its name, since `:` is a valid anchor-name character, so
only a space-separated `:` marks the alias as a key.

Fixes yaml-test-suite E76Z and 26DV; raises parse-level conformance to 310/333
(93.1%) and value-level to 203/203 (100.0%), with the should-error rate
unchanged.
2026-06-29 03:35:56 +02:00
c858339307 fix(parsing): bind an inline first-key property to the key
A property written inline before a block mapping's first key (`&a key: v`) was
attached to the whole mapping, so an anchor registered only when the mapping
ended, too late for an alias inside one of the mapping's values to resolve.

The YAML grammar requires a block collection's own properties to be followed by
a line break (`s-l+block-collection`), so a property on the first key's line
belongs to the key, not the collection. The mapping now keeps only a property
written on its own line above the first key; an inline one is threaded back to
the first pair, where the key parses it and an anchor registers with the key.

Fixes yaml-test-suite 2SXE (`&a:` and `&a` are distinct anchors, since a name
includes a trailing `:`) and HMQ5 (a key carrying both a tag and an anchor).
Raises parse-level conformance to 308/333 (92.5%) and value-level to 201/201
(100.0%), with the should-error rate unchanged.
2026-06-29 03:24:30 +02:00
e3caa5be0d fix(parsing): resolve an alias to an anchor on a null value
An anchor on an empty mapping value (`a: &anchor`) failed to register, so a
later `*anchor` errored as an undefined alias. Two bugs compounded:

  - The parser dispatched the anchor-only value position to a nested block
    mapping, absorbing the sibling key as the anchored node's child instead of
    emitting the empty anchored node. It now emits an empty anchored node when
    only a sibling mapping key follows, since the indented and inline-nested
    cases are already handled by earlier branches.
  - The stream composer registered an anchor by its raw scalar text, so an
    anchor on an empty scalar came back as "" and a typed scalar (`&a 42`) as
    text. It now registers the composed value, fixing a latent typed-anchor bug.

Fixes yaml-test-suite 6KGN; raises parse-level conformance to 306/333 (91.9%)
and value-level to 199/199 (100.0%), with the should-error rate unchanged.
2026-06-29 02:56:17 +02:00
7879818ce5 fix(parsing): accept a higher-minor %YAML version
The %YAML version check accepted only 1.1 and 1.2, rejecting anything else,
so `%YAML 1.3` errored. The spec calls for processing a document whose minor
version is higher than the processor's, with a warning, rather than refusing
it: only a different major version is incompatible.

Accept any well-formed `1.minor` version and reject only a different major
(`2.0`) or a malformed version. Three tests that asserted `%YAML 1.3` was an
error are corrected to use `2.0`, the genuinely incompatible case, and a test
covers that 1.3 now parses.

Raises yaml-test-suite parse-level conformance to 305/333 (91.6%) with the
value-level (100%) and should-error rejection rates unchanged. 486 tests pass.
2026-06-29 02:34:55 +02:00
4b06f6e5fc fix(parsing): end an inline item value at a sibling block entry
A block entry with an inline node recorded only the node's indent (one past
the dash) as a valid implicit level, not the dash's own indent. So a plain
scalar that is an inline sequence item value could not tell a sibling entry
that returns to the sequence indent from its own content, and a compact nested
sequence as an explicit key's value folded into one scalar: `? a` / `: - b` /
`  - - c` / `   - d` became `{a: [b -  - c - d]}` instead of `{a: [b, [c, d]]}`.

Record the entry's own indent as a sibling level too, so the continuation
stops there. Skip it under a pending explicit key, whose own sequence key
drives the implicit indentation differently (an explicit key whose key is a
sequence, `? - x`, must keep parsing). A block entry more indented than its
container is still plain content, so a wrapped value beginning a line with
`- ` folds in. The block-entry recording moved to a helper to keep the file
within its length limit.

Brings yaml-test-suite value-level conformance to 197/197 (100.0%) with the
parse-level and should-error rejection rates unchanged. 485 tests pass.
2026-06-29 02:09:57 +02:00
3b504a9c3a fix(parsing): keep a tab on a leading blank of a block scalar
A blank line within a block scalar keeps any whitespace past the content
indentation as content, but the leading blank lines, those before the first
content line, were only counted and emitted as empty. So an otherwise-blank
`tab` line under a one-space content indent lost its tab, and a folded scalar
beginning with such a line dropped the leading tab.

Record each leading blank line's whitespace verbatim instead of a count, and
once the content indentation is established emit the surplus past it, the same
way an interior blank line does. A leading blank with no surplus still emits an
empty line, and an empty block scalar (no content indentation) still emits
empty lines for its blanks.

Raises yaml-test-suite value-level conformance to 196/197 (99.5%) with the
parse-level and should-error rejection rates unchanged. 482 tests pass.
2026-06-29 01:52:58 +02:00
31da488055 fix(parsing): allow a flow mapping entry with no value
A flow mapping entry may omit its `:` value indicator, in which case its key
maps to null: `{ a }` and `{ a, b }` are flow sets. parseFlowMappingPair
required a `:` after every key, so it rejected these entries, and rejected a
valueless key that spanned lines (`{ multi\n  line, a: b}`) with them.

Parse a value only when a `:` follows the key; otherwise emit a null value for
the key. A unit test that asserted `{ a, }` was an error is corrected to expect
the set `{a: null}`, the behaviour the yaml-test-suite cases 8KB6 and 9BXH
require.

Raises yaml-test-suite parse-level conformance to 304/333 (91.3%) and
value-level to 195/197, with the should-error rejection rate unchanged.
480 tests pass.
2026-06-29 01:42:02 +02:00
5c99086a38 fix(parsing): let a flow plain key span lines
A plain scalar continuation stops before a line ending at `:`, since a block
implicit key may not span lines and the next line is a new key. A flow key may
span lines, so the same rule wrongly split `{ multi\n  line: v }`: the scalar
returned just `multi` and the parser mis-read `line: v` as a separate entry.

Fold across the `:` in flow as well, leaving the `:` to be scanned as the
value indicator, so the key folds to `multi line`. A block implicit key keeps
the previous behaviour and still stops before the `:`.

Raises yaml-test-suite parse-level conformance to 301/333 (90.4%) and
value-level to 193/195, with the should-error rejection rate unchanged.
476 tests pass.
2026-06-29 01:38:33 +02:00