Compare commits

...

36 Commits
0.2.0 ... main

Author SHA1 Message Date
938ad85d9e ci: run the workflows on git.aleahim.com, and point the CI links to it
Some checks failed
rules-freshness / rules (push) Failing after 3s
2026-11-04 21:14:08 +01:00
a11858380b docs: point the links of this repository to git.aleahim.com
Some checks failed
Conformance / Conformance (push) Successful in 23s
Gates / Gates (push) Failing after 25s
Linux / Linux (push) Failing after 2s
macOS / macOS (push) Successful in 1m34s
No Cursor attribution / Scan commits (push) Successful in 3s
WASM / WASM (push) Failing after 1m2s
Windows / Windows (push) Has been cancelled
2026-11-04 20:50:07 +01:00
1572307de9 fix(build): read the symbol graph from SwiftPM and say why a build fails 2026-11-03 12:38:42 +01:00
7992c104a5 fix(hooks): accept the critic-fix commit type 2026-11-03 12:36:31 +01:00
28c89f3713 chore(rules): track rules-swift v2.14.0 (freshness marker) 2026-11-02 22:02:23 +01:00
d2421aa0ce fix(parsing): a zero followed by a sign is text, not an octal integer
Int(_, radix:) accepts a sign, so 0-3 (a CPU range, a port range) was read as the integer -3, and 0x-1, 0b-1 and 0o-1 as negative numbers. After a radix prefix only digits may follow. Octal, hex, binary and signed integers read as before.
2026-11-02 18:25:06 +01:00
ae85ec97c9 fix(emitting): quote every string the parser reads as another type
The emitter kept its own list of reserved words and number shapes,
apart from the parser's, and the two disagreed: the string yes was
written plain and read back as a boolean, as were 0x1F, 1_000 and
.inf. The parser's reading of a plain scalar is now one type that
both use, and the emitter quotes what does not read back as a string.

Also quoted: the merge key, the document end marker, and a string
with a CR+LF break, which was written raw.
2026-10-28 23:32:40 +01:00
93b90adfab fix(tests): unwrap url.path the same way as lastPathComponent
contentsOfFile: fixed the documented NSURL/URL bridging bug
(swiftlang/swift-corelibs-foundation#3707) in the previous commit,
confirmed by the Linux relay's error moving off String(contentsOf:)
entirely -- but url.path has the identical Darwin String vs Linux
String? split lastPathComponent already had. Same fix, same reason.

Verified via a full clean macOS build and the specific test passing,
plus swiftformat/swiftlint --strict clean.
2026-07-18 23:56:16 +02:00
7fcfdea68c fix(tests): read fixtures via contentsOfFile:, not String(contentsOf:)
String(contentsOf: url, encoding:) itself was the failure, independent
of how url was obtained -- a bare `for url in urls` with no sort at
all reproduces the same NSURL/URL error on this toolchain. This is a
documented swift-corelibs-foundation issue (SR-7620): String(contentsOf:)
has inconsistent NSURL/URL bridging on Linux.

contentsOfFile: takes a String path instead of a URL, so it has no
exposure to that bridging ambiguity at all.

Verified via a full clean macOS build and the specific test passing,
plus swiftformat/swiftlint --strict clean; the Linux relay confirms
or refutes the actual claim on the next push.
2026-07-18 23:54:27 +02:00
ecd1b34a18 fix(tests): sort plain Int indices, never a derived URL array
Precomputing (url, name) tuples and mapping back to [URL] via a
keypath still put a URL through a map/sorted chain, and the relay
showed the SAME NSURL/URL inference failure moved one line further
down (String(contentsOf:) on the loop variable this time).

Sorting names.indices (plain Int) and subscripting the ORIGINAL,
never-transformed urls array at the one point each URL is used
removes every map/sorted call from ever touching a URL-typed value
at all, keeping the one place a URL is read structurally identical
to a bare `for url in urls` (confirmed safe from the very first
relay run, before any of these fix attempts).

Verified via a full clean macOS build, the specific test passing,
and swiftformat/swiftlint --strict clean; the Linux relay is the
actual proof and needs the next push to confirm.
2026-07-18 23:51:57 +02:00
f1bf9e7a18 fix(tests): precompute the sort key outside sorted(by:) on Linux
The previous attempt still cast inside the sorted(by:) trailing
closure itself; the relay showed the SAME NSURL/URL inference failure
had moved to the next line (String(contentsOf: url, ...)), confirming
the cast inside that specific closure -- not the earlier named-helper
form -- was still what confused the resolver.

Precomputing (url, name) tuples via a separate map, then sorting on
the plain String name, keeps the sorted(by:) closure itself exactly
as simple as the original working comparison (no cast, no call
inside it), which is what compiled cleanly before any of this.

Verified via a full clean macOS build and the specific test passing,
plus swiftformat/swiftlint --strict clean; the actual claim (Linux
relay passes) needs the next push to confirm.
2026-07-18 23:49:16 +02:00
d29ac65d4d fix(tests): avoid a URL/NSURL bridging mismatch in the sort closure
The prior fix's separate `func name(_ url: URL) -> String` helper,
called as `name($0)`/`name($1)` inside the sort closure, made the
Linux relay fail differently: "'NSURL' is not implicitly convertible
to 'URL'" at both call sites, even though the surrounding loop's
`url` (same array, same element) type-checked fine everywhere else.
Declaring no explicit URL parameter type anywhere and inlining the
`as String? ?? ""` coercion directly at each of the three use sites
sidesteps whatever overload-resolution path the named helper's
concrete parameter type was forcing.

Verified via a full clean macOS build and test run (the specific test
passing) and swiftformat/swiftlint --strict clean; the actual claim
(Linux relay passes) still needs the next push to confirm, since this
Foundation/bridging behavior cannot be reproduced on macOS directly.
2026-07-18 23:46:48 +02:00
d465f500e3 fix(tests): normalize lastPathComponent for Linux Foundation
Darwin's Foundation returns String from URL.lastPathComponent;
swift-corelibs-foundation on this toolchain returns String?. The three
uses in TaggedNodeCorpusRoundTripTests (a sort comparator and two
Testing.Comment interpolations) compiled on Darwin but failed the
Linux build outright, an unrelated pre-existing gap surfaced while
verifying the anchor-expansion-bomb fix on the Linux CI relay.

An explicit `(url.lastPathComponent as String?) ?? ""` coercion makes
both platforms produce a uniform String? before the `??`, rather than
picking one platform's non-optional type and breaking the other.

Verified via a full clean macOS build and test run (674 tests, all
passing) and swiftformat/swiftlint --strict clean.
2026-07-18 23:43:59 +02:00
1bbf715fa5 docs(changelog): note the anchor-expansion bomb fix 2026-07-18 23:36:40 +02:00
20b96fbfd4 fix(parsing): reject anchor-expansion (billion-laughs) bombs
enforceNestingLimit (Parser.Events.swift) bounds PHYSICAL collection
depth in the raw event stream, since it counts sequenceStart/
mappingStart events directly. It does nothing against an
anchor-expansion bomb: a handful of shallow, small anchors each
aliased many times, each alias nested inside the next, keeps raw
event depth at a constant 2 per line while the LOGICAL tree an alias
resolves to grows exponentially in the number of alias levels. A
25-level, fan-out-9 document (~1.5KB) expands past 9^25 logical
nodes; before this fix, parse accepted it in under 2ms, and any
full-tree walk downstream (JSON representability checking, JSON
writing, validation) never terminated.

Added a total expanded-node budget, charged incrementally as the
document streams through the composer rather than by walking
anything: every scalar charges 1, every completed container charges 1
for its own wrapper (children already charged their own cost as they
completed), and every alias resolution charges the target's CACHED
expanded size -- computed once when the anchor was defined, looked up
in O(1). This makes a nested bomb's true compounded size visible
arithmetically within a few alias levels, without ever materializing
or walking the exponential tree it would otherwise produce. A
document with genuinely wide but non-exponential alias reuse (the
same small anchor referenced thousands of times at one level, no
nesting) stays comfortably inside the budget and parses normally.

Verified via the full existing test suite (674 tests, all passing,
zero regressions -- including the YAML Test Suite conformance corpus
and the tagged round-trip corpus), two new tests proving the bomb is
rejected (in under 2ms) and that wide non-exponential fan-out still
parses, and swiftformat/swiftlint --strict clean on all five touched
files.

PureYAML.Parsing.EventComposer and Tagged.EventComposer (two other,
separate composer implementations in this package, used by the
diagnostic-validation and tagged-parsing entry points respectively)
share the same architectural gap and would benefit from the same
fix, but are not reachable from the confirmed exploit path this
change closes -- disclosed, not fixed here.
2026-07-18 23:35:35 +02:00
2fcf54684e fix: correct emitter and tagged round-trip and validation bugs
Seven correctness fixes to the recently landed anchors/styles, canonical,
timestamp/binary, and full-node-key work, each reproduced and covered by a
regression test.

- Stream dump doubled the `---` marker under explicitDocumentStart or
  canonical: the single-document body now renders marker-free and the
  stream path owns marker emission, so a two-document stream reparses as
  two documents, not four.
- Tagged flow-mapping keys used block-context safety, so a key like `a,b`
  emitted bare and reparsed as several entries; flow keys now use the
  flow-aware plain-scalar check.
- Plain scalars ending in `:` reparse as an empty-valued mapping key, so
  both the model and tagged emit paths now fall back to quoting them.
- An alias mapping key pointing at a defined non-scalar anchor raised the
  misleading undefinedAlias; it now raises expectedScalarKey, and a truly
  undefined alias key still raises undefinedAlias.
- Tagged duplicate-key detection keyed on source spelling: it now keys on
  the resolved scalar value plus effective tag, so 0x1 and 1 collide while
  1 and "1" do not and custom-tagged keys stay distinct.
- Dumping a tree nested past the emitter limit overflowed the stack.

- Timestamp now accepts a leap-second value of 60, counted as 60 elapsed
  seconds in the epoch math.
- Tagged.Dumper honors sortKeys in its block and canonical paths for
  all-plain-string-key mappings, matching the model dumper.

Nesting-guard design: dump is public and non-throwing across 90+ call
sites, so making it throwing would be a breaking change. A tree nested
deeper than the parser's own maxNestingDepth cannot round-trip through
PureYAML (the parser rejects it), so no faithful emission exists. Each
dump entry point now measures depth iteratively (no recursion, so the
guard itself cannot overflow) and, past the limit, returns a documented,
reparseable marker document that resolves to null. Under the limit the
existing recursive renderers run byte-identically, so parsed trees and
every realistic hand-built tree are unaffected. This mirrors the parser's
enforceNestingLimit, which bounds depth before any recursive consumer.
2026-07-16 16:29:08 +02:00
e51df57c35 feat(tagged): carry mapping keys as full nodes
Closes #12 and closes #13. Tagged.Pair stores keyNode instead of a
bare string: alias mapping keys parse on the Tagged path (composer
resolves them through the anchors table, dumper re-emits the reference
with the mandatory space before the colon since a colon is a legal
anchor character), key anchors register for later aliases and re-emit,
and a key's presentation style survives dump and reparse. key and
keyTag remain as read-through computed accessors and the string-key
convenience init keeps most call sites source compatible. A style-less
hand-built key keeps the plain-when-safe emission convention. The
corpus round-trip law compares key nodes structurally; alias-key
documents move from rejected to round-tripping.
2026-07-16 15:42:56 +02:00
81e2794c9c docs(changelog): note the validation framework rename 2026-07-12 18:12:57 +02:00
8a3deaebb4 chore(validation): update coverage manifest for the renamed types
The pre-push validation-coverage gate caught the previous commit's renames
(Check/Issue/SubjectContext/BuiltinCheck/AnyCheck) as unclassified new
types and stale old entries. Reclassify them under their new names and add
the new Traversable protocol.
2026-07-12 18:11:40 +02:00
bf2dc4ece7 refactor(validation): rename framework to match the validation idiom
The validation module already implemented the composable-rule idiom under
its own names (Check/Issue/SubjectContext) and a value-type Validator with
duplicated traversal machinery per layer. Renamed the core types to the
canonical names (Validation/ValidationError/ValidationErrorCollection/
ValidationContext/BuiltinValidation/AnyValidation), converted Validator to
a reference-type class with the two-tier default-rule split, and genericized
it over both Subject and Document via a new Traversable protocol so the
model and tagged layers share one walk engine instead of two copies. Wired
the JSON-representability rule set into the same shape and closed the two
missing pieces (unwrap combinator, JSON coverage/config-pin tests).
2026-07-12 18:10:00 +02:00
e1c08260c4 feat(tagged): add typed timestamp and binary scalar accessors
Completes issue #11. Tagged.Scalar.timestampValue parses a !!timestamp
scalar under the YAML 1.1 grammar into the pure-Swift Tagged.Timestamp
(calendar components, timezone offset, secondsSinceEpoch via the
days-from-civil closed form; epoch test expectations measured against
Python's independent implementation). Tagged.Scalar.binaryValue base64
decodes a !!binary scalar into bytes, accepting unpadded and block
scalar wrapped content and rejecting corrupt input whole.

Yams hands back Foundation Date/Data; this package is mechanically
Foundation-free, so the bridges are caller-owned one-liners:
Date(timeIntervalSince1970:) and Data(_:). !!set and !!omap stay
skipped since Model.Mapping already preserves order and duplicates.

Closes #11
2026-07-12 14:20:23 +02:00
8df3f0b8d4 feat(emitting): add canonical mode, document markers, and lineWidth
Completes issue #10. Canonical mode follows libyaml's canonical layout
on both the Model and Tagged dump paths (tags explicit, scalars double
quoted, flow one entry per line, anchors and aliases preserved on the
Tagged path). explicitDocumentStart/End wrap the body in --- and ...
markers. lineWidth wraps overflowing flow collections one entry per
line and folds plain block scalars at single-space boundaries, vetoing
any fold that would manufacture syntax, so values always reparse
unchanged.

Closes #10
2026-07-12 14:13:19 +02:00
58872248d5 fix(scripts): let check-wasm skip when the SDK is absent
check-all.sh defines exit 77 as a non-fatal infrastructure skip and
check-linux.sh already uses it for an unreachable runner, but a missing
wasm SDK hard-failed with exit 2, blocking every push from a machine
without the toolchain. The hosted CI wasm job stays authoritative; a
machine with the SDK installed still fails hard on a real build break.
2026-07-12 13:32:17 +02:00
6fa49b7ffc style: bring four files back under the swiftformat gate
swiftformat --lint failed on these pre-existing files and the pre-push
hook checks the whole tree, blocking every push until they conform.
The wrapped if bodies pushed YAMLTestSuiteGapsTests over the 400-line
lint limit, so its private JSONStreamScanner helper moves to its own
support file.
2026-07-12 13:29:05 +02:00
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
147 changed files with 6127 additions and 1529 deletions

View File

@ -1,17 +1,17 @@
# Managed by rules-swift (scripts/sync-rules.sh). Verifies this repo tracks the
# CURRENT shared-rules baseline. The repo's own .swiftformat/.swiftlint.yml are
# repo-specific (baseline + project rules) and are intentionally NOT byte-checked.
name: rules-conformance
# Managed by rules-swift (scripts/sync-rules.sh). Verifies only that this repo's
# freshness marker matches the current shared-rules version. It does not claim
# semantic rule conformance; each consumer owns and runs its project-specific gates.
name: rules-freshness
on:
push:
pull_request:
workflow_dispatch:
jobs:
rules:
runs-on: macos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rules baseline freshness (VERSION)
- name: Shared-rules freshness marker
run: |
BASE=https://codeberg.org/Mihaela/rules-swift/raw/branch/main
canon=$(curl -fsSL "$BASE/VERSION" | tr -d '[:space:]')

View File

@ -136,9 +136,9 @@ case "$SUBJECT" in
printf ' Rule: commits.md Rule 1\n' >&2
FAIL=1
fi
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|critic-fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
printf 'commit-msg: subject must be Conventional Commits: "type(scope): summary".\n' >&2
printf ' Allowed types: feat fix docs style refactor perf test build ci chore\n' >&2
printf ' Allowed types: feat fix critic-fix docs style refactor perf test build ci chore\n' >&2
printf ' Rule: commits.md Rule 1, Rule 2\n' >&2
FAIL=1
fi

View File

@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and ideas
url: https://codeberg.org/Mihaela/PureYAML/discussions
about: Ask questions, float ideas, or discuss design in Discussions.
url: https://git.aleahim.com/NodetteHQ/PureYAML/issues
about: Ask questions, float ideas, or discuss design in an issue.
- name: Security issue
url: https://codeberg.org/Mihaela/PureYAML/blob/main/SECURITY.md
url: https://git.aleahim.com/NodetteHQ/PureYAML/src/branch/main/SECURITY.md
about: Do not file security issues publicly. Follow the security policy instead.

View File

@ -1 +1 @@
1.1.2
2.14.0

View File

@ -14,5 +14,10 @@ force_cast: error
line_length:
warning: 180
error: 180
# Exhaustive switches over closed enums are the intended way to make
# impossible states unrepresentable; counting their cases as complexity
# pushes code toward split mappings with trapping defaults instead.
cyclomatic_complexity:
ignores_case_statements: true
trailing_comma:
mandatory_comma: true

View File

@ -7,7 +7,469 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
Nothing yet.
### Added
- `Tagged.Pair` now stores its key as a full node (`keyNode`), closing
issues #12 and #13: alias mapping keys (`*b : value`, yaml-test-suite
E76Z/26DV) parse on the `Tagged` path instead of throwing
`expectedScalarKey`, key anchors register and re-emit, and a key's
presentation style (a deliberately quoted `"on":`) survives dump and
reparse. `key: String` and `keyTag` remain as read-through computed
accessors (an alias key resolves to its anchored scalar's text), and
the `init(key:keyTag:value:)` convenience still builds plain string
keys, so most call sites compile unchanged. A style-less hand-built
key keeps the traditional plain-when-safe emission convention rather
than inheriting the document scalar policy. The corpus round-trip law
now compares key nodes structurally, and alias-key documents move
from the rejected column to round-tripping.
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar
components, timezone offset, and `secondsSinceEpoch` via the
days-from-civil closed form; epoch expectations in the tests are
measured against Python's independent implementation), and
`binaryValue` base64-decodes a `!!binary` scalar into `[UInt8]`,
accepting unpadded and block-scalar-wrapped content and rejecting
corrupt input whole. Yams hands back Foundation `Date`/`Data`; this
package is mechanically Foundation-free, so the bridges are one-liners
the caller owns: `Date(timeIntervalSince1970:)` and `Data(_:)`.
`!!set`/`!!omap` are deliberately skipped: `Model.Mapping` already
preserves order and duplicates without a special tag.
- `Emitting.Options` gains `canonical`, `explicitDocumentStart`,
`explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the
earlier `indentWidth`/`sortKeys` slice deliberately deferred these).
Canonical mode emits the YAML-specification canonical form matching
libyaml's layout: explicit `---`, an explicit tag on every node
(carried tags on the `Tagged` path, the value's implicit tag
otherwise), double-quoted scalars, and flow collections with one
`? key : value,` entry per line; anchors and alias references survive
on the `Tagged` path. `lineWidth` wraps flow collections one entry per
line when the single-line form overflows, and folds plain block
scalars at single-space boundaries onto more-indented continuation
lines; a fold that would manufacture syntax (a segment ending in `:`,
a continuation starting with `#` or an indicator) or disturb
consecutive spaces is vetoed and the line stays long, so the value
always survives reparse. Gated by golden-output tests plus
reparse-equivalence tests for every option.
- Anchors, aliases, and per-node presentation styles now survive the
`Tagged` tree (issue #9, the feature-parity audit's biggest blocker to
switching from Yams). `Tagged.Scalar`/`.Sequence`/`.Mapping` gain
`anchor` and `style` fields (`Tagged.ScalarStyle`: plain, single/double
quoted, literal, folded; `Tagged.CollectionStyle`: block, flow), and
`Tagged.Node` gains an `alias(anchor:node:)` case carrying both the
reference and its resolution, so value-oriented consumers read through it
without a resolution step (`modelValueErasingTags` matches `parse(_:)`'s
expansion) while emitters re-emit the reference. Cycles are impossible by
construction: an anchor registers only after its node fully composes, so
a self-referential alias stays an `undefinedAlias` parse error.
- `PureYAML.dump(_ node: Tagged.Node, options:)` and `Tagged.Dumper`: emit
a tagged tree back to YAML with anchors (`&name`), alias references
(`*name`, never expanded copies), tags (`!!short` for built-ins,
`!<verbatim>` otherwise), and per-node styles that win over the
document-wide policy. Literal blocks handle keep-chomping (`|+`),
strip-chomped single lines (`|-`), leading blank lines, tab-led and
whitespace-only interior lines, and whitespace-led first lines via an
explicit indentation indicator (`|2`); folded scalars re-emit as `>` when
the folding rules invert exactly, degrading to a literal block (value
still exact) otherwise. Plain scalars re-emit their source spelling, so
`NO` resolved as a bool does not normalize to `false` and an empty null
stays empty. The round-trip law is corpus-tested over every real-world
fixture: `parseTagged(dump(parseTagged(d)))` equals `parseTagged(d)`
with exactly one documented style degradation (single-quoted content
containing line breaks re-emits double-quoted; the value is unchanged).
- Add `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's first
slice), closing two of the four gaps a feature-parity audit against Yams
found in the emitter (`PureYAMLYamsComparison`'s `FEATURE-PARITY.md`).
`indentWidth` replaces every hardcoded `indent + 2` in `Dumper.swift`/
`Dumper.Mapping.swift` with `indent + options.indentWidth`; verified with
a round-trip test (dump at a non-default width, reparse, confirm the
value survives) since indentation is purely a rendering choice and must
never affect what a document means. `sortKeys` sorts a mapping's pairs by
their string key before rendering, in both block and flow style, but only
when every key in that mapping is a plain string: a mapping with any
`Model.Key.mapping`/`.sequence` key is left in insertion order regardless
of the option, since there is no well-defined lexicographic ordering
across mixed key kinds to sort by. Off by default, since insertion order
is what makes a duplicate-key-preserving `Mapping` round-trip meaningfully
in the first place.
Deliberately scoped to stop here: `width`-based line wrapping and
canonical mode (the other two gaps the audit found) are each a
correctness surface of their own (real line-folding logic for the former,
a defined canonical form for the latter) and remain open, not silently
dropped from this change's scope.
- Reject raw control characters anywhere in the input, matching libyaml's
own reader-level behavior. Found by directly comparing `PureYAML.parse()`
against Yams on a 221MB real-world OpenAPI/compose-file corpus (150
files): a genuinely public file with corrupted, mojibake-looking encoding
contains a raw C0 control character, which Yams's underlying libyaml
rejects unconditionally at the reader level ("control characters are not
allowed"), but `PureYAML.parse()` silently accepted and built a value tree
from. `PureYAML` already had a printable-character predicate matching
libyaml's `c-printable` set (`CharacterClass.isPrintable`), but it was
only ever applied narrowly to anchor names
(`LibYAML.Scanner.Anchor.swift`), never to the input generally; the
`Validation.PreflightScanner`'s separate `invalidControlCharacterColumn`
check exists but is opt-in via `validate()`, not part of what `parse()`
itself runs.
`Reader` now records the index of the first non-printable scalar in
whatever it has buffered (`init`/`append(_:)`, covering both batch and
streaming input), and `fetchNextToken()` throws once actual scanning has
reached that position (gated on `reader.index >= badIndex`, not just "one
exists"), so a real syntax error earlier in the document is not hidden
behind a control character occurring later, matching the order libyaml's
own byte-by-byte reader would surface them in. New `ParseError` case
`.invalidControlCharacter(line:column:)`, translated from the scanner
error the same way `.tabIndentation` already is.
Verified with 5 new tests (a bare control character rejected at the exact
position; a tab in scalar content, and CR/LF, both still legal, since
`isPrintable` already correctly includes them; a control character 5000
lines into a document still caught; an earlier real syntax error not
masked by a later control character) plus a full re-run of the official
yaml-test-suite conformance (still 333/333 parse-level, 208/208
value-level of comparable cases, unchanged) and the whole-suite streaming
differential (still clean), confirming this closes a real gap without
regressing anything already passing. Negligible throughput impact
measured on the existing 9.5MB benchmark fixture (within normal run-to-run
noise).
Disclosed limitation: the check is applied in `fetchNextToken()`, which
`scanStep()`'s plain-scalar resume path (`resumePlainScalar`, the
streaming API) does not route through, so a token containing a control
character can be returned to a streaming caller one token before this
fires. `scanAll()`/`PureYAML.parse()`, the primary target here (matching
what the corpus comparison actually calls), always calls
`fetchNextToken()` again before returning, so it is caught before a batch
parse completes.
- Add a public `PureYAML.Streaming` namespace (issue #4), exposing the
internal `LibYAML.Scanner.scanStep()` proven below for the first time as a
callable public API rather than a module-internal implementation detail.
Needed because `scanStep()` and everything in
`LibYAML.Scanner.Streaming.swift` were internal-only: no consumer outside
the `PureYAML` module, including the separate `PureYAMLStreaming` incubator
package that issue #4 names as this feature's actual intended user, could
call it. `PureYAML.Streaming.Scanner` (`init()`, `feed(_:)`, `finish()`,
`next() throws -> Outcome`) is a thin wrapper with no logic of its own,
translating `scanStep()`'s `ScanOutcome`/internal `LibYAML.Token` into
public `Outcome`/`Streaming.Token` types under public naming that does not
leak the libyaml port's internal type names (`Streaming.Token.Kind` mirrors
`LibYAML.TokenType`'s 22 cases 1:1; `Streaming.Token.ScalarStyle` mirrors
the scanner's internal `Parsing.ScalarStyle`, a distinct 5-case type from
the existing public `Emitting.ScalarStyle`, which is an output rendering
policy, not a scanned style; `Streaming.Token.range` reuses the existing
public `Model.SourceRange`/`SourcePosition` rather than inventing a new
position type). `Token.Kind.init(_:)`'s mapping switch is split across
three chained initializers purely to stay under the project's cyclomatic
complexity lint limit (a mechanical 1:1 case mapping over 22 cases has no
way to be less than 22-branches complex in one function); the split carries
no semantic meaning.
Proven with a new differential test (`PublicStreamingScannerTests.swift`)
distinct from the existing `StreamingScannerDifferentialTests.swift`: that
existing proof covers whether `scanStep()` itself is correct, but does not
exercise the public wrapper or its translation layer at all, so a wrong
case mapping or a dropped field there would not have been caught by it.
The new proof drives the public `Streaming.Scanner` at several chunk sizes
and asserts it matches the batch scanner's own tokens, translated through
the identical `Streaming.Token.init(_:)` the public scanner itself uses,
clean across the whole yaml-test-suite corpus.
Deliberately scoped to stop here: this makes the public API exist and
proves it correct, but does not yet rewire the separate `PureYAMLStreaming`
package's own `ResumableDocumentScanner` (currently a naive line-based
`---` heuristic scanner with no relationship to real YAML grammar) onto it.
That rewiring is real, separate, cross-repo work with its own build/test/CI
cycle, deferred to a follow-up round rather than folded in here.
- Implement persisted mid-token suspension for plain scalars (issue #4), the
deferred piece the previous entry predicted would eliminate the checkpoint
floor entirely. `scanPlainScalar()`'s loop (`LibYAML.Scanner.PlainScalar.swift`)
is now an explicit `PlainScalarPhase` state machine (`.start`,
`.consumingContent`, `.afterContent`, `.consumingBreaks`, `.afterBreaks`)
that throws `PlainScalarSuspended` (carrying its accumulated `PlainState`,
indent, start mark, and phase) the moment a lookahead runs dry, and
`scanStep()` resumes it directly from `Scanner.suspendedPlainScalar` with no
checkpoint at all, rather than rolling back and rescanning the whole token
from its start on every chunk boundary inside it.
Getting this correct took four rounds of bugs, each found by the existing
whole-yaml-test-suite streaming differential test
(`PUREYAML_RUN_YAML_TEST_SUITE=1 swift test --filter
"StreamingScannerDifferentialTests|YAMLTestSuiteGapsTests" --no-parallel`),
none of them hypothetical: (1) checking `reader.ranDry` only once at the
loop's end is not enough, since a nested lookahead
(`documentMarkerAhead()`, the `:`-followed-by-blank check, `blankLineAhead()`)
can independently go dry and produce a wrong-but-plausible answer that gets
used by the next step before an end-of-loop check would ever see the taint;
fixed by checking immediately after every peek-dependent decision, before
its result is used for anything. (2) A resume must never redo a pre-check
that only applies at a fresh entry point (the document-marker/comment check
in `.start`, the blank-or-break guard in `.afterContent`): resuming a
dry-interrupted content or break run via `.start`/`.afterContent` again can
wrongly end a scalar right at a literal `#` or reinterpret an in-progress
break run, since those positions are not guaranteed to be fresh boundaries.
Fixed with dedicated `.consumingContent`/`.consumingBreaks` phases that
re-enter the same in-progress call with no pre-check. (3) `dispatchToken()`
itself has an `isDocumentMarker` pre-check, ahead of ever calling
`fetchPlainScalar()`, that can suffer the identical dry-lookahead problem;
since nothing can undo a wrong dispatch after the fact, `scanStep()` only
trusts a suspend enough to resume it when real content was already
accumulated (`phase == .start` with an empty `state.string` retries from
scratch instead). (4) `blankLineAhead()` inside `consumePlainBreaks` is
itself a multi-character lookahead distinct from the single
already-confirmed `peek()` before it, so it can independently go dry; fixed
by checking `ranDry` right after computing its result, before a
tab-violation decision trusts it.
A synthetic worst-case test was added for this specifically because the
9.5MB real-world fixture used above has no plain scalars long enough to
exercise it (`test_measureLongPlainScalar`, one 1MB single-line plain
scalar). It caught a fifth bug, a real one, not a suspicion: the first
working version of `resumePlainScalar` took the whole `PlainScalarSuspended`
as a by-value parameter and read `var state = suspended.state` inside it,
but `suspended.startMark` being read again at the very end kept the whole
parameter (including its `state.string` reference) alive as an ordinary
borrowed value for the call's entire duration, aliasing `state.string` to
it throughout `runPlainScalarLoop` and forcing copy-on-write to fully copy
the entire accumulated scalar on every single resume; for a scalar rebuilt
across many small chunks that is O(N^2) in the scalar's length, not the
O(N) this feature exists to guarantee, and measured **29.05x** at
chunk-size 16 on the synthetic case, worse than not suspending at all.
Passing `state` `inout` directly, with the alias broken at the call site
(`scanStep()`'s new `resumeSuspendedPlainScalarIfNeeded()`, overwriting
`suspended.state` with a fresh, unrelated value immediately after copying
it out and before ever calling into `resumePlainScalar`) fixed it to
**3.67x** at the same chunk size, with no correctness regression
(whole-suite differential clean in both debug and release, full default
suite of 597 tests passing).
A single before/after data point does not by itself distinguish "fixed" from
"smaller constant, still quadratic", so a second, permanent test
(`test_longPlainScalarStaysLinear`) checks the actual complexity claim: per-
byte cost at a fixed chunk size (16) across a 16x range of scalar lengths
(250KB to 4MB), asserting the largest measured per-byte cost stays under 3x
the smallest (generous headroom over the ~1.08x actually measured; a real
O(N^2) regression would blow well past 3x as length grows). Genuinely O(N)
work should hold per-byte cost flat regardless of total length, which this
confirms rather than assumes from the single 1MB case above.
Measured honestly rather than assumed to have delivered the predicted win:
on the 9.5MB real-world fixture, the checkpoint floor and rescan-driven
tail are statistically unchanged from before this feature existed
(floor 1.75x versus the prior entry's 1.75x-1.76x; 10.74x at chunk-size 16
versus the prior entry's 11.54x, both within measurement noise across
runs). The prior entry's prediction that mid-token suspension "would
eliminate the floor too" is corrected here: it eliminates rescanning
specifically for plain scalars that themselves span a chunk boundary, but
`github-rest-api.yaml`'s plain scalars are short enough that this was
never a meaningful contributor to its measured floor in the first place,
and the floor's actual cause (a `Scanner` struct copy taken on every
`fetchNextToken()` call whenever `!isFinal`, regardless of token type,
established in the entry below) is untouched by a plain-scalar-specific
fix. The synthetic long-scalar test above is the fixture that actually
demonstrates this feature's value (a 29x-worse-than-no-suspension bug,
fixed down to 3-4x); the real-world fixture simply does not have the
shape of input this feature targets.
- Add `Tests/StreamingScannerPerformanceTests.swift` (issue #4), measuring
`scanStep()`'s overhead over `scanAll()` on the largest real-world fixture
(github-rest-api.yaml, 9.5MB) across a spread of chunk sizes, gated behind
`PUREYAML_STREAMING_PERF_MEASURE=1`. First pass ran in debug mode (release
test builds hit an unrelated `PureYAMLJSON` test-target issue) and showed a
~1.5x floor even in a control with `isFinal: true` from construction, where
`ranDry` can never be set and no rollback ever happens; fixed by skipping the
checkpoint entirely in that case, which only closed part of the gap in debug
numbers (1.49x to 1.38x).
`sample`(1) profiling of that debug build pointed at `mutating throws`
methods on multi-field structs (`consumePlainContent`, called from every
plain scalar), which is a known trigger for defensive retain/copy traffic
under `-Onone` that the optimizer removes in release. Confirmed by working
around the release-test-build issue with `-Xswiftc -enable-testing`: in
release, the `isFinal: true` control measured **1.04x**, essentially batch
parity, meaning the checkpoint-skip fix was actually a full fix for that
case, not the small, unexplained one it looked like in debug mode.
What remains splits into two distinct costs, decomposed by one more
measurement rather than assumed: feeding the whole 9.5MB file as a single
chunk (`isFinal` stays false until the true end, so at most one token in the
entire file could ever actually be rescanned) still measured 1.75x, the same
as 1,000,000-scalar chunks (1.74x, ~10 chunks) and 65536-scalar chunks
(1.74x). Real rescanning is not the floor's cause, since there is
essentially no rescanning to have caused it here. The floor is the cost of
copying the whole `Scanner` on every `fetchNextToken()` call whenever
`isFinal` is false, whether or not that copy is ever used to roll back;
since a chunked run's `isFinal` only becomes true once, on the last chunk,
that copy is taken for virtually every token in the file. Only *shrinking*
chunk sizes below roughly 4096 scalars adds a second, genuinely
rescan-driven cost on top of that floor (11.54x at 16 scalars).
Two follow-up checks, not assumed: ruled out buffer-population strategy as a
contributor (`Reader.init`'s `Array(string.unicodeScalars)` versus
`Reader.append(_:)`'s `append(contentsOf:)` measured 1.00x against each
other on the same 9.5MB string, no scanning involved), and re-profiled with
`sample`(1) on the release build this time (the first profile, in the entry
above, was debug-only and already shown to be misleading for magnitude; it
turns out to also be misleading for location). The release profile's
hottest symbols are String-building-adjacent
(`_StringGuts.append`, `UnicodeScalarView.distance(from:to:)`,
`consumePlainContent`) rather than a separately named "copy of Scanner"
symbol, most likely because the optimizer inlines the checkpoint's struct
copy directly into `scanStep()` (190 samples on its own, disproportionate to
its few lines of source) rather than outlining it as in debug, and the extra
live reference the checkpoint holds to `indentEngine.tokens` forces
conservative uniqueness handling that shows up as slower string mutation
nearby rather than as its own line item.
The structural cause, confirmed by an A/B the project already had (not a
new one): the checkpoint code path is still the only difference between the
1.04x `isFinal: true` control and the 1.75x-1.76x single-chunk/large-chunk
results, both driven through the identical per-token loop. This corrects the
previous entry's conclusion in an important way: the deferred persisted mid-
token suspension would not merely eliminate the rescan-driven tail while
leaving the floor untouched, it would eliminate the floor too, since it
removes the need for a checkpoint entirely rather than making one cheaper.
A cheaper checkpoint, absent mid-token suspension, 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 of fields is obviously
safe to skip, and `isFinal: true` remains the only provably safe condition
for skipping it outright.
- Broaden the real-seed mutation property suite (issue #6) with three mutation
classes named in that issue but not yet covered: `invalidControlCharacter`
(a raw control byte injected into a line), `mixedTabSpaceIndentation` (a tab
after leading spaces, distinct from the existing pure-tab case), and
`truncatedFlowCollection` (an unterminated `[a, b, c` appended after the real
seed sample, exercising the deep parser's `malformed` error rather than the
preflight scanner). The test now also asserts diagnostic quality, not just
pass/fail: every matching diagnostic's `line` must fall within the mutated
document, and its `column`, when present, must be 1-based. Some `ParseError`
cases (`.tabIndentation`) are line-only by design, so column is checked when
present rather than required.
- Add a resumable, one-token-at-a-time scanner entry point, `Scanner.scanStep()`
(internal), the first increment of true chunked input streaming (issue #4).
`Reader` gains `isFinal`/`append(_:)`/`markFinal()`: past the buffered end while
`!isFinal`, `peek(_:)` flags `ranDry` instead of asserting true end of input.
`scanStep()` snapshots the scanner before each `fetchNextToken()` and restores it
in full whenever a fetch runs dry, so a fetch spanning a chunk boundary is
retried from scratch after more input arrives rather than misread as an early
end of input. Every existing call site keeps `isFinal: true` (the default), so
`scanAll()` and all batch parsing are unchanged; `ranDry` can never be set when
`isFinal` is true, by construction, not just by test.
A token already queued is not always safe to release: libyaml's simple-key
mechanism can still splice a KEY token in front of it once a later `:` is found,
so `scanStep()` ports libyaml's `yaml_parser_fetch_more_tokens` release
condition, holding the queue front until no possible simple key still targets
that position.
Proof: `Tests/StreamingScannerDifferentialTests.swift` drives `scanStep()` at
several chunk sizes (1, 2, 3, 7, 64, whole-input) over representative cases
always, and over the entire yaml-test-suite manifest (402 cases) behind
`PUREYAML_RUN_YAML_TEST_SUITE=1`, asserting the resulting token sequence (or
thrown error) is byte-identical to `scanAll()`.
That proof surfaced a real bug, now fixed: `lastTokenForbidsInlineSequence()`
(`LibYAML.Scanner.Guards.swift`) read `indentEngine.tokens.last?.type` to check
whether a `key: - a` value indicator bars an inline block sequence. Batch mode
never dequeues, so the queue never empties and `.last` always works; `scanStep()`
releases and discards tokens as soon as they are safe, so the queue can be empty
exactly when this check runs. `IndentEngine` now tracks `lastToken` explicitly,
kept in sync with `tokens.last` after every enqueue/insert/roll/unroll, which is
provably identical to the old `tokens.last` read in batch mode (nothing is ever
dequeued there) and survives dequeuing in streaming mode.
Not yet wired into `PureYAMLStreaming`: `scanStep()` and its supporting types are
internal, and true mid-scalar suspension (a plain/block/flow scalar spanning a
chunk boundary currently re-scans from the last released token rather than
resuming mid-token) is deferred, tracked at
<https://git.aleahim.com/NodetteHQ/PureYAML/issues/4>.
### Changed
- The validation framework's public types are renamed to match the
OpenAPIKit validation idiom's canonical names: `Check` is now
`Validation`, `Issue` is `ValidationError`, `Issue.Collection` is the
top-level `ValidationErrorCollection` (with `.issues` renamed to
`.values`), `SubjectContext` is `ValidationContext`, `BuiltinCheck` is
`BuiltinValidation`, and `AnyCheck` is `AnyValidation`. `ValidationError.description`
drops its severity prefix and renders `"<reason> at root of document"` /
`"<reason> at path: <path>"`. `Validator` is now a reference-type
`final class` with a two-tier `nonReferenceDefaultValidations`/
`referenceDefaultValidations` split, generic over both the walked
`Subject` type and the cross-cutting `Document` via a new `Traversable`
protocol; `Tagged.Validator` is now a type alias over the same shared
class instead of its own duplicated traversal implementation. The JSON
representability rule set (`PureYAMLJSON`) is built with the same tiered
constructor. Adds the `unwrap` combinator.
- `parseTagged(_:)`/`parseTaggedStream(_:)` now preserve alias references
as `alias` nodes instead of expanding them into copies of the anchored
node, and populate the new `anchor`/`style` fields from the source.
Consumers switching exhaustively over `Tagged.Node` gain a case;
`Tagged.NodeKind` gains `.alias`. Resolved values are unchanged:
`modelValueErasingTags` and `Tagged.Constructor` read through aliases.
`Tagged.Validator` walks each anchored subtree once at its definition
site and does not re-walk it per alias reference.
### Fixed
- A plain scalar that starts with `0` and continues with a sign is text, not an
octal integer. `Int(_, radix:)` accepts a leading sign, so `0-3` (a CPU set, a
port range) read as the integer -3, and `0x-1`, `0b-1` and `0o-1` as negative
numbers. After a radix prefix only digits may follow. Octal (`0755`),
hexadecimal, binary and signed integers read as before.
- A string the emitter wrote without quotes could be read back as something
else, so the string `yes` (and `no`, `on`, `off` in every case), `0x1F`,
`0b1`, `0o17`, `1_000`, `.inf` and `.nan` did not survive a dump and parse.
`PureYAML.Parsing.PlainScalar` is now the one definition of how a plain
scalar reads, and the parser and the emitter both take it from there; the
emitter quotes every string that does not read back as itself. Also quoted
now: `<<` (the merge key) and `...` (the document end marker), and a string
with a CR+LF break, which was written raw because Swift counts CR+LF as one
character. `PlainScalarRoundTripTests` holds 128 awkward strings at the
root, as a mapping key and value, and as a sequence item, in block and flow
style. `PureYAML.JSON.Convert` no longer refuses `{"s":"yes"}`.
- Stream dump no longer doubles the `---` document marker under
`explicitDocumentStart` or `canonical`. The stream path now owns marker
emission and renders each document body marker-free, so a two-document
stream reparses as two documents instead of four (an empty document
ahead of each real one).
- `Tagged` flow-mapping keys now use flow-aware plain-scalar safety, so a
key containing a flow delimiter (`a,b`, `[`, `{`) is quoted instead of
emitted bare, which previously reparsed as several entries.
- Plain scalars ending in `:` now fall back to quoting on both the model
and `Tagged` emit paths. A trailing colon reparses as a mapping key
with an empty value, so the emitter must never leave one plain.
- A mapping key that is an alias to a defined but non-scalar anchor now
raises `expectedScalarKey` instead of the misleading `undefinedAlias`;
a genuinely undefined alias key still raises `undefinedAlias`.
- `Tagged` duplicate-key detection now keys on the resolved scalar value
plus effective tag rather than the source spelling, so `0x1` and `1`
(both `int(1)`) are reported as duplicates while `1` (`int`) and `"1"`
(`str`) are not, and custom-tagged keys stay distinct.
- Dumping a value nested deeper than the emitter's nesting limit (the
same bound the parser enforces) now returns a documented, reparseable
marker document instead of overflowing the stack. Such a value cannot
round-trip through PureYAML's own parser, so it is refused rather than
emitted as YAML the parser would reject.
- `Tagged.Timestamp` accepts a leap-second value of `60` per the YAML 1.1
timestamp grammar, treating it as 60 elapsed seconds in the epoch math.
- `Tagged.Dumper` honors `sortKeys` in its block and canonical paths for
mappings whose keys are all plain, untagged, unanchored string scalars,
matching the model dumper's `orderedPairs` rule.
- `parse`/`parseStreaming` now reject an anchor-expansion ("billion
laughs") bomb instead of accepting it in milliseconds and handing a
caller a value whose logical size is exponential in the alias nesting
depth. `enforceNestingLimit` bounds physical collection depth in the
raw event stream, which does nothing against a handful of shallow,
small anchors each aliased many times with each alias nested inside
the next: a 25-level, fan-out-9 document (~1.5KB) expands past 9^25
logical nodes while raw event depth stays at 2. `EventStreamComposer`
now charges a total expanded-node budget incrementally as the document
streams through -- every scalar, alias resolution, and completed
container charges its own marginal cost, with each anchor's expanded
size cached at definition and looked up in O(1) when later aliased --
so a bomb is rejected within a few alias levels without ever
materializing or walking the exponential tree it would otherwise
produce. A document with genuinely wide but non-exponential alias
reuse (the same anchor referenced thousands of times at one level, no
nesting) still parses normally.
- `TaggedNodeCorpusRoundTripTests` now normalizes `URL.lastPathComponent`
through an explicit `as String?` coercion before use: this toolchain's
swift-corelibs-foundation returns `String?` where Darwin's Foundation
returns `String`, and the mismatch failed the Linux build outright
(an unrelated pre-existing gap surfaced while verifying the
anchor-expansion fix above on the Linux CI relay).
## [0.2.0] - 2026-07-05

View File

@ -1,11 +1,11 @@
# PureYAML
[![Gates](https://codeberg.org/Mihaela/PureYAML/actions/workflows/gates.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=gates.yml)
[![macOS](https://codeberg.org/Mihaela/PureYAML/actions/workflows/macos.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=macos.yml)
[![Linux](https://codeberg.org/Mihaela/PureYAML/actions/workflows/linux.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=linux.yml)
[![Windows](https://codeberg.org/Mihaela/PureYAML/actions/workflows/windows.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=windows.yml)
[![WASM](https://codeberg.org/Mihaela/PureYAML/actions/workflows/wasm.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=wasm.yml)
[![Conformance](https://codeberg.org/Mihaela/PureYAML/actions/workflows/conformance.yml/badge.svg?branch=main)](https://codeberg.org/Mihaela/PureYAML/actions?workflow=conformance.yml)
[![Gates](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/gates.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=gates.yml)
[![macOS](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/macos.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=macos.yml)
[![Linux](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/linux.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=linux.yml)
[![Windows](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/windows.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=windows.yml)
[![WASM](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/wasm.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=wasm.yml)
[![Conformance](https://git.aleahim.com/NodetteHQ/PureYAML/actions/workflows/conformance.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=conformance.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
PureYAML is a dependency-free YAML package written entirely in Swift.
@ -20,69 +20,6 @@ C-backed parsers. The package is intentionally strict about portability:
- root Swift package layout
- macOS, Linux, Windows, and WASI build gates
## Roadmap
Mermaid status legend:
The roadmap uses the TileDown Mermaid palette: green for merged work, yellow for
review, purple for epic grouping, and gray for open work with no PR.
```mermaid
flowchart TB
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
%% Completed parser replacement epics: #1 #8 #9 #10 #11 #12 #13
LDone["In main now"]:::done
LReview["PR in review"]:::review
LEpic["Epic grouping"]:::epic
LTodo["Open issue, no PR"]:::todo
LDone --> LReview
LReview --> LEpic
LEpic --> LTodo
```
Parser Replacement Roadmap #8 is complete in main.
Production Hardening and Validation Readiness #54 is active. PureYAML now has
checked-corpus Yams parse-success parity, structured diagnostics and validation,
JSON compatibility proof gates, internal token/event streaming, real-corpus
property tests, and repeatable performance comparison artifacts. Remaining work
is concentrated in downstream integration, true chunked input streaming, broader
malformed fixtures, and continued throughput optimization:
```mermaid
flowchart TB
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
E54["#54 Production hardening epic"]:::epic
I49["#49 Structured validation reports"]:::done
I50["#50 False-positive diagnostic triage"]:::done
I51["#51 Bad-YAML mutation suite"]:::todo
I55["#55 Corpus fuzz and property gates"]:::done
I44["#44 Humongous YAML corpus gate"]:::done
I45["#45 Yams execution and diagnostics comparison"]:::done
I47["#47 Yams-parseable OpenAPI gaps"]:::done
I52["#52 OpenAPIDoctor integration dry run"]:::todo
I53["#53 Yams differential regression gate"]:::done
I43["#43 True chunked input streaming"]:::todo
I48["#48 Parser throughput toward libyaml parity"]:::todo
E54 --> I49
I49 --> I50
I50 --> I51
I51 --> I55
I55 --> I44
I44 --> I45
I45 --> I47
I47 --> I53
I53 --> I52
I52 --> I43
I43 --> I48
```
## Status
The current release is 0.2.0. It includes block mappings, block sequences,
@ -111,6 +48,18 @@ Unsupported gaps are pinned by executable tests so they produce exact errors,
exact validation issues, or exact fallback value trees instead of silent parser
drift.
PureYAML now has checked-corpus Yams parse-success parity, structured
diagnostics and validation, JSON compatibility proof gates, internal
token/event streaming, real-corpus property tests, and repeatable performance
comparison artifacts. Remaining work is tracked under the
[production hardening and validation readiness](https://git.aleahim.com/NodetteHQ/PureYAML/issues/8)
epic:
[true chunked input streaming](https://git.aleahim.com/NodetteHQ/PureYAML/issues/4),
[throughput toward libyaml parity](https://git.aleahim.com/NodetteHQ/PureYAML/issues/5),
[a broader bad-YAML mutation suite](https://git.aleahim.com/NodetteHQ/PureYAML/issues/6),
and an
[OpenAPIDoctor integration dry run](https://git.aleahim.com/NodetteHQ/PureYAML/issues/7).
## Attribution
PureYAML is informed by Yams and its bundled `CYaml` / libyaml-derived parser,
@ -308,7 +257,6 @@ bash scripts/check-style.sh
bash scripts/check-namespacing.sh
bash scripts/check-forbidden-patterns.sh
bash scripts/check-changelog-touched.sh
bash scripts/check-roadmap.sh
swiftformat . --config .swiftformat --lint
swiftlint --config .swiftlint.yml --strict
swift build

View File

@ -36,13 +36,23 @@ public extension PureYAML {
Emitting.Dumper(options: options).dump(documents)
}
/// Serializes a tag-preserving node tree into YAML, re-emitting anchors,
/// aliases (as `*name` references, not expanded copies), explicit tags,
/// and per-node presentation styles.
static func dump(
_ node: Tagged.Node,
options: Emitting.Options = .default,
) -> String {
Tagged.Dumper(options: options).dump(node)
}
/// Validates a parsed YAML value with the default validation rules.
@discardableResult
static func validate(
_ value: Model.Value,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
strict: Bool = true,
) throws -> [Validation.Issue] {
) throws -> [Validation.ValidationError] {
try validator.validate(value, strict: strict)
}
@ -50,7 +60,7 @@ public extension PureYAML {
@discardableResult
static func validate(
_ documents: [Stream.Document],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
strict: Bool = true,
) throws -> [Stream.Issue] {
try validator.validate(documents, strict: strict)

View File

@ -79,20 +79,20 @@ extension PureYAML.Decoding {
_ value: PureYAML.Model.Value,
codingPath: [any CodingKey],
) throws {
let result = PureYAML.Validation.Validator().collect(value)
guard !result.issues.isEmpty else {
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
guard !result.values.isEmpty else {
return
}
let pathPrefix = PureYAML.Validation.Path(codingPath: codingPath)
let issues = result.issues.map { issue in
PureYAML.Validation.Issue(
severity: issue.severity,
reason: issue.reason,
path: .init(pathPrefix.components + issue.path.components),
let errors = result.values.map { error in
PureYAML.Validation.ValidationError(
severity: error.severity,
reason: error.reason,
path: .init(pathPrefix.components + error.path.components),
)
}
throw PureYAML.Validation.Issue.Collection(issues)
throw PureYAML.Validation.ValidationErrorCollection(errors)
}
}

View File

@ -0,0 +1,93 @@
extension PureYAML.Emitting.Dumper {
/// The YAML-specification canonical form, matching libyaml's canonical
/// emitter layout: an explicit `---` document start, an explicit tag on
/// every node, double-quoted scalars, and flow collections with one
/// entry per line, mapping entries in the explicit `? key : value,`
/// form. Tags derive from each value's case, the same resolution the
/// parser applies in reverse.
func canonicalDocument(_ value: PureYAML.Model.Value) -> String {
"---\n" + canonicalBody(value)
}
/// The canonical body without the leading `---` marker, so the marker is
/// owned by the single-document and stream paths, not baked into the body.
func canonicalBody(_ value: PureYAML.Model.Value) -> String {
canonicalLines(value, indent: 0).joined(separator: "\n") + "\n"
}
func canonicalLines(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
switch value {
case let .sequence(values):
return canonicalSequenceLines(values, indent: indent)
case let .mapping(mapping):
return canonicalMappingLines(mapping, indent: indent)
default:
return [prefix + canonicalScalar(value)]
}
}
func canonicalSequenceLines(
_ values: [PureYAML.Model.Value],
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
guard !values.isEmpty else {
return [prefix + "!!seq []"]
}
var lines = [prefix + "!!seq ["]
for value in values {
var entry = canonicalLines(value, indent: indent + options.indentWidth)
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "]")
return lines
}
func canonicalMappingLines(
_ mapping: PureYAML.Model.Mapping,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
guard !mapping.pairs.isEmpty else {
return [prefix + "!!map {}"]
}
let inner = indent + options.indentWidth
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + "!!map {"]
for pair in orderedPairs(mapping) {
var keyLines = canonicalLines(pair.keyNode.value, indent: inner)
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
lines.append(contentsOf: keyLines)
var valueLines = canonicalLines(pair.value, indent: inner)
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
valueLines[valueLines.count - 1] += ","
lines.append(contentsOf: valueLines)
}
lines.append(prefix + "}")
return lines
}
/// A canonical scalar: the value's implicit tag made explicit, content
/// double-quoted so no resolution ambiguity survives.
func canonicalScalar(_ value: PureYAML.Model.Value) -> String {
switch value {
case .null:
"!!null \"null\""
case let .bool(flag):
"!!bool \"\(flag ? "true" : "false")\""
case let .int(number):
"!!int \"\(number)\""
case let .double(number):
"!!float \"\(number)\""
case let .string(string):
"!!str " + quote(string)
case .sequence, .mapping:
""
}
}
}

View File

@ -8,11 +8,30 @@ extension PureYAML.Emitting.Dumper {
return [prefix + "{}"]
}
return mapping.pairs.flatMap { pair in
return orderedPairs(mapping).flatMap { pair in
renderMappingPair(pair, indent: indent)
}
}
/// `mapping.pairs` in insertion order, or sorted by their rendered
/// string-keyed form when `options.sortKeys` is set. Only string keys
/// are sortable this way; a mapping with any non-string key is left in
/// insertion order regardless of the option, since there is no
/// well-defined lexicographic ordering across mixed key kinds
/// (`Model.Key.mapping`/`.sequence`) to sort by.
func orderedPairs(_ mapping: PureYAML.Model.Mapping) -> [PureYAML.Model.Pair] {
guard options.sortKeys, mapping.pairs.allSatisfy({ pair in
if case .string = pair.keyNode {
true
} else {
false
}
}) else {
return mapping.pairs
}
return mapping.pairs.sorted { $0.key < $1.key }
}
func renderMappingPair(
_ pair: PureYAML.Model.Pair,
indent: Int,
@ -36,7 +55,7 @@ extension PureYAML.Emitting.Dumper {
switch pair.value {
case .mapping, .sequence:
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + options.indentWidth)
default:
return renderMappingScalarPair(pair, indent: indent)
}
@ -54,11 +73,11 @@ extension PureYAML.Emitting.Dumper {
return [prefix + "? " + pair.keyNode.flowDescription]
+ [prefix + ":"]
+ render(pair.value, indent: indent + 2)
+ render(pair.value, indent: indent + options.indentWidth)
}
func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String {
let pairs = mapping.pairs
let pairs = orderedPairs(mapping)
.map { pair in
"\(renderFlowKey(pair.keyNode)): \(renderFlow(pair.value))"
}

View File

@ -0,0 +1,77 @@
extension PureYAML.Emitting {
/// The deepest collection nesting the recursive renderers walk before a
/// value is refused. It matches the parser's ``PureYAML/Parsing/Parser/maxNestingDepth``
/// exactly: a value nested deeper than the parser accepts can never round-trip
/// through PureYAML, so emitting it would produce YAML the paired parser
/// rejects. Parsed trees are always within this bound, so real round-trips
/// are unaffected.
static var maxNestingDepth: Int {
PureYAML.Parsing.Parser.maxNestingDepth
}
/// The reparseable marker returned for a value nested past ``maxNestingDepth``.
/// It is a valid YAML document that resolves to `null`, with a comment that
/// names the refused limit, so a caller never receives a crash or silent
/// truncation.
static var nestingLimitDocument: String {
"# value nesting exceeds the emitter limit of \(maxNestingDepth); not serialized\nnull\n"
}
/// Iteratively (no recursion, so this cannot itself overflow) reports whether
/// a model value nests collections deeper than ``maxNestingDepth``. Complex
/// keys count toward depth like values; scalars are leaves.
static func exceedsNestingLimit(_ value: PureYAML.Model.Value) -> Bool {
var stack: [(value: PureYAML.Model.Value, depth: Int)] = [(value, 1)]
while let (node, depth) = stack.popLast() {
switch node {
case let .sequence(values):
if depth > maxNestingDepth {
return true
}
for child in values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode.value, depth + 1))
}
case .null, .bool, .int, .double, .string:
continue
}
}
return false
}
/// Iteratively reports whether a tagged node nests collections deeper than
/// ``maxNestingDepth``. An alias renders as a `*name` reference, not an
/// expanded subtree, so it is a leaf here and its aliased node is not walked.
static func exceedsNestingLimit(_ node: PureYAML.Tagged.Node) -> Bool {
var stack: [(node: PureYAML.Tagged.Node, depth: Int)] = [(node, 1)]
while let (current, depth) = stack.popLast() {
switch current {
case let .sequence(sequence):
if depth > maxNestingDepth {
return true
}
for child in sequence.values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode, depth + 1))
}
case .scalar, .alias:
continue
}
}
return false
}
}

View File

@ -0,0 +1,188 @@
// MARK: - Scalar analysis
extension PureYAML.Emitting.Dumper {
func canRenderLiteralBlockString(_ value: String) -> Bool {
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
return false
}
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
guard !contentSource.isEmpty else {
return false
}
return contentSource
.split(separator: "\n", omittingEmptySubsequences: false)
.allSatisfy(canRenderLiteralBlockLine)
}
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
return false
}
let value = String(line)
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
return false
}
guard let first = line.first else {
return false
}
return canRenderLiteralBlockLineStarting(with: first, line: line)
}
func canRenderLiteralBlockLineStarting(
with first: Character,
line: Substring,
) -> Bool {
switch first {
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
false
case "-":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case "?":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case ":":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
default:
true
}
}
func hasOnlyIndicator(
_ indicator: Character,
line: Substring,
) -> Bool {
line.count == 1 && line.first == indicator
}
func containsCommentStart(_ value: String) -> Bool {
var previousWasWhitespace = true
for character in value {
if character == "#", previousWasWhitespace {
return true
}
previousWasWhitespace = character.isWhitespace
}
return false
}
func containsMappingSeparator(_ value: String) -> Bool {
var previousWasColon = false
for character in value {
if previousWasColon, isMappingSeparatorBoundary(character) {
return true
}
previousWasColon = character == ":"
}
return previousWasColon
}
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
character.isWhitespace
|| character == ","
|| character == "]"
|| character == "}"
}
func canRenderPlainString(_ value: String) -> Bool {
guard !value.isEmpty else {
return false
}
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
return false
}
// By unicode scalar: CR+LF is one Character in Swift, so `contains("\n")` is false for it and a string with a CRLF break would be written raw.
guard !value.unicodeScalars.contains(where: { $0 == "\n" || $0 == "\r" || $0 == "\t" }) else {
return false
}
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
return false
}
guard let first = value.first, !isPlainScalarIndicator(first) else {
return false
}
// A trailing `:` reparses as a mapping key with an empty value, so a
// scalar that ends in one cannot render plain even though no `:` is
// followed by a space inside it.
guard value.last != ":" else {
return false
}
// `<<` as a key is the merge key, and `...` at the start of a line ends the document.
guard value != "<<", value != "...", !value.hasPrefix("... ") else {
return false
}
return !containsColonSpace(value) && !value.contains("#")
}
func canRenderFlowPlainString(_ value: String) -> Bool {
canRenderPlainString(value) && !containsFlowDelimiter(value)
}
func containsFlowDelimiter(_ value: String) -> Bool {
value.contains(",")
|| value.contains("[")
|| value.contains("]")
|| value.contains("{")
|| value.contains("}")
}
func containsColonSpace(_ value: String) -> Bool {
var previousWasColon = false
for character in value {
if previousWasColon, character == " " {
return true
}
previousWasColon = character == ":"
}
return false
}
/// A word the parser reads as something other than a string (null, a boolean, a number): written plain, a string of it would come back changed.
func isReservedPlainScalar(_ value: String) -> Bool {
!PureYAML.Parsing.PlainScalar.readsAsString(value)
}
func isNumberLikePlainScalar(_ value: String) -> Bool {
if Int(value) != nil, !value.contains(".") {
return true
}
if value.contains(".") || value.contains("e") || value.contains("E") {
return Double(value) != nil
}
return false
}
func isPlainScalarIndicator(_ character: Character) -> Bool {
"-?:,[]{}#&*!|>'\"%@`".contains(character)
}
func escapeKey(_ key: String) -> String {
canRenderPlainString(key) ? key : quote(key)
}
func quote(_ value: String) -> String {
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
// single extended grapheme cluster in Swift, so a Character-level switch
// would match neither "\r" nor "\n" and emit the line break unescaped,
// which does not round-trip through the folding parser.
var output = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"":
output += "\\\""
case "\\":
output += "\\\\"
case "\n":
output += "\\n"
case "\r":
output += "\\r"
case "\t":
output += "\\t"
default:
output.unicodeScalars.append(scalar)
}
}
output += "\""
return output
}
}

View File

@ -1,9 +1,18 @@
public extension PureYAML.Emitting.Dumper {
/// Serializes indexed YAML stream documents with explicit document starts.
extension PureYAML.Emitting.Dumper {
/// Serializes a stream of documents. The stream path owns the document
/// markers: it emits one `---` start per document and, when
/// `explicitDocumentEnd` is set, one `...` end per document. Each document
/// body is rendered marker-free so `explicitDocumentStart` and `canonical`
/// cannot stack a second `---` onto the separator, which previously turned
/// every document into two (an empty one plus the real one) on reparse.
func dump(_ documents: [PureYAML.Stream.Document]) -> String {
documents
.map { document in
"---\n" + dump(document.value)
let body = PureYAML.Emitting.exceedsNestingLimit(document.value)
? PureYAML.Emitting.nestingLimitDocument
: documentBody(document.value)
let end = options.explicitDocumentEnd ? "...\n" : ""
return "---\n" + body + end
}
.joined()
}

View File

@ -0,0 +1,147 @@
extension PureYAML.Emitting.Dumper {
/// Width-aware flow rendering: when a flow collection's single-line
/// form (with its indent) overflows `lineWidth`, it re-renders with one
/// entry per line, recursively, which is equally valid flow YAML. With
/// wrapping disabled or content that fits, the single-line form is
/// unchanged.
func renderFlowWrappedDocument(_ value: PureYAML.Model.Value) -> String {
renderFlowWrappedLines(value, indent: 0).joined(separator: "\n") + "\n"
}
func renderFlowWrappedLines(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let single = renderFlow(value)
if indent + single.count <= options.lineWidth {
return [prefix + single]
}
switch value {
case let .sequence(values):
guard !values.isEmpty else {
return [prefix + "[]"]
}
var lines = [prefix + "["]
for item in values {
var entry = renderFlowWrappedLines(item, indent: indent + options.indentWidth)
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "]")
return lines
case let .mapping(mapping):
guard !mapping.pairs.isEmpty else {
return [prefix + "{}"]
}
let inner = indent + options.indentWidth
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + "{"]
for pair in orderedPairs(mapping) {
let key = renderFlowKey(pair.keyNode)
var entry = renderFlowWrappedLines(pair.value, indent: inner)
entry[0] = innerPrefix + key + ": " + String(entry[0].dropFirst(innerPrefix.count))
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "}")
return lines
default:
// A scalar has no flow break points; an overlong one stays on
// its single line.
return [prefix + single]
}
}
/// Folds an overlong plain scalar line at single-space boundaries onto
/// more-indented continuation lines. Parsing folds each break back to
/// exactly one space, so the value is unchanged. Returns `nil` when
/// wrapping is off, the line fits, the scalar did not render plain, or
/// no safe break point exists.
func foldedPlainScalarLine(
head: String,
value: PureYAML.Model.Value,
rendered: String,
indent: Int,
) -> [String]? {
guard options.lineWidth > 0, head.count + rendered.count > options.lineWidth else {
return nil
}
guard case let .string(string) = value, rendered == string else {
return nil
}
let continuationIndent = indent + options.indentWidth
let continuationPrefix = String(repeating: " ", count: continuationIndent)
let segments = foldSegments(
string,
firstBudget: options.lineWidth - head.count,
continuationBudget: options.lineWidth - continuationIndent,
)
guard segments.count > 1, foldSegmentsAreSafe(segments) else {
return nil
}
var lines = [head + segments[0]]
for segment in segments.dropFirst() {
lines.append(continuationPrefix + segment)
}
return lines
}
/// A fold boundary must not manufacture YAML syntax: a segment ending
/// in `:` would put a colon before a line break (a value indicator), a
/// continuation line starting with `#` would open a comment (the fold
/// break is whitespace), and one starting with `-`, `?`, or `:` before
/// a space (or alone) would read as a block indicator. Any unsafe
/// boundary vetoes the whole fold; the line stays long.
func foldSegmentsAreSafe(_ segments: [String]) -> Bool {
for (index, segment) in segments.enumerated() {
if index < segments.count - 1, segment.hasSuffix(":") {
return false
}
guard index > 0 else {
continue
}
if segment.hasPrefix("#") {
return false
}
if let first = segment.first, "-?:".contains(first) {
let second = segment.dropFirst().first
if second == nil || second == " " {
return false
}
}
}
return true
}
/// Splits at spaces whose neighbors are non-space, so no segment gains
/// a leading or trailing space and every fold restores to exactly one
/// space on reparse. A word longer than the budget stays whole.
func foldSegments(
_ string: String,
firstBudget: Int,
continuationBudget: Int,
) -> [String] {
var segments: [String] = []
var current = ""
var budget = firstBudget
for word in string.split(separator: " ", omittingEmptySubsequences: false) {
if current.isEmpty {
current = String(word)
} else if current.count + 1 + word.count <= budget || word.isEmpty || current.hasSuffix(" ") {
// Empty words mark runs of consecutive spaces: those spaces
// must stay literal (a fold would swallow them), so they
// glue onto the current segment and forbid breaking here.
current += " " + word
} else {
segments.append(current)
current = String(word)
budget = continuationBudget
}
}
if !current.isEmpty {
segments.append(current)
}
return segments
}
}

View File

@ -7,10 +7,36 @@ public extension PureYAML.Emitting {
self.options = options
}
/// Serializes one document. A value nested deeper than the parser's own
/// ``PureYAML/Parsing/Parser/maxNestingDepth`` cannot round-trip through
/// PureYAML (the parser rejects it), and the recursive renderers would
/// overflow the stack on it, so such a value is refused with a
/// documented, reparseable marker document instead of crashing.
public func dump(_ value: PureYAML.Model.Value) -> String {
switch options.collectionStyle {
guard !PureYAML.Emitting.exceedsNestingLimit(value) else {
return PureYAML.Emitting.nestingLimitDocument
}
var body = documentBody(value)
if options.canonical || options.explicitDocumentStart {
body = "---\n" + body
}
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
/// The document body with no leading `---` or trailing `...` markers, so
/// the single-document and stream paths can each own marker emission.
func documentBody(_ value: PureYAML.Model.Value) -> String {
if options.canonical {
return canonicalBody(value)
}
return switch options.collectionStyle {
case .block:
render(value, indent: 0).joined(separator: "\n") + "\n"
case .flow where options.lineWidth > 0:
renderFlowWrappedDocument(value)
case .flow:
renderFlowDocument(value)
}
@ -49,7 +75,7 @@ extension PureYAML.Emitting.Dumper {
switch value {
case .mapping, .sequence:
return [prefix + "-"] + render(value, indent: indent + 2)
return [prefix + "-"] + render(value, indent: indent + options.indentWidth)
default:
return renderSequenceScalarItem(value, indent: indent)
}
@ -116,10 +142,14 @@ extension PureYAML.Emitting.Dumper {
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
return [prefix + lines.header] + lines.content
}
return [prefix + renderScalar(value)]
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix, value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + rendered]
}
func renderMappingScalarPair(
@ -128,10 +158,15 @@ extension PureYAML.Emitting.Dumper {
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let key = escapeKey(pair.key)
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + 2) {
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) {
return [prefix + key + ": " + lines.header] + lines.content
}
return [prefix + key + ": " + renderScalar(pair.value)]
let rendered = renderScalar(pair.value)
let head = prefix + key + ": "
if let folded = foldedPlainScalarLine(head: head, value: pair.value, rendered: rendered, indent: indent) {
return folded
}
return [head + rendered]
}
func renderSequenceScalarItem(
@ -139,10 +174,14 @@ extension PureYAML.Emitting.Dumper {
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
return [prefix + "- " + lines.header] + lines.content
}
return [prefix + "- " + renderScalar(value)]
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix + "- ", value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + "- " + rendered]
}
func renderScalar(_ value: PureYAML.Model.Value) -> String {
@ -202,182 +241,4 @@ extension PureYAML.Emitting.Dumper {
.map { prefix + String($0) }
return (header, content)
}
func canRenderLiteralBlockString(_ value: String) -> Bool {
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
return false
}
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
guard !contentSource.isEmpty else {
return false
}
return contentSource
.split(separator: "\n", omittingEmptySubsequences: false)
.allSatisfy(canRenderLiteralBlockLine)
}
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
return false
}
let value = String(line)
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
return false
}
guard let first = line.first else {
return false
}
return canRenderLiteralBlockLineStarting(with: first, line: line)
}
func canRenderLiteralBlockLineStarting(
with first: Character,
line: Substring,
) -> Bool {
switch first {
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
false
case "-":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case "?":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
case ":":
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
default:
true
}
}
func hasOnlyIndicator(
_ indicator: Character,
line: Substring,
) -> Bool {
line.count == 1 && line.first == indicator
}
func containsCommentStart(_ value: String) -> Bool {
var previousWasWhitespace = true
for character in value {
if character == "#", previousWasWhitespace {
return true
}
previousWasWhitespace = character.isWhitespace
}
return false
}
func containsMappingSeparator(_ value: String) -> Bool {
var previousWasColon = false
for character in value {
if previousWasColon, isMappingSeparatorBoundary(character) {
return true
}
previousWasColon = character == ":"
}
return previousWasColon
}
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
character.isWhitespace
|| character == ","
|| character == "]"
|| character == "}"
}
func canRenderPlainString(_ value: String) -> Bool {
guard !value.isEmpty else {
return false
}
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
return false
}
guard !value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
return false
}
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
return false
}
guard let first = value.first, !isPlainScalarIndicator(first) else {
return false
}
return !containsColonSpace(value) && !value.contains("#")
}
func canRenderFlowPlainString(_ value: String) -> Bool {
canRenderPlainString(value) && !containsFlowDelimiter(value)
}
func containsFlowDelimiter(_ value: String) -> Bool {
value.contains(",")
|| value.contains("[")
|| value.contains("]")
|| value.contains("{")
|| value.contains("}")
}
func containsColonSpace(_ value: String) -> Bool {
var previousWasColon = false
for character in value {
if previousWasColon, character == " " {
return true
}
previousWasColon = character == ":"
}
return false
}
func isReservedPlainScalar(_ value: String) -> Bool {
switch value {
case "~", "null", "Null", "NULL", "true", "True", "TRUE", "false", "False", "FALSE":
true
default:
false
}
}
func isNumberLikePlainScalar(_ value: String) -> Bool {
if Int(value) != nil, !value.contains(".") {
return true
}
if value.contains(".") || value.contains("e") || value.contains("E") {
return Double(value) != nil
}
return false
}
func isPlainScalarIndicator(_ character: Character) -> Bool {
"-?:,[]{}#&*!|>'\"%@`".contains(character)
}
func escapeKey(_ key: String) -> String {
canRenderPlainString(key) ? key : quote(key)
}
func quote(_ value: String) -> String {
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
// single extended grapheme cluster in Swift, so a Character-level switch
// would match neither "\r" nor "\n" and emit the line break unescaped,
// which does not round-trip through the folding parser.
var output = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"":
output += "\\\""
case "\\":
output += "\\\\"
case "\n":
output += "\\n"
case "\r":
output += "\\r"
case "\t":
output += "\\t"
default:
output.unicodeScalars.append(scalar)
}
}
output += "\""
return output
}
}

View File

@ -5,13 +5,62 @@ public extension PureYAML.Emitting {
public var scalarStyle: ScalarStyle
public var collectionStyle: CollectionStyle
/// Spaces per nesting level in block style. Has no effect on flow
/// style, which has no indentation to control. Must be at least 1:
/// block YAML distinguishes nesting only by indentation, so 0 would
/// make sibling and child content indistinguishable and produce
/// output that cannot round-trip back through the parser. Not
/// enforced at construction (a plain `Int`, matching this struct's
/// existing options), so passing 0 or negative is a caller error
/// that surfaces as unparseable output, not a thrown error.
public var indentWidth: Int
/// Sort mapping keys lexicographically by their rendered (already
/// quoted/escaped) form before emitting, instead of preserving
/// `Model.Mapping`'s insertion order. Off by default: insertion
/// order is what makes round-tripping a duplicate-key-preserving
/// `Mapping` back to itself meaningful, so sorting is opt-in, not
/// the default.
public var sortKeys: Bool
/// Emit the YAML-specification canonical form: an explicit `---`
/// document start, an explicit `!!tag` on every node, double-quoted
/// scalars, and flow collections with one `? key : value,` entry
/// per line. When set, `scalarStyle`, `collectionStyle`, and
/// `lineWidth` are ignored; `indentWidth` and `sortKeys` still
/// apply. Off by default.
public var canonical: Bool
/// Emit an explicit `---` document start marker. Implied by
/// `canonical`. Off by default.
public var explicitDocumentStart: Bool
/// Emit an explicit `...` document end marker. Off by default.
public var explicitDocumentEnd: Bool
/// The preferred maximum line width. `0` (the default) disables
/// wrapping. When positive, a flow collection whose single-line
/// form would overflow the width re-renders with one entry per
/// line, and a plain block scalar folds at spaces onto
/// more-indented continuation lines; folding restores each break
/// to the same single space on reparse, so the value is unchanged.
/// Content with no legal break point (a single overlong word, a
/// quoted or block scalar) stays on one line.
public var lineWidth: Int
public init(
scalarStyle: ScalarStyle = .quoted,
collectionStyle: CollectionStyle = .block,
indentWidth: Int = 2,
sortKeys: Bool = false,
canonical: Bool = false,
explicitDocumentStart: Bool = false,
explicitDocumentEnd: Bool = false,
lineWidth: Int = 0,
) {
self.scalarStyle = scalarStyle
self.collectionStyle = collectionStyle
self.indentWidth = indentWidth
self.sortKeys = sortKeys
self.canonical = canonical
self.explicitDocumentStart = explicitDocumentStart
self.explicitDocumentEnd = explicitDocumentEnd
self.lineWidth = lineWidth
}
}
}

View File

@ -0,0 +1,26 @@
extension PureYAML.Model.Value: PureYAML.Validation.Traversable {
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this), and each pair's
/// key node at its own path (so a key-typed check can inspect it, e.g. "is this key a string").
/// Nothing extra for any other case.
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
guard case let .mapping(mapping) = self else {
return []
}
return [(nil, mapping)] + mapping.pairs.map { pair in
(component: pair.keyNode.pathComponent, value: pair.keyNode)
}
}
/// A mapping's children are its pairs' values, keyed by their key node's path component; a
/// sequence's children are its values, keyed by index. A scalar has none.
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Model.Value)] {
switch self {
case let .mapping(mapping):
mapping.pairs.map { pair in (component: pair.keyNode.pathComponent, value: pair.value) }
case let .sequence(values):
values.indices.map { index in (component: .index(index), value: values[index]) }
case .null, .bool, .int, .double, .string:
[]
}
}
}

View File

@ -0,0 +1,26 @@
extension PureYAML.Parsing.EventStreamComposer {
/// Charges `cost` new expanded nodes against the document's remaining
/// ``EventStreamComposer/expandedNodeBudget``, throwing `expansionTooLarge` the moment it
/// runs out.
///
/// `enforceNestingLimit` (`Parser.Events.swift`) bounds PHYSICAL collection depth in the raw
/// event stream; it does nothing against an anchor-expansion ("billion laughs") bomb, where a
/// handful of shallow, small anchors are each aliased many times, each alias nested inside the
/// next, so the LOGICAL tree an alias resolves to is exponential in the number of alias levels
/// while the physical document stays kilobytes -- a 25-level, fan-out-9 document already
/// expands past 9^25 logical nodes.
///
/// Called once per scalar, alias resolution, or completed container -- never re-derived from
/// a walk -- so a bomb is rejected within a few alias levels, long before its true size could
/// ever be materialized: each charge is the MARGINAL cost this one node adds, with any nested
/// alias expansion it contains already folded into a prior charge (cached in
/// ``EventStreamComposer/anchorSizes``) and looked up in O(1), never re-counted here.
/// ``EventStreamComposer/expandedNodeBudget``'s initial 5,000,000 is far past any real
/// document's node count.
mutating func chargeExpansion(_ cost: Int, mark: PureYAML.Parsing.Mark) throws {
expandedNodeBudget -= cost
guard expandedNodeBudget >= 0 else {
throw PureYAML.Parsing.ParseError.expansionTooLarge(line: mark.line, column: mark.column)
}
}
}

View File

@ -6,6 +6,10 @@ extension PureYAML.Parsing {
// default value path never sets this, so it stays byte-identical to before.
var tracksLocations = false
var anchors: [String: PureYAML.Model.Value] = [:]
/// Each anchor's cached EXPANDED node count; see `chargeExpansion` for why.
var anchorSizes: [String: Int] = [:]
// The expanded-node budget remaining for this document; see `chargeExpansion`.
var expandedNodeBudget = 5_000_000
var containers: [EventStreamContainer] = []
var documentValues: [PureYAML.Model.Value] = []
var documentLocated: [PureYAML.Model.LocatedValue] = []
@ -103,6 +107,8 @@ private extension PureYAML.Parsing.EventStreamComposer {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line)
}
anchors = [:]
anchorSizes = [:]
expandedNodeBudget = 5_000_000
currentDocumentValue = nil
}
@ -126,8 +132,10 @@ private extension PureYAML.Parsing.EventStreamComposer {
marks: (start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark),
) throws {
let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: marks.start)
try chargeExpansion(1, mark: marks.start)
if let anchor {
anchors[anchor] = model
anchorSizes[anchor] = 1
}
try complete(.init(
value: model,
@ -139,7 +147,7 @@ private extension PureYAML.Parsing.EventStreamComposer {
),
mark: marks.start,
located: tracksLocations ? .scalar(model, range(start: marks.start, end: marks.end)) : nil,
))
), cost: 1)
}
mutating func consumeAlias(
@ -153,6 +161,12 @@ private extension PureYAML.Parsing.EventStreamComposer {
column: mark.column,
)
}
// The target's own cached expanded size (itself plus every descendant, its own nested
// aliases already resolved): an alias to a value built from other aliases charges the
// FULL compounded cost here, in O(1), which is exactly what makes a nested bomb's true
// size visible without ever materializing it.
let cost = anchorSizes[anchor] ?? 1
try chargeExpansion(cost, mark: mark)
try complete(.init(
value: value,
mappingKey: .init(
@ -163,15 +177,20 @@ private extension PureYAML.Parsing.EventStreamComposer {
),
mark: mark,
located: tracksLocations ? .synthesized(from: value, at: range(at: mark)) : nil,
))
), cost: cost)
}
mutating func completeSequence(mark: PureYAML.Parsing.Mark) throws {
guard let container = containers.popLast(), container.kind == .sequence else {
throw unexpectedEvent(expected: "sequence start", mark: mark)
}
// Only the container's own wrapper is a NEW charge here; every child already charged its
// own cost (including any alias expansion) as it completed, accumulated into
// `logicalSize`. The cache for a later alias to THIS container needs the full total.
try chargeExpansion(1, mark: container.mark)
let totalCost = container.logicalSize + 1
let value = PureYAML.Model.Value.sequence(container.values.map(\.value))
store(value, anchor: container.anchor)
store(value, anchor: container.anchor, cost: totalCost)
try complete(.init(
value: value,
mappingKey: .init(
@ -185,7 +204,7 @@ private extension PureYAML.Parsing.EventStreamComposer {
located: tracksLocations
? .sequence(container.locatedValues, range(start: container.mark, end: mark))
: nil,
))
), cost: totalCost)
}
mutating func completeMapping(mark: PureYAML.Parsing.Mark) throws {
@ -195,12 +214,14 @@ private extension PureYAML.Parsing.EventStreamComposer {
guard container.pendingKey == nil else {
throw unexpectedEvent(expected: "mapping value", mark: mark)
}
try chargeExpansion(1, mark: container.mark)
let totalCost = container.logicalSize + 1
let pairs = mergedPairs(
from: container.mergeSources,
localPairs: container.localPairs,
)
let value = PureYAML.Model.Value.mapping(.init(pairs))
store(value, anchor: container.anchor)
store(value, anchor: container.anchor, cost: totalCost)
let located: PureYAML.Model.LocatedValue? = tracksLocations
? .mapping(
mergedLocatedPairs(from: container.mergeLocatedSources, localPairs: container.localLocatedPairs),
@ -217,10 +238,10 @@ private extension PureYAML.Parsing.EventStreamComposer {
),
mark: container.mark,
located: located,
))
), cost: totalCost)
}
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode) throws {
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode, cost: Int) throws {
guard !containers.isEmpty else {
currentDocumentValue = node.value
if tracksLocations {
@ -232,11 +253,13 @@ private extension PureYAML.Parsing.EventStreamComposer {
switch containers[containers.count - 1].kind {
case .sequence:
containers[containers.count - 1].values.append(node)
containers[containers.count - 1].logicalSize += cost
if tracksLocations {
containers[containers.count - 1].locatedValues.append(locatedNode(node))
}
case .mapping:
try completeMappingNode(node)
containers[containers.count - 1].logicalSize += cost
}
}
@ -277,11 +300,13 @@ private extension PureYAML.Parsing.EventStreamComposer {
mutating func store(
_ value: PureYAML.Model.Value,
anchor: String?,
cost: Int,
) {
guard let anchor else {
return
}
anchors[anchor] = value
anchorSizes[anchor] = cost
}
func mergeSources(

View File

@ -7,6 +7,11 @@ extension PureYAML.Parsing {
var localPairs: [PureYAML.Model.Pair] = []
var pendingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey?
var mark: PureYAML.Parsing.Mark
/// The running sum of every child's own expanded-node cost (see
/// `EventStreamComposer.expandedNodeBudget`), so this container's total logical size --
/// itself plus every descendant, alias expansion already accounted for -- is available in
/// O(1) the moment it completes, without re-walking anything.
var logicalSize = 0
// Location tracking (parallel to the above, only when the composer tracks locations).
var locatedValues: [PureYAML.Model.LocatedValue] = []
var mergeLocatedSources: [[PureYAML.Model.LocatedPair]] = []

View File

@ -19,6 +19,15 @@ extension PureYAML.Parsing.LibYAML {
/// `parser->tokens_parsed`, the count already dequeued, used to turn an
/// absolute token number into an index into the live queue.
var tokensParsed = 0
/// The most recently enqueued or spliced token, kept in sync with
/// `tokens.last` after every mutation below. Batch scanning never dequeues
/// (`tokensParsed` stays 0, `dequeue()` unused), so `tokens.last` alone
/// would suffice there; this field exists for a resumable scanner
/// (`Scanner.scanStep()`), which releases and discards tokens as soon as
/// they are safe to hand out, so `tokens` can be empty exactly when a check
/// like `lastTokenForbidsInlineSequence()` needs to know what the last
/// token was.
private(set) var lastToken: Token?
/// Port of `yaml_parser_roll_indent`. In flow context it does nothing.
/// When the column is greater than the current indent, it pushes the
@ -44,6 +53,7 @@ extension PureYAML.Parsing.LibYAML {
} else {
tokens.insert(token, at: number - tokensParsed)
}
lastToken = tokens.last
}
}
@ -59,17 +69,20 @@ extension PureYAML.Parsing.LibYAML {
tokens.append(Token(type: .blockEnd, startMark: mark, endMark: mark))
indent = indents.removeLast()
}
lastToken = tokens.last ?? lastToken
}
/// `ENQUEUE`: append a token to the queue.
mutating func enqueue(_ token: Token) {
tokens.append(token)
lastToken = token
}
/// `QUEUE_INSERT`: insert a token at an absolute token number, used to
/// splice a `KEY` token in front of a node already queued.
mutating func insert(_ token: Token, atTokenNumber number: Int) {
tokens.insert(token, at: number - tokensParsed)
lastToken = tokens.last
}
/// Dequeue the front token, advancing `tokensParsed` like libyaml's

View File

@ -6,20 +6,71 @@ extension PureYAML.Parsing.LibYAML {
/// libyaml's 0-based line and column convention; conversion to the package's
/// 1-based `Mark` happens at token emission during integration.
struct Reader {
private let scalars: [Unicode.Scalar]
private var scalars: [Unicode.Scalar]
private(set) var index = 0
private(set) var line = 0
private(set) var column = 0
/// Whether the buffered `scalars` are the complete input. True for every
/// batch caller (the default), so `ranDry` can never be set and this type's
/// behavior is unchanged for `scanAll()`. A resumable caller passes `false`
/// and later calls `markFinal()` once the last chunk has been appended.
private(set) var isFinal: Bool
/// Set by `peek(_:)` when it was asked for a position past the buffered
/// scalars while `!isFinal`: the query is inconclusive, not a real end of
/// input, and the caller must suspend and retry after `append(_:)`. Batch
/// callers (`isFinal == true`) never set this, since the guard below is
/// unreachable when `isFinal` is true.
private(set) var ranDry = false
init(_ string: String) {
/// The index of the first non-`c-printable` scalar found in `scalars`
/// (libyaml's reader-level check, `yaml_parser_update_buffer`'s printable
/// validation), or nil if none has been found yet. Set once, by whichever
/// of `init`/`append(_:)` first buffers the offending scalar; never
/// cleared. `fetchNextToken()` checks this once per token and throws,
/// mirroring libyaml rejecting raw control characters (anything outside
/// `c-printable`, i.e. not a plain tab/CR/LF and not in the printable
/// ranges `CharacterClass.isPrintable` implements) wherever they occur in
/// the stream, not only inside specific constructs like anchor names.
private(set) var invalidControlCharacterIndex: Int?
init(_ string: String = "", isFinal: Bool = true) {
scalars = Array(string.unicodeScalars)
self.isFinal = isFinal
recordFirstInvalidControlCharacter(in: 0 ..< scalars.count)
}
/// `PEEK_TOKEN` / `CHECK_AT`: the scalar `offset` ahead, or nil past the
/// end (libyaml's `\0`).
func peek(_ offset: Int = 0) -> Unicode.Scalar? {
/// end (libyaml's `\0`). Past the buffered end while more chunks may still
/// arrive, this flags `ranDry` instead of asserting true end of input.
mutating func peek(_ offset: Int = 0) -> Unicode.Scalar? {
let position = index + offset
return position < scalars.count ? scalars[position] : nil
if position < scalars.count {
return scalars[position]
}
if !isFinal {
ranDry = true
}
return nil
}
/// Append a chunk of newly arrived input. Only meaningful before
/// `markFinal()`. Clears `ranDry`: new data may satisfy whatever `peek(_:)`
/// call set it, and nothing else ever clears it. The existing
/// checkpoint-restore path gets this for free (the restored `Reader` is a
/// copy from before the dry read), but a resumed scan that keeps its
/// reader position instead of restoring a snapshot (`PlainScalarSuspended`)
/// has no other point where a stale `true` would ever become false again.
mutating func append(_ chunk: String) {
let previousCount = scalars.count
scalars.append(contentsOf: chunk.unicodeScalars)
ranDry = false
recordFirstInvalidControlCharacter(in: previousCount ..< scalars.count)
}
/// Declare that no further chunks are coming: a `peek` past the buffered
/// end is now a real end of input, matching the batch reader's behavior.
mutating func markFinal() {
isFinal = true
}
var isAtEnd: Bool {
@ -53,5 +104,49 @@ extension PureYAML.Parsing.LibYAML {
line += 1
column = 0
}
/// The 0-based line/column of `scalars[index]`, computed by counting line
/// breaks from the start of the buffer. Not the hot `mark` path (that
/// tracks `line`/`column` incrementally as scanning consumes characters);
/// used only once, lazily, when `fetchNextToken()` actually needs to report
/// `invalidControlCharacterIndex` as a `Mark`, since that index can be
/// anywhere in the buffer, not necessarily at the reader's current
/// scanning position.
func mark(atIndex target: Int) -> PureYAML.Parsing.Mark {
var line = 0
var column = 0
var cursor = 0
while cursor < target, cursor < scalars.count {
if scalars[cursor] == "\r", cursor + 1 < scalars.count, scalars[cursor + 1] == "\n" {
line += 1
column = 0
cursor += 2
} else if CharacterClass.isBreak(scalars[cursor]) {
line += 1
column = 0
cursor += 1
} else {
column += 1
cursor += 1
}
}
return PureYAML.Parsing.Mark(line: line, column: column, index: target)
}
/// Scans `range` (a slice of `scalars` just buffered by `init`/`append(_:)`)
/// for the first scalar outside `CharacterClass.isPrintable`, recording its
/// index if one is found and none was already recorded. Only the first
/// occurrence overall matters (matching libyaml, which reports the first
/// invalid byte and stops), so once `invalidControlCharacterIndex` is set
/// this is a no-op.
private mutating func recordFirstInvalidControlCharacter(in range: Range<Int>) {
guard invalidControlCharacterIndex == nil else {
return
}
for index in range where !CharacterClass.isPrintable(scalars[index]) {
invalidControlCharacterIndex = index
return
}
}
}
}

View File

@ -161,7 +161,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Fold the pending breaks at the start of a content line (folded style joins a
/// single break between non-blank lines into a space; otherwise breaks are
/// kept).
private func foldBlockLine(_ state: inout BlockState, literal: Bool) {
private mutating func foldBlockLine(_ state: inout BlockState, literal: Bool) {
let trailingBlank = Classify.isBlank(reader.peek())
if !literal, state.leadingBreak.first == "\n", !state.leadingBlank, !trailingBlank {
if state.trailingBreaks.isEmpty {
@ -184,7 +184,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Whether a `---` or `...` document marker (three repeats then a blank, break,
/// or end of input) starts at the current position.
private func documentMarkerAhead() -> Bool {
private mutating func documentMarkerAhead() -> Bool {
let first = reader.peek()
guard first == "-" || first == "." else {
return false

View File

@ -23,6 +23,25 @@ extension PureYAML.Parsing.LibYAML.Scanner {
fetchStreamStart()
return
}
// Gated on `reader.index >= badIndex`, not just "is one set", so a real
// syntax error earlier in the document (which normal scanning would reach
// and throw first) is not hidden behind a control character that only
// occurs later: this only fires once the reader has genuinely scanned up
// to the offending position, matching the order libyaml's own byte-by-byte
// reader would hit them in. `resumePlainScalar`'s own suspend/resume path
// (`scanStep()`, the streaming API) does not route through
// `fetchNextToken()` and so is not covered by this check; a token
// containing a control character can be returned to a streaming caller one
// token before this fires. `scanAll()`/`PureYAML.parse()`, the primary
// target, always calls `fetchNextToken()` again before returning, so this
// still surfaces before a batch parse completes.
if let badIndex = reader.invalidControlCharacterIndex, reader.index >= badIndex {
throw ScannerError(
context: "while scanning for the next token",
problem: "control characters are not allowed",
mark: reader.mark(atIndex: badIndex),
)
}
try scanToNextToken()
try checkFlowContinuationIndent()
@ -352,7 +371,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
indentEngine.enqueue(token)
}
private func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
private mutating func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
reader.peek(0) == character
&& reader.peek(1) == character
&& reader.peek(2) == character

View File

@ -215,7 +215,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a quoted scalar", problem: problem, mark: mark)
}
private func documentMarkerAhead() -> Bool {
private mutating func documentMarkerAhead() -> Bool {
let first = reader.peek()
guard first == "-" || first == "." else {
return false

View File

@ -29,7 +29,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// same line: a mapping value indicator or a node property (anchor/tag). After
/// another block entry a compact sequence is allowed, so that is not barred.
func lastTokenForbidsInlineSequence() -> Bool {
switch indentEngine.tokens.last?.type {
switch indentEngine.lastToken?.type {
case .anchor, .tag:
true
case .value:

View File

@ -1,10 +1,58 @@
extension PureYAML.Parsing.LibYAML.Scanner {
/// Which step of `runPlainScalarLoop`'s outer iteration a suspend happened
/// at. Resuming must land back on the step that was actually interrupted,
/// not on whatever step normally follows it, and in particular must never
/// redo a decision (document marker, comment, or "does a blank/break run
/// follow") that a *previous, already-passed* check already answered:
/// `.start`'s document-marker/`#` checks and `.afterContent`'s blank/break
/// guard are only meant to run once, at a genuine entry point, not at an
/// arbitrary position inside an already-in-progress content or break run.
/// A first version of this type resumed a dry `consumePlainContent` via
/// `.start` on the (wrong) assumption that redoing its checks was harmless;
/// it is not; if the resume position happens to land on a literal `#`
/// that is legitimate mid-scalar content (e.g. a value containing every
/// printable character), `.start`'s comment check wrongly ends the scalar
/// right there. `consumePlainContent` and `consumePlainBreaks` each get
/// their own dedicated "continue this same call, no pre-check" case
/// (`.consumingContent`, `.consumingBreaks`) for exactly this reason.
enum PlainScalarPhase: Equatable {
/// Check for a document marker or comment, then consume content.
case start
/// Inside consuming an already-started content run (the document
/// marker/comment checks that gated entry into it do not apply here).
case consumingContent
/// Content just finished; decide whether a blank/break run follows.
case afterContent
/// Inside consuming an already-confirmed blank/break run.
case consumingBreaks
/// A blank/break run just finished; check whether the scalar dedents.
case afterBreaks
}
/// Thrown by `runPlainScalarLoop` when accumulating a plain scalar's content
/// runs dry (`Reader.ranDry`) while `!Reader.isFinal`. Carries everything
/// `resumePlainScalar(_:)` needs to continue from exactly this point once more
/// input is appended, rather than rescanning the scalar's content from its
/// start. `scanStep()` catches this specifically and stores it on
/// `Scanner.suspendedPlainScalar`, distinct from a generic dry-run catch: no
/// checkpoint restore happens for this error, since the whole point is to keep
/// the reader position and accumulated content rather than discard them.
struct PlainScalarSuspended: Swift.Error {
var state: PlainState
var indent: Int
var startMark: PureYAML.Parsing.Mark
var phase: PlainScalarPhase
}
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
/// The folding state carried through `scan_plain_scalar`: the accumulated
/// value plus the pending leading break, trailing breaks, and whitespace that
/// are folded in before the next content character.
private struct PlainState {
/// are folded in before the next content character. Internal rather than
/// private: `PlainScalarSuspended` carries one across a `scanStep()` suspend
/// point, so `Scanner`'s own file needs to name the type for its stored
/// `suspendedPlainScalar` property.
struct PlainState {
var string = ""
var leadingBreak = ""
var trailingBreaks = ""
@ -22,24 +70,146 @@ extension PureYAML.Parsing.LibYAML.Scanner {
let indent = indentEngine.indent + 1
let startMark = reader.mark
var state = PlainState(endMark: reader.mark)
try runPlainScalarLoop(&state, indent: indent, startMark: startMark)
return finishPlainScalar(state, startMark: startMark)
}
/// Resumes a plain scalar suspended by a previous `PlainScalarSuspended`,
/// continuing from its saved state, phase, and reader position rather than
/// rescanning the content already accumulated before the suspend.
///
/// Takes `state` `inout` rather than the whole `PlainScalarSuspended`, on
/// purpose: the caller (`scanStep()`) must disalias `state` from the
/// suspended value it came from *before* this call, since a `let
/// suspended = ...; resumePlainScalar(suspended)` call would keep the
/// caller's own `suspended.state` reference to the same string buffer
/// alive for this whole call's duration regardless of anything done
/// inside this function's own body (a value parameter is borrowed for
/// the call, not owned, so a local disalias inside the callee cannot
/// remove a reference the caller itself is still holding). With `state`
/// passed by direct reference and no wrapping struct crossing this call
/// boundary at all, there is exactly one owner of its string buffer
/// throughout, so appending inside the loop below never triggers a
/// spurious copy-on-write copy of the whole accumulated scalar. See
/// `scanStep()`'s resume branch for where the disalias happens.
mutating func resumePlainScalar(
_ state: inout PlainState,
indent: Int,
startMark: PureYAML.Parsing.Mark,
phase: PlainScalarPhase,
) throws -> PureYAML.Parsing.LibYAML.Token {
try runPlainScalarLoop(&state, indent: indent, startMark: startMark, from: phase)
return finishPlainScalar(state, startMark: startMark)
}
/// The scan loop shared by a fresh attempt and a resume, restructured as an
/// explicit `PlainScalarPhase` state machine (see its own doc comment for
/// why) with a `suspendIfDry` check after every step that peeked. Checking
/// only once, after the whole loop exits, is not enough: a dry read
/// (`reader.ranDry`, see `Reader.peek(_:)`) inside a lookahead
/// (`documentMarkerAhead()`, `consumePlainContent`'s `:`-followed-by-blank
/// check, `consumePlainBreaks`'s `blankLineAhead()` guard) reads as "blank or
/// end" (`CharacterClass.isBlankZ`/`isBreakZ` treat a missing scalar as
/// true), not as "not enough data yet", so the *next* step can reach a
/// wrong-but-plausible decision (e.g. `documentMarkerAhead` wrongly says
/// "not a marker", so `consumePlainContent` appends `---` as scalar content)
/// before a single end-of-loop check would ever see the taint that caused
/// it. Checking right after each peeking step, before its result is used for
/// anything further, catches the taint before it can influence another
/// decision. Batch parsing (`isFinal: true`) is unaffected: every check can
/// only throw when `!reader.isFinal`, which batch callers never set, so the
/// loop's own logic is otherwise identical to the original.
///
/// `state` and the reader position are always resumable at a suspend point:
/// nothing here calls `reader.skip()` on a dry `nil`, so accumulated content
/// and position both stop exactly at the last successfully read scalar.
private mutating func runPlainScalarLoop(
_ state: inout PlainState,
indent: Int,
startMark: PureYAML.Parsing.Mark,
from initialPhase: PlainScalarPhase = .start,
) throws {
var phase = initialPhase
while true {
if reader.column == 0, documentMarkerAhead() {
break
}
if reader.peek() == "#" {
break
}
try consumePlainContent(&state, startMark: startMark)
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
break
}
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
if flowLevel == 0, reader.column < indent {
break
switch phase {
case .start:
let atDocumentMarker = reader.column == 0 && documentMarkerAhead()
// A dry lookahead inside `documentMarkerAhead()` must resume by
// redoing this whole step, not by trusting `atDocumentMarker`
// (which `isBlankZ`'s nil-is-true bias could have gotten wrong)
// and moving on: hence `phase: .start` here, not `.consumingContent`.
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .start)
if atDocumentMarker {
return
}
if reader.peek() == "#" {
return
}
try consumePlainContent(&state, startMark: startMark)
// consumePlainContent stopping is not yet known to be a real
// answer (terminator found) versus a dry cutoff: check before
// trusting it enough to move to `.afterContent`'s guard. On dry,
// resume via `.consumingContent`, not `.start`: the document-marker
// and `#` checks above only apply at a genuine entry point, and
// the reader position where a content run was interrupted can
// land on a literal `#` or `-` that is legitimate content, not a
// fresh comment or marker.
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
phase = .afterContent
case .consumingContent:
try consumePlainContent(&state, startMark: startMark)
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
phase = .afterContent
case .afterContent:
// Reached only once consumePlainContent is known to have stopped
// for a real reason, so this peek cannot itself be dry: it
// re-reads the exact position consumePlainContent's own last
// (already non-dry) peek confirmed, and nothing advanced between.
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
return
}
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
// Same reasoning as consumePlainContent above, but resuming here
// must not redo `.afterContent`'s guard against new data: that
// guard gates *entry* into a blank/break run, and re-asking it
// after the run is already underway would wrongly treat "the run
// continues" as "the run never started" (or vice versa) instead
// of just continuing the same run. `.consumingBreaks` re-enters
// consumePlainBreaks directly.
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
phase = .afterBreaks
case .consumingBreaks:
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
phase = .afterBreaks
case .afterBreaks:
// `reader.column` is a plain stored field, never read via
// `peek(_:)`, so this check cannot itself observe a dry read; by
// construction every path reaching this case already confirmed
// `!ranDry` before transitioning here.
if flowLevel == 0, reader.column < indent {
return
}
phase = .start
}
}
}
private func suspendIfDry(
_ state: PlainState,
indent: Int,
startMark: PureYAML.Parsing.Mark,
phase: PlainScalarPhase,
) throws {
if !reader.isFinal, reader.ranDry {
throw PlainScalarSuspended(state: state, indent: indent, startMark: startMark, phase: phase)
}
}
private mutating func finishPlainScalar(
_ state: PlainState,
startMark: PureYAML.Parsing.Mark,
) -> PureYAML.Parsing.LibYAML.Token {
if state.leadingBlanks {
simpleKeyAllowed = true
}
@ -106,11 +276,22 @@ extension PureYAML.Parsing.LibYAML.Scanner {
private mutating func consumePlainBreaks(_ state: inout PlainState, indent: Int, startMark: PureYAML.Parsing.Mark) throws {
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
if Classify.isBlank(reader.peek()) {
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()), !blankLineAhead() {
// A tab below the indentation is illegal only when it indents
// real content; on an otherwise-blank line it is just a blank
// line the scalar folds over. libyaml rejects it either way.
throw scannerError("found a tab character that violates indentation", at: startMark)
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()) {
// `blankLineAhead()` is its own multi-character lookahead (not
// just the single already-confirmed-real `peek()` above), so it
// can independently run dry: check immediately, before the
// tab-violation decision below trusts its answer. Unchecked,
// `isBreakZ`'s nil-is-true bias makes an incomplete lookahead
// read as "blank line", suppressing a real violation until more
// data arrives proves otherwise.
let onBlankLine = blankLineAhead()
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
if !onBlankLine {
// A tab below the indentation is illegal only when it indents
// real content; on an otherwise-blank line it is just a blank
// line the scalar folds over. libyaml rejects it either way.
throw scannerError("found a tab character that violates indentation", at: startMark)
}
}
if !state.leadingBlanks, let blank = reader.peek() {
state.whitespaces.unicodeScalars.append(blank)
@ -132,7 +313,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a plain scalar", problem: problem, mark: mark)
}
private func documentMarkerAhead() -> Bool {
private mutating func documentMarkerAhead() -> Bool {
let first = reader.peek()
guard first == "-" || first == "." else {
return false

View File

@ -78,7 +78,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
/// Whether the rest of the current line, from the current position, is only
/// spaces and tabs up to a line break or end of stream (a blank line).
func blankLineAhead() -> Bool {
mutating func blankLineAhead() -> Bool {
var offset = 0
while let character = reader.peek(offset), character == " " || Classify.isTab(character) {
offset += 1

View File

@ -0,0 +1,165 @@
extension PureYAML.Parsing.LibYAML.Scanner {
/// The result of one `scanStep()` call.
enum ScanOutcome: Equatable {
/// A token is safe to release to the caller now.
case token(PureYAML.Parsing.LibYAML.Token)
/// The buffered input was exhausted mid-fetch; append more input (via
/// `reader.append(_:)`) and call `scanStep()` again. The scanner's state is
/// unchanged from before this call, as if it had not been called.
case needMoreInput
/// STREAM-END has been released and no further tokens remain.
case done
}
/// Resumable, one-token-at-a-time counterpart to `scanAll()`. Where `scanAll()`
/// assumes the full input is already buffered, `scanStep()` may be driven across
/// a `Reader` still receiving chunks (`isFinal == false`): a fetch that runs off
/// the buffered end is rolled back in full (as if it had not been attempted)
/// rather than mistaken for true end of input.
///
/// A token already queued is not always safe to hand out immediately: libyaml's
/// simple-key mechanism can still splice a KEY (and a BLOCK-MAPPING-START) in
/// front of it once a later `:` is found (`saveSimpleKey`/`insert(_:atTokenNumber:)`
/// in `LibYAML.Scanner.swift`/`LibYAML.IndentEngine.swift`). Port of libyaml's
/// `yaml_parser_fetch_more_tokens` release condition: the token at the queue
/// front (position `tokensParsed`) is safe to release only when no currently
/// possible simple key (at any flow level) is still recorded at that exact
/// position; such a key could still resolve into a splice targeting that slot.
mutating func scanStep() throws -> ScanOutcome {
while true {
if isFrontSafeToRelease, let token = indentEngine.dequeue() {
return .token(token)
}
if streamEndProduced {
return .done
}
// A plain scalar in progress resumes from exactly where it stopped,
// with no checkpoint at all: `runPlainScalarLoop` never leaves `reader`
// or `state` in a state that needs rolling back (see
// `PlainScalarSuspended`), so there is nothing to restore if it runs
// dry again.
switch try resumeSuspendedPlainScalarIfNeeded() {
case .notSuspended:
break
case .tokenReady:
continue
case .stillDry:
return .needMoreInput
}
if let outcome = try fetchNextTokenCheckpointed() {
return outcome
}
}
}
/// A dry run can only ever be reported while `!reader.isFinal` (see
/// `Reader.peek(_:)`), so once the caller has appended every chunk and
/// called `markFinal()`, no fetch can ever need rolling back. Skipping
/// the snapshot in that case matters: it is the entire cost difference
/// between `scanStep()` and `scanAll()` once the whole input is already
/// buffered (measured in StreamingScannerPerformanceTests), since neither
/// reads nor mutations inside `fetchNextToken()` become any cheaper by
/// being wrapped in an unused checkpoint. Returns `nil` to mean "keep
/// looping in `scanStep()`", matching a fetch that produced no directly
/// returnable outcome (a non-plain-scalar token was merely enqueued, or a
/// document/directive token dispatched with no scalar involved).
private mutating func fetchNextTokenCheckpointed() throws -> ScanOutcome? {
guard !reader.isFinal else {
try fetchNextToken()
return nil
}
let checkpoint = self
do {
try fetchNextToken()
} catch let suspended as PlainScalarSuspended {
// `fetchPlainScalar()` calls `saveSimpleKey()` and sets
// `simpleKeyAllowed = false` before ever calling `scanPlainScalar()`,
// and a plain-scalar-only resume has no way to undo those: it is
// only safe to keep them applied and resume past this suspend if
// real progress (content) exists to preserve. `phase == .start`
// with an empty `state` means the very first check inside the loop
// (before `consumePlainContent` ever ran, e.g. `documentMarkerAhead()`'s
// own lookahead went dry) suspended: nothing would be saved by
// resuming instead of retrying from scratch, and retrying from
// scratch is what correctly re-derives whether this token should
// have been dispatched as a plain scalar at all. `dispatchToken()`'s
// own `isDocumentMarker` check, called before `fetchPlainScalar()`
// is ever reached, can suffer the exact same dry-lookahead problem
// `runPlainScalarLoop` guards against; only a full retry re-checks it
// with more data, since nothing here can undo a wrong dispatch.
if suspended.phase == .start, suspended.state.string.isEmpty {
self = checkpoint
return .needMoreInput
}
suspendedPlainScalar = suspended
return .needMoreInput
} catch {
if reader.ranDry {
self = checkpoint
return .needMoreInput
}
throw error
}
if reader.ranDry {
self = checkpoint
return .needMoreInput
}
return nil
}
private var isFrontSafeToRelease: Bool {
guard !indentEngine.tokens.isEmpty else {
return false
}
return !simpleKeys.contains { $0.possible && $0.tokenNumber == indentEngine.tokensParsed }
}
private enum ResumeOutcome {
/// No plain scalar was suspended; `scanStep()` should fall through
/// to its normal fetch path.
case notSuspended
/// The suspended scalar was resumed to completion and its token was
/// enqueued; `scanStep()` should loop back to the top and try to
/// release it, not fall through to a fresh fetch.
case tokenReady
/// The resume itself ran dry again; `scanStep()` should report
/// `.needMoreInput`.
case stillDry
}
/// Extracted out of `scanStep()` (which was tripping the cyclomatic
/// complexity limit) rather than inlined, this also fixes a real O(N^2)
/// bug the inline version had: extracting `state` and immediately
/// overwriting `suspended.state` with a fresh, unrelated value before
/// calling `resumePlainScalar` is required, not optional. A plain
/// `resumePlainScalar(suspended)` call, passing the whole suspended
/// value, would keep a reference to it alive (as an ordinary borrowed
/// value parameter) for this call's entire duration regardless of what
/// the callee does internally, leaving `state`'s string buffer with two
/// live owners throughout `runPlainScalarLoop` and forcing copy-on-write
/// to fully copy the accumulated scalar on every single append.
/// Overwriting `suspended.state` here, before the call, drops that
/// reference for good, so `state` is the sole owner by the time
/// `resumePlainScalar` starts mutating it (see
/// `StreamingScannerPerformanceTests.test_measureLongPlainScalar`, which
/// caught this at a measured 29x versus the ~3-4x this fix gives).
private mutating func resumeSuspendedPlainScalarIfNeeded() throws -> ResumeOutcome {
guard var suspended = suspendedPlainScalar else {
return .notSuspended
}
suspendedPlainScalar = nil
var state = suspended.state
suspended.state = PlainState(endMark: state.endMark)
let indent = suspended.indent
let startMark = suspended.startMark
let phase = suspended.phase
do {
let token = try resumePlainScalar(&state, indent: indent, startMark: startMark, phase: phase)
indentEngine.enqueue(token)
return .tokenReady
} catch let resuspended as PlainScalarSuspended {
suspendedPlainScalar = resuspended
return .stillDry
}
}
}

View File

@ -120,10 +120,18 @@ extension PureYAML.Parsing.LibYAML.Scanner {
}
private func utf8Width(of octet: UInt8) -> Int {
if octet & 0x80 == 0x00 { return 1 }
if octet & 0xE0 == 0xC0 { return 2 }
if octet & 0xF0 == 0xE0 { return 3 }
if octet & 0xF8 == 0xF0 { return 4 }
if octet & 0x80 == 0x00 {
return 1
}
if octet & 0xE0 == 0xC0 {
return 2
}
if octet & 0xF0 == 0xE0 {
return 3
}
if octet & 0xF8 == 0xF0 {
return 4
}
return 0
}

View File

@ -42,9 +42,13 @@ extension PureYAML.Parsing.LibYAML {
var streamStartProduced = false
/// `parser->stream_end_produced`.
var streamEndProduced = false
/// A plain scalar fetch that ran dry mid-content (`scanStep()` only, batch
/// scanning never sets this): resumed from here on the next `scanStep()`
/// call instead of restarting the scalar from its first scalar.
var suspendedPlainScalar: PlainScalarSuspended?
init(_ input: String = "") {
reader = Reader(input)
init(_ input: String = "", isFinal: Bool = true) {
reader = Reader(input, isFinal: isFinal)
}
/// `parser->flow_level`, kept on the indent engine.

View File

@ -8,6 +8,8 @@ extension PureYAML.Parsing.ParseError {
let context = error.context ?? ""
if error.problem.hasPrefix("found a tab") {
self = .tabIndentation(line: line)
} else if error.problem == "control characters are not allowed" {
self = .invalidControlCharacter(line: line, column: column)
} else if context == "while scanning a quoted scalar", error.problem.hasSuffix("end of stream") {
self = .unterminatedQuotedString(line: line)
} else if context.hasPrefix("while scanning a tag") || context.hasSuffix("TAG directive") {

View File

@ -3,6 +3,7 @@ public extension PureYAML.Parsing {
enum ParseError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case emptyDocument
case tabIndentation(line: Int)
case invalidControlCharacter(line: Int, column: Int)
case unexpectedIndentation(line: Int)
case mixedCollectionStyles(line: Int)
case expectedMappingKey(line: Int)
@ -22,6 +23,13 @@ public extension PureYAML.Parsing {
case unexpectedEvent(expected: String, actual: String, line: Int, column: Int)
case unexpectedToken(expected: String, actual: String, line: Int, column: Int)
case nestingTooDeep(line: Int, column: Int)
/// The document's total EXPANDED node count (after resolving every `*alias` to its
/// target) exceeds the budget. `nestingTooDeep` bounds physical collection depth in the
/// event stream; it does nothing against an anchor-expansion ("billion laughs") bomb,
/// where a handful of shallow, small anchors are each aliased many times, each alias
/// nested inside the next, so the LOGICAL tree an alias resolves to is exponential in
/// the number of alias levels while the physical document stays tiny.
case expansionTooLarge(line: Int, column: Int)
case malformed(reason: String, line: Int, column: Int)
public var description: String {
@ -30,6 +38,8 @@ public extension PureYAML.Parsing {
"document is empty"
case let .tabIndentation(line):
"tabs are not allowed for indentation at line \(line)"
case let .invalidControlCharacter(line, column):
"invalid control character at line \(line), column \(column)"
case let .unexpectedIndentation(line):
"unexpected indentation at line \(line)"
case let .mixedCollectionStyles(line):
@ -68,6 +78,8 @@ public extension PureYAML.Parsing {
"expected \(expected) at line \(line), column \(column), found \(actual)"
case let .nestingTooDeep(line, column):
"nesting is too deep at line \(line), column \(column)"
case let .expansionTooLarge(line, column):
"the document's expanded size (after resolving aliases) is too large at line \(line), column \(column)"
case let .malformed(reason, line, column):
"\(reason) at line \(line), column \(column)"
}
@ -79,6 +91,8 @@ public extension PureYAML.Parsing {
"emptyDocument"
case .tabIndentation:
"tabIndentation"
case .invalidControlCharacter:
"invalidControlCharacter"
case .unexpectedIndentation:
"unexpectedIndentation"
case .mixedCollectionStyles:
@ -117,6 +131,8 @@ public extension PureYAML.Parsing {
"unexpectedToken"
case .nestingTooDeep:
"nestingTooDeep"
case .expansionTooLarge:
"expansionTooLarge"
case .malformed:
"malformed"
}
@ -147,6 +163,8 @@ public extension PureYAML.Parsing {
let .unexpectedEvent(_, _, line, _),
let .unexpectedToken(_, _, line, _),
let .nestingTooDeep(line, _),
let .expansionTooLarge(line, _),
let .invalidControlCharacter(line, _),
let .malformed(_, line, _):
line
}
@ -176,6 +194,8 @@ public extension PureYAML.Parsing {
let .unexpectedEvent(_, _, _, column),
let .unexpectedToken(_, _, _, column),
let .nestingTooDeep(_, column),
let .expansionTooLarge(_, column),
let .invalidControlCharacter(_, column),
let .malformed(_, _, column):
column
}

View File

@ -0,0 +1,99 @@
extension PureYAML.Parsing {
/// How a plain (unquoted) scalar reads: as null, a boolean, an integer, a floating-point number, or else a string.
///
/// This is the one definition of that reading. The parser takes the value of every plain scalar from it, and the emitter asks it whether a string can be
/// written without quotes, since a string that reads as anything else would come back changed. Two lists of the same words kept apart drift apart: the
/// emitter once wrote the string `yes` plain while the parser read `yes` as a boolean.
enum PlainScalar {
/// The value `text` has as a plain scalar. `text` has no surrounding whitespace.
static func resolve(_ text: String) -> PureYAML.Model.Value {
switch text {
case "", "~", "null", "Null", "NULL":
return .null
default:
break
}
if let bool = bool(text) {
return .bool(bool)
}
if let int = integer(text), !text.contains(".") {
return .int(int)
}
if text.contains(".") || text.contains("e") || text.contains("E"), let double = double(text) {
return .double(double)
}
return .string(text)
}
/// Whether `text` written plain reads back as the same string.
static func readsAsString(_ text: String) -> Bool {
if case .string = resolve(text) {
return true
}
return false
}
static func bool(_ text: String) -> Bool? {
switch text {
case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON":
true
case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF":
false
default:
nil
}
}
static func integer(_ text: String) -> Int? {
let value = removingNumericSeparators(from: text)
let negative = value.hasPrefix("-")
let hasSign = negative || value.hasPrefix("+")
let unsigned = hasSign ? String(value.dropFirst()) : value
guard !unsigned.isEmpty else {
return nil
}
guard unsigned != "0" else {
return 0
}
let radixPrefixes: [(prefix: String, radix: Int)] = [
("0x", 16),
("0X", 16),
("0b", 2),
("0B", 2),
("0o", 8),
("0O", 8),
("0", 8),
]
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
// `Int(_, radix:)` accepts a sign, so `0-3` would read as minus three: after the prefix only digits may follow
guard let first = digits.first, first != "-", first != "+", let magnitude = Int(digits, radix: radixPrefix.radix) else {
return nil
}
return negative ? -magnitude : magnitude
}
return Int(value)
}
static func double(_ text: String) -> Double? {
switch text {
case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF":
.infinity
case "-.inf", "-.Inf", "-.INF":
-.infinity
case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN":
.nan
default:
Double(removingNumericSeparators(from: text))
}
}
private static func removingNumericSeparators(from text: String) -> String {
text.filter { $0 != "_" }
}
}
}

View File

@ -14,93 +14,25 @@ extension PureYAML.Parsing.Parser {
line: Int,
) throws -> PureYAML.Model.Value {
let value = trim(text)
switch value {
case "", "~", "null", "Null", "NULL":
return .null
default:
break
}
if let bool = parseBool(value) {
return .bool(bool)
}
if value.hasPrefix("\"") {
return try .string(parseDoubleQuoted(value, line: line))
}
if value.hasPrefix("'") {
return try .string(parseSingleQuoted(value, line: line))
}
if let int = parseInteger(value), !value.contains(".") {
return .int(int)
}
if value.contains(".") || value.contains("e") || value.contains("E") {
if let double = parseDouble(value) {
return .double(double)
}
}
return .string(value)
return PureYAML.Parsing.PlainScalar.resolve(value)
}
func parseBool(_ text: String) -> Bool? {
switch text {
case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON":
true
case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF":
false
default:
nil
}
PureYAML.Parsing.PlainScalar.bool(text)
}
func parseInteger(_ text: String) -> Int? {
let value = removingNumericSeparators(from: text)
let negative = value.hasPrefix("-")
let hasSign = negative || value.hasPrefix("+")
let unsigned = hasSign ? String(value.dropFirst()) : value
guard !unsigned.isEmpty else {
return nil
}
guard unsigned != "0" else {
return 0
}
let radixPrefixes: [(prefix: String, radix: Int)] = [
("0x", 16),
("0X", 16),
("0b", 2),
("0B", 2),
("0o", 8),
("0O", 8),
("0", 8),
]
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
guard !digits.isEmpty, let magnitude = Int(digits, radix: radixPrefix.radix) else {
return nil
}
return negative ? -magnitude : magnitude
}
return Int(value)
PureYAML.Parsing.PlainScalar.integer(text)
}
func parseDouble(_ text: String) -> Double? {
switch text {
case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF":
.infinity
case "-.inf", "-.Inf", "-.INF":
-.infinity
case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN":
.nan
default:
Double(removingNumericSeparators(from: text))
}
}
func removingNumericSeparators(from text: String) -> String {
text.filter { $0 != "_" }
PureYAML.Parsing.PlainScalar.double(text)
}
func parseSingleQuoted(

View File

@ -21,6 +21,9 @@ public enum PureYAML {
/// YAML stream types.
public enum Stream {}
/// Resumable, chunked YAML scanning types.
public enum Streaming {}
/// Tag-preserving YAML document model types.
public enum Tagged {}
}

View File

@ -1,17 +1,17 @@
import PureYAML
public extension PureYAML.Validation.BuiltinCheck {
public extension PureYAML.Validation.BuiltinValidation {
/// Reports any mapping key that is not a string. JSON object names must be
/// strings, so a YAML complex key (a sequence or mapping used as a key) has no
/// JSON form. PureYAML core already ships ``mappingKeysAreUnique``; this check and
/// ``numbersAreFinite`` complete the JSON-representability set.
static var mappingKeysAreStrings: PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Mapping keys are strings") { context in
static var mappingKeysAreStrings: PureYAML.Validation.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Mapping keys are strings") { context in
context.subject.pairs.compactMap { pair in
guard pair.keyNode.stringValue == nil else {
return nil
}
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Mapping key '\(pair.keyNode.description)' is not a string",
path: context.path.appending(.complexKey(pair.keyNode)),
@ -22,13 +22,13 @@ public extension PureYAML.Validation.BuiltinCheck {
/// Reports any non-finite number (NaN or infinity). JSON has no literal for
/// these, so a value tree carrying one is not representable as JSON.
static var numbersAreFinite: PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Numbers are finite") { context in
static var numbersAreFinite: PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Numbers are finite") { context in
guard case let .double(number) = context.subject, !number.isFinite else {
return []
}
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Number '\(number)' is not finite",
path: context.path,

View File

@ -6,7 +6,7 @@ public extension PureYAML.JSON.Convert {
/// The source text did not parse.
case sourceInvalid(String)
/// The value cannot be represented in the target format.
case notRepresentable([PureYAML.Validation.Issue])
case notRepresentable([PureYAML.Validation.ValidationError])
/// The emitted output did not parse back to the source value.
case roundTripMismatch(PureYAML.JSON.Format)
}

View File

@ -30,7 +30,7 @@ public extension PureYAML.JSON {
/// representability first; the round-trip check is not run here (use
/// ``convert(_:to:pretty:)`` for the fully validated path).
public static func emit(_ value: PureYAML.Model.Value, as target: Format, pretty: Bool = true) throws -> String {
let issues = validator(for: target).collect(value).issues
let issues = validator(for: target).collect(value).values
guard issues.isEmpty else {
throw Failure.notRepresentable(issues)
}
@ -44,7 +44,7 @@ public extension PureYAML.JSON {
/// The validator for a target format. JSON additionally requires string keys
/// and finite numbers; YAML allows complex keys but still forbids duplicates.
public static func validator(for target: Format) -> PureYAML.Validation.Validator {
public static func validator(for target: Format) -> PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
switch target {
case .json:
PureYAML.JSON.Validator.representability

View File

@ -9,22 +9,25 @@ public extension PureYAML.JSON {
///
/// JSON the format tolerates duplicate object names, but a tree that carries two
/// pairs with the same key emits an ambiguous document, so the representability
/// validator rejects duplicates for determinism. Drop ``unique`` from the rule
/// set if you want the permissive reading.
/// validator rejects duplicates for determinism. Drop ``PureYAML/Validation/BuiltinValidation/mappingKeysAreUnique``
/// from the rule set if you want the permissive reading.
enum Validator {
/// The full JSON-representability rule set: unique keys, string keys, finite
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`.
public static var representability: PureYAML.Validation.Validator {
PureYAML.Validation.Validator(checks: [
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique),
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreStrings),
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.numbersAreFinite),
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`. Built with
/// the same tiered `Validator` shape every other layer uses, so it carries the
/// same `.blank`/`.validating`/`.withoutValidating`/`.validationDescriptions`
/// machinery (validation-rules.md, principle 10).
public static var representability: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validator(nonReferenceDefaultValidations: [
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique),
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings),
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.numbersAreFinite),
])
}
/// Every reason the value cannot be emitted as strict JSON, in document order.
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.Issue] {
representability.collect(value).issues
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.ValidationError] {
representability.collect(value).values
}
/// Whether the value can be emitted as strict JSON.

View File

@ -2,11 +2,11 @@ public extension PureYAML.Stream {
/// Validation issue with the document index preserved.
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var documentIndex: Int
public var issue: PureYAML.Validation.Issue
public var issue: PureYAML.Validation.ValidationError
public init(
documentIndex: Int,
issue: PureYAML.Validation.Issue,
issue: PureYAML.Validation.ValidationError,
) {
self.documentIndex = documentIndex
self.issue = issue

View File

@ -0,0 +1,13 @@
public extension PureYAML.Streaming {
/// The result of one ``Scanner/next()`` call.
enum Outcome: Equatable, Sendable {
/// A token is ready.
case token(Token)
/// The buffered input was exhausted mid-scan; call ``Scanner/feed(_:)``
/// with more input (or ``Scanner/finish()`` if there is no more) and
/// call ``Scanner/next()`` again.
case needMoreInput
/// The stream has ended; no further tokens remain.
case done
}
}

View File

@ -0,0 +1,102 @@
public extension PureYAML.Streaming {
/// A resumable, chunked YAML token scanner: the public counterpart to the
/// internal libyaml-ported scanner's `scanStep()`, which this wraps
/// directly with no independent logic of its own. Proven to match batch
/// scanning byte-for-byte across the whole yaml-test-suite corpus and at
/// several chunk sizes (`StreamingScannerDifferentialTests` in the test
/// suite exercises the identical internal `scanStep()` this type calls).
struct Scanner: Sendable {
private var scanner: PureYAML.Parsing.LibYAML.Scanner
/// Creates a scanner with no input yet. Feed input with ``feed(_:)``
/// and mark the end of input with ``finish()``.
public init() {
scanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false)
}
/// Appends a chunk of YAML source. Chunks are concatenated in the
/// order fed; a chunk need not align with any YAML grammar boundary.
public mutating func feed(_ chunk: String) {
scanner.reader.append(chunk)
}
/// Marks that no further input will be fed. After this, ``next()``
/// never returns ``Outcome/needMoreInput``: a fetch that would have
/// needed more input instead surfaces as a real scanning error, since
/// there is no more input coming that could resolve it.
public mutating func finish() {
scanner.reader.markFinal()
}
/// Advances the scan by one token. Call in a loop, feeding more input
/// on ``Outcome/needMoreInput`` and stopping on ``Outcome/done``.
public mutating func next() throws -> Outcome {
switch try scanner.scanStep() {
case let .token(token):
.token(Token(token))
case .needMoreInput:
.needMoreInput
case .done:
.done
}
}
}
}
extension PureYAML.Streaming.Token {
init(_ token: PureYAML.Parsing.LibYAML.Token) {
self.init(
kind: Kind(token.type),
range: PureYAML.Model.SourceRange(
start: PureYAML.Model.SourcePosition(line: token.startMark.line, column: token.startMark.column),
end: PureYAML.Model.SourcePosition(line: token.endMark.line, column: token.endMark.column),
),
)
}
}
extension PureYAML.Streaming.Token.Kind {
/// A mechanical 1:1 mapping over all 22 `TokenType` cases in one
/// exhaustive switch: no `default`, so adding a `TokenType` case is a
/// compile error here instead of a runtime trap. The lint's complexity
/// metric ignores case statements precisely for closed-enum mappings
/// like this one.
init(_ type: PureYAML.Parsing.LibYAML.TokenType) {
switch type {
case .streamStart: self = .streamStart
case .streamEnd: self = .streamEnd
case .documentStart: self = .documentStart
case .documentEnd: self = .documentEnd
case .blockSequenceStart: self = .blockSequenceStart
case .blockMappingStart: self = .blockMappingStart
case .blockEnd: self = .blockEnd
case .blockEntry: self = .blockEntry
case .flowSequenceStart: self = .flowSequenceStart
case .flowMappingStart: self = .flowMappingStart
case .flowSequenceEnd: self = .flowSequenceEnd
case .flowMappingEnd: self = .flowMappingEnd
case .flowEntry: self = .flowEntry
case .key: self = .key
case .value: self = .value
case .reservedDirective: self = .reservedDirective
case let .versionDirective(major, minor): self = .versionDirective(major: major, minor: minor)
case let .tagDirective(handle, prefix): self = .tagDirective(handle: handle, prefix: prefix)
case let .anchor(name): self = .anchor(name: name)
case let .alias(name): self = .alias(name: name)
case let .tag(handle, suffix): self = .tag(handle: handle, suffix: suffix)
case let .scalar(value, style): self = .scalar(value: value, style: PureYAML.Streaming.Token.ScalarStyle(style))
}
}
}
extension PureYAML.Streaming.Token.ScalarStyle {
init(_ style: PureYAML.Parsing.ScalarStyle) {
switch style {
case .plain: self = .plain
case .singleQuoted: self = .singleQuoted
case .doubleQuoted: self = .doubleQuoted
case .literal: self = .literal
case .folded: self = .folded
}
}
}

View File

@ -0,0 +1,29 @@
public extension PureYAML.Streaming.Token {
/// A scanner token's kind, mirroring the internal libyaml-ported scanner's
/// own token kinds (`yaml_token_type_t`) one for one, under public naming
/// that does not leak the port's internal type names.
enum Kind: Equatable, Sendable {
case streamStart
case streamEnd
case documentStart
case documentEnd
case blockSequenceStart
case blockMappingStart
case blockEnd
case blockEntry
case flowSequenceStart
case flowMappingStart
case flowSequenceEnd
case flowMappingEnd
case flowEntry
case key
case value
case versionDirective(major: Int, minor: Int)
case tagDirective(handle: String, prefix: String)
case reservedDirective
case anchor(name: String)
case alias(name: String)
case tag(handle: String, suffix: String)
case scalar(value: String, style: PureYAML.Streaming.Token.ScalarStyle)
}
}

View File

@ -0,0 +1,12 @@
public extension PureYAML.Streaming.Token {
/// The quoting or block style a scalar token was scanned with. Distinct
/// from ``PureYAML/Emitting/ScalarStyle``, which is an output rendering
/// policy, not the style a scanned scalar was actually written in.
enum ScalarStyle: Equatable, Sendable {
case plain
case singleQuoted
case doubleQuoted
case literal
case folded
}
}

View File

@ -0,0 +1,14 @@
public extension PureYAML.Streaming {
/// A scanner token: a kind plus the source span it covers. Produced by
/// ``Scanner/next()`` one at a time, in the same order ``PureYAML/parse(_:)``
/// would see them internally.
struct Token: Equatable, Sendable {
public var kind: Kind
public var range: PureYAML.Model.SourceRange
public init(kind: Kind, range: PureYAML.Model.SourceRange) {
self.kind = kind
self.range = range
}
}
}

View File

@ -1,55 +1,85 @@
public extension PureYAML.Tagged {
/// The caseless namespace of built-in type-directed checks for the tagged-node tree, the tagged
/// layer's counterpart of `Validation.BuiltinCheck`. Each is a `Check<Node, Node>` on the one
/// layer's counterpart of `Validation.BuiltinValidation`. Each is a `Validation<_, Node>` on the one
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
enum BuiltinCheck {
enum BuiltinValidation {
/// Reports each duplicate key within a single tagged mapping. The tagged-layer counterpart of
/// `Validation.BuiltinCheck.mappingKeysAreUnique`; duplicate keys are as invalid in a
/// `Validation.BuiltinValidation.mappingKeysAreUnique`; duplicate keys are as invalid in a
/// tag-preserving tree as in the plain model, and `Tagged.Mapping` keeps every parsed pair.
public static var mappingKeysAreUnique: PureYAML.Validation.Check<Mapping, Node> {
PureYAML.Validation.Check(description: "Mapping keys are unique") { context in
var seen = Set<String>()
var issues: [PureYAML.Validation.Issue] = []
for pair in context.subject.pairs where !seen.insert(pair.key).inserted {
issues.append(PureYAML.Validation.Issue(
public static var mappingKeysAreUnique: PureYAML.Validation.Validation<Mapping, Node> {
PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in
var seen = Set<PureYAML.Tagged.MappingKeyIdentity>()
var errors: [PureYAML.Validation.ValidationError] = []
for pair in context.subject.pairs where !seen.insert(keyIdentity(of: pair)).inserted {
errors.append(PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '\(pair.key)'",
path: context.path.appending(.key(pair.key)),
))
}
return issues
return errors
}
}
static func keyIdentity(of pair: PureYAML.Tagged.Pair) -> PureYAML.Tagged.MappingKeyIdentity {
let node = pair.keyNode.resolved
guard case let .scalar(scalar) = node else {
// Collection keys do not arise from parsing; fall back to the
// resolved key text so equal spellings still collide.
return PureYAML.Tagged.MappingKeyIdentity(value: .string(pair.key), tag: node.tag?.rawValue)
}
return PureYAML.Tagged.MappingKeyIdentity(value: scalar.value, tag: effectiveTag(of: scalar)?.rawValue)
}
static func effectiveTag(of scalar: PureYAML.Tagged.Scalar) -> PureYAML.Tagged.Tag? {
if let tag = scalar.tag {
return tag
}
switch scalar.value {
case .null:
return .null
case .bool:
return .bool
case .int:
return .int
case .double:
return .float
case .string:
return .string
case .sequence, .mapping:
return nil
}
}
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind, on the
/// node itself and on each mapping key.
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Check<Node, Node> {
PureYAML.Validation.Check(description: "Built-in tags are supported and kind-consistent") { context in
var issues: [PureYAML.Validation.Issue] = []
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Validation<Node, Node> {
PureYAML.Validation.Validation(description: "Built-in tags are supported and kind-consistent") { context in
var errors: [PureYAML.Validation.ValidationError] = []
if case let .mapping(mapping) = context.subject {
issues.append(contentsOf: mapping.pairs.compactMap { pair in
errors.append(contentsOf: mapping.pairs.compactMap { pair in
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
})
}
guard let tag = context.subject.tag, tag.isBuiltIn else {
return issues
return errors
}
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
issues.append(
PureYAML.Validation.Issue(
errors.append(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
path: context.path,
),
)
return issues
return errors
}
if !tag.isSupportedBuiltInTag {
issues.append(
PureYAML.Validation.Issue(
errors.append(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)'",
path: context.path,
@ -57,7 +87,7 @@ public extension PureYAML.Tagged {
)
}
return issues
return errors
} when: { context in
if context.subject.tag?.isBuiltIn == true {
return true
@ -72,7 +102,7 @@ public extension PureYAML.Tagged {
static func keyTagIssue(
for pair: PureYAML.Tagged.Pair,
at path: PureYAML.Validation.Path,
) -> PureYAML.Validation.Issue? {
) -> PureYAML.Validation.ValidationError? {
guard let tag = pair.keyTag, tag.isBuiltIn else {
return nil
}
@ -80,21 +110,21 @@ public extension PureYAML.Tagged {
return nil
}
if tag == .merge {
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
path: path,
)
}
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
path: path,
)
}
guard tag.isSupportedBuiltInTag else {
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
path: path,

View File

@ -0,0 +1,23 @@
public extension PureYAML.Tagged {
/// The presentation style a collection had in the source document, or the
/// style a caller wants a collection to take on output. `nil` on a node
/// means "no per-node preference": the emitter's document-wide policy
/// decides.
enum CollectionStyle: String, Equatable, Sendable, CustomStringConvertible {
case block
case flow
public var description: String {
rawValue
}
}
}
extension PureYAML.Tagged.CollectionStyle {
init(_ style: PureYAML.Parsing.CollectionStyle) {
self = switch style {
case .block: .block
case .flow: .flow
}
}
}

View File

@ -0,0 +1,132 @@
// MARK: - Canonical form
extension PureYAML.Tagged.Dumper {
/// The YAML-specification canonical form over a tagged tree: an
/// explicit `---` document start, every node's tag explicit (the
/// carried tag when present, the value's implicit tag otherwise),
/// double-quoted scalar content, flow collections one entry per line,
/// and anchors and alias references preserved. Canonical normalizes
/// scalar spellings: content renders from the resolved value, not the
/// source rawValue.
func canonicalDocument(_ node: PureYAML.Tagged.Node) -> String {
"---\n" + canonicalLines(node, indent: 0).joined(separator: "\n") + "\n"
}
func canonicalLines(
_ node: PureYAML.Tagged.Node,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
switch node {
case let .alias(anchor, _):
return [prefix + "*" + anchor]
case let .scalar(scalar):
return [prefix + canonicalProperties(node) + canonicalScalarContent(scalar)]
case let .sequence(sequence):
return canonicalSequenceLines(sequence, node: node, indent: indent)
case let .mapping(mapping):
return canonicalMappingLines(mapping, node: node, indent: indent)
}
}
func canonicalSequenceLines(
_ sequence: PureYAML.Tagged.Sequence,
node: PureYAML.Tagged.Node,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let props = canonicalProperties(node, fallback: .sequence)
guard !sequence.values.isEmpty else {
return [prefix + props + "[]"]
}
var lines = [prefix + props + "["]
for value in sequence.values {
var entry = canonicalLines(value, indent: indent + options.indentWidth)
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "]")
return lines
}
func canonicalMappingLines(
_ mapping: PureYAML.Tagged.Mapping,
node: PureYAML.Tagged.Node,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let props = canonicalProperties(node, fallback: .mapping)
guard !mapping.pairs.isEmpty else {
return [prefix + props + "{}"]
}
let inner = indent + options.indentWidth
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + props + "{"]
for pair in orderedPairs(mapping) {
var keyLines = canonicalLines(pair.keyNode, indent: inner)
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
lines.append(contentsOf: keyLines)
var valueLines = canonicalLines(pair.value, indent: inner)
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
valueLines[valueLines.count - 1] += ","
lines.append(contentsOf: valueLines)
}
lines.append(prefix + "}")
return lines
}
/// The canonical `&anchor !tag ` prefix: anchor first, then the carried
/// tag, or the value's implicit tag made explicit when none is carried.
func canonicalProperties(
_ node: PureYAML.Tagged.Node,
fallback: PureYAML.Tagged.Tag? = nil,
) -> String {
var parts: [String] = []
if let anchor = node.anchor, node.kind != .alias {
parts.append("&" + anchor)
}
if let tag = node.tag ?? fallback ?? implicitTag(of: node) {
parts.append(renderTag(tag))
}
return parts.joined(separator: " ") + " "
}
func implicitTag(of node: PureYAML.Tagged.Node) -> PureYAML.Tagged.Tag? {
guard case let .scalar(scalar) = node else {
return nil
}
switch scalar.value {
case .null:
return .null
case .bool:
return .bool
case .int:
return .int
case .double:
return .float
case .string:
return .string
case .sequence, .mapping:
return nil
}
}
/// Canonical scalar content: always double-quoted, rendered from the
/// resolved value so `NO` normalizes to `"false"` under its bool tag.
func canonicalScalarContent(_ scalar: PureYAML.Tagged.Scalar) -> String {
switch scalar.value {
case .null:
"\"null\""
case let .bool(flag):
"\"\(flag ? "true" : "false")\""
case let .int(number):
"\"\(number)\""
case let .double(number):
"\"\(number)\""
case let .string(string):
PureYAML.Emitting.Dumper(options: options).quote(string)
case .sequence, .mapping:
"\"\""
}
}
}

View File

@ -0,0 +1,294 @@
// MARK: - Scalar styles
extension PureYAML.Tagged.Dumper {
/// Renders a scalar honoring its per-node style where the value can
/// legally take it, falling back to double quoting otherwise. Values
/// with a plain source style re-emit their source spelling when that
/// spelling is syntactically safe.
func renderScalarValue(
_ scalar: PureYAML.Tagged.Scalar,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> String {
if scalar.value == .null, scalar.style == .plain, isPlainNullForm(scalar.rawValue) {
// A plain null re-emits its source spelling: `key:` with an empty
// value, `~`, and the null word forms all reparse to null, so the
// source form is preserved instead of normalizing to "null".
return scalar.rawValue
}
guard case let .string(string) = scalar.value else {
return renderNonString(scalar, flow: flow, renderer: renderer)
}
switch scalar.style {
case .plain:
return renderPlainString(string, tag: scalar.tag, flow: flow, renderer: renderer)
case .singleQuoted:
return renderSingleQuoted(string, renderer: renderer)
case .doubleQuoted:
return renderer.quote(string)
case .literal, .folded:
// Multi-line handling happens in renderLiteralBlock; reaching
// here means the value cannot take a block form (single line, or
// flow context, or block-unsafe content), so quote it.
return renderer.quote(string)
case nil:
return flow ? renderer.renderFlowScalar(scalar.value) : renderer.renderScalar(scalar.value)
}
}
/// A plain non-string re-emits its source spelling when that spelling
/// is syntactically safe: `NO` resolved to a bool and `1_000` to an int
/// on parse, so the same resolver maps the same text back to the same
/// value, and normalizing the spelling (`NO` to `false`) would lose the
/// source form for nothing.
func renderNonString(
_ scalar: PureYAML.Tagged.Scalar,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> String {
if scalar.style == .plain, isSyntacticallyPlainSafe(scalar.rawValue, flow: flow, renderer: renderer) {
return scalar.rawValue
}
return renderer.renderScalar(scalar.value)
}
/// A plain string must stay a string on reparse: without an explicit
/// tag, content that resolves as a number, bool, or null must be
/// quoted; with one, the tag governs resolution (`!!str 42` reparses as
/// the string "42") and only syntax matters.
func renderPlainString(
_ string: String,
tag: PureYAML.Tagged.Tag?,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> String {
let stringSafe = tag != nil
|| (!renderer.isReservedPlainScalar(string) && !renderer.isNumberLikePlainScalar(string))
return stringSafe && isSyntacticallyPlainSafe(string, flow: flow, renderer: renderer)
? string
: renderer.quote(string)
}
/// Single quoting escapes only the quote itself (by doubling); content
/// with line breaks has no exact single-quoted form (quoted folding
/// would rewrite the breaks), so it degrades to double quoting.
func renderSingleQuoted(
_ string: String,
renderer: PureYAML.Emitting.Dumper,
) -> String {
guard !string.contains("\n"), !string.contains("\r") else {
return renderer.quote(string)
}
var escaped = ""
for character in string {
escaped.append(character)
if character == "'" {
escaped.append("'")
}
}
return "'" + escaped + "'"
}
/// The plain source spellings that reparse to null: an empty value, `~`,
/// and the three null word forms.
func isPlainNullForm(_ rawValue: String) -> Bool {
switch rawValue {
case "", "~", "null", "Null", "NULL":
true
default:
false
}
}
/// Syntactic plain safety, following the spec's actual rules rather than
/// the emitter policy path's coarser ones: a leading `-`, `?`, or `:` is
/// safe unless followed by a space (or alone), and `#` only opens a
/// comment after whitespace, so a URL fragment stays plain. Resolution
/// safety (a string that would re-resolve as a number or bool) is the
/// caller's concern: it applies to untagged strings but is exactly wrong
/// for non-strings re-emitting their source spelling.
func isSyntacticallyPlainSafe(
_ string: String,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> Bool {
guard !string.isEmpty else {
return false
}
guard string.first?.isWhitespace == false, string.last?.isWhitespace == false else {
return false
}
guard !string.contains("\n"), !string.contains("\r"), !string.contains("\t") else {
return false
}
// A trailing `:` reparses as a mapping key with an empty value, so a
// scalar that ends in one is not plain-safe even when no interior `:`
// is followed by a space.
guard string.last != ":" else {
return false
}
guard let first = string.first else {
return false
}
let second = string.dropFirst().first
switch first {
case "-", "?", ":":
if second == nil || second == " " {
return false
}
case ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", "\"", "%", "@", "`":
return false
default:
break
}
guard !renderer.containsColonSpace(string), !containsCommentAfterSpace(string) else {
return false
}
return !flow || !renderer.containsFlowDelimiter(string)
}
/// `#` starts a comment only when preceded by whitespace; a `#` embedded
/// in a word (a URL fragment, an IP:port#tag form) is plain content.
func containsCommentAfterSpace(_ string: String) -> Bool {
var previousWasWhitespace = false
for character in string {
if character == "#", previousWasWhitespace {
return true
}
previousWasWhitespace = character.isWhitespace
}
return false
}
}
// MARK: - Block scalars
extension PureYAML.Tagged.Dumper {
/// Block-scalar rendering for `literal` and `folded` styles. A folded
/// scalar re-emits as `>` when the value's shape allows an exact
/// inversion of the folding rules, otherwise it degrades to a literal
/// block (`|`), which still reproduces the value exactly; only the fold
/// marks differ. Returns `nil` when the value cannot take any block
/// form, in which case the scalar falls back to double quoting.
func renderLiteralBlock(
_ scalar: PureYAML.Tagged.Scalar,
props: String,
contentIndent: Int,
) -> (header: String, content: [String])? {
guard
case let .string(string) = scalar.value,
scalar.style == .literal || scalar.style == .folded
else {
return nil
}
if scalar.style == .folded, let folded = renderFoldedBlock(string, props: props, contentIndent: contentIndent) {
return folded
}
guard canRenderLiteralBlockContent(string) else {
return nil
}
let trailingNewlines = countTrailingNewlines(string)
let chomping: String
let contentSource: String
switch trailingNewlines {
case 0:
chomping = "-"
contentSource = string
case 1:
chomping = ""
contentSource = String(string.dropLast())
default:
// Two or more trailing newlines need keep chomping: clip (`|`)
// would collapse them to one.
chomping = "+"
contentSource = String(string.dropLast())
}
// A whitespace-led first content line defeats indentation
// auto-detection, so the header carries an explicit indentation
// indicator and the leading whitespace becomes content.
let needsIndicator = firstContentLine(of: contentSource)?.first?.isWhitespace == true
if needsIndicator, !(1 ... 9).contains(options.indentWidth) {
return nil
}
let indicator = "|" + (needsIndicator ? String(options.indentWidth) : "") + chomping
let header = props.isEmpty ? indicator : props + " " + indicator
let prefix = String(repeating: " ", count: contentIndent)
let content = contentSource
.split(separator: "\n", omittingEmptySubsequences: false)
.map { $0.isEmpty ? "" : prefix + String($0) }
return (header, content)
}
/// Exact inversion of folding for the shapes that allow one: every
/// logical line separated by single newlines, no empty logical lines, no
/// leading or trailing whitespace per line. Emitting each logical line
/// as its own paragraph (blank line between) folds back to exactly one
/// newline per boundary.
func renderFoldedBlock(
_ string: String,
props: String,
contentIndent: Int,
) -> (header: String, content: [String])? {
let trailingNewlines = countTrailingNewlines(string)
guard trailingNewlines <= 1 else {
return nil
}
let contentSource = trailingNewlines == 1 ? String(string.dropLast()) : string
guard !contentSource.isEmpty, !contentSource.contains("\r"), !contentSource.contains("\t") else {
return nil
}
let lines = contentSource.split(separator: "\n", omittingEmptySubsequences: false)
let safe = lines.allSatisfy { line in
!line.isEmpty && line.first?.isWhitespace == false && line.last?.isWhitespace == false
}
guard safe else {
return nil
}
let indicator = trailingNewlines == 1 ? ">" : ">-"
let header = props.isEmpty ? indicator : props + " " + indicator
let prefix = String(repeating: " ", count: contentIndent)
var content: [String] = []
for (index, line) in lines.enumerated() {
if index > 0 {
content.append("")
}
content.append(prefix + String(line))
}
return (header, content)
}
func countTrailingNewlines(_ string: String) -> Int {
var count = 0
for character in string.reversed() {
guard character == "\n" else {
break
}
count += 1
}
return count
}
/// A literal block can hold any string with at least one non-empty line
/// and no carriage returns (single-line content emits as `|-`, a
/// whitespace-led first line gets an explicit indentation indicator).
/// Trailing spaces, tab-led or whitespace-only interior lines, leading
/// blank lines, and more deeply indented interior lines are all
/// preserved content: the block's own indent is stripped and the rest
/// survives verbatim.
func canRenderLiteralBlockContent(_ value: String) -> Bool {
guard !value.contains("\r") else {
return false
}
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
guard !contentSource.isEmpty else {
return false
}
return firstContentLine(of: contentSource) != nil
}
func firstContentLine(of contentSource: String) -> Substring? {
contentSource
.split(separator: "\n", omittingEmptySubsequences: false)
.first { !$0.isEmpty }
}
}

View File

@ -0,0 +1,395 @@
public extension PureYAML.Tagged {
/// Serializes a tag-preserving node tree back to YAML, re-emitting
/// anchors as `&name`, aliases as `*name` references (not expanded
/// copies), explicit tags, and per-node presentation styles.
///
/// Per-node styles win over the document-wide policy in `options`; a
/// node with a `nil` style falls back to that policy. A requested style
/// the value cannot legally take (a plain-unsafe string asked to be
/// plain, a single-line string asked to be literal) falls back to double
/// quoting, which can represent every string.
struct Dumper: Sendable {
public var options: PureYAML.Emitting.Options
public init(options: PureYAML.Emitting.Options = .default) {
self.options = options
}
public func dump(_ node: PureYAML.Tagged.Node) -> String {
guard !PureYAML.Emitting.exceedsNestingLimit(node) else {
return PureYAML.Emitting.nestingLimitDocument
}
if options.canonical {
var body = canonicalDocument(node)
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
let scalarRenderer = PureYAML.Emitting.Dumper(options: options)
var body = if isFlowRendered(node) {
renderFlow(node, renderer: scalarRenderer) + "\n"
} else {
renderBlock(node, indent: 0, renderer: scalarRenderer)
.joined(separator: "\n") + "\n"
}
if options.explicitDocumentStart {
body = "---\n" + body
}
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
}
}
extension PureYAML.Tagged.Dumper {
typealias Node = PureYAML.Tagged.Node
/// The `&anchor !tag` prefix for a node, empty when the node has neither.
/// Anchor precedes tag, matching libyaml's canonical property order.
func properties(_ node: Node) -> String {
var parts: [String] = []
if let anchor = node.anchor, node.kind != .alias {
parts.append("&" + anchor)
}
if let tag = node.tag {
parts.append(renderTag(tag))
}
return parts.joined(separator: " ")
}
/// Built-in tags render in `!!suffix` shorthand; everything else renders
/// verbatim so unknown tag URIs and local tags survive unchanged.
func renderTag(_ tag: PureYAML.Tagged.Tag) -> String {
if tag.isBuiltIn {
return "!!" + tag.rawValue.dropFirst("tag:yaml.org,2002:".count)
}
return "!<" + tag.rawValue + ">"
}
func isFlowRendered(_ node: Node) -> Bool {
switch node {
case let .mapping(mapping):
(mapping.style ?? collectionPolicy) == .flow
case let .sequence(sequence):
(sequence.style ?? collectionPolicy) == .flow
case .scalar, .alias:
false
}
}
var collectionPolicy: PureYAML.Tagged.CollectionStyle {
switch options.collectionStyle {
case .block: .block
case .flow: .flow
}
}
}
// MARK: - Block rendering
extension PureYAML.Tagged.Dumper {
func renderBlock(
_ node: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
switch node {
case .alias:
return [prefix + renderInline(node, renderer: renderer)]
case .scalar:
return renderBlockScalar(node, indent: indent, renderer: renderer)
case let .sequence(sequence):
return renderBlockSequence(sequence, node: node, indent: indent, renderer: renderer)
case let .mapping(mapping):
return renderBlockMapping(mapping, node: node, indent: indent, renderer: renderer)
}
}
func renderBlockScalar(
_ node: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let props = properties(node)
guard case let .scalar(scalar) = node else {
return [prefix + renderInline(node, renderer: renderer)]
}
if let block = renderLiteralBlock(
scalar,
props: props,
contentIndent: indent + options.indentWidth,
) {
return [prefix + block.header] + block.content
}
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
return [prefix + (props.isEmpty ? rendered : props + " " + rendered)]
}
func renderBlockSequence(
_ sequence: PureYAML.Tagged.Sequence,
node: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let props = properties(node)
var lines: [String] = []
if !props.isEmpty {
lines.append(prefix + props)
}
guard !sequence.values.isEmpty else {
return props.isEmpty ? [prefix + "[]"] : [prefix + props + " []"]
}
for value in sequence.values {
lines.append(contentsOf: renderBlockSequenceItem(value, indent: indent, renderer: renderer))
}
return lines
}
func renderBlockSequenceItem(
_ value: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let block = literalBlock(for: value, contentIndent: indent + options.indentWidth) {
return [prefix + "- " + block.header] + block.content
}
if let inline = renderInlineIfSimple(value, renderer: renderer) {
return [inline.isEmpty ? prefix + "-" : prefix + "- " + inline]
}
let childProps = properties(value)
var lines: [String] = []
if childProps.isEmpty {
lines.append(prefix + "-")
} else {
lines.append(prefix + "- " + childProps)
}
lines.append(contentsOf: renderBlockBody(value, indent: indent + options.indentWidth, renderer: renderer))
return lines
}
func renderBlockMapping(
_ mapping: PureYAML.Tagged.Mapping,
node: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let props = properties(node)
var lines: [String] = []
if !props.isEmpty {
lines.append(prefix + props)
}
guard !mapping.pairs.isEmpty else {
return props.isEmpty ? [prefix + "{}"] : [prefix + props + " {}"]
}
for pair in orderedPairs(mapping) {
lines.append(contentsOf: renderBlockPair(pair, indent: indent, renderer: renderer))
}
return lines
}
func renderBlockPair(
_ pair: PureYAML.Tagged.Pair,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let key = renderKey(pair.keyNode, flow: false, renderer: renderer)
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
return [prefix + key + ": " + block.header] + block.content
}
if let inline = renderInlineIfSimple(pair.value, renderer: renderer) {
return [inline.isEmpty ? prefix + key + ":" : prefix + key + ": " + inline]
}
let childProps = properties(pair.value)
var lines: [String] = []
if childProps.isEmpty {
lines.append(prefix + key + ":")
} else {
lines.append(prefix + key + ": " + childProps)
}
lines.append(contentsOf: renderBlockBody(pair.value, indent: indent + options.indentWidth, renderer: renderer))
return lines
}
/// The node's content lines with its properties already emitted by the
/// caller (on the `-` or `key:` line), so collections render bare here.
func renderBlockBody(
_ node: Node,
indent: Int,
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
switch node {
case let .sequence(sequence):
renderBlockSequence(
sequence,
node: .sequence(.init(values: sequence.values)),
indent: indent,
renderer: renderer,
)
case let .mapping(mapping):
renderBlockMapping(
mapping,
node: .mapping(.init(pairs: mapping.pairs)),
indent: indent,
renderer: renderer,
)
case .scalar, .alias:
renderBlock(node, indent: indent, renderer: renderer)
}
}
/// A mapping key rendered from its node: an alias key emits `*name `
/// with a trailing space (`:` is a legal anchor character, so the colon
/// must not touch the alias name), a scalar key emits its properties
/// and its style-honoring single-line form (block styles on a key fall
/// back to quoting inside `renderScalarValue`).
func renderKey(
_ keyNode: Node,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> String {
switch keyNode {
case let .alias(anchor, _):
return "*" + anchor + " "
case let .scalar(scalar):
let props = properties(keyNode)
// A style-less key keeps the traditional key convention (plain
// when safe, quoted otherwise) instead of inheriting the
// document's scalar policy, which would quote every key under
// the default options. In a flow mapping the safety check must
// also reject flow delimiters (`,{}[]`), or a key like `a,b`
// would emit unquoted and reparse as two entries.
let rendered: String
if scalar.style == nil {
let plainSafe = flow
? renderer.canRenderFlowPlainString(scalar.rawValue)
: renderer.canRenderPlainString(scalar.rawValue)
rendered = plainSafe ? scalar.rawValue : renderer.quote(scalar.rawValue)
} else {
rendered = renderScalarValue(scalar, flow: flow, renderer: renderer)
}
return props.isEmpty ? rendered : props + " " + rendered
case .mapping, .sequence:
// Unreachable from parsing (keys compose as scalars or aliases);
// a hand-built collection key renders as its resolved string.
return renderer.escapeKey("")
}
}
/// The block-scalar form for a scalar node whose style asks for one,
/// with the node's properties folded into the header; `nil` for
/// non-scalars and for scalars that render inline.
func literalBlock(
for node: Node,
contentIndent: Int,
) -> (header: String, content: [String])? {
guard case let .scalar(scalar) = node else {
return nil
}
return renderLiteralBlock(scalar, props: properties(node), contentIndent: contentIndent)
}
/// One-line rendering when the node fits on the parent's line: aliases,
/// scalars without a literal block, flow-styled collections, and empty
/// collections. Returns `nil` when the node needs its own block lines.
func renderInlineIfSimple(
_ node: Node,
renderer: PureYAML.Emitting.Dumper,
) -> String? {
let props = properties(node)
switch node {
case .alias:
return renderInline(node, renderer: renderer)
case let .scalar(scalar):
if renderLiteralBlock(scalar, props: props, contentIndent: 0) != nil {
return nil
}
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
return props.isEmpty ? rendered : props + " " + rendered
case let .sequence(sequence):
if isFlowRendered(node) || sequence.values.isEmpty {
return renderInline(node, renderer: renderer)
}
return nil
case let .mapping(mapping):
if isFlowRendered(node) || mapping.pairs.isEmpty {
return renderInline(node, renderer: renderer)
}
return nil
}
}
}
// MARK: - Flow and inline rendering
extension PureYAML.Tagged.Dumper {
func renderInline(
_ node: Node,
renderer: PureYAML.Emitting.Dumper,
) -> String {
let props = properties(node)
let body: String
switch node {
case let .alias(anchor, _):
return "*" + anchor
case let .scalar(scalar):
body = renderScalarValue(scalar, flow: true, renderer: renderer)
case let .sequence(sequence):
body = "[" + sequence.values
.map { renderFlow($0, renderer: renderer) }
.joined(separator: ", ") + "]"
case let .mapping(mapping):
body = "{" + orderedPairs(mapping)
.map { pair in
renderKey(pair.keyNode, flow: true, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
}
.joined(separator: ", ") + "}"
}
return props.isEmpty ? body : props + " " + body
}
func renderFlow(
_ node: Node,
renderer: PureYAML.Emitting.Dumper,
) -> String {
renderInline(node, renderer: renderer)
}
}
// MARK: - Key ordering
extension PureYAML.Tagged.Dumper {
/// `mapping.pairs` in insertion order, or sorted by their plain string key
/// when `options.sortKeys` is set and every key is a plain, untagged,
/// unanchored string scalar. Any anchored, tagged, styled, alias, or
/// non-string key leaves the mapping in insertion order: there is no
/// well-defined lexicographic order across those, and reordering an
/// anchored key would move an anchor definition relative to its scalar.
/// Mirrors the model dumper's `orderedPairs` rule.
func orderedPairs(_ mapping: PureYAML.Tagged.Mapping) -> [PureYAML.Tagged.Pair] {
guard options.sortKeys, mapping.pairs.allSatisfy(isPlainStringKey) else {
return mapping.pairs
}
return mapping.pairs.sorted { $0.key < $1.key }
}
func isPlainStringKey(_ pair: PureYAML.Tagged.Pair) -> Bool {
guard case let .scalar(scalar) = pair.keyNode else {
return false
}
guard case .string = scalar.value else {
return false
}
return scalar.anchor == nil
&& scalar.tag == nil
&& (scalar.style == .plain || scalar.style == nil)
}
}

View File

@ -101,9 +101,9 @@ extension PureYAML.Tagged.EventComposer {
column: mark.column,
)
}
return value
case let .mappingStart(anchor, tag, _, _):
let value = try composeMapping(tag: tag)
return .alias(anchor: anchor, node: value)
case let .mappingStart(anchor, tag, style, _):
let value = try composeMapping(tag: tag, anchor: anchor, style: style)
store(value, anchor: anchor)
return value
case let .scalar(value, anchor, tag, style, mark, _):
@ -117,12 +117,14 @@ extension PureYAML.Tagged.EventComposer {
mark: mark,
),
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
)
let node = PureYAML.Tagged.Node.scalar(scalar)
store(node, anchor: anchor)
return node
case let .sequenceStart(anchor, tag, _, _):
let value = try composeSequence(tag: tag)
case let .sequenceStart(anchor, tag, style, _):
let value = try composeSequence(tag: tag, anchor: anchor, style: style)
store(value, anchor: anchor)
return value
default:
@ -130,7 +132,11 @@ extension PureYAML.Tagged.EventComposer {
}
}
mutating func composeSequence(tag: String?) throws -> PureYAML.Tagged.Node {
mutating func composeSequence(
tag: String?,
anchor: String?,
style: PureYAML.Parsing.CollectionStyle,
) throws -> PureYAML.Tagged.Node {
_ = try expect("sequence start") { event in
if case .sequenceStart = event {
return true
@ -142,10 +148,19 @@ extension PureYAML.Tagged.EventComposer {
try values.append(composeNode())
}
_ = try expect("sequence end") { $0.isSequenceEnd }
return .sequence(.init(values: values, tag: .normalized(tag)))
return .sequence(.init(
values: values,
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
))
}
mutating func composeMapping(tag: String?) throws -> PureYAML.Tagged.Node {
mutating func composeMapping(
tag: String?,
anchor: String?,
style: PureYAML.Parsing.CollectionStyle,
) throws -> PureYAML.Tagged.Node {
_ = try expect("mapping start") { event in
if case .mappingStart = event {
return true
@ -154,18 +169,40 @@ extension PureYAML.Tagged.EventComposer {
}
var pairs: [PureYAML.Tagged.Pair] = []
while current?.isMappingEnd != true {
let key = try composeMappingKey()
let keyNode = try composeMappingKey()
let value = try composeNode()
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
pairs.append(.init(keyNode: keyNode, value: value))
}
_ = try expect("mapping end") { $0.isMappingEnd }
return .mapping(.init(pairs: pairs, tag: .normalized(tag)))
return .mapping(.init(
pairs: pairs,
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
))
}
mutating func composeMappingKey() throws -> ComposedMappingKey {
mutating func composeMappingKey() throws -> PureYAML.Tagged.Node {
guard let event = current else {
throw unexpectedEvent(expected: "mapping key")
}
if case let .alias(anchor, mark) = event {
index += 1
guard let resolved = anchors[anchor] else {
throw PureYAML.Parsing.ParseError.undefinedAlias(
anchor: anchor,
line: mark.line,
column: mark.column,
)
}
// A defined anchor that resolves to a collection cannot serve as a
// mapping key here (this layer keys mappings on scalars), so it is
// an expected-scalar-key error, not an undefined-alias error.
guard case .scalar = resolved.resolved else {
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
}
return .alias(anchor: anchor, node: resolved)
}
guard case let .scalar(value, anchor, tag, style, mark, _) = event else {
let mark = event.mark
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
@ -175,18 +212,15 @@ extension PureYAML.Tagged.EventComposer {
rawValue: value,
value: scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark),
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
)
store(.scalar(scalar), anchor: anchor)
return ComposedMappingKey(value: value, tag: .normalized(tag))
return .scalar(scalar)
}
}
extension PureYAML.Tagged.EventComposer {
struct ComposedMappingKey {
var value: String
var tag: PureYAML.Tagged.Tag?
}
var current: PureYAML.Parsing.Event? {
guard events.indices.contains(index) else {
return nil

View File

@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
struct Mapping: Equatable, Sendable {
public var pairs: [Pair]
public var tag: Tag?
/// The `&name` anchor on this mapping in the source document, or the
/// anchor to attach on output. `nil` when the node is unanchored.
public var anchor: String?
/// The mapping's presentation style. Populated from the source on
/// parse; `nil` on hand-built nodes that have no style preference.
public var style: CollectionStyle?
public init(
pairs: [Pair],
tag: Tag? = nil,
anchor: String? = nil,
style: CollectionStyle? = nil,
) {
self.pairs = pairs
self.tag = tag
self.anchor = anchor
self.style = style
}
public subscript(_ key: String) -> Node? {

View File

@ -0,0 +1,12 @@
extension PureYAML.Tagged {
/// Key identity for duplicate detection: the resolved scalar value plus the
/// key's effective tag (its carried tag, or the implicit tag of the resolved
/// value). Two keys are the same key iff both match. Using the resolved value
/// rather than the source spelling makes `0x1` and `1` (both `int(1)`)
/// collide while `1` (`int`) and `"1"` (`str`) do not, and keeps
/// custom-tagged keys (`!foo 1` vs `!bar 1`) distinct.
struct MappingKeyIdentity: Hashable {
var value: PureYAML.Model.Value
var tag: String?
}
}

View File

@ -1,5 +1,7 @@
public extension PureYAML.Tagged.Node {
/// Converts a tagged node into a model value while explicitly erasing tags.
/// An alias converts to its resolved node's value, matching `parse(_:)`'s
/// alias-expanding semantics.
var modelValueErasingTags: PureYAML.Model.Value {
switch self {
case let .mapping(mapping):
@ -10,6 +12,8 @@ public extension PureYAML.Tagged.Node {
scalar.value
case let .sequence(sequence):
.sequence(sequence.values.map(\.modelValueErasingTags))
case let .alias(_, node):
node.modelValueErasingTags
}
}
}

View File

@ -0,0 +1,26 @@
extension PureYAML.Tagged.Node: PureYAML.Validation.Traversable {
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this). Unlike the model
/// layer, key nodes are not offered separately: a tagged mapping's keys are plain `String`, not a
/// `Validatable` key type of their own.
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
guard case let .mapping(mapping) = self else {
return []
}
return [(nil, mapping)]
}
/// A mapping's children are its pairs' values, keyed by their string key; a sequence's children are
/// its values, keyed by index. An alias's aliased subtree was already walked at its anchor's
/// definition site, so recursing here would report every error in that subtree once per reference;
/// a scalar has no children either.
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Tagged.Node)] {
switch self {
case let .mapping(mapping):
mapping.pairs.map { pair in (component: .key(pair.key), value: pair.value) }
case let .sequence(sequence):
sequence.values.indices.map { index in (component: .index(index), value: sequence.values[index]) }
case .scalar, .alias:
[]
}
}
}

View File

@ -1,9 +1,18 @@
public extension PureYAML.Tagged {
/// YAML node tree that preserves explicit source tags.
enum Node: Equatable, Sendable {
/// YAML node tree that preserves explicit source tags, anchors, aliases,
/// and per-node presentation styles.
///
/// An `alias` case carries both the `*name` anchor it referenced and the
/// node it resolved to, so value-oriented consumers can read through the
/// alias without a separate resolution step while emitters can re-emit
/// the reference instead of expanding it. Resolution cannot cycle: the
/// composer registers an anchor only after its node is fully composed, so
/// a self-referential alias is an `undefinedAlias` parse error.
indirect enum Node: Equatable, Sendable {
case scalar(Scalar)
case sequence(Sequence)
case mapping(Mapping)
case alias(anchor: String, node: Node)
public var tag: Tag? {
switch self {
@ -13,6 +22,8 @@ public extension PureYAML.Tagged {
scalar.tag
case let .sequence(sequence):
sequence.tag
case .alias:
nil
}
}
@ -24,6 +35,33 @@ public extension PureYAML.Tagged {
.scalar
case .sequence:
.sequence
case .alias:
.alias
}
}
/// The `&name` anchor attached to this node, `nil` when unanchored.
/// An alias reports the anchor it references.
public var anchor: String? {
switch self {
case let .mapping(mapping):
mapping.anchor
case let .scalar(scalar):
scalar.anchor
case let .sequence(sequence):
sequence.anchor
case let .alias(anchor, _):
anchor
}
}
/// The node an alias resolved to; any other node returns itself.
public var resolved: Node {
switch self {
case .mapping, .scalar, .sequence:
self
case let .alias(_, node):
node
}
}
}

View File

@ -3,6 +3,7 @@ public extension PureYAML.Tagged {
case scalar
case sequence
case mapping
case alias
public var description: String {
rawValue

View File

@ -1,17 +1,44 @@
public extension PureYAML.Tagged {
struct Pair: Equatable, Sendable {
public var key: String
public var keyTag: Tag?
/// The key as a full node, carrying its tag, anchor, presentation
/// style, and alias-ness, so anchored, styled, and alias keys
/// round-trip instead of collapsing to a bare string (issues #12
/// and #13).
public var keyNode: Node
public var value: Node
public init(
keyNode: Node,
value: Node,
) {
self.keyNode = keyNode
self.value = value
}
/// Convenience for the overwhelmingly common plain string key.
public init(
key: String,
keyTag: Tag? = nil,
value: Node,
) {
self.key = key
self.keyTag = keyTag
self.value = value
self.init(
keyNode: .scalar(.init(rawValue: key, value: .string(key), tag: keyTag)),
value: value,
)
}
/// The key's resolved raw text: an alias key reads through to its
/// anchored scalar, matching `parse(_:)`'s alias-key semantics.
public var key: String {
guard case let .scalar(scalar) = keyNode.resolved else {
return ""
}
return scalar.rawValue
}
/// The key's tag, read through an alias to its resolution.
public var keyTag: Tag? {
keyNode.resolved.tag
}
}
}

View File

@ -0,0 +1,84 @@
public extension PureYAML.Tagged.Scalar {
/// The scalar's `!!timestamp` value: non-`nil` when the scalar carries
/// the standard timestamp tag and its raw content parses under the
/// YAML 1.1 timestamp grammar. Bridge to a Foundation date with
/// `Date(timeIntervalSince1970: timestampValue!.secondsSinceEpoch)`;
/// the package itself stays Foundation-free.
var timestampValue: PureYAML.Tagged.Timestamp? {
guard tag == .timestamp else {
return nil
}
return PureYAML.Tagged.Timestamp(yaml: rawValue)
}
/// The scalar's `!!binary` value: non-`nil` when the scalar carries the
/// standard binary tag and its raw content is valid base64 (whitespace
/// and line breaks between groups are ignored, matching base64 content
/// spread over a block scalar). Bridge to Foundation with
/// `Data(binaryValue!)`; the package itself stays Foundation-free.
var binaryValue: [UInt8]? {
guard tag == .binary else {
return nil
}
return PureYAML.Tagged.Scalar.decodeBase64(rawValue)
}
/// Pure-Swift base64 decoding over the standard alphabet with `=`
/// padding. Whitespace is skipped; any other non-alphabet character
/// rejects the whole input rather than decoding a prefix.
static func decodeBase64(_ text: String) -> [UInt8]? {
var bytes: [UInt8] = []
var buffer = 0
var bitCount = 0
var padding = 0
for character in text {
if character == " " || character == "\n" || character == "\t" || character == "\r" {
continue
}
if character == "=" {
padding += 1
continue
}
guard padding == 0, let value = base64Value(of: character) else {
return nil
}
buffer = (buffer << 6) | value
bitCount += 6
if bitCount >= 8 {
bitCount -= 8
bytes.append(UInt8((buffer >> bitCount) & 0xFF))
}
}
// Leftover bits must be zero-padding from the final group, and the
// group structure must be consistent: 8 leftover bits (two missing
// characters) pairs with two `=`, 4 bits with one, 0 with none.
let expectedPadding = bitCount == 4 ? 2 : bitCount == 2 ? 1 : 0
guard bitCount != 6, padding == expectedPadding || padding == 0 else {
return nil
}
guard buffer & ((1 << bitCount) - 1) == 0 || bitCount == 0 else {
return nil
}
return bytes
}
private static func base64Value(of character: Character) -> Int? {
guard let ascii = character.asciiValue else {
return nil
}
switch ascii {
case 65 ... 90:
return Int(ascii - 65)
case 97 ... 122:
return Int(ascii - 97) + 26
case 48 ... 57:
return Int(ascii - 48) + 52
case 43:
return 62
case 47:
return 63
default:
return nil
}
}
}

View File

@ -3,15 +3,25 @@ public extension PureYAML.Tagged {
public var rawValue: String
public var value: PureYAML.Model.Value
public var tag: Tag?
/// The `&name` anchor on this scalar in the source document, or the
/// anchor to attach on output. `nil` when the node is unanchored.
public var anchor: String?
/// The scalar's presentation style. Populated from the source on
/// parse; `nil` on hand-built nodes that have no style preference.
public var style: ScalarStyle?
public init(
rawValue: String,
value: PureYAML.Model.Value,
tag: Tag? = nil,
anchor: String? = nil,
style: ScalarStyle? = nil,
) {
self.rawValue = rawValue
self.value = value
self.tag = tag
self.anchor = anchor
self.style = style
}
}
}

View File

@ -0,0 +1,28 @@
public extension PureYAML.Tagged {
/// The presentation style a scalar had in the source document, or the
/// style a caller wants a scalar to take on output. `nil` on a node means
/// "no per-node preference": the emitter's document-wide policy decides.
enum ScalarStyle: String, Equatable, Sendable, CustomStringConvertible {
case plain
case singleQuoted
case doubleQuoted
case literal
case folded
public var description: String {
rawValue
}
}
}
extension PureYAML.Tagged.ScalarStyle {
init(_ style: PureYAML.Parsing.ScalarStyle) {
self = switch style {
case .plain: .plain
case .singleQuoted: .singleQuoted
case .doubleQuoted: .doubleQuoted
case .literal: .literal
case .folded: .folded
}
}
}

View File

@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
struct Sequence: Equatable, Sendable {
public var values: [Node]
public var tag: Tag?
/// The `&name` anchor on this sequence in the source document, or the
/// anchor to attach on output. `nil` when the node is unanchored.
public var anchor: String?
/// The sequence's presentation style. Populated from the source on
/// parse; `nil` on hand-built nodes that have no style preference.
public var style: CollectionStyle?
public init(
values: [Node],
tag: Tag? = nil,
anchor: String? = nil,
style: CollectionStyle? = nil,
) {
self.values = values
self.tag = tag
self.anchor = anchor
self.style = style
}
}
}

View File

@ -0,0 +1,241 @@
public extension PureYAML.Tagged {
/// A parsed YAML 1.1 `!!timestamp` value, held as calendar components
/// plus a timezone offset, with `secondsSinceEpoch` for the callers that
/// want an absolute instant (`Date(timeIntervalSince1970:)` bridges it
/// in one line; the package itself stays Foundation-free).
///
/// Accepted source forms are the YAML 1.1 timestamp grammar: the date
/// alone (`2002-12-14`), or date and time separated by `T`/`t` or
/// whitespace, with an optional fractional second and an optional
/// timezone (`Z`, `+5`, `-05:30`). A missing timezone means UTC per the
/// YAML 1.1 timestamp specification's treatment of unlabeled times.
struct Timestamp: Equatable, Hashable, Sendable {
public var year: Int
public var month: Int
public var day: Int
public var hour: Int
public var minute: Int
public var second: Int
/// Fraction of a second in the half-open range `0..<1`.
public var fraction: Double
/// Offset from UTC in seconds; `0` for `Z` or an absent timezone.
public var offsetSeconds: Int
public init(
year: Int,
month: Int,
day: Int,
hour: Int = 0,
minute: Int = 0,
second: Int = 0,
fraction: Double = 0,
offsetSeconds: Int = 0,
) {
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.fraction = fraction
self.offsetSeconds = offsetSeconds
}
/// Seconds since 1970-01-01T00:00:00Z, using the proleptic Gregorian
/// calendar (the days-from-civil closed form), with the timezone
/// offset applied.
public var secondsSinceEpoch: Double {
let shiftedYear = month <= 2 ? year - 1 : year
let era = (shiftedYear >= 0 ? shiftedYear : shiftedYear - 399) / 400
let yearOfEra = shiftedYear - era * 400
let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
let days = era * 146_097 + dayOfEra - 719_468
let seconds = days * 86400 + hour * 3600 + minute * 60 + second - offsetSeconds
return Double(seconds) + fraction
}
}
}
extension PureYAML.Tagged.Timestamp {
/// Parses the YAML 1.1 timestamp grammar. Returns `nil` for anything
/// outside it, including calendar-invalid dates like February 30.
init?(yaml raw: String) {
var cursor = Substring(raw)
guard let date = Self.takeDate(&cursor) else {
return nil
}
if cursor.isEmpty {
self.init(year: date.year, month: date.month, day: date.day)
return
}
guard
cursor.takeTimeSeparator(),
let time = Self.takeTime(&cursor),
let offsetSeconds = Self.takeOffset(&cursor),
cursor.isEmpty
else {
return nil
}
self.init(
year: date.year,
month: date.month,
day: date.day,
hour: time.hour,
minute: time.minute,
second: time.second,
fraction: time.fraction,
offsetSeconds: offsetSeconds,
)
}
struct DateParts {
var year: Int
var month: Int
var day: Int
}
struct TimeParts {
var hour: Int
var minute: Int
var second: Int
var fraction: Double
}
static func takeDate(_ cursor: inout Substring) -> DateParts? {
guard
let year = cursor.takeDigits(exactly: 4),
cursor.take("-"),
let month = cursor.takeDigits(upTo: 2),
cursor.take("-"),
let day = cursor.takeDigits(upTo: 2),
isValidDate(year: year, month: month, day: day)
else {
return nil
}
return DateParts(year: year, month: month, day: day)
}
static func takeTime(_ cursor: inout Substring) -> TimeParts? {
guard
let hour = cursor.takeDigits(upTo: 2), hour <= 23,
cursor.take(":"),
let minute = cursor.takeDigits(exactly: 2), minute <= 59,
cursor.take(":"),
let second = cursor.takeDigits(exactly: 2), second <= 60
else {
return nil
}
var fraction = 0.0
if cursor.take(".") {
var scale = 0.1
while let digit = cursor.takeDigit() {
fraction += Double(digit) * scale
scale /= 10
}
}
return TimeParts(hour: hour, minute: minute, second: second, fraction: fraction)
}
/// The timezone part: `Z`, a signed hour with optional `:minutes`, or
/// nothing (UTC). Returns the offset in seconds, or `nil` on a
/// malformed offset (which fails the whole parse; absence is `0`).
static func takeOffset(_ cursor: inout Substring) -> Int? {
cursor.takeWhitespace()
if cursor.take("Z") {
return 0
}
guard let sign = cursor.takeSign() else {
return 0
}
guard let offsetHour = cursor.takeDigits(upTo: 2), offsetHour <= 23 else {
return nil
}
var offsetMinute = 0
if cursor.take(":") {
guard let parsed = cursor.takeDigits(exactly: 2), parsed <= 59 else {
return nil
}
offsetMinute = parsed
}
return sign * (offsetHour * 3600 + offsetMinute * 60)
}
static func isValidDate(year: Int, month: Int, day: Int) -> Bool {
guard (1 ... 12).contains(month), day >= 1 else {
return false
}
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
let lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
return day <= lengths[month - 1]
}
}
private extension Substring {
mutating func take(_ character: Character) -> Bool {
guard first == character else {
return false
}
removeFirst()
return true
}
mutating func takeDigit() -> Int? {
guard let first, let digit = first.wholeNumberValue, first.isASCII, (0 ... 9).contains(digit) else {
return nil
}
removeFirst()
return digit
}
/// Between one and `upTo` digits.
mutating func takeDigits(upTo limit: Int) -> Int? {
var value: Int?
for _ in 0 ..< limit {
guard let digit = takeDigit() else {
break
}
value = (value ?? 0) * 10 + digit
}
return value
}
mutating func takeDigits(exactly count: Int) -> Int? {
var value = 0
for _ in 0 ..< count {
guard let digit = takeDigit() else {
return nil
}
value = value * 10 + digit
}
return value
}
/// `T`, `t`, or one-or-more spaces/tabs between date and time.
mutating func takeTimeSeparator() -> Bool {
if take("T") || take("t") {
return true
}
guard first == " " || first == "\t" else {
return false
}
takeWhitespace()
return true
}
mutating func takeWhitespace() {
while first == " " || first == "\t" {
removeFirst()
}
}
mutating func takeSign() -> Int? {
if take("+") {
return 1
}
if take("-") {
return -1
}
return nil
}
}

View File

@ -1,131 +1,44 @@
public extension PureYAML.Tagged {
/// Traverses a tag-preserving YAML node tree and applies type-directed checks. The checks, their
/// erasure, and their context all come from the one validation framework: this layer adds only its
/// own document type (`Node`), its built-in check, and its traversal (validation-rules.md,
/// principle 10).
struct Validator: Sendable {
public var checks: [PureYAML.Validation.AnyCheck<Node>]
public init(checks: [PureYAML.Validation.AnyCheck<Node>] = Self.defaultChecks) {
self.checks = checks
}
public static var blank: Self {
Self(checks: [])
}
public static var defaultChecks: [PureYAML.Validation.AnyCheck<Node>] {
[
PureYAML.Validation.AnyCheck(BuiltinCheck.builtInTagsAreSupportedAndKindConsistent),
PureYAML.Validation.AnyCheck(BuiltinCheck.mappingKeysAreUnique),
]
}
@discardableResult
public func validate(
_ node: Node,
strict: Bool = true,
) throws -> [PureYAML.Validation.Issue] {
let result = collect(node)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Validation.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ node: Node) -> PureYAML.Validation.Result {
var issues: [PureYAML.Validation.Issue] = []
walk(root: node, subject: node, path: .root, issues: &issues)
return PureYAML.Validation.Result(issues)
}
@discardableResult
public func validate(
_ documents: [Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ documents: [Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.node)
issues.append(contentsOf: result.issues.map { issue in
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
})
}
return PureYAML.Stream.Result(issues)
}
/// Adds a type-directed check, which fires only on the `Node` values it specialises on
/// wherever they occur in the tree.
public func validating(_ check: PureYAML.Validation.Check<Node, Node>) -> Self {
var copy = self
copy.checks.append(PureYAML.Validation.AnyCheck(check))
return copy
}
/// The descriptions of the active checks, in application order: the configuration fixed point.
public var validationDescriptions: [String] {
checks.map(\.description)
}
}
/// The tagged layer's validator: the same shared `PureYAML.Validation.Validator` traversal engine
/// as every other layer (validation-rules.md, principle 10), specialised on `Node` for both the
/// walked subject and the cross-cutting document.
typealias Validator = PureYAML.Validation.Validator<Node, Node>
}
extension PureYAML.Tagged.Validator {
func walk(
root: PureYAML.Tagged.Node,
subject: PureYAML.Tagged.Node,
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
// The document is the whole tagged tree; each check filters by runtime type. Offer the node,
// and for a mapping the mapping too, so a `Check<Node, Node>` and a `Check<Mapping, Node>`
// each fire on what they specialise on.
offer(subject, at: path, root: root, issues: &issues)
if case let .mapping(mapping) = subject {
offer(mapping, at: path, root: root, issues: &issues)
}
switch subject {
case let .mapping(mapping):
for pair in mapping.pairs {
walk(
root: root,
subject: pair.value,
path: path.appending(.key(pair.key)),
issues: &issues,
)
}
case let .sequence(sequence):
for index in sequence.values.indices {
walk(
root: root,
subject: sequence.values[index],
path: path.appending(.index(index)),
issues: &issues,
)
}
case .scalar:
break
}
public extension PureYAML.Validation.Validator where Subject == PureYAML.Tagged.Node, Document == PureYAML.Tagged.Node {
/// The tagged layer's default checks.
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Tagged.Node>] {
[
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.builtInTagsAreSupportedAndKindConsistent),
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.mappingKeysAreUnique),
]
}
func offer(
_ subject: any PureYAML.Validation.Validatable,
at path: PureYAML.Validation.Path,
root: PureYAML.Tagged.Node,
issues: inout [PureYAML.Validation.Issue],
) {
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
convenience init() {
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
}
@discardableResult
func validate(
_ documents: [PureYAML.Tagged.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
func collect(_ documents: [PureYAML.Tagged.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.node)
issues.append(contentsOf: result.values.map { error in
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
})
}
return PureYAML.Stream.Result(issues)
}
}

View File

@ -1,19 +1,19 @@
public extension PureYAML.Validation {
/// A type-erased `Check`, so checks specialised on different subject types live in one list, all
/// A type-erased `Validation`, so checks specialised on different subject types live in one list, all
/// sharing one `Document` (the validated tree's root type). It filters by runtime type: a
/// `Check<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
/// `Validation<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
/// `type(of:)` guard) or a value of any other type.
struct AnyCheck<Document: Sendable>: Sendable {
struct AnyValidation<Document: Sendable>: Sendable {
public let description: String
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
private let applyErased: @Sendable (any Validatable, Path, Document) -> [ValidationError]
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
description = check.description
public init<Subject: Validatable>(_ validation: Validation<Subject, Document>) {
description = validation.description
applyErased = { subject, path, document in
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
return []
}
return check.apply(SubjectContext(document: document, subject: typed, path: path))
return validation.apply(ValidationContext(document: document, subject: typed, path: path))
}
}
@ -21,7 +21,7 @@ public extension PureYAML.Validation {
to subject: any Validatable,
at path: Path,
in document: Document,
) -> [Issue] {
) -> [ValidationError] {
applyErased(subject, path, document)
}
}

View File

@ -1,28 +1,28 @@
public extension PureYAML.Validation {
/// The caseless namespace of built-in type-directed checks, referenceable by name.
enum BuiltinCheck {
enum BuiltinValidation {
/// Reports each duplicate key within a single mapping. The type-directed counterpart of the
/// legacy `Rule.mappingKeysAreUnique`; fires on `Model.Mapping` values directly rather than
/// switching on a `Model.Value`.
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Check(description: "Mapping keys are unique") { context in
public static var mappingKeysAreUnique: Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Validation(description: "Mapping keys are unique") { context in
var seen = Set<PureYAML.Model.Key>()
var issues: [Issue] = []
var errors: [ValidationError] = []
for pair in context.subject.pairs where !seen.insert(pair.keyNode).inserted {
issues.append(Issue(
errors.append(ValidationError(
severity: .error,
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
path: context.path.appending(pair.keyNode.pathComponent),
))
}
return issues
return errors
}
}
}
}
extension PureYAML.Model.Key {
/// The path component a duplicate-key issue points at: a plain `.key` for a string key, a
/// The path component a duplicate-key error points at: a plain `.key` for a string key, a
/// `.complexKey` for a sequence or mapping key (which has no string spelling).
var pathComponent: PureYAML.Validation.Path.Component {
switch self {

View File

@ -1,49 +0,0 @@
public extension PureYAML.Validation {
/// A type-directed validation value (the `Validation<Subject>` of the validation idiom): a
/// positive `description`, a `predicate` that gates application, and a multi-error `check`. It is
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree. `Document`
/// is the type of the validated tree's root, the same for every check a given validator runs.
struct Check<Subject: Validatable, Document: Sendable>: Sendable {
public let description: String
public let predicate: @Sendable (SubjectContext<Subject, Document>) -> Bool
public let check: @Sendable (SubjectContext<Subject, Document>) -> [Issue]
/// Multi-error form: return one issue per problem. Use when a subject can fail several ways.
public init(
description: String,
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> [Issue],
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
self.check = check
}
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
/// one issue `"Failed to satisfy: <description>"`. The dominant form.
public init(
description: String,
severity: Severity = .error,
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool,
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
check(context)
? []
: [Issue(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
},
when: predicate,
)
}
/// Gates on the predicate, then runs the check.
public func apply(_ context: SubjectContext<Subject, Document>) -> [Issue] {
guard predicate(context) else {
return []
}
return check(context)
}
}
}

View File

@ -3,7 +3,7 @@ public extension PureYAML {
static func diagnosticValidationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.Report {
diagnosticValidationResult(
@ -17,7 +17,7 @@ public extension PureYAML {
/// Validates many YAML inputs with preflight diagnostics for damaged input.
static func diagnosticValidationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
failOnWarnings: Bool = false,
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.BatchReport {
@ -41,7 +41,7 @@ public extension PureYAML {
static func parseValidated(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> Model.Value {
let documents = try parseValidatedStream(
@ -57,7 +57,7 @@ public extension PureYAML {
static func parseValidatedStream(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> [Stream.Document] {
let result = diagnosticValidationResult(
@ -82,7 +82,7 @@ private extension PureYAML {
static func diagnosticValidationResult(
_ yaml: String,
file: String?,
using validator: Validation.Validator,
using validator: Validation.Validator<Model.Value, Model.Value>,
preflightScanner: Validation.PreflightScanner,
) -> DiagnosticValidationResult {
var diagnostics = preflightScanner.diagnostics(in: yaml, file: file)

View File

@ -1,14 +0,0 @@
public extension PureYAML.Validation.Issue {
/// Error thrown when validation reports failing issues.
struct Collection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var issues: [PureYAML.Validation.Issue]
public init(_ issues: [PureYAML.Validation.Issue]) {
self.issues = issues
}
public var description: String {
issues.map(\.description).joined(separator: "\n")
}
}
}

View File

@ -1,23 +0,0 @@
public extension PureYAML.Validation {
/// Path-aware validation issue.
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var severity: Severity
public var reason: String
public var path: Path
public init(
severity: Severity,
reason: String,
path: Path = .root,
) {
self.severity = severity
self.reason = reason
self.path = path
}
public var description: String {
let location = path.isRoot ? "root" : path.description
return "\(severity.description): \(reason) at \(location)"
}
}
}

View File

@ -1,18 +1,19 @@
public extension PureYAML.Validation {
/// Collected validation issues.
/// Collected validation errors, the non-throwing outcome accessor beside the throwing
/// `Validator.validate(_:strict:)` gate.
struct Result: Equatable, Sendable {
public var issues: [Issue]
public var values: [ValidationError]
public init(_ issues: [Issue] = []) {
self.issues = issues
public init(_ values: [ValidationError] = []) {
self.values = values
}
public var errors: [Issue] {
issues.filter { $0.severity == .error }
public var errors: [ValidationError] {
values.filter { $0.severity == .error }
}
public var warnings: [Issue] {
issues.filter { $0.severity == .warning }
public var warnings: [ValidationError] {
values.filter { $0.severity == .warning }
}
public var isValid: Bool {

View File

@ -0,0 +1,15 @@
public extension PureYAML.Validation {
/// A tree node a `Validator` can walk. `Validator`'s traversal engine is shared by every layer
/// (validation-rules.md, principle 10); each `Traversable` conformance supplies only that layer's
/// own traversal shape: "each layer defines only its own document, its rules, and its traversal".
protocol Traversable: Validatable {
/// Extra values to offer to type-directed checks at this node, beyond the node itself (e.g. a
/// mapping's own type, or a mapping's key nodes). A `nil` path component offers at this node's
/// own path; a non-nil one offers at that child's path (e.g. a mapping key's location).
var extraOffers: [(component: Path.Component?, value: any Validatable)] { get }
/// This node's children to recurse into, each paired with the path component that reaches it
/// from this node.
var children: [(component: Path.Component, value: Self)] { get }
}
}

View File

@ -1,5 +1,5 @@
public extension PureYAML.Validation {
/// Marker for a value a type-directed validation can be applied to. A `Check<Subject>` fires on
/// Marker for a value a type-directed validation can be applied to. A `Validation<Subject>` fires on
/// `Subject` values wherever they appear in the traversed tree; this protocol constrains the
/// subjects so the erased dispatch can filter by runtime type.
protocol Validatable: Sendable {}

View File

@ -198,7 +198,7 @@ public extension PureYAML {
static func validationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
) -> Validation.Report {
do {
let documents = try parseStream(yaml)
@ -241,7 +241,7 @@ public extension PureYAML {
/// Validates many YAML inputs without throwing or stopping at the first failure.
static func validationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
failOnWarnings: Bool = false,
) -> Validation.BatchReport {
let reports = sources.map { source in

View File

@ -0,0 +1,49 @@
public extension PureYAML.Validation {
/// A type-directed validation value: a positive `description`, a `predicate` that gates
/// application, and a multi-error `check`. It is applied only to values of type `Subject`, anywhere
/// they occur in the traversed tree. `Document` is the type of the validated tree's root, the same
/// for every check a given validator runs.
struct Validation<Subject: Validatable, Document: Sendable>: Sendable {
public let description: String
public let predicate: @Sendable (ValidationContext<Subject, Document>) -> Bool
public let check: @Sendable (ValidationContext<Subject, Document>) -> [ValidationError]
/// Multi-error form: return one error per problem. Use when a subject can fail several ways.
public init(
description: String,
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> [ValidationError],
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
self.check = check
}
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
/// one error `"Failed to satisfy: <description>"`. The dominant form.
public init(
description: String,
severity: Severity = .error,
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool,
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
check(context)
? []
: [ValidationError(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
},
when: predicate,
)
}
/// Gates on the predicate, then runs the check.
public func apply(_ context: ValidationContext<Subject, Document>) -> [ValidationError] {
guard predicate(context) else {
return []
}
return check(context)
}
}
}

View File

@ -1,9 +1,10 @@
// The combinator algebra for type-directed checks (validation-rules.md, "The combinator algebra").
// Declarative building blocks composed by value: comparison operators and `take` lift a subject or
// context value into a Bool predicate; `&&` / `||` compose checks (concatenating or short-circuiting
// their errors); `all` and `lift` apply many checks to one or a child value. `lookup` is omitted: it
// resolves references against a component store, which the generic model does not carry; a consumer
// with references supplies it over its own document.
// their errors); `all` and `lift` apply many checks to one or a child value; `unwrap` errors on a nil
// optional, else runs child checks. `lookup` is omitted: it resolves references against a component
// store, which the generic model does not carry; a consumer with references supplies it over its own
// document.
//
// Each combinator is generic over both the subject `S` and the document `D` (the validated tree's
// root type), so the same algebra serves the model layer (`D == Model.Value`) and the tagged layer
@ -18,49 +19,49 @@ private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
// MARK: comparison operators lifting a key path and a literal into a predicate
public func == <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] == rhs }
}
public func != <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] != rhs }
}
public func > <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] > rhs }
}
public func >= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] >= rhs }
}
public func < <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] < rhs }
}
public func <= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] <= rhs }
}
@ -69,9 +70,9 @@ public extension PureYAML.Validation {
/// Digs to a value through a key path on the context and runs arbitrary logic, the escape hatch
/// beyond equality.
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
_ keyPath: KeyPath<ValidationContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S, D>) -> Bool {
) -> @Sendable (ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0[keyPath: boxed.keyPath]) }
}
@ -80,15 +81,15 @@ public extension PureYAML.Validation {
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<S, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S, D>) -> Bool {
) -> @Sendable (ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0.subject[keyPath: boxed.keyPath]) }
}
/// Applies every check to the same context, concatenating their errors.
static func all<S, D>(_ checks: Check<S, D>...) -> Check<S, D> {
let list = checks
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
static func all<S, D>(_ validations: Validation<S, D>...) -> Validation<S, D> {
let list = validations
return Validation(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
list.flatMap { $0.apply(context) }
}
}
@ -97,12 +98,12 @@ public extension PureYAML.Validation {
/// and document.
static func lift<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child>,
into checks: Check<Child, D>...,
) -> Check<S, D> {
let list = checks
into validations: Validation<Child, D>...,
) -> Validation<S, D> {
let list = validations
let boxed = SendableKeyPath(keyPath: keyPath)
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = SubjectContext(
return Validation(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = ValidationContext(
document: context.document,
subject: context.subject[keyPath: boxed.keyPath],
path: context.path,
@ -110,24 +111,43 @@ public extension PureYAML.Validation {
return list.flatMap { $0.apply(childContext) }
}
}
/// Unwraps an optional reached through a key path, erroring if nil, else running child checks
/// against the unwrapped value.
static func unwrap<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child?>,
into validations: Validation<Child, D>...,
description: String? = nil,
) -> Validation<S, D> {
let list = validations
let boxed = SendableKeyPath(keyPath: keyPath)
return Validation(description: description ?? "unwraps: \(list.map(\.description).joined(separator: ", "))") { context in
guard let child = context.subject[keyPath: boxed.keyPath] else {
let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil"
return [ValidationError(severity: .error, reason: reason, path: context.path)]
}
let childContext = ValidationContext(document: context.document, subject: child, path: context.path)
return list.flatMap { $0.apply(childContext) }
}
}
}
// MARK: composing two checks (&& concatenates errors, || short-circuits)
public func && <S, D>(
lhs: PureYAML.Validation.Check<S, D>,
rhs: PureYAML.Validation.Check<S, D>,
) -> PureYAML.Validation.Check<S, D> {
PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in
lhs: PureYAML.Validation.Validation<S, D>,
rhs: PureYAML.Validation.Validation<S, D>,
) -> PureYAML.Validation.Validation<S, D> {
PureYAML.Validation.Validation(description: "\(lhs.description) and \(rhs.description)") { context in
lhs.apply(context) + rhs.apply(context)
}
}
public func || <S, D>(
lhs: PureYAML.Validation.Check<S, D>,
rhs: PureYAML.Validation.Check<S, D>,
) -> PureYAML.Validation.Check<S, D> {
PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in
lhs: PureYAML.Validation.Validation<S, D>,
rhs: PureYAML.Validation.Validation<S, D>,
) -> PureYAML.Validation.Validation<S, D> {
PureYAML.Validation.Validation(description: "\(lhs.description) or \(rhs.description)") { context in
let left = lhs.apply(context)
guard !left.isEmpty else {
return []

View File

@ -3,7 +3,7 @@ public extension PureYAML.Validation {
/// checks), the current `subject` of the check's type, and the `path` to where it sits. `Document`
/// is the type of the validated tree's root, so each layer carries a truthful document: `Model.Value`
/// for the model tree, `Tagged.Node` for the tagged tree.
struct SubjectContext<Subject: Validatable, Document: Sendable>: Sendable {
struct ValidationContext<Subject: Validatable, Document: Sendable>: Sendable {
public var document: Document
public var subject: Subject
public var path: Path

View File

@ -0,0 +1,26 @@
public extension PureYAML.Validation {
/// Path-aware validation error.
struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var severity: Severity
public var reason: String
public var path: Path
public init(
severity: Severity,
reason: String,
path: Path = .root,
) {
self.severity = severity
self.reason = reason
self.path = path
}
/// Strips a trailing period from `reason` and renders the location consistently:
/// `"<reason> at root of document"` at the root, `"<reason> at path: <path>"` elsewhere.
/// `severity` is a separate stored property (see `Result.errors`/`.warnings`), not rendered here.
public var description: String {
let reasonText = reason.hasSuffix(".") ? String(reason.dropLast()) : reason
return path.isRoot ? "\(reasonText) at root of document" : "\(reasonText) at path: \(path.description)"
}
}
}

View File

@ -0,0 +1,14 @@
public extension PureYAML.Validation {
/// Error thrown when validation reports failing errors.
struct ValidationErrorCollection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var values: [ValidationError]
public init(_ values: [ValidationError]) {
self.values = values
}
public var description: String {
values.map(\.description).joined(separator: "\n")
}
}
}

View File

@ -1,142 +1,166 @@
public extension PureYAML.Validation {
/// Traverses a YAML value tree and applies type-directed checks at each node. The check value, its
/// erasure, and its context all come from the one validation framework (validation-rules.md,
/// principle 10); this layer adds only its document type (`Model.Value`), its built-in checks, and
/// its traversal.
struct Validator: Sendable {
public var checks: [AnyCheck<PureYAML.Model.Value>]
/// Traverses a `Subject` tree and applies type-directed checks at each node. The validation value,
/// its erasure, its context, the combinators, and this traversal engine are all the one shared
/// framework (validation-rules.md, principle 10); each layer supplies only its own `Subject`/
/// `Document` types (through `Traversable`), its built-in checks, and its own traversal shape.
///
/// `@unchecked Sendable`: immutable after `init` (every stored validation list is a `let`), so
/// concurrent reads race on nothing; the erased `AnyValidation` closures are simply not provably
/// `Sendable` to the compiler.
final class Validator<Subject: Traversable, Document: Sendable>: @unchecked Sendable {
fileprivate let nonReferenceDefaultValidations: [AnyValidation<Document>]
fileprivate let referenceDefaultValidations: [AnyValidation<Document>]
fileprivate let customValidations: [AnyValidation<Document>]
public init(checks: [AnyCheck<PureYAML.Model.Value>] = Self.defaultChecks) {
self.checks = checks
public init(
nonReferenceDefaultValidations: [AnyValidation<Document>] = [],
referenceDefaultValidations: [AnyValidation<Document>] = [],
customValidations: [AnyValidation<Document>] = [],
) {
self.nonReferenceDefaultValidations = nonReferenceDefaultValidations
self.referenceDefaultValidations = referenceDefaultValidations
self.customValidations = customValidations
}
public static var blank: Self {
Self(checks: [])
/// A validator with no checks; add your own with `validating(_:)`.
public static var blank: Validator {
Validator()
}
public static var defaultChecks: [AnyCheck<PureYAML.Model.Value>] {
[AnyCheck(BuiltinCheck.mappingKeysAreUnique)]
}
@discardableResult
public func validate(
_ value: PureYAML.Model.Value,
strict: Bool = true,
) throws -> [Issue] {
let result = collect(value)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ value: PureYAML.Model.Value) -> Result {
var issues: [Issue] = []
walk(root: value, subject: value, path: .root, issues: &issues)
return Result(issues)
}
@discardableResult
public func validate(
_ documents: [PureYAML.Stream.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.value)
issues.append(contentsOf: result.issues.map { issue in
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
})
}
return PureYAML.Stream.Result(issues)
}
/// Adds a type-directed check, which fires only on values of its `Subject` type wherever they
/// occur in the tree.
public func validating(_ check: Check<some Validatable, PureYAML.Model.Value>) -> Self {
var copy = self
copy.checks.append(AnyCheck(check))
return copy
}
/// Removes every check whose description matches, the identity the validation idiom uses for
/// removal. Descriptions are therefore required to be unique and stable.
public func withoutValidating(_ description: String) -> Self {
var copy = self
copy.checks.removeAll { $0.description == description }
return copy
}
/// The descriptions of the active checks, in application order: the fixed point a
/// configuration-pin test asserts against, so adding, removing, or rewording one changes it
/// deliberately.
/// The descriptions of every active check, in application order.
public var validationDescriptions: [String] {
checks.map(\.description)
validations.map(\.description)
}
/// Descriptions of the non-reference default tier, in order.
public var nonReferenceValidationDescriptions: [String] {
nonReferenceDefaultValidations.map(\.description)
}
/// Descriptions of the reference default tier, in order.
public var referenceValidationDescriptions: [String] {
referenceDefaultValidations.map(\.description)
}
/// Descriptions of the custom tier, in order.
public var customValidationDescriptions: [String] {
customValidations.map(\.description)
}
fileprivate var validations: [AnyValidation<Document>] {
nonReferenceDefaultValidations + referenceDefaultValidations + customValidations
}
/// Returns a validator with `validation` appended to the custom tier.
@discardableResult
public func validating(_ validation: Validation<some Validatable, Document>) -> Validator {
Validator(
nonReferenceDefaultValidations: nonReferenceDefaultValidations,
referenceDefaultValidations: referenceDefaultValidations,
customValidations: customValidations + [AnyValidation(validation)],
)
}
/// Returns a validator with the checks matching `descriptions` removed from every tier, the
/// identity the validation idiom uses for removal. Descriptions are therefore required to be
/// unique and stable.
@discardableResult
public func withoutValidating(_ descriptions: String...) -> Validator {
let drop = Set(descriptions)
func filter(_ tier: [AnyValidation<Document>]) -> [AnyValidation<Document>] {
tier.filter { !drop.contains($0.description) }
}
return Validator(
nonReferenceDefaultValidations: filter(nonReferenceDefaultValidations),
referenceDefaultValidations: filter(referenceDefaultValidations),
customValidations: filter(customValidations),
)
}
public func collect(_ subject: Subject, in document: Document) -> Result {
var errors: [ValidationError] = []
walk(subject, at: .root, in: document, into: &errors)
return Result(errors)
}
@discardableResult
public func validate(
_ subject: Subject,
in document: Document,
strict: Bool = true,
) throws -> [ValidationError] {
let result = collect(subject, in: document)
let failures = strict ? result.values : result.errors
if !failures.isEmpty {
throw ValidationErrorCollection(failures)
}
return strict ? [] : result.warnings
}
private func walk(_ subject: Subject, at path: Path, in document: Document, into errors: inout [ValidationError]) {
// Offer the node itself, then any extra values this layer's Traversable conformance wants
// offered here (a mapping's own type, its key nodes), then recurse into its children.
offer(subject, at: path, in: document, into: &errors)
for (component, value) in subject.extraOffers {
offer(value, at: component.map(path.appending) ?? path, in: document, into: &errors)
}
for (component, child) in subject.children {
walk(child, at: path.appending(component), in: document, into: &errors)
}
}
private func offer(_ value: any Validatable, at path: Path, in document: Document, into errors: inout [ValidationError]) {
for validation in validations {
errors.append(contentsOf: validation.apply(to: value, at: path, in: document))
}
}
}
}
extension PureYAML.Validation.Validator {
func walk(
root: PureYAML.Model.Value,
subject: PureYAML.Model.Value,
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
// Offer each type-directed check the values it may specialise on at this node: the value
// itself, and, for a mapping, the mapping and each of its keys. The erased check filters by
// runtime type, so a `Check<Model.Mapping>` ignores the value and key offers, and a
// `Check<Model.Value>` (a whole-tree rule) fires on the value offer.
offer(subject, at: path, root: root, issues: &issues)
if case let .mapping(mapping) = subject {
offer(mapping, at: path, root: root, issues: &issues)
for pair in mapping.pairs {
offer(pair.keyNode, at: path.appending(pair.keyNode.pathComponent), root: root, issues: &issues)
}
}
switch subject {
case let .mapping(mapping):
for pair in mapping.pairs {
walk(
root: root,
subject: pair.value,
path: path.appending(pair.keyNode.pathComponent),
issues: &issues,
)
}
case let .sequence(values):
for index in values.indices {
walk(
root: root,
subject: values[index],
path: path.appending(.index(index)),
issues: &issues,
)
}
case .null, .bool, .int, .double, .string:
break
}
public extension PureYAML.Validation.Validator where Subject == Document {
/// Convenience for the common case where the walked tree's root doubles as the cross-cutting
/// document (every PureYAML layer, today).
func collect(_ subject: Subject) -> PureYAML.Validation.Result {
collect(subject, in: subject)
}
func offer(
_ subject: any PureYAML.Validation.Validatable,
at path: PureYAML.Validation.Path,
root: PureYAML.Model.Value,
issues: inout [PureYAML.Validation.Issue],
) {
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
}
@discardableResult
func validate(_ subject: Subject, strict: Bool = true) throws -> [PureYAML.Validation.ValidationError] {
try validate(subject, in: subject, strict: strict)
}
}
public extension PureYAML.Validation.Validator where Subject == PureYAML.Model.Value, Document == PureYAML.Model.Value {
/// The model layer's default checks: schema-independent structural rules.
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Model.Value>] {
[PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique)]
}
convenience init() {
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
}
@discardableResult
func validate(
_ documents: [PureYAML.Stream.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.value)
issues.append(contentsOf: result.values.map { error in
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
})
}
return PureYAML.Stream.Result(issues)
}
}

View File

@ -20,12 +20,12 @@ struct ArbitraryAnyDecisionTests {
mapping.pairs.map { ($0.key, $0.value) },
uniquingKeysWith: { _, second in second },
)
let validationResult = PureYAML.Validation.Validator().collect(value)
let validationResult = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(mapping.pairs.map(\.key) == ["title", "title", "slug"])
#expect(projectedDictionary.keys.sorted() == ["slug", "title"])
#expect(projectedDictionary["title"] == .string("Second"))
#expect(validationResult.issues == [
#expect(validationResult.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",

View File

@ -127,8 +127,12 @@ enum CanonicalEventCorpus {
static func props(_ anchor: String?, _ tag: String?) -> String {
var result = ""
if let anchor { result += " &" + anchor }
if let tag { result += " <" + tag + ">" }
if let anchor {
result += " &" + anchor
}
if let tag {
result += " <" + tag + ">"
}
return result
}

View File

@ -102,14 +102,14 @@ struct CodableCompatibilityTests {
.init(key: "/users", value: .int(2)),
])))
} check: { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key '/users'",
path: .init([.key("/users")]),
),
])
#expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]")
#expect(collection.description == "Duplicate mapping key '/users' at path: $[\"/users\"]")
}
}
@ -225,12 +225,12 @@ private func expectEncodingError(
private func expectValidationError(
operation: () throws -> some Any,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -38,7 +38,7 @@ enum CollectionCompatibilityFixtures {
struct ValidationCase: CustomStringConvertible {
var name: String
var yaml: String
var expectedIssues: [PureYAML.Validation.Issue]
var expectedIssues: [PureYAML.Validation.ValidationError]
var description: String {
name

View File

@ -10,20 +10,20 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '[\"a\", \"b\"]'",
path: .init([.complexKey(key)]),
)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key '[\"a\", \"b\"]' at $[?[\"a\", \"b\"]]",
#expect(result.values == [expectedIssue])
#expect(result.values.map(\.description) == [
"Duplicate mapping key '[\"a\", \"b\"]' at path: $[?[\"a\", \"b\"]]",
])
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.values == [expectedIssue])
#expect(collection.description == expectedIssue.description)
}
}
@ -37,16 +37,16 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '{\"name\": \"service\"}'",
path: .init([.complexKey(key)]),
)
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.values == [expectedIssue])
#expect(collection.description == """
error: Duplicate mapping key '{"name": "service"}' at $[?{"name": "service"}]
Duplicate mapping key '{"name": "service"}' at path: $[?{"name": "service"}]
""")
}
}
@ -58,7 +58,7 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '[nan]'",
path: .init([.complexKey(key)]),
@ -66,8 +66,8 @@ struct ComplexMappingKeyValidationTests {
#expect(PureYAML.Model.Value.double(.nan) == .double(.nan))
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.description == "error: Duplicate mapping key '[nan]' at $[?[nan]]")
#expect(collection.values == [expectedIssue])
#expect(collection.description == "Duplicate mapping key '[nan]' at path: $[?[nan]]")
}
}
@ -82,7 +82,7 @@ struct ComplexMappingKeyValidationTests {
let issues = try PureYAML.validate(value, strict: false)
#expect(issues.isEmpty)
#expect(PureYAML.Validation.Validator().collect(value).issues.isEmpty)
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values.isEmpty)
#expect(value.rootMapping?["[\"a\"]"] == .string("scalar"))
#expect(value.rootMapping?[sequenceKey] == .string("complex"))
#expect(value.rootMapping?["a"] == nil)
@ -98,19 +98,19 @@ struct ComplexMappingKeyValidationTests {
.init(key: "mode", value: .string("legacy")),
]))),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.complexKey(key), .key("mode")]),
)
let result = PureYAML.Validation.Validator.blank
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(legacyModeRule(severity: .error))
.collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.first?.description == """
error: Legacy mode is not allowed at $[?{"name": "service"}].mode
#expect(result.values == [expectedIssue])
#expect(result.values.first?.description == """
Legacy mode is not allowed at path: $[?{"name": "service"}].mode
""")
}
}

View File

@ -110,14 +110,14 @@ struct DocumentationExampleTests {
)
let tagIssues = PureYAML.Tagged.Validator().collect(tagged)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp'",
path: .init([.key("value")]),
),
]
#expect(tagIssues.issues == expectedIssues)
#expect(tagIssues.values == expectedIssues)
}
@Test("README tagged constructor example stays executable")
@ -180,20 +180,20 @@ struct DocumentationExampleTests {
.init(key: "title", value: .string("Second")),
]))
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
),
]
let result = PureYAML.Validation.Validator().collect(document)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(document)
#expect(try PureYAML.validate(validDocument, strict: false).isEmpty)
#expect(result.errors == expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationFailure(document) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}
@ -210,12 +210,12 @@ struct DocumentationExampleTests {
func expectValidationFailure(
_ value: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
_ = try PureYAML.validate(value)
recordIssue("expected validation failure")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -116,7 +116,7 @@ struct DownstreamDocumentTests {
#expect(api?["name"] == .string("API"))
#expect(api?["retries"] == .int(3))
#expect(api?["timeoutSeconds"] == .int(30))
#expect(PureYAML.Validation.Validator().collect(.mapping(root)) == .init())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root)) == .init())
}
func requireRootMapping(_ yaml: String) throws -> PureYAML.Model.Mapping {

View File

@ -0,0 +1,161 @@
@testable import PureYAML
import Testing
@Suite("Canonical mode, document markers, and line width")
struct EmitterCanonicalAndWidthTests {
@Test("Canonical form emits explicit tags, quotes, and flow entries per line")
func test_canonicalGoldenOutput() throws {
let value = try PureYAML.parse("name: config\ncount: 3\nitems: [a, b]\n")
let dumped = PureYAML.dump(value, options: .init(canonical: true))
#expect(dumped == """
---
!!map {
? !!str "name"
: !!str "config",
? !!str "count"
: !!int "3",
? !!str "items"
: !!seq [
!!str "a",
!!str "b",
],
}
""")
}
@Test("Canonical output reparses to the identical value")
func test_canonicalReparsesToSameValue() throws {
let source = """
server: web-1
replicas: 2
healthy: true
threshold: 0.75
fallback: null
tags: [prod, eu]
limits:
memory: 512
"""
let value = try PureYAML.parse(source)
let canonical = PureYAML.dump(value, options: .init(canonical: true))
let reparsed = try PureYAML.parse(canonical)
#expect(reparsed == value)
}
@Test("Canonical scalar document and empty collections")
func test_canonicalScalarAndEmpties() {
#expect(PureYAML.dump(.string("x"), options: .init(canonical: true)) == "---\n!!str \"x\"\n")
#expect(PureYAML.dump(.mapping(.init(pairs: [])), options: .init(canonical: true)) == "---\n!!map {}\n")
#expect(PureYAML.dump(.sequence([]), options: .init(canonical: true)) == "---\n!!seq []\n")
}
@Test("Canonical tagged tree keeps anchors, aliases, and carried tags")
func test_canonicalTaggedTree() throws {
let node = try PureYAML.parseTagged("base: &b !Custom widget\nref: *b\n")
let canonical = PureYAML.dump(node, options: .init(canonical: true))
#expect(canonical == """
---
!!map {
? !!str "base"
: &b !<!Custom> "widget",
? !!str "ref"
: *b,
}
""")
let reparsed = try PureYAML.parseTagged(canonical)
#expect(reparsed.modelValueErasingTags == node.modelValueErasingTags)
guard case let .mapping(mapping) = reparsed else {
Issue.record("expected mapping")
return
}
#expect(mapping["ref"]?.kind == .alias)
#expect(mapping["base"]?.tag == .init("!Custom"))
}
@Test("Explicit document markers wrap the body")
func test_explicitDocumentMarkers() throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: "a", value: .int(1))]))
let both = PureYAML.dump(value, options: .init(explicitDocumentStart: true, explicitDocumentEnd: true))
#expect(both == "---\na: 1\n...\n")
let startOnly = PureYAML.dump(value, options: .init(explicitDocumentStart: true))
#expect(startOnly == "---\na: 1\n")
let reparsed = try PureYAML.parse(both)
#expect(reparsed == value)
}
@Test("Line width folds long plain scalars and the value survives")
func test_lineWidthFoldsPlainScalars() throws {
let long = "the quick brown fox jumps over the lazy dog and keeps going well past any reasonable width"
let value = PureYAML.Model.Value.mapping(.init([.init(key: "sentence", value: .string(long))]))
let options = PureYAML.Emitting.Options(scalarStyle: .plainWhenSafe, lineWidth: 40)
let dumped = PureYAML.dump(value, options: options)
#expect(dumped.split(separator: "\n").count > 1)
#expect(dumped.split(separator: "\n").allSatisfy { $0.count <= 40 })
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Line width leaves quoted scalars and unbreakable words alone")
func test_lineWidthLeavesUnsafeContentAlone() {
let word = String(repeating: "x", count: 90)
let value = PureYAML.Model.Value.mapping(.init([.init(key: "blob", value: .string(word))]))
let plain = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 40))
#expect(plain == "blob: \(word)\n")
let quoted = PureYAML.dump(value, options: .init(scalarStyle: .quoted, lineWidth: 40))
#expect(quoted == "blob: \"\(word)\"\n")
}
@Test("A fold that would manufacture syntax is vetoed")
func test_unsafeFoldBoundariesVetoed() throws {
let risky = "watch the colon: placement and the - dash and the # mark carefully now"
let value = PureYAML.Model.Value.string(risky)
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Line width wraps flow collections one entry per line")
func test_lineWidthWrapsFlow() throws {
let value = try PureYAML.parse("list: [alpha, beta, gamma, delta, epsilon, zeta, eta, theta]\n")
let options = PureYAML.Emitting.Options(
scalarStyle: .plainWhenSafe,
collectionStyle: .flow,
lineWidth: 30,
)
let dumped = PureYAML.dump(value, options: options)
#expect(dumped.split(separator: "\n").count > 1)
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Sequence items fold with safe continuation indentation")
func test_sequenceItemFolding() throws {
let long = "one two three four five six seven eight nine ten eleven twelve thirteen"
let value = PureYAML.Model.Value.sequence([.string(long), .string("short")])
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Consecutive interior spaces forbid folding at that boundary")
func test_consecutiveSpacesSurviveFolding() throws {
let doubled = "left middle and then a good number of extra words to overflow the width right"
let value = PureYAML.Model.Value.string(doubled)
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 25))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Canonical wins over conflicting style options")
func test_canonicalOverridesStyles() throws {
let value = try PureYAML.parse("a: 1\n")
let styled = PureYAML.dump(value, options: .init(
scalarStyle: .plainWhenSafe,
collectionStyle: .flow,
canonical: true,
lineWidth: 10,
))
#expect(styled == "---\n!!map {\n ? !!str \"a\"\n : !!int \"1\",\n}\n")
}
}

View File

@ -0,0 +1,162 @@
@testable import PureYAML
import Testing
/// Regressions for the round-trip and validation fixes: stream document markers,
/// flow-key safety, trailing-colon plain safety, alias-key error classification,
/// duplicate-key identity, the emitter nesting guard, leap seconds, and tagged
/// `sortKeys`.
@Suite("Emitter and tagged fix regressions")
struct EmitterFixRegressionTests {
// MARK: - Item 1: stream document markers
@Test("A two-document stream stays two documents with explicitDocumentStart")
func test_streamExplicitStartStaysTwoDocuments() throws {
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
let dumped = PureYAML.dump(docs, options: .init(explicitDocumentStart: true))
let reparsed = try PureYAML.parseStream(dumped)
#expect(reparsed.count == 2)
#expect(reparsed.map(\.value) == docs.map(\.value))
}
@Test("A two-document stream stays two documents in canonical mode")
func test_streamCanonicalStaysTwoDocuments() throws {
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
let dumped = PureYAML.dump(docs, options: .init(canonical: true))
let reparsed = try PureYAML.parseStream(dumped)
#expect(reparsed.count == 2)
#expect(reparsed.map(\.value) == docs.map(\.value))
}
// MARK: - Item 2: flow-key safety
@Test("A flow mapping key containing a comma round-trips instead of splitting")
func test_flowCommaKeyRoundTrips() throws {
let node = PureYAML.Tagged.Node.mapping(.init(
pairs: [.init(key: "a,b", value: .scalar(.init(rawValue: "1", value: .int(1))))],
style: .flow,
))
let dumped = PureYAML.dump(node)
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == .mapping(.init([.init(key: "a,b", value: .int(1))])))
}
// MARK: - Item 3: trailing-colon plain safety
@Test("A model scalar ending in a colon falls back to quoting and reparses")
func test_modelTrailingColonQuotes() throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: "k", value: .string("foo:"))]))
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("A tagged plain scalar ending in a colon falls back to quoting and reparses")
func test_taggedTrailingColonQuotes() throws {
let node = PureYAML.Tagged.Node.mapping(.init(pairs: [
.init(key: "k", value: .scalar(.init(rawValue: "foo:", value: .string("foo:"), style: .plain))),
]))
let dumped = PureYAML.dump(node)
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == .mapping(.init([.init(key: "k", value: .string("foo:"))])))
}
// MARK: - Item 4: alias-key error classification
@Test("An undefined alias as a mapping key is an undefinedAlias error")
func test_undefinedAliasKey() throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parseTagged("a: 1\n*missing : v\n")
}
do {
_ = try PureYAML.parseTagged("a: 1\n*missing : v\n")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode == "undefinedAlias")
}
}
@Test("A defined non-scalar alias as a mapping key is an expectedScalarKey error")
func test_nonScalarAliasKey() throws {
do {
_ = try PureYAML.parseTagged("a: &s [1, 2]\n*s : v\n")
Issue.record("expected a parse error")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode == "expectedScalarKey")
}
}
// MARK: - Item 5: duplicate-key identity
@Test("An int key and a string key spelled the same are not duplicates")
func test_intVersusStringKeyNotDuplicate() throws {
let node = try PureYAML.parseTagged("1: a\n\"1\": b\n")
#expect(PureYAML.Tagged.Validator().collect(node).values.isEmpty)
}
@Test("Two keys resolving to the same int are duplicates")
func test_sameResolvedIntKeysAreDuplicate() throws {
let node = try PureYAML.parseTagged("0x1: a\n1: b\n")
#expect(PureYAML.Tagged.Validator().collect(node).values.count == 1)
}
// MARK: - Item 6: emitter nesting guard
@Test("A model tree nested past the limit yields a reparseable marker, not a crash")
func test_modelDeepTreeMarker() throws {
var value = PureYAML.Model.Value.string("x")
for _ in 0 ..< 200 {
value = .sequence([value])
}
let dumped = PureYAML.dump(value)
#expect(dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == .null)
}
@Test("A tagged tree nested past the limit yields a reparseable marker, not a crash")
func test_taggedDeepTreeMarker() throws {
var node = PureYAML.Tagged.Node.scalar(.init(rawValue: "x", value: .string("x")))
for _ in 0 ..< 200 {
node = .sequence(.init(values: [node]))
}
let dumped = PureYAML.dump(node)
#expect(dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == .null)
}
@Test("A tree at the limit still renders normally")
func test_treeAtLimitStillRenders() throws {
var value = PureYAML.Model.Value.string("x")
for _ in 0 ..< (PureYAML.Emitting.maxNestingDepth - 1) {
value = .sequence([value])
}
let dumped = PureYAML.dump(value)
#expect(!dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == value)
}
// MARK: - Item 7a: leap second
@Test("A leap second value of 60 parses and adds 60 elapsed seconds")
func test_leapSecondParses() throws {
let stamp = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:60Z"))
#expect(stamp.second == 60)
let base = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:00Z"))
#expect(stamp.secondsSinceEpoch == base.secondsSinceEpoch + 60)
}
// MARK: - Item 7b: tagged sortKeys
@Test("Tagged block dump honors sortKeys for plain string keys")
func test_taggedBlockSortKeys() throws {
let node = try PureYAML.parseTagged("b: 1\na: 2\nc: 3\n")
let dumped = PureYAML.dump(node, options: .init(sortKeys: true))
#expect(dumped == "a: 2\nb: 1\nc: 3\n")
}
@Test("Tagged canonical dump honors sortKeys for plain string keys")
func test_taggedCanonicalSortKeys() throws {
let node = try PureYAML.parseTagged("b: 1\na: 2\n")
let dumped = PureYAML.dump(node, options: .init(sortKeys: true, canonical: true))
let firstKeyLine = dumped.split(separator: "\n").first { $0.contains("? ") }
#expect(firstKeyLine?.contains("\"a\"") == true)
}
}

View File

@ -0,0 +1,117 @@
@testable import PureYAML
import Testing
/// Coverage for `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's
/// first slice: `width`-based line wrapping and canonical mode are separate,
/// larger correctness surfaces of their own and remain open, not silently
/// dropped).
@Suite("Emitter indent width and sortKeys")
struct EmitterIndentAndSortKeysTests {
@Test("A custom indent width nests block sequences and mappings consistently")
func test_customIndentWidth() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "servers", value: .sequence([
.mapping(.init([
.init(key: "url", value: .string("/")),
])),
])),
]))
let dumped = PureYAML.dump(value, options: .init(indentWidth: 4))
#expect(dumped == """
servers:
-
url: "/"
""")
// Indentation is purely a rendering choice: reparsing must recover
// the identical value regardless of indent width.
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("Indent width 1 is the tightest that still round-trips")
func test_indentWidthOne() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "a", value: .mapping(.init([
.init(key: "b", value: .int(1)),
]))),
]))
let dumped = PureYAML.dump(value, options: .init(indentWidth: 1))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("sortKeys orders string-keyed mapping pairs lexicographically")
func test_sortKeysOrdersStringKeys() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "zebra", value: .int(1)),
.init(key: "apple", value: .int(2)),
.init(key: "mango", value: .int(3)),
]))
#expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """
apple: 2
mango: 3
zebra: 1
""")
// Off by default: insertion order is preserved unless opted in.
#expect(PureYAML.dump(value) == """
zebra: 1
apple: 2
mango: 3
""")
}
@Test("sortKeys applies to nested mappings and flow mappings too")
func test_sortKeysAppliesToNestedAndFlowMappings() {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "outer", value: .mapping(.init([
.init(key: "z", value: .int(1)),
.init(key: "a", value: .int(2)),
]))),
]))
#expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """
outer:
a: 2
z: 1
""")
let flowOptions = PureYAML.Emitting.Options(collectionStyle: .flow, sortKeys: true)
#expect(PureYAML.dump(.mapping(.init([
.init(key: "z", value: .int(1)),
.init(key: "a", value: .int(2)),
])), options: flowOptions) == "{\"a\": 2, \"z\": 1}\n")
}
@Test("sortKeys leaves a mapping with any non-string key in insertion order")
func test_sortKeysSkipsMixedKeyMappings() {
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: .string("zebra"), value: .int(1)),
.init(keyNode: .sequence([.int(1), .int(2)]), value: .int(2)),
.init(keyNode: .string("apple"), value: .int(3)),
]))
let sorted = PureYAML.dump(value, options: .init(sortKeys: true))
let unsorted = PureYAML.dump(value)
#expect(sorted == unsorted)
}
@Test("sortKeys still round-trips a mapping with duplicate keys")
func test_sortKeysRoundTripsDuplicateKeys() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "a", value: .int(2)),
]))
let dumped = PureYAML.dump(value, options: .init(sortKeys: true))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
}

View File

@ -36,11 +36,11 @@ struct EventStreamingParserTests {
flow: {id: one, id: two}
""")
let issues = PureYAML.Validation.Validator().collect(value).issues
let issues = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values
#expect(issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.root.title",
"error: Duplicate mapping key 'id' at $.flow.id",
"Duplicate mapping key 'title' at path: $.root.title",
"Duplicate mapping key 'id' at path: $.flow.id",
])
}
}

View File

@ -0,0 +1,55 @@
@testable import PureYAML
import Testing
/// The reader-level control-character check (`Reader.recordFirstInvalidControlCharacter`,
/// `fetchNextToken()`'s check in `LibYAML.Scanner.Fetch.swift`) added to close a real gap
/// found comparing PureYAML against Yams on a real-world OpenAPI corpus: Yams's underlying
/// libyaml rejects raw control characters (`c-printable` violations) anywhere in the input,
/// while `PureYAML.parse()` previously accepted them silently (the only existing check,
/// `PreflightScanner.invalidControlCharacterColumn`, is opt-in via `validate()`, not part of
/// the core scanner `parse()` uses).
@Suite("Invalid control character rejection")
struct InvalidControlCharacterTests {
@Test("A raw control character in scalar content is rejected with the correct position")
func test_rawControlCharacterRejected() {
expectParseError(
"key: value\u{01}with control\n",
.invalidControlCharacter(line: 1, column: 11),
)
}
@Test("A tab in scalar content, not indentation, is still legal")
func test_tabInContentStillAccepted() throws {
let value = try PureYAML.parse("key: value\twith tab\n")
let mapping = requireMapping(value)
#expect(mapping?["key"] == .string("value\twith tab"))
}
@Test("CR and LF are still legal")
func test_crAndLFStillAccepted() throws {
let value = try PureYAML.parse("key: |\n line one\n line two\n")
let mapping = requireMapping(value)
#expect(mapping?["key"] == .string("line one\nline two\n"))
}
@Test("A control character deep inside a large document is still caught")
func test_controlCharacterFarIntoDocumentIsCaught() {
let padding = String(repeating: "line: value\n", count: 5000)
expectParseError(
padding + "bad: value\u{02}here\n",
.invalidControlCharacter(line: 5001, column: 11),
)
}
@Test("An earlier real syntax error is not hidden behind a later control character")
func test_earlierSyntaxErrorNotHiddenByLaterControlCharacter() {
do {
_ = try PureYAML.parse("key: \"unterminated\nbad: value\u{02}here\n")
recordIssue("expected parse to throw")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode != "invalidControlCharacter")
} catch {
recordIssue("expected a ParseError, got \(error)")
}
}
}

View File

@ -73,7 +73,7 @@ struct KeyedCodingTests {
.init(key: "title", value: .string("Second")),
]))
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
@ -81,8 +81,8 @@ struct KeyedCodingTests {
]
expectValidationError(value) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}
@ -94,7 +94,7 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
@ -114,14 +114,14 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("metadata"), .key("title")]),
),
])
#expect(collection.description == "error: Duplicate mapping key 'title' at $.metadata.title")
#expect(collection.description == "Duplicate mapping key 'title' at path: $.metadata.title")
} operation: {
_ = try TitleOnly(from: PureYAML.Decoding.Decoder(
value: value,
@ -312,13 +312,13 @@ private func expectEncodingError(
private func expectValidationError(
_: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
operation: () throws -> some Any,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")
@ -327,7 +327,7 @@ private func expectValidationError(
private func expectValidationError(
_ value: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
expectValidationError(value, check: check) {
try PureYAML.decode(TitleOnly.self, from: value)

View File

@ -24,7 +24,7 @@ struct MergeKeyCompatibilityTests {
#expect(service?["retries"] == .int(3))
#expect(service?["name"] == .string("API"))
#expect(service?["<<"] == nil)
#expect(PureYAML.Validation.Validator().collect(.mapping(root ?? .init())) == .init())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root ?? .init())) == .init())
}
@Test("Expands merge sequence values with first source precedence and local overrides")
@ -86,20 +86,20 @@ struct MergeKeyCompatibilityTests {
name: Backend
""",
)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'name'",
path: .init([.key("service"), .key("name")]),
),
]
#expect(result.issues == expectedIssues)
#expect(result.values == expectedIssues)
#expect(result.errors == expectedIssues)
expectValidationError(value) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'name' at $.service.name")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'name' at path: $.service.name")
}
let root = requireMapping(value, "expected root mapping")

View File

@ -19,4 +19,46 @@ struct ParsingAliasTests {
}
#expect(second["name"] == .string("Example"))
}
/// The regression this guards: `enforceNestingLimit` (Parser.Events.swift) bounds PHYSICAL
/// collection depth in the raw event stream, which does nothing against an anchor-expansion
/// ("billion laughs") bomb -- a handful of shallow, small anchors each aliased many times,
/// each alias nested inside the next, so the LOGICAL tree an alias resolves to is exponential
/// in the number of alias levels while the physical document stays kilobytes. A 25-level,
/// fan-out-9 document like this one expands past 9^25 logical nodes; before this fix,
/// `PureYAML.parse` accepted it in under 2ms (the nesting cap never fires, since raw event
/// depth per line stays at 2) and any full-tree walk downstream (JSON representability
/// checking, JSON writing, validation) never terminated.
@Test("Rejects a nested anchor-expansion bomb instead of accepting an astronomically large tree")
func test_rejectsAnchorExpansionBomb() {
var yaml = "a0: &a0 [x, x, x]\n"
for level in 1 ... 25 {
let previous = "*a\(level - 1)"
let refs = Array(repeating: previous, count: 9).joined(separator: ", ")
yaml += "a\(level): &a\(level) [\(refs)]\n"
}
yaml += "bomb: *a25\n"
#expect {
try PureYAML.parse(yaml)
} throws: { error in
guard case .expansionTooLarge = error as? PureYAML.Parsing.ParseError else { return false }
return true
}
}
/// The fix must not reject ordinary, non-exponential alias reuse: the same small anchor
/// referenced many times at ONE level (no nesting) stays comfortably inside the budget.
@Test("A large but non-exponential alias fan-out still parses")
func test_wideNonExponentialFanOutStillParses() throws {
let refs = Array(repeating: "*item", count: 5000).joined(separator: ", ")
let yaml = "item: &item {name: Example}\nlist: [\(refs)]\n"
let root = try requireMapping(PureYAML.parse(yaml))
guard case let .sequence(list)? = root?["list"] else {
recordIssue("expected a sequence")
return
}
#expect(list.count == 5000)
}
}

View File

@ -12,7 +12,7 @@ struct ParsingCompatibilityTests {
#expect(value == testCase.expected)
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
}
@Test(
@ -30,13 +30,13 @@ struct ParsingCompatibilityTests {
)
func test_collectionDuplicateKeyValidation(testCase: CollectionCompatibilityFixtures.ValidationCase) throws {
let value = try PureYAML.parse(testCase.yaml)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(result.issues == testCase.expectedIssues)
#expect(result.values == testCase.expectedIssues)
#expect(result.errors == testCase.expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationError(value) { collection in
#expect(collection.issues == testCase.expectedIssues)
#expect(collection.values == testCase.expectedIssues)
#expect(collection.description == testCase.expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@ -59,13 +59,13 @@ struct ParsingCompatibilityTests {
#expect(value == testCase.expected)
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
}
@Test("Validates direct values for gaps ordinary loaders may collapse")
func test_directUnsupportedGapValueValidation() {
let value = UnsupportedYAMLGapsFixtures.directPreservedValue
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
guard case let .mapping(mapping) = value else {
recordIssue("expected direct mapping value")
@ -76,13 +76,13 @@ struct ParsingCompatibilityTests {
#expect(mapping["<<"] == .mapping(.init([
.init(key: "enabled", value: .bool(true)),
])))
#expect(result.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.title",
#expect(result.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.values.map(\.description) == [
"Duplicate mapping key 'title' at path: $.title",
])
expectValidationError(value) { collection in
#expect(collection.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}

View File

@ -0,0 +1,56 @@
@testable import PureYAML
import Testing
/// A string the emitter writes without quotes must be read back as that same string. The parser decides what a plain word means (a boolean, a number, null,
/// or a string), so the emitter quotes every string the parser would read as anything else.
@Suite("Plain scalar round trip")
struct PlainScalarRoundTripTests {
/// Words and shapes that a reader may take for something other than a string, and shapes that break plain syntax.
static let corpus: [String] = [
"yes", "Yes", "YES", "no", "No", "NO", "on", "On", "ON", "off", "Off", "OFF", "y", "n", "Y", "N", "true", "True", "TRUE", "false", "False", "FALSE",
"null", "Null", "NULL", "~", "0", "-0", "+0", "1", "+1", "-1", "017", "0123", "0o17", "0O17", "0x1F", "0X1f", "0b101", "0B1", "-0x1F", "+0x1F", "1_000",
"1__0", "0_1", "_1", "1_", "1e3", "1E3", "1e+3", "1.5", "1.", ".5", "-.5", "+.5", ".inf", ".Inf", ".INF", "-.inf", "+.inf", ".nan", ".NaN", ".NAN", "inf",
"nan", "Infinity", "1e400", "0x", "0b", "0o", "9223372036854775807", "9223372036854775808", "-9223372036854775808", "12345678901234567890",
"2001-01-01", "12:30:45", "1:30", "<<", "=", "---", "...", "?", "- x", "-", "a: b", "a:", "a :", ":a", "#c", "a #c", "x#c", "!x", "&a", "*a", "|", ">",
"%x", "@x", "`x", "'x", "\"x", " x", "x ", "a\tb", "a\nb", "a\r\nb", "héllo", "✓", "a,b", "[a]", "{a}", "a]", "k: v", "x:y", "x: ", "", " ", "word",
"two words", "a-b", "a?b", "?x", "-x", "- ", "1 2", "1e", "e1", ".", "..", "+", "0.", "00", "0e0", "1_0.5", "١٢٣", "٣.٥",
]
private static let styles: [PureYAML.Emitting.CollectionStyle] = [.block, .flow]
private func roundTrip(_ value: PureYAML.Model.Value, _ style: PureYAML.Emitting.CollectionStyle) throws -> PureYAML.Model.Value {
let yaml = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, collectionStyle: style))
return try PureYAML.parse(yaml)
}
@Test("A string at the root reads back as itself", arguments: corpus)
func root(text: String) throws {
for style in Self.styles {
#expect(try roundTrip(.string(text), style) == .string(text), "\(style): \(text.debugDescription)")
}
}
@Test("A string as a mapping value reads back as itself", arguments: corpus)
func mappingValue(text: String) throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: "k", value: .string(text))]))
for style in Self.styles {
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
}
}
@Test("A string as a mapping key reads back as itself", arguments: corpus)
func mappingKey(text: String) throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: text, value: .int(1))]))
for style in Self.styles {
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
}
}
@Test("A string in a sequence reads back as itself", arguments: corpus)
func sequenceItem(text: String) throws {
let value = PureYAML.Model.Value.sequence([.string(text), .string("after")])
for style in Self.styles {
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
}
}
}

View File

@ -0,0 +1,113 @@
import Foundation
@testable import PureYAML
import Testing
/// Differential proof for the public `PureYAML.Streaming.Scanner`, the thin
/// translation wrapper around the internal `LibYAML.Scanner.scanStep()`
/// already proven by `StreamingScannerDifferentialTests`. That existing proof
/// covers `scanStep()` itself; this one covers the translation layer on top
/// of it (`Streaming.Token`/`Streaming.Token.Kind`/`Streaming.Token.ScalarStyle`
/// mapped from the internal `LibYAML.Token`/`TokenType`/`ScalarStyle`), since a
/// wrong case mapping or a dropped field would not be caught by a proof that
/// never looks at the public types at all.
@Suite("Public streaming scanner")
struct PublicStreamingScannerTests {
@Test("Public Scanner matches the internal scanAll() reference, translated, at several chunk sizes", arguments: [
"title: Example API",
"""
title: Example
servers:
- url: /
""",
"flow: {a: 1, b: [2, 3]}",
"""
base: &anchor
value: 1
derived: *anchor
""",
"block: |\n line one\n line two\n",
"&a a: b\nc: &d d\n",
])
func test_publicScannerMatchesTranslatedReference(yaml: String) throws {
try assertPublicScannerMatchesReference(yaml)
}
@Test("Public Scanner matches the internal scanAll() reference over the whole yaml-test-suite corpus")
func test_publicScannerMatchesReferenceOverWholeSuite() throws {
guard ProcessInfo.processInfo.environment["PUREYAML_RUN_YAML_TEST_SUITE"] == "1" else {
return
}
let manifest = try YAMLTestSuiteManifest.load()
var mismatches: [String] = []
for testCase in manifest.cases {
let yaml = try testCase.loadInput()
do {
try assertPublicScannerMatchesReference(yaml)
} catch {
mismatches.append("\(testCase.id): \(error)")
}
}
#expect(mismatches.isEmpty, "public/internal mismatches (\(mismatches.count)): \(mismatches.prefix(10).joined(separator: "; "))")
}
}
private func assertPublicScannerMatchesReference(_ yaml: String) throws {
let reference = translatedBatchReference(yaml)
for chunkSize in [1, 2, 3, 7, 64, yaml.unicodeScalars.count + 1] {
let observed = try publicScan(yaml, chunkSize: chunkSize)
#expect(observed == reference, "chunk size \(chunkSize) diverged from the translated reference for: \(yaml.debugDescription)")
}
}
private enum PublicScanResult: Equatable {
case tokens([PureYAML.Streaming.Token])
case threw(String)
}
/// The reference: the batch scanner's own tokens, run through the exact same
/// `Streaming.Token.init(_:)` translator the public `Scanner` uses, so this
/// proof isolates "does the public wrapper preserve what the internal scanner
/// already produces" from "is the internal scanner itself correct" (the
/// latter is `StreamingScannerDifferentialTests`'s job, not this one's).
private func translatedBatchReference(_ yaml: String) -> PublicScanResult {
var scanner = PureYAML.Parsing.LibYAML.Scanner(yaml)
do {
return try .tokens(scanner.scanAll().map(PureYAML.Streaming.Token.init))
} catch {
return .threw("\(error)")
}
}
private func publicScan(_ yaml: String, chunkSize: Int) throws -> PublicScanResult {
let scalars = Array(yaml.unicodeScalars)
var chunks: [String] = []
var index = 0
while index < scalars.count {
let end = min(index + chunkSize, scalars.count)
chunks.append(String(String.UnicodeScalarView(scalars[index ..< end])))
index = end
}
var scanner = PureYAML.Streaming.Scanner()
var nextChunk = 0
var tokens: [PureYAML.Streaming.Token] = []
do {
while true {
switch try scanner.next() {
case let .token(token):
tokens.append(token)
case .done:
return .tokens(tokens)
case .needMoreInput:
if nextChunk < chunks.count {
scanner.feed(chunks[nextChunk])
nextChunk += 1
} else {
scanner.finish()
}
}
}
} catch {
return .threw("\(error)")
}
}

Some files were not shown because too many files have changed in this diff Show More