Compare commits

..

No commits in common. "main" and "0.2.0" have entirely different histories.
main ... 0.2.0

147 changed files with 1516 additions and 6114 deletions

View File

@ -1,17 +1,17 @@
# 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
# 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
on:
push:
pull_request:
workflow_dispatch:
jobs:
rules:
runs-on: ubuntu-latest
runs-on: macos
steps:
- uses: actions/checkout@v4
- name: Shared-rules freshness marker
- name: Rules baseline freshness (VERSION)
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|critic-fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|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 critic-fix docs style refactor perf test build ci chore\n' >&2
printf ' Allowed types: feat 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://git.aleahim.com/NodetteHQ/PureYAML/issues
about: Ask questions, float ideas, or discuss design in an issue.
url: https://codeberg.org/Mihaela/PureYAML/discussions
about: Ask questions, float ideas, or discuss design in Discussions.
- name: Security issue
url: https://git.aleahim.com/NodetteHQ/PureYAML/src/branch/main/SECURITY.md
url: https://codeberg.org/Mihaela/PureYAML/blob/main/SECURITY.md
about: Do not file security issues publicly. Follow the security policy instead.

View File

@ -1 +1 @@
2.14.0
1.1.2

View File

@ -14,10 +14,5 @@ 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,469 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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).
Nothing yet.
## [0.2.0] - 2026-07-05

View File

@ -1,11 +1,11 @@
# PureYAML
[![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)
[![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)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
PureYAML is a dependency-free YAML package written entirely in Swift.
@ -20,6 +20,69 @@ 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,
@ -48,18 +111,6 @@ 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,
@ -257,6 +308,7 @@ 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,23 +36,13 @@ 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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .init(),
strict: Bool = true,
) throws -> [Validation.ValidationError] {
) throws -> [Validation.Issue] {
try validator.validate(value, strict: strict)
}
@ -60,7 +50,7 @@ public extension PureYAML {
@discardableResult
static func validate(
_ documents: [Stream.Document],
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
guard !result.values.isEmpty else {
let result = PureYAML.Validation.Validator().collect(value)
guard !result.issues.isEmpty else {
return
}
let pathPrefix = PureYAML.Validation.Path(codingPath: codingPath)
let errors = result.values.map { error in
PureYAML.Validation.ValidationError(
severity: error.severity,
reason: error.reason,
path: .init(pathPrefix.components + error.path.components),
let issues = result.issues.map { issue in
PureYAML.Validation.Issue(
severity: issue.severity,
reason: issue.reason,
path: .init(pathPrefix.components + issue.path.components),
)
}
throw PureYAML.Validation.ValidationErrorCollection(errors)
throw PureYAML.Validation.Issue.Collection(issues)
}
}

View File

@ -1,93 +0,0 @@
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,30 +8,11 @@ extension PureYAML.Emitting.Dumper {
return [prefix + "{}"]
}
return orderedPairs(mapping).flatMap { pair in
return mapping.pairs.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,
@ -55,7 +36,7 @@ extension PureYAML.Emitting.Dumper {
switch pair.value {
case .mapping, .sequence:
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + options.indentWidth)
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
default:
return renderMappingScalarPair(pair, indent: indent)
}
@ -73,11 +54,11 @@ extension PureYAML.Emitting.Dumper {
return [prefix + "? " + pair.keyNode.flowDescription]
+ [prefix + ":"]
+ render(pair.value, indent: indent + options.indentWidth)
+ render(pair.value, indent: indent + 2)
}
func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String {
let pairs = orderedPairs(mapping)
let pairs = mapping.pairs
.map { pair in
"\(renderFlowKey(pair.keyNode)): \(renderFlow(pair.value))"
}

View File

@ -1,77 +0,0 @@
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

@ -1,188 +0,0 @@
// 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,18 +1,9 @@
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.
public extension PureYAML.Emitting.Dumper {
/// Serializes indexed YAML stream documents with explicit document starts.
func dump(_ documents: [PureYAML.Stream.Document]) -> String {
documents
.map { document in
let body = PureYAML.Emitting.exceedsNestingLimit(document.value)
? PureYAML.Emitting.nestingLimitDocument
: documentBody(document.value)
let end = options.explicitDocumentEnd ? "...\n" : ""
return "---\n" + body + end
"---\n" + dump(document.value)
}
.joined()
}

View File

@ -1,147 +0,0 @@
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,36 +7,10 @@ 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 {
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 {
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)
}
@ -75,7 +49,7 @@ extension PureYAML.Emitting.Dumper {
switch value {
case .mapping, .sequence:
return [prefix + "-"] + render(value, indent: indent + options.indentWidth)
return [prefix + "-"] + render(value, indent: indent + 2)
default:
return renderSequenceScalarItem(value, indent: indent)
}
@ -142,14 +116,10 @@ extension PureYAML.Emitting.Dumper {
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
return [prefix + lines.header] + lines.content
}
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix, value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + rendered]
return [prefix + renderScalar(value)]
}
func renderMappingScalarPair(
@ -158,15 +128,10 @@ extension PureYAML.Emitting.Dumper {
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let key = escapeKey(pair.key)
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) {
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + 2) {
return [prefix + key + ": " + lines.header] + lines.content
}
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]
return [prefix + key + ": " + renderScalar(pair.value)]
}
func renderSequenceScalarItem(
@ -174,14 +139,10 @@ extension PureYAML.Emitting.Dumper {
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
return [prefix + "- " + lines.header] + lines.content
}
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix + "- ", value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + "- " + rendered]
return [prefix + "- " + renderScalar(value)]
}
func renderScalar(_ value: PureYAML.Model.Value) -> String {
@ -241,4 +202,182 @@ 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,62 +5,13 @@ 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

@ -1,26 +0,0 @@
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

@ -1,26 +0,0 @@
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,10 +6,6 @@ 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] = []
@ -107,8 +103,6 @@ private extension PureYAML.Parsing.EventStreamComposer {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line)
}
anchors = [:]
anchorSizes = [:]
expandedNodeBudget = 5_000_000
currentDocumentValue = nil
}
@ -132,10 +126,8 @@ 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,
@ -147,7 +139,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(
@ -161,12 +153,6 @@ 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(
@ -177,20 +163,15 @@ 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, cost: totalCost)
store(value, anchor: container.anchor)
try complete(.init(
value: value,
mappingKey: .init(
@ -204,7 +185,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 {
@ -214,14 +195,12 @@ 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, cost: totalCost)
store(value, anchor: container.anchor)
let located: PureYAML.Model.LocatedValue? = tracksLocations
? .mapping(
mergedLocatedPairs(from: container.mergeLocatedSources, localPairs: container.localLocatedPairs),
@ -238,10 +217,10 @@ private extension PureYAML.Parsing.EventStreamComposer {
),
mark: container.mark,
located: located,
), cost: totalCost)
))
}
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode, cost: Int) throws {
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode) throws {
guard !containers.isEmpty else {
currentDocumentValue = node.value
if tracksLocations {
@ -253,13 +232,11 @@ 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
}
}
@ -300,13 +277,11 @@ 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,11 +7,6 @@ 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,15 +19,6 @@ 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
@ -53,7 +44,6 @@ extension PureYAML.Parsing.LibYAML {
} else {
tokens.insert(token, at: number - tokensParsed)
}
lastToken = tokens.last
}
}
@ -69,20 +59,17 @@ 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,71 +6,20 @@ 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 var scalars: [Unicode.Scalar]
private let 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
/// 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) {
init(_ string: String) {
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`). 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? {
/// end (libyaml's `\0`).
func peek(_ offset: Int = 0) -> Unicode.Scalar? {
let position = index + offset
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
return position < scalars.count ? scalars[position] : nil
}
var isAtEnd: Bool {
@ -104,49 +53,5 @@ 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 mutating func foldBlockLine(_ state: inout BlockState, literal: Bool) {
private 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 mutating func documentMarkerAhead() -> Bool {
private func documentMarkerAhead() -> Bool {
let first = reader.peek()
guard first == "-" || first == "." else {
return false

View File

@ -23,25 +23,6 @@ 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()
@ -371,7 +352,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
indentEngine.enqueue(token)
}
private mutating func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
private 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 mutating func documentMarkerAhead() -> Bool {
private 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.lastToken?.type {
switch indentEngine.tokens.last?.type {
case .anchor, .tag:
true
case .value:

View File

@ -1,58 +1,10 @@
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. 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 {
/// are folded in before the next content character.
private struct PlainState {
var string = ""
var leadingBreak = ""
var trailingBreaks = ""
@ -70,146 +22,24 @@ 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 {
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
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
}
}
}
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
}
@ -276,22 +106,11 @@ 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()` 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, 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, let blank = reader.peek() {
state.whitespaces.unicodeScalars.append(blank)
@ -313,7 +132,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a plain scalar", problem: problem, mark: mark)
}
private mutating func documentMarkerAhead() -> Bool {
private 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).
mutating func blankLineAhead() -> Bool {
func blankLineAhead() -> Bool {
var offset = 0
while let character = reader.peek(offset), character == " " || Classify.isTab(character) {
offset += 1

View File

@ -1,165 +0,0 @@
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,18 +120,10 @@ 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,13 +42,9 @@ 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 = "", isFinal: Bool = true) {
reader = Reader(input, isFinal: isFinal)
init(_ input: String = "") {
reader = Reader(input)
}
/// `parser->flow_level`, kept on the indent engine.

View File

@ -8,8 +8,6 @@ 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,7 +3,6 @@ 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)
@ -23,13 +22,6 @@ 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 {
@ -38,8 +30,6 @@ 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):
@ -78,8 +68,6 @@ 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)"
}
@ -91,8 +79,6 @@ public extension PureYAML.Parsing {
"emptyDocument"
case .tabIndentation:
"tabIndentation"
case .invalidControlCharacter:
"invalidControlCharacter"
case .unexpectedIndentation:
"unexpectedIndentation"
case .mixedCollectionStyles:
@ -131,8 +117,6 @@ public extension PureYAML.Parsing {
"unexpectedToken"
case .nestingTooDeep:
"nestingTooDeep"
case .expansionTooLarge:
"expansionTooLarge"
case .malformed:
"malformed"
}
@ -163,8 +147,6 @@ public extension PureYAML.Parsing {
let .unexpectedEvent(_, _, line, _),
let .unexpectedToken(_, _, line, _),
let .nestingTooDeep(line, _),
let .expansionTooLarge(line, _),
let .invalidControlCharacter(line, _),
let .malformed(_, line, _):
line
}
@ -194,8 +176,6 @@ 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

@ -1,99 +0,0 @@
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,25 +14,93 @@ 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))
}
return PureYAML.Parsing.PlainScalar.resolve(value)
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)
}
func parseBool(_ text: String) -> Bool? {
PureYAML.Parsing.PlainScalar.bool(text)
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
}
}
func parseInteger(_ text: String) -> Int? {
PureYAML.Parsing.PlainScalar.integer(text)
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)
}
func parseDouble(_ text: String) -> Double? {
PureYAML.Parsing.PlainScalar.double(text)
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 != "_" }
}
func parseSingleQuoted(

View File

@ -21,9 +21,6 @@ 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.BuiltinValidation {
public extension PureYAML.Validation.BuiltinCheck {
/// 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.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Mapping keys are strings") { context in
static var mappingKeysAreStrings: PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Mapping keys are strings") { context in
context.subject.pairs.compactMap { pair in
guard pair.keyNode.stringValue == nil else {
return nil
}
return PureYAML.Validation.ValidationError(
return PureYAML.Validation.Issue(
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.BuiltinValidation {
/// 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.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Numbers are finite") { context in
static var numbersAreFinite: PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Numbers are finite") { context in
guard case let .double(number) = context.subject, !number.isFinite else {
return []
}
return [
PureYAML.Validation.ValidationError(
PureYAML.Validation.Issue(
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.ValidationError])
case notRepresentable([PureYAML.Validation.Issue])
/// 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).values
let issues = validator(for: target).collect(value).issues
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<PureYAML.Model.Value, PureYAML.Model.Value> {
public static func validator(for target: Format) -> PureYAML.Validation.Validator {
switch target {
case .json:
PureYAML.JSON.Validator.representability

View File

@ -9,25 +9,22 @@ 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 ``PureYAML/Validation/BuiltinValidation/mappingKeysAreUnique``
/// from the rule set if you want the permissive reading.
/// validator rejects duplicates for determinism. Drop ``unique`` 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`. 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),
/// 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),
])
}
/// Every reason the value cannot be emitted as strict JSON, in document order.
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.ValidationError] {
representability.collect(value).values
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.Issue] {
representability.collect(value).issues
}
/// 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.ValidationError
public var issue: PureYAML.Validation.Issue
public init(
documentIndex: Int,
issue: PureYAML.Validation.ValidationError,
issue: PureYAML.Validation.Issue,
) {
self.documentIndex = documentIndex
self.issue = issue

View File

@ -1,13 +0,0 @@
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

@ -1,102 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,14 +0,0 @@
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,85 +1,55 @@
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.BuiltinValidation`. Each is a `Validation<_, Node>` on the one
/// layer's counterpart of `Validation.BuiltinCheck`. Each is a `Check<Node, Node>` on the one
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
enum BuiltinValidation {
enum BuiltinCheck {
/// Reports each duplicate key within a single tagged mapping. The tagged-layer counterpart of
/// `Validation.BuiltinValidation.mappingKeysAreUnique`; duplicate keys are as invalid in a
/// `Validation.BuiltinCheck.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.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(
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(
severity: .error,
reason: "Duplicate mapping key '\(pair.key)'",
path: context.path.appending(.key(pair.key)),
))
}
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
return issues
}
}
/// 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.Validation<Node, Node> {
PureYAML.Validation.Validation(description: "Built-in tags are supported and kind-consistent") { context in
var errors: [PureYAML.Validation.ValidationError] = []
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] = []
if case let .mapping(mapping) = context.subject {
errors.append(contentsOf: mapping.pairs.compactMap { pair in
issues.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 errors
return issues
}
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
errors.append(
PureYAML.Validation.ValidationError(
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
path: context.path,
),
)
return errors
return issues
}
if !tag.isSupportedBuiltInTag {
errors.append(
PureYAML.Validation.ValidationError(
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)'",
path: context.path,
@ -87,7 +57,7 @@ public extension PureYAML.Tagged {
)
}
return errors
return issues
} when: { context in
if context.subject.tag?.isBuiltIn == true {
return true
@ -102,7 +72,7 @@ public extension PureYAML.Tagged {
static func keyTagIssue(
for pair: PureYAML.Tagged.Pair,
at path: PureYAML.Validation.Path,
) -> PureYAML.Validation.ValidationError? {
) -> PureYAML.Validation.Issue? {
guard let tag = pair.keyTag, tag.isBuiltIn else {
return nil
}
@ -110,21 +80,21 @@ public extension PureYAML.Tagged {
return nil
}
if tag == .merge {
return PureYAML.Validation.ValidationError(
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
path: path,
)
}
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
return PureYAML.Validation.ValidationError(
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
path: path,
)
}
guard tag.isSupportedBuiltInTag else {
return PureYAML.Validation.ValidationError(
return PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
path: path,

View File

@ -1,23 +0,0 @@
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

@ -1,132 +0,0 @@
// 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

@ -1,294 +0,0 @@
// 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

@ -1,395 +0,0 @@
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 .alias(anchor: anchor, node: value)
case let .mappingStart(anchor, tag, style, _):
let value = try composeMapping(tag: tag, anchor: anchor, style: style)
return value
case let .mappingStart(anchor, tag, _, _):
let value = try composeMapping(tag: tag)
store(value, anchor: anchor)
return value
case let .scalar(value, anchor, tag, style, mark, _):
@ -117,14 +117,12 @@ 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, style, _):
let value = try composeSequence(tag: tag, anchor: anchor, style: style)
case let .sequenceStart(anchor, tag, _, _):
let value = try composeSequence(tag: tag)
store(value, anchor: anchor)
return value
default:
@ -132,11 +130,7 @@ extension PureYAML.Tagged.EventComposer {
}
}
mutating func composeSequence(
tag: String?,
anchor: String?,
style: PureYAML.Parsing.CollectionStyle,
) throws -> PureYAML.Tagged.Node {
mutating func composeSequence(tag: String?) throws -> PureYAML.Tagged.Node {
_ = try expect("sequence start") { event in
if case .sequenceStart = event {
return true
@ -148,19 +142,10 @@ extension PureYAML.Tagged.EventComposer {
try values.append(composeNode())
}
_ = try expect("sequence end") { $0.isSequenceEnd }
return .sequence(.init(
values: values,
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
))
return .sequence(.init(values: values, tag: .normalized(tag)))
}
mutating func composeMapping(
tag: String?,
anchor: String?,
style: PureYAML.Parsing.CollectionStyle,
) throws -> PureYAML.Tagged.Node {
mutating func composeMapping(tag: String?) throws -> PureYAML.Tagged.Node {
_ = try expect("mapping start") { event in
if case .mappingStart = event {
return true
@ -169,40 +154,18 @@ extension PureYAML.Tagged.EventComposer {
}
var pairs: [PureYAML.Tagged.Pair] = []
while current?.isMappingEnd != true {
let keyNode = try composeMappingKey()
let key = try composeMappingKey()
let value = try composeNode()
pairs.append(.init(keyNode: keyNode, value: value))
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
}
_ = try expect("mapping end") { $0.isMappingEnd }
return .mapping(.init(
pairs: pairs,
tag: .normalized(tag),
anchor: anchor,
style: .init(style),
))
return .mapping(.init(pairs: pairs, tag: .normalized(tag)))
}
mutating func composeMappingKey() throws -> PureYAML.Tagged.Node {
mutating func composeMappingKey() throws -> ComposedMappingKey {
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)
@ -212,15 +175,18 @@ 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 .scalar(scalar)
return ComposedMappingKey(value: value, tag: .normalized(tag))
}
}
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,23 +2,13 @@ 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

@ -1,12 +0,0 @@
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,7 +1,5 @@
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):
@ -12,8 +10,6 @@ public extension PureYAML.Tagged.Node {
scalar.value
case let .sequence(sequence):
.sequence(sequence.values.map(\.modelValueErasingTags))
case let .alias(_, node):
node.modelValueErasingTags
}
}
}

View File

@ -1,26 +0,0 @@
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,18 +1,9 @@
public extension PureYAML.Tagged {
/// 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 {
/// YAML node tree that preserves explicit source tags.
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 {
@ -22,8 +13,6 @@ public extension PureYAML.Tagged {
scalar.tag
case let .sequence(sequence):
sequence.tag
case .alias:
nil
}
}
@ -35,33 +24,6 @@ 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,7 +3,6 @@ public extension PureYAML.Tagged {
case scalar
case sequence
case mapping
case alias
public var description: String {
rawValue

View File

@ -1,44 +1,17 @@
public extension PureYAML.Tagged {
struct Pair: Equatable, Sendable {
/// 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 key: String
public var keyTag: Tag?
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.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
self.key = key
self.keyTag = keyTag
self.value = value
}
}
}

View File

@ -1,84 +0,0 @@
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,25 +3,15 @@ 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

@ -1,28 +0,0 @@
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,23 +2,13 @@ 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

@ -1,241 +0,0 @@
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,44 +1,131 @@
public extension PureYAML.Tagged {
/// 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>
/// 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)
}
}
}
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),
]
}
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)
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
}
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)
})
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))
}
return PureYAML.Stream.Result(issues)
}
}

View File

@ -1,19 +1,19 @@
public extension PureYAML.Validation {
/// A type-erased `Validation`, so checks specialised on different subject types live in one list, all
/// A type-erased `Check`, 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
/// `Validation<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
/// `Check<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 AnyValidation<Document: Sendable>: Sendable {
struct AnyCheck<Document: Sendable>: Sendable {
public let description: String
private let applyErased: @Sendable (any Validatable, Path, Document) -> [ValidationError]
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
public init<Subject: Validatable>(_ validation: Validation<Subject, Document>) {
description = validation.description
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
description = check.description
applyErased = { subject, path, document in
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
return []
}
return validation.apply(ValidationContext(document: document, subject: typed, path: path))
return check.apply(SubjectContext(document: document, subject: typed, path: path))
}
}
@ -21,7 +21,7 @@ public extension PureYAML.Validation {
to subject: any Validatable,
at path: Path,
in document: Document,
) -> [ValidationError] {
) -> [Issue] {
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 BuiltinValidation {
enum BuiltinCheck {
/// 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: Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Validation(description: "Mapping keys are unique") { context in
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Check(description: "Mapping keys are unique") { context in
var seen = Set<PureYAML.Model.Key>()
var errors: [ValidationError] = []
var issues: [Issue] = []
for pair in context.subject.pairs where !seen.insert(pair.keyNode).inserted {
errors.append(ValidationError(
issues.append(Issue(
severity: .error,
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
path: context.path.appending(pair.keyNode.pathComponent),
))
}
return errors
return issues
}
}
}
}
extension PureYAML.Model.Key {
/// The path component a duplicate-key error points at: a plain `.key` for a string key, a
/// The path component a duplicate-key issue 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,10 +1,9 @@
// 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; `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.
// 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.
//
// 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
@ -19,49 +18,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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<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.ValidationContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] <= rhs }
}
@ -70,9 +69,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<ValidationContext<S, D>, U>,
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (ValidationContext<S, D>) -> Bool {
) -> @Sendable (SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0[keyPath: boxed.keyPath]) }
}
@ -81,15 +80,15 @@ public extension PureYAML.Validation {
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<S, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (ValidationContext<S, D>) -> Bool {
) -> @Sendable (SubjectContext<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>(_ validations: Validation<S, D>...) -> Validation<S, D> {
let list = validations
return Validation(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
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
list.flatMap { $0.apply(context) }
}
}
@ -98,12 +97,12 @@ public extension PureYAML.Validation {
/// and document.
static func lift<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child>,
into validations: Validation<Child, D>...,
) -> Validation<S, D> {
let list = validations
into checks: Check<Child, D>...,
) -> Check<S, D> {
let list = checks
let boxed = SendableKeyPath(keyPath: keyPath)
return Validation(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = ValidationContext(
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = SubjectContext(
document: context.document,
subject: context.subject[keyPath: boxed.keyPath],
path: context.path,
@ -111,43 +110,24 @@ 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.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: 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.apply(context) + rhs.apply(context)
}
}
public func || <S, D>(
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
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
let left = lhs.apply(context)
guard !left.isEmpty else {
return []

View File

@ -0,0 +1,49 @@
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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<Model.Value, Model.Value>,
using validator: Validation.Validator,
preflightScanner: Validation.PreflightScanner,
) -> DiagnosticValidationResult {
var diagnostics = preflightScanner.diagnostics(in: yaml, file: file)

View File

@ -0,0 +1,14 @@
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

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

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 ValidationContext<Subject: Validatable, Document: Sendable>: Sendable {
struct SubjectContext<Subject: Validatable, Document: Sendable>: Sendable {
public var document: Document
public var subject: Subject
public var path: Path

View File

@ -1,15 +0,0 @@
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 `Validation<Subject>` fires on
/// Marker for a value a type-directed validation can be applied to. A `Check<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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .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<Model.Value, Model.Value> = .init(),
using validator: Validation.Validator = .init(),
failOnWarnings: Bool = false,
) -> Validation.BatchReport {
let reports = sources.map { source in

View File

@ -1,49 +0,0 @@
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,26 +0,0 @@
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

@ -1,14 +0,0 @@
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,166 +1,142 @@
public extension PureYAML.Validation {
/// 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>]
/// 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>]
public init(
nonReferenceDefaultValidations: [AnyValidation<Document>] = [],
referenceDefaultValidations: [AnyValidation<Document>] = [],
customValidations: [AnyValidation<Document>] = [],
) {
self.nonReferenceDefaultValidations = nonReferenceDefaultValidations
self.referenceDefaultValidations = referenceDefaultValidations
self.customValidations = customValidations
public init(checks: [AnyCheck<PureYAML.Model.Value>] = Self.defaultChecks) {
self.checks = checks
}
/// A validator with no checks; add your own with `validating(_:)`.
public static var blank: Validator {
Validator()
public static var blank: Self {
Self(checks: [])
}
/// The descriptions of every active check, in application order.
public var validationDescriptions: [String] {
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)
public static var defaultChecks: [AnyCheck<PureYAML.Model.Value>] {
[AnyCheck(BuiltinCheck.mappingKeysAreUnique)]
}
@discardableResult
public func validate(
_ subject: Subject,
in document: Document,
_ value: PureYAML.Model.Value,
strict: Bool = true,
) throws -> [ValidationError] {
let result = collect(subject, in: document)
let failures = strict ? result.values : result.errors
) throws -> [Issue] {
let result = collect(value)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw ValidationErrorCollection(failures)
throw Issue.Collection(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)
}
public func collect(_ value: PureYAML.Model.Value) -> Result {
var issues: [Issue] = []
walk(root: value, subject: value, path: .root, issues: &issues)
return Result(issues)
}
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))
@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.
public var validationDescriptions: [String] {
checks.map(\.description)
}
}
}
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)
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
}
}
@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)
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))
}
}
}

View File

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

View File

@ -127,12 +127,8 @@ 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.values == [
#expect(collection.issues == [
.init(
severity: .error,
reason: "Duplicate mapping key '/users'",
path: .init([.key("/users")]),
),
])
#expect(collection.description == "Duplicate mapping key '/users' at path: $[\"/users\"]")
#expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]")
}
}
@ -225,12 +225,12 @@ private func expectEncodingError(
private func expectValidationError(
operation: () throws -> some Any,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
check: (PureYAML.Validation.Issue.Collection) -> Void,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
} catch let collection as PureYAML.Validation.Issue.Collection {
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.ValidationError]
var expectedIssues: [PureYAML.Validation.Issue]
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.ValidationError(
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '[\"a\", \"b\"]'",
path: .init([.complexKey(key)]),
)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let result = PureYAML.Validation.Validator().collect(value)
#expect(result.values == [expectedIssue])
#expect(result.values.map(\.description) == [
"Duplicate mapping key '[\"a\", \"b\"]' at path: $[?[\"a\", \"b\"]]",
#expect(result.issues == [expectedIssue])
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key '[\"a\", \"b\"]' at $[?[\"a\", \"b\"]]",
])
expectValidationError(value) { collection in
#expect(collection.values == [expectedIssue])
#expect(collection.issues == [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.ValidationError(
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '{\"name\": \"service\"}'",
path: .init([.complexKey(key)]),
)
expectValidationError(value) { collection in
#expect(collection.values == [expectedIssue])
#expect(collection.issues == [expectedIssue])
#expect(collection.description == """
Duplicate mapping key '{"name": "service"}' at path: $[?{"name": "service"}]
error: Duplicate mapping key '{"name": "service"}' at $[?{"name": "service"}]
""")
}
}
@ -58,7 +58,7 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.ValidationError(
let expectedIssue = PureYAML.Validation.Issue(
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.values == [expectedIssue])
#expect(collection.description == "Duplicate mapping key '[nan]' at path: $[?[nan]]")
#expect(collection.issues == [expectedIssue])
#expect(collection.description == "error: Duplicate mapping key '[nan]' at $[?[nan]]")
}
}
@ -82,7 +82,7 @@ struct ComplexMappingKeyValidationTests {
let issues = try PureYAML.validate(value, strict: false)
#expect(issues.isEmpty)
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values.isEmpty)
#expect(PureYAML.Validation.Validator().collect(value).issues.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.ValidationError(
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.complexKey(key), .key("mode")]),
)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
let result = PureYAML.Validation.Validator.blank
.validating(legacyModeRule(severity: .error))
.collect(value)
#expect(result.values == [expectedIssue])
#expect(result.values.first?.description == """
Legacy mode is not allowed at path: $[?{"name": "service"}].mode
#expect(result.issues == [expectedIssue])
#expect(result.issues.first?.description == """
error: Legacy mode is not allowed at $[?{"name": "service"}].mode
""")
}
}

View File

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

View File

@ -1,161 +0,0 @@
@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

@ -1,162 +0,0 @@
@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

@ -1,117 +0,0 @@
@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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values
let issues = PureYAML.Validation.Validator().collect(value).issues
#expect(issues.map(\.description) == [
"Duplicate mapping key 'title' at path: $.root.title",
"Duplicate mapping key 'id' at path: $.flow.id",
"error: Duplicate mapping key 'title' at $.root.title",
"error: Duplicate mapping key 'id' at $.flow.id",
])
}
}

View File

@ -1,55 +0,0 @@
@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.ValidationError(
PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
@ -81,8 +81,8 @@ struct KeyedCodingTests {
]
expectValidationError(value) { collection in
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
}
}
@ -94,7 +94,7 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.values == [
#expect(collection.issues == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
@ -114,14 +114,14 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.values == [
#expect(collection.issues == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("metadata"), .key("title")]),
),
])
#expect(collection.description == "Duplicate mapping key 'title' at path: $.metadata.title")
#expect(collection.description == "error: Duplicate mapping key 'title' at $.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.ValidationErrorCollection) -> Void,
check: (PureYAML.Validation.Issue.Collection) -> Void,
operation: () throws -> some Any,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
} catch let collection as PureYAML.Validation.Issue.Collection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")
@ -327,7 +327,7 @@ private func expectValidationError(
private func expectValidationError(
_ value: PureYAML.Model.Value,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
check: (PureYAML.Validation.Issue.Collection) -> 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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root ?? .init())) == .init())
#expect(PureYAML.Validation.Validator().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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let result = PureYAML.Validation.Validator().collect(value)
let expectedIssues = [
PureYAML.Validation.ValidationError(
PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key 'name'",
path: .init([.key("service"), .key("name")]),
),
]
#expect(result.values == expectedIssues)
#expect(result.issues == expectedIssues)
#expect(result.errors == expectedIssues)
expectValidationError(value) { collection in
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'name' at path: $.service.name")
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'name' at $.service.name")
}
let root = requireMapping(value, "expected root mapping")

View File

@ -19,46 +19,4 @@ 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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator().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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let result = PureYAML.Validation.Validator().collect(value)
#expect(result.values == testCase.expectedIssues)
#expect(result.issues == testCase.expectedIssues)
#expect(result.errors == testCase.expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationError(value) { collection in
#expect(collection.values == testCase.expectedIssues)
#expect(collection.issues == 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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator().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<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let result = PureYAML.Validation.Validator().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.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.values.map(\.description) == [
"Duplicate mapping key 'title' at path: $.title",
#expect(result.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.title",
])
expectValidationError(value) { collection in
#expect(collection.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
#expect(collection.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
}
}

View File

@ -1,56 +0,0 @@
@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

@ -1,113 +0,0 @@
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