Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 938ad85d9e | |||
| a11858380b | |||
| 1572307de9 | |||
| 7992c104a5 | |||
| 28c89f3713 | |||
| d2421aa0ce | |||
| ae85ec97c9 | |||
| 93b90adfab | |||
| 7fcfdea68c | |||
| ecd1b34a18 | |||
| f1bf9e7a18 | |||
| d29ac65d4d | |||
| d465f500e3 | |||
| 1bbf715fa5 | |||
| 20b96fbfd4 | |||
| 2fcf54684e | |||
| e51df57c35 | |||
| 81e2794c9c | |||
| 8a3deaebb4 | |||
| bf2dc4ece7 | |||
| e1c08260c4 | |||
| 8df3f0b8d4 | |||
| 58872248d5 | |||
| 6fa49b7ffc | |||
| 20f080a9f7 | |||
| b48a30f978 | |||
| 2a84d6616f | |||
| ec3642bacc | |||
| f9fe2325f7 | |||
| 41ca600d93 | |||
| 6d3f3009d2 | |||
| c6740ca299 | |||
| 7add6f0087 | |||
| 18fdf9b69a | |||
| 1dc986f146 | |||
| 8031568858 |
@ -1,17 +1,17 @@
|
||||
# Managed by rules-swift (scripts/sync-rules.sh). Verifies this repo tracks the
|
||||
# CURRENT shared-rules baseline. The repo's own .swiftformat/.swiftlint.yml are
|
||||
# repo-specific (baseline + project rules) and are intentionally NOT byte-checked.
|
||||
name: rules-conformance
|
||||
# Managed by rules-swift (scripts/sync-rules.sh). Verifies only that this repo's
|
||||
# freshness marker matches the current shared-rules version. It does not claim
|
||||
# semantic rule conformance; each consumer owns and runs its project-specific gates.
|
||||
name: rules-freshness
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
rules:
|
||||
runs-on: macos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rules baseline freshness (VERSION)
|
||||
- name: Shared-rules freshness marker
|
||||
run: |
|
||||
BASE=https://codeberg.org/Mihaela/rules-swift/raw/branch/main
|
||||
canon=$(curl -fsSL "$BASE/VERSION" | tr -d '[:space:]')
|
||||
@ -136,9 +136,9 @@ case "$SUBJECT" in
|
||||
printf ' Rule: commits.md Rule 1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
|
||||
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|critic-fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
|
||||
printf 'commit-msg: subject must be Conventional Commits: "type(scope): summary".\n' >&2
|
||||
printf ' Allowed types: feat fix docs style refactor perf test build ci chore\n' >&2
|
||||
printf ' Allowed types: feat fix critic-fix docs style refactor perf test build ci chore\n' >&2
|
||||
printf ' Rule: commits.md Rule 1, Rule 2\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and ideas
|
||||
url: https://codeberg.org/Mihaela/PureYAML/discussions
|
||||
about: Ask questions, float ideas, or discuss design in Discussions.
|
||||
url: https://git.aleahim.com/NodetteHQ/PureYAML/issues
|
||||
about: Ask questions, float ideas, or discuss design in an issue.
|
||||
- name: Security issue
|
||||
url: https://codeberg.org/Mihaela/PureYAML/blob/main/SECURITY.md
|
||||
url: https://git.aleahim.com/NodetteHQ/PureYAML/src/branch/main/SECURITY.md
|
||||
about: Do not file security issues publicly. Follow the security policy instead.
|
||||
|
||||
@ -1 +1 @@
|
||||
1.1.2
|
||||
2.14.0
|
||||
|
||||
@ -14,5 +14,10 @@ force_cast: error
|
||||
line_length:
|
||||
warning: 180
|
||||
error: 180
|
||||
# Exhaustive switches over closed enums are the intended way to make
|
||||
# impossible states unrepresentable; counting their cases as complexity
|
||||
# pushes code toward split mappings with trapping defaults instead.
|
||||
cyclomatic_complexity:
|
||||
ignores_case_statements: true
|
||||
trailing_comma:
|
||||
mandatory_comma: true
|
||||
|
||||
464
CHANGELOG.md
464
CHANGELOG.md
@ -7,7 +7,469 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Nothing yet.
|
||||
### Added
|
||||
|
||||
- `Tagged.Pair` now stores its key as a full node (`keyNode`), closing
|
||||
issues #12 and #13: alias mapping keys (`*b : value`, yaml-test-suite
|
||||
E76Z/26DV) parse on the `Tagged` path instead of throwing
|
||||
`expectedScalarKey`, key anchors register and re-emit, and a key's
|
||||
presentation style (a deliberately quoted `"on":`) survives dump and
|
||||
reparse. `key: String` and `keyTag` remain as read-through computed
|
||||
accessors (an alias key resolves to its anchored scalar's text), and
|
||||
the `init(key:keyTag:value:)` convenience still builds plain string
|
||||
keys, so most call sites compile unchanged. A style-less hand-built
|
||||
key keeps the traditional plain-when-safe emission convention rather
|
||||
than inheriting the document scalar policy. The corpus round-trip law
|
||||
now compares key nodes structurally, and alias-key documents move
|
||||
from the rejected column to round-tripping.
|
||||
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
|
||||
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
|
||||
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar
|
||||
components, timezone offset, and `secondsSinceEpoch` via the
|
||||
days-from-civil closed form; epoch expectations in the tests are
|
||||
measured against Python's independent implementation), and
|
||||
`binaryValue` base64-decodes a `!!binary` scalar into `[UInt8]`,
|
||||
accepting unpadded and block-scalar-wrapped content and rejecting
|
||||
corrupt input whole. Yams hands back Foundation `Date`/`Data`; this
|
||||
package is mechanically Foundation-free, so the bridges are one-liners
|
||||
the caller owns: `Date(timeIntervalSince1970:)` and `Data(_:)`.
|
||||
`!!set`/`!!omap` are deliberately skipped: `Model.Mapping` already
|
||||
preserves order and duplicates without a special tag.
|
||||
- `Emitting.Options` gains `canonical`, `explicitDocumentStart`,
|
||||
`explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the
|
||||
earlier `indentWidth`/`sortKeys` slice deliberately deferred these).
|
||||
Canonical mode emits the YAML-specification canonical form matching
|
||||
libyaml's layout: explicit `---`, an explicit tag on every node
|
||||
(carried tags on the `Tagged` path, the value's implicit tag
|
||||
otherwise), double-quoted scalars, and flow collections with one
|
||||
`? key : value,` entry per line; anchors and alias references survive
|
||||
on the `Tagged` path. `lineWidth` wraps flow collections one entry per
|
||||
line when the single-line form overflows, and folds plain block
|
||||
scalars at single-space boundaries onto more-indented continuation
|
||||
lines; a fold that would manufacture syntax (a segment ending in `:`,
|
||||
a continuation starting with `#` or an indicator) or disturb
|
||||
consecutive spaces is vetoed and the line stays long, so the value
|
||||
always survives reparse. Gated by golden-output tests plus
|
||||
reparse-equivalence tests for every option.
|
||||
- Anchors, aliases, and per-node presentation styles now survive the
|
||||
`Tagged` tree (issue #9, the feature-parity audit's biggest blocker to
|
||||
switching from Yams). `Tagged.Scalar`/`.Sequence`/`.Mapping` gain
|
||||
`anchor` and `style` fields (`Tagged.ScalarStyle`: plain, single/double
|
||||
quoted, literal, folded; `Tagged.CollectionStyle`: block, flow), and
|
||||
`Tagged.Node` gains an `alias(anchor:node:)` case carrying both the
|
||||
reference and its resolution, so value-oriented consumers read through it
|
||||
without a resolution step (`modelValueErasingTags` matches `parse(_:)`'s
|
||||
expansion) while emitters re-emit the reference. Cycles are impossible by
|
||||
construction: an anchor registers only after its node fully composes, so
|
||||
a self-referential alias stays an `undefinedAlias` parse error.
|
||||
- `PureYAML.dump(_ node: Tagged.Node, options:)` and `Tagged.Dumper`: emit
|
||||
a tagged tree back to YAML with anchors (`&name`), alias references
|
||||
(`*name`, never expanded copies), tags (`!!short` for built-ins,
|
||||
`!<verbatim>` otherwise), and per-node styles that win over the
|
||||
document-wide policy. Literal blocks handle keep-chomping (`|+`),
|
||||
strip-chomped single lines (`|-`), leading blank lines, tab-led and
|
||||
whitespace-only interior lines, and whitespace-led first lines via an
|
||||
explicit indentation indicator (`|2`); folded scalars re-emit as `>` when
|
||||
the folding rules invert exactly, degrading to a literal block (value
|
||||
still exact) otherwise. Plain scalars re-emit their source spelling, so
|
||||
`NO` resolved as a bool does not normalize to `false` and an empty null
|
||||
stays empty. The round-trip law is corpus-tested over every real-world
|
||||
fixture: `parseTagged(dump(parseTagged(d)))` equals `parseTagged(d)`
|
||||
with exactly one documented style degradation (single-quoted content
|
||||
containing line breaks re-emits double-quoted; the value is unchanged).
|
||||
|
||||
- Add `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's first
|
||||
slice), closing two of the four gaps a feature-parity audit against Yams
|
||||
found in the emitter (`PureYAMLYamsComparison`'s `FEATURE-PARITY.md`).
|
||||
`indentWidth` replaces every hardcoded `indent + 2` in `Dumper.swift`/
|
||||
`Dumper.Mapping.swift` with `indent + options.indentWidth`; verified with
|
||||
a round-trip test (dump at a non-default width, reparse, confirm the
|
||||
value survives) since indentation is purely a rendering choice and must
|
||||
never affect what a document means. `sortKeys` sorts a mapping's pairs by
|
||||
their string key before rendering, in both block and flow style, but only
|
||||
when every key in that mapping is a plain string: a mapping with any
|
||||
`Model.Key.mapping`/`.sequence` key is left in insertion order regardless
|
||||
of the option, since there is no well-defined lexicographic ordering
|
||||
across mixed key kinds to sort by. Off by default, since insertion order
|
||||
is what makes a duplicate-key-preserving `Mapping` round-trip meaningfully
|
||||
in the first place.
|
||||
Deliberately scoped to stop here: `width`-based line wrapping and
|
||||
canonical mode (the other two gaps the audit found) are each a
|
||||
correctness surface of their own (real line-folding logic for the former,
|
||||
a defined canonical form for the latter) and remain open, not silently
|
||||
dropped from this change's scope.
|
||||
|
||||
- Reject raw control characters anywhere in the input, matching libyaml's
|
||||
own reader-level behavior. Found by directly comparing `PureYAML.parse()`
|
||||
against Yams on a 221MB real-world OpenAPI/compose-file corpus (150
|
||||
files): a genuinely public file with corrupted, mojibake-looking encoding
|
||||
contains a raw C0 control character, which Yams's underlying libyaml
|
||||
rejects unconditionally at the reader level ("control characters are not
|
||||
allowed"), but `PureYAML.parse()` silently accepted and built a value tree
|
||||
from. `PureYAML` already had a printable-character predicate matching
|
||||
libyaml's `c-printable` set (`CharacterClass.isPrintable`), but it was
|
||||
only ever applied narrowly to anchor names
|
||||
(`LibYAML.Scanner.Anchor.swift`), never to the input generally; the
|
||||
`Validation.PreflightScanner`'s separate `invalidControlCharacterColumn`
|
||||
check exists but is opt-in via `validate()`, not part of what `parse()`
|
||||
itself runs.
|
||||
`Reader` now records the index of the first non-printable scalar in
|
||||
whatever it has buffered (`init`/`append(_:)`, covering both batch and
|
||||
streaming input), and `fetchNextToken()` throws once actual scanning has
|
||||
reached that position (gated on `reader.index >= badIndex`, not just "one
|
||||
exists"), so a real syntax error earlier in the document is not hidden
|
||||
behind a control character occurring later, matching the order libyaml's
|
||||
own byte-by-byte reader would surface them in. New `ParseError` case
|
||||
`.invalidControlCharacter(line:column:)`, translated from the scanner
|
||||
error the same way `.tabIndentation` already is.
|
||||
Verified with 5 new tests (a bare control character rejected at the exact
|
||||
position; a tab in scalar content, and CR/LF, both still legal, since
|
||||
`isPrintable` already correctly includes them; a control character 5000
|
||||
lines into a document still caught; an earlier real syntax error not
|
||||
masked by a later control character) plus a full re-run of the official
|
||||
yaml-test-suite conformance (still 333/333 parse-level, 208/208
|
||||
value-level of comparable cases, unchanged) and the whole-suite streaming
|
||||
differential (still clean), confirming this closes a real gap without
|
||||
regressing anything already passing. Negligible throughput impact
|
||||
measured on the existing 9.5MB benchmark fixture (within normal run-to-run
|
||||
noise).
|
||||
Disclosed limitation: the check is applied in `fetchNextToken()`, which
|
||||
`scanStep()`'s plain-scalar resume path (`resumePlainScalar`, the
|
||||
streaming API) does not route through, so a token containing a control
|
||||
character can be returned to a streaming caller one token before this
|
||||
fires. `scanAll()`/`PureYAML.parse()`, the primary target here (matching
|
||||
what the corpus comparison actually calls), always calls
|
||||
`fetchNextToken()` again before returning, so it is caught before a batch
|
||||
parse completes.
|
||||
|
||||
- Add a public `PureYAML.Streaming` namespace (issue #4), exposing the
|
||||
internal `LibYAML.Scanner.scanStep()` proven below for the first time as a
|
||||
callable public API rather than a module-internal implementation detail.
|
||||
Needed because `scanStep()` and everything in
|
||||
`LibYAML.Scanner.Streaming.swift` were internal-only: no consumer outside
|
||||
the `PureYAML` module, including the separate `PureYAMLStreaming` incubator
|
||||
package that issue #4 names as this feature's actual intended user, could
|
||||
call it. `PureYAML.Streaming.Scanner` (`init()`, `feed(_:)`, `finish()`,
|
||||
`next() throws -> Outcome`) is a thin wrapper with no logic of its own,
|
||||
translating `scanStep()`'s `ScanOutcome`/internal `LibYAML.Token` into
|
||||
public `Outcome`/`Streaming.Token` types under public naming that does not
|
||||
leak the libyaml port's internal type names (`Streaming.Token.Kind` mirrors
|
||||
`LibYAML.TokenType`'s 22 cases 1:1; `Streaming.Token.ScalarStyle` mirrors
|
||||
the scanner's internal `Parsing.ScalarStyle`, a distinct 5-case type from
|
||||
the existing public `Emitting.ScalarStyle`, which is an output rendering
|
||||
policy, not a scanned style; `Streaming.Token.range` reuses the existing
|
||||
public `Model.SourceRange`/`SourcePosition` rather than inventing a new
|
||||
position type). `Token.Kind.init(_:)`'s mapping switch is split across
|
||||
three chained initializers purely to stay under the project's cyclomatic
|
||||
complexity lint limit (a mechanical 1:1 case mapping over 22 cases has no
|
||||
way to be less than 22-branches complex in one function); the split carries
|
||||
no semantic meaning.
|
||||
Proven with a new differential test (`PublicStreamingScannerTests.swift`)
|
||||
distinct from the existing `StreamingScannerDifferentialTests.swift`: that
|
||||
existing proof covers whether `scanStep()` itself is correct, but does not
|
||||
exercise the public wrapper or its translation layer at all, so a wrong
|
||||
case mapping or a dropped field there would not have been caught by it.
|
||||
The new proof drives the public `Streaming.Scanner` at several chunk sizes
|
||||
and asserts it matches the batch scanner's own tokens, translated through
|
||||
the identical `Streaming.Token.init(_:)` the public scanner itself uses,
|
||||
clean across the whole yaml-test-suite corpus.
|
||||
Deliberately scoped to stop here: this makes the public API exist and
|
||||
proves it correct, but does not yet rewire the separate `PureYAMLStreaming`
|
||||
package's own `ResumableDocumentScanner` (currently a naive line-based
|
||||
`---` heuristic scanner with no relationship to real YAML grammar) onto it.
|
||||
That rewiring is real, separate, cross-repo work with its own build/test/CI
|
||||
cycle, deferred to a follow-up round rather than folded in here.
|
||||
|
||||
- Implement persisted mid-token suspension for plain scalars (issue #4), the
|
||||
deferred piece the previous entry predicted would eliminate the checkpoint
|
||||
floor entirely. `scanPlainScalar()`'s loop (`LibYAML.Scanner.PlainScalar.swift`)
|
||||
is now an explicit `PlainScalarPhase` state machine (`.start`,
|
||||
`.consumingContent`, `.afterContent`, `.consumingBreaks`, `.afterBreaks`)
|
||||
that throws `PlainScalarSuspended` (carrying its accumulated `PlainState`,
|
||||
indent, start mark, and phase) the moment a lookahead runs dry, and
|
||||
`scanStep()` resumes it directly from `Scanner.suspendedPlainScalar` with no
|
||||
checkpoint at all, rather than rolling back and rescanning the whole token
|
||||
from its start on every chunk boundary inside it.
|
||||
Getting this correct took four rounds of bugs, each found by the existing
|
||||
whole-yaml-test-suite streaming differential test
|
||||
(`PUREYAML_RUN_YAML_TEST_SUITE=1 swift test --filter
|
||||
"StreamingScannerDifferentialTests|YAMLTestSuiteGapsTests" --no-parallel`),
|
||||
none of them hypothetical: (1) checking `reader.ranDry` only once at the
|
||||
loop's end is not enough, since a nested lookahead
|
||||
(`documentMarkerAhead()`, the `:`-followed-by-blank check, `blankLineAhead()`)
|
||||
can independently go dry and produce a wrong-but-plausible answer that gets
|
||||
used by the next step before an end-of-loop check would ever see the taint;
|
||||
fixed by checking immediately after every peek-dependent decision, before
|
||||
its result is used for anything. (2) A resume must never redo a pre-check
|
||||
that only applies at a fresh entry point (the document-marker/comment check
|
||||
in `.start`, the blank-or-break guard in `.afterContent`): resuming a
|
||||
dry-interrupted content or break run via `.start`/`.afterContent` again can
|
||||
wrongly end a scalar right at a literal `#` or reinterpret an in-progress
|
||||
break run, since those positions are not guaranteed to be fresh boundaries.
|
||||
Fixed with dedicated `.consumingContent`/`.consumingBreaks` phases that
|
||||
re-enter the same in-progress call with no pre-check. (3) `dispatchToken()`
|
||||
itself has an `isDocumentMarker` pre-check, ahead of ever calling
|
||||
`fetchPlainScalar()`, that can suffer the identical dry-lookahead problem;
|
||||
since nothing can undo a wrong dispatch after the fact, `scanStep()` only
|
||||
trusts a suspend enough to resume it when real content was already
|
||||
accumulated (`phase == .start` with an empty `state.string` retries from
|
||||
scratch instead). (4) `blankLineAhead()` inside `consumePlainBreaks` is
|
||||
itself a multi-character lookahead distinct from the single
|
||||
already-confirmed `peek()` before it, so it can independently go dry; fixed
|
||||
by checking `ranDry` right after computing its result, before a
|
||||
tab-violation decision trusts it.
|
||||
A synthetic worst-case test was added for this specifically because the
|
||||
9.5MB real-world fixture used above has no plain scalars long enough to
|
||||
exercise it (`test_measureLongPlainScalar`, one 1MB single-line plain
|
||||
scalar). It caught a fifth bug, a real one, not a suspicion: the first
|
||||
working version of `resumePlainScalar` took the whole `PlainScalarSuspended`
|
||||
as a by-value parameter and read `var state = suspended.state` inside it,
|
||||
but `suspended.startMark` being read again at the very end kept the whole
|
||||
parameter (including its `state.string` reference) alive as an ordinary
|
||||
borrowed value for the call's entire duration, aliasing `state.string` to
|
||||
it throughout `runPlainScalarLoop` and forcing copy-on-write to fully copy
|
||||
the entire accumulated scalar on every single resume; for a scalar rebuilt
|
||||
across many small chunks that is O(N^2) in the scalar's length, not the
|
||||
O(N) this feature exists to guarantee, and measured **29.05x** at
|
||||
chunk-size 16 on the synthetic case, worse than not suspending at all.
|
||||
Passing `state` `inout` directly, with the alias broken at the call site
|
||||
(`scanStep()`'s new `resumeSuspendedPlainScalarIfNeeded()`, overwriting
|
||||
`suspended.state` with a fresh, unrelated value immediately after copying
|
||||
it out and before ever calling into `resumePlainScalar`) fixed it to
|
||||
**3.67x** at the same chunk size, with no correctness regression
|
||||
(whole-suite differential clean in both debug and release, full default
|
||||
suite of 597 tests passing).
|
||||
A single before/after data point does not by itself distinguish "fixed" from
|
||||
"smaller constant, still quadratic", so a second, permanent test
|
||||
(`test_longPlainScalarStaysLinear`) checks the actual complexity claim: per-
|
||||
byte cost at a fixed chunk size (16) across a 16x range of scalar lengths
|
||||
(250KB to 4MB), asserting the largest measured per-byte cost stays under 3x
|
||||
the smallest (generous headroom over the ~1.08x actually measured; a real
|
||||
O(N^2) regression would blow well past 3x as length grows). Genuinely O(N)
|
||||
work should hold per-byte cost flat regardless of total length, which this
|
||||
confirms rather than assumes from the single 1MB case above.
|
||||
Measured honestly rather than assumed to have delivered the predicted win:
|
||||
on the 9.5MB real-world fixture, the checkpoint floor and rescan-driven
|
||||
tail are statistically unchanged from before this feature existed
|
||||
(floor 1.75x versus the prior entry's 1.75x-1.76x; 10.74x at chunk-size 16
|
||||
versus the prior entry's 11.54x, both within measurement noise across
|
||||
runs). The prior entry's prediction that mid-token suspension "would
|
||||
eliminate the floor too" is corrected here: it eliminates rescanning
|
||||
specifically for plain scalars that themselves span a chunk boundary, but
|
||||
`github-rest-api.yaml`'s plain scalars are short enough that this was
|
||||
never a meaningful contributor to its measured floor in the first place,
|
||||
and the floor's actual cause (a `Scanner` struct copy taken on every
|
||||
`fetchNextToken()` call whenever `!isFinal`, regardless of token type,
|
||||
established in the entry below) is untouched by a plain-scalar-specific
|
||||
fix. The synthetic long-scalar test above is the fixture that actually
|
||||
demonstrates this feature's value (a 29x-worse-than-no-suspension bug,
|
||||
fixed down to 3-4x); the real-world fixture simply does not have the
|
||||
shape of input this feature targets.
|
||||
|
||||
- Add `Tests/StreamingScannerPerformanceTests.swift` (issue #4), measuring
|
||||
`scanStep()`'s overhead over `scanAll()` on the largest real-world fixture
|
||||
(github-rest-api.yaml, 9.5MB) across a spread of chunk sizes, gated behind
|
||||
`PUREYAML_STREAMING_PERF_MEASURE=1`. First pass ran in debug mode (release
|
||||
test builds hit an unrelated `PureYAMLJSON` test-target issue) and showed a
|
||||
~1.5x floor even in a control with `isFinal: true` from construction, where
|
||||
`ranDry` can never be set and no rollback ever happens; fixed by skipping the
|
||||
checkpoint entirely in that case, which only closed part of the gap in debug
|
||||
numbers (1.49x to 1.38x).
|
||||
`sample`(1) profiling of that debug build pointed at `mutating throws`
|
||||
methods on multi-field structs (`consumePlainContent`, called from every
|
||||
plain scalar), which is a known trigger for defensive retain/copy traffic
|
||||
under `-Onone` that the optimizer removes in release. Confirmed by working
|
||||
around the release-test-build issue with `-Xswiftc -enable-testing`: in
|
||||
release, the `isFinal: true` control measured **1.04x**, essentially batch
|
||||
parity, meaning the checkpoint-skip fix was actually a full fix for that
|
||||
case, not the small, unexplained one it looked like in debug mode.
|
||||
What remains splits into two distinct costs, decomposed by one more
|
||||
measurement rather than assumed: feeding the whole 9.5MB file as a single
|
||||
chunk (`isFinal` stays false until the true end, so at most one token in the
|
||||
entire file could ever actually be rescanned) still measured 1.75x, the same
|
||||
as 1,000,000-scalar chunks (1.74x, ~10 chunks) and 65536-scalar chunks
|
||||
(1.74x). Real rescanning is not the floor's cause, since there is
|
||||
essentially no rescanning to have caused it here. The floor is the cost of
|
||||
copying the whole `Scanner` on every `fetchNextToken()` call whenever
|
||||
`isFinal` is false, whether or not that copy is ever used to roll back;
|
||||
since a chunked run's `isFinal` only becomes true once, on the last chunk,
|
||||
that copy is taken for virtually every token in the file. Only *shrinking*
|
||||
chunk sizes below roughly 4096 scalars adds a second, genuinely
|
||||
rescan-driven cost on top of that floor (11.54x at 16 scalars).
|
||||
Two follow-up checks, not assumed: ruled out buffer-population strategy as a
|
||||
contributor (`Reader.init`'s `Array(string.unicodeScalars)` versus
|
||||
`Reader.append(_:)`'s `append(contentsOf:)` measured 1.00x against each
|
||||
other on the same 9.5MB string, no scanning involved), and re-profiled with
|
||||
`sample`(1) on the release build this time (the first profile, in the entry
|
||||
above, was debug-only and already shown to be misleading for magnitude; it
|
||||
turns out to also be misleading for location). The release profile's
|
||||
hottest symbols are String-building-adjacent
|
||||
(`_StringGuts.append`, `UnicodeScalarView.distance(from:to:)`,
|
||||
`consumePlainContent`) rather than a separately named "copy of Scanner"
|
||||
symbol, most likely because the optimizer inlines the checkpoint's struct
|
||||
copy directly into `scanStep()` (190 samples on its own, disproportionate to
|
||||
its few lines of source) rather than outlining it as in debug, and the extra
|
||||
live reference the checkpoint holds to `indentEngine.tokens` forces
|
||||
conservative uniqueness handling that shows up as slower string mutation
|
||||
nearby rather than as its own line item.
|
||||
The structural cause, confirmed by an A/B the project already had (not a
|
||||
new one): the checkpoint code path is still the only difference between the
|
||||
1.04x `isFinal: true` control and the 1.75x-1.76x single-chunk/large-chunk
|
||||
results, both driven through the identical per-token loop. This corrects the
|
||||
previous entry's conclusion in an important way: the deferred persisted mid-
|
||||
token suspension would not merely eliminate the rescan-driven tail while
|
||||
leaving the floor untouched, it would eliminate the floor too, since it
|
||||
removes the need for a checkpoint entirely rather than making one cheaper.
|
||||
A cheaper checkpoint, absent mid-token suspension, is not yet designed:
|
||||
every field the checkpoint covers can be mutated before a dry point is
|
||||
reached within a single fetch, so no smaller subset of fields is obviously
|
||||
safe to skip, and `isFinal: true` remains the only provably safe condition
|
||||
for skipping it outright.
|
||||
|
||||
- Broaden the real-seed mutation property suite (issue #6) with three mutation
|
||||
classes named in that issue but not yet covered: `invalidControlCharacter`
|
||||
(a raw control byte injected into a line), `mixedTabSpaceIndentation` (a tab
|
||||
after leading spaces, distinct from the existing pure-tab case), and
|
||||
`truncatedFlowCollection` (an unterminated `[a, b, c` appended after the real
|
||||
seed sample, exercising the deep parser's `malformed` error rather than the
|
||||
preflight scanner). The test now also asserts diagnostic quality, not just
|
||||
pass/fail: every matching diagnostic's `line` must fall within the mutated
|
||||
document, and its `column`, when present, must be 1-based. Some `ParseError`
|
||||
cases (`.tabIndentation`) are line-only by design, so column is checked when
|
||||
present rather than required.
|
||||
|
||||
- Add a resumable, one-token-at-a-time scanner entry point, `Scanner.scanStep()`
|
||||
(internal), the first increment of true chunked input streaming (issue #4).
|
||||
`Reader` gains `isFinal`/`append(_:)`/`markFinal()`: past the buffered end while
|
||||
`!isFinal`, `peek(_:)` flags `ranDry` instead of asserting true end of input.
|
||||
`scanStep()` snapshots the scanner before each `fetchNextToken()` and restores it
|
||||
in full whenever a fetch runs dry, so a fetch spanning a chunk boundary is
|
||||
retried from scratch after more input arrives rather than misread as an early
|
||||
end of input. Every existing call site keeps `isFinal: true` (the default), so
|
||||
`scanAll()` and all batch parsing are unchanged; `ranDry` can never be set when
|
||||
`isFinal` is true, by construction, not just by test.
|
||||
A token already queued is not always safe to release: libyaml's simple-key
|
||||
mechanism can still splice a KEY token in front of it once a later `:` is found,
|
||||
so `scanStep()` ports libyaml's `yaml_parser_fetch_more_tokens` release
|
||||
condition, holding the queue front until no possible simple key still targets
|
||||
that position.
|
||||
Proof: `Tests/StreamingScannerDifferentialTests.swift` drives `scanStep()` at
|
||||
several chunk sizes (1, 2, 3, 7, 64, whole-input) over representative cases
|
||||
always, and over the entire yaml-test-suite manifest (402 cases) behind
|
||||
`PUREYAML_RUN_YAML_TEST_SUITE=1`, asserting the resulting token sequence (or
|
||||
thrown error) is byte-identical to `scanAll()`.
|
||||
That proof surfaced a real bug, now fixed: `lastTokenForbidsInlineSequence()`
|
||||
(`LibYAML.Scanner.Guards.swift`) read `indentEngine.tokens.last?.type` to check
|
||||
whether a `key: - a` value indicator bars an inline block sequence. Batch mode
|
||||
never dequeues, so the queue never empties and `.last` always works; `scanStep()`
|
||||
releases and discards tokens as soon as they are safe, so the queue can be empty
|
||||
exactly when this check runs. `IndentEngine` now tracks `lastToken` explicitly,
|
||||
kept in sync with `tokens.last` after every enqueue/insert/roll/unroll, which is
|
||||
provably identical to the old `tokens.last` read in batch mode (nothing is ever
|
||||
dequeued there) and survives dequeuing in streaming mode.
|
||||
Not yet wired into `PureYAMLStreaming`: `scanStep()` and its supporting types are
|
||||
internal, and true mid-scalar suspension (a plain/block/flow scalar spanning a
|
||||
chunk boundary currently re-scans from the last released token rather than
|
||||
resuming mid-token) is deferred, tracked at
|
||||
<https://git.aleahim.com/NodetteHQ/PureYAML/issues/4>.
|
||||
|
||||
### Changed
|
||||
|
||||
- The validation framework's public types are renamed to match the
|
||||
OpenAPIKit validation idiom's canonical names: `Check` is now
|
||||
`Validation`, `Issue` is `ValidationError`, `Issue.Collection` is the
|
||||
top-level `ValidationErrorCollection` (with `.issues` renamed to
|
||||
`.values`), `SubjectContext` is `ValidationContext`, `BuiltinCheck` is
|
||||
`BuiltinValidation`, and `AnyCheck` is `AnyValidation`. `ValidationError.description`
|
||||
drops its severity prefix and renders `"<reason> at root of document"` /
|
||||
`"<reason> at path: <path>"`. `Validator` is now a reference-type
|
||||
`final class` with a two-tier `nonReferenceDefaultValidations`/
|
||||
`referenceDefaultValidations` split, generic over both the walked
|
||||
`Subject` type and the cross-cutting `Document` via a new `Traversable`
|
||||
protocol; `Tagged.Validator` is now a type alias over the same shared
|
||||
class instead of its own duplicated traversal implementation. The JSON
|
||||
representability rule set (`PureYAMLJSON`) is built with the same tiered
|
||||
constructor. Adds the `unwrap` combinator.
|
||||
- `parseTagged(_:)`/`parseTaggedStream(_:)` now preserve alias references
|
||||
as `alias` nodes instead of expanding them into copies of the anchored
|
||||
node, and populate the new `anchor`/`style` fields from the source.
|
||||
Consumers switching exhaustively over `Tagged.Node` gain a case;
|
||||
`Tagged.NodeKind` gains `.alias`. Resolved values are unchanged:
|
||||
`modelValueErasingTags` and `Tagged.Constructor` read through aliases.
|
||||
`Tagged.Validator` walks each anchored subtree once at its definition
|
||||
site and does not re-walk it per alias reference.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A plain scalar that starts with `0` and continues with a sign is text, not an
|
||||
octal integer. `Int(_, radix:)` accepts a leading sign, so `0-3` (a CPU set, a
|
||||
port range) read as the integer -3, and `0x-1`, `0b-1` and `0o-1` as negative
|
||||
numbers. After a radix prefix only digits may follow. Octal (`0755`),
|
||||
hexadecimal, binary and signed integers read as before.
|
||||
|
||||
- A string the emitter wrote without quotes could be read back as something
|
||||
else, so the string `yes` (and `no`, `on`, `off` in every case), `0x1F`,
|
||||
`0b1`, `0o17`, `1_000`, `.inf` and `.nan` did not survive a dump and parse.
|
||||
`PureYAML.Parsing.PlainScalar` is now the one definition of how a plain
|
||||
scalar reads, and the parser and the emitter both take it from there; the
|
||||
emitter quotes every string that does not read back as itself. Also quoted
|
||||
now: `<<` (the merge key) and `...` (the document end marker), and a string
|
||||
with a CR+LF break, which was written raw because Swift counts CR+LF as one
|
||||
character. `PlainScalarRoundTripTests` holds 128 awkward strings at the
|
||||
root, as a mapping key and value, and as a sequence item, in block and flow
|
||||
style. `PureYAML.JSON.Convert` no longer refuses `{"s":"yes"}`.
|
||||
|
||||
- Stream dump no longer doubles the `---` document marker under
|
||||
`explicitDocumentStart` or `canonical`. The stream path now owns marker
|
||||
emission and renders each document body marker-free, so a two-document
|
||||
stream reparses as two documents instead of four (an empty document
|
||||
ahead of each real one).
|
||||
- `Tagged` flow-mapping keys now use flow-aware plain-scalar safety, so a
|
||||
key containing a flow delimiter (`a,b`, `[`, `{`) is quoted instead of
|
||||
emitted bare, which previously reparsed as several entries.
|
||||
- Plain scalars ending in `:` now fall back to quoting on both the model
|
||||
and `Tagged` emit paths. A trailing colon reparses as a mapping key
|
||||
with an empty value, so the emitter must never leave one plain.
|
||||
- A mapping key that is an alias to a defined but non-scalar anchor now
|
||||
raises `expectedScalarKey` instead of the misleading `undefinedAlias`;
|
||||
a genuinely undefined alias key still raises `undefinedAlias`.
|
||||
- `Tagged` duplicate-key detection now keys on the resolved scalar value
|
||||
plus effective tag rather than the source spelling, so `0x1` and `1`
|
||||
(both `int(1)`) are reported as duplicates while `1` (`int`) and `"1"`
|
||||
(`str`) are not, and custom-tagged keys stay distinct.
|
||||
- Dumping a value nested deeper than the emitter's nesting limit (the
|
||||
same bound the parser enforces) now returns a documented, reparseable
|
||||
marker document instead of overflowing the stack. Such a value cannot
|
||||
round-trip through PureYAML's own parser, so it is refused rather than
|
||||
emitted as YAML the parser would reject.
|
||||
- `Tagged.Timestamp` accepts a leap-second value of `60` per the YAML 1.1
|
||||
timestamp grammar, treating it as 60 elapsed seconds in the epoch math.
|
||||
- `Tagged.Dumper` honors `sortKeys` in its block and canonical paths for
|
||||
mappings whose keys are all plain, untagged, unanchored string scalars,
|
||||
matching the model dumper's `orderedPairs` rule.
|
||||
- `parse`/`parseStreaming` now reject an anchor-expansion ("billion
|
||||
laughs") bomb instead of accepting it in milliseconds and handing a
|
||||
caller a value whose logical size is exponential in the alias nesting
|
||||
depth. `enforceNestingLimit` bounds physical collection depth in the
|
||||
raw event stream, which does nothing against a handful of shallow,
|
||||
small anchors each aliased many times with each alias nested inside
|
||||
the next: a 25-level, fan-out-9 document (~1.5KB) expands past 9^25
|
||||
logical nodes while raw event depth stays at 2. `EventStreamComposer`
|
||||
now charges a total expanded-node budget incrementally as the document
|
||||
streams through -- every scalar, alias resolution, and completed
|
||||
container charges its own marginal cost, with each anchor's expanded
|
||||
size cached at definition and looked up in O(1) when later aliased --
|
||||
so a bomb is rejected within a few alias levels without ever
|
||||
materializing or walking the exponential tree it would otherwise
|
||||
produce. A document with genuinely wide but non-exponential alias
|
||||
reuse (the same anchor referenced thousands of times at one level, no
|
||||
nesting) still parses normally.
|
||||
- `TaggedNodeCorpusRoundTripTests` now normalizes `URL.lastPathComponent`
|
||||
through an explicit `as String?` coercion before use: this toolchain's
|
||||
swift-corelibs-foundation returns `String?` where Darwin's Foundation
|
||||
returns `String`, and the mismatch failed the Linux build outright
|
||||
(an unrelated pre-existing gap surfaced while verifying the
|
||||
anchor-expansion fix above on the Linux CI relay).
|
||||
|
||||
## [0.2.0] - 2026-07-05
|
||||
|
||||
|
||||
88
README.md
88
README.md
@ -1,11 +1,11 @@
|
||||
# PureYAML
|
||||
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=gates.yml)
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=macos.yml)
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=linux.yml)
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=windows.yml)
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=wasm.yml)
|
||||
[](https://codeberg.org/Mihaela/PureYAML/actions?workflow=conformance.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=gates.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=macos.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=linux.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=windows.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=wasm.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureYAML/actions?workflow=conformance.yml)
|
||||
[](LICENSE)
|
||||
|
||||
PureYAML is a dependency-free YAML package written entirely in Swift.
|
||||
@ -20,69 +20,6 @@ C-backed parsers. The package is intentionally strict about portability:
|
||||
- root Swift package layout
|
||||
- macOS, Linux, Windows, and WASI build gates
|
||||
|
||||
## Roadmap
|
||||
|
||||
Mermaid status legend:
|
||||
|
||||
The roadmap uses the TileDown Mermaid palette: green for merged work, yellow for
|
||||
review, purple for epic grouping, and gray for open work with no PR.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
|
||||
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
%% Completed parser replacement epics: #1 #8 #9 #10 #11 #12 #13
|
||||
LDone["In main now"]:::done
|
||||
LReview["PR in review"]:::review
|
||||
LEpic["Epic grouping"]:::epic
|
||||
LTodo["Open issue, no PR"]:::todo
|
||||
LDone --> LReview
|
||||
LReview --> LEpic
|
||||
LEpic --> LTodo
|
||||
```
|
||||
|
||||
Parser Replacement Roadmap #8 is complete in main.
|
||||
|
||||
Production Hardening and Validation Readiness #54 is active. PureYAML now has
|
||||
checked-corpus Yams parse-success parity, structured diagnostics and validation,
|
||||
JSON compatibility proof gates, internal token/event streaming, real-corpus
|
||||
property tests, and repeatable performance comparison artifacts. Remaining work
|
||||
is concentrated in downstream integration, true chunked input streaming, broader
|
||||
malformed fixtures, and continued throughput optimization:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
|
||||
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
E54["#54 Production hardening epic"]:::epic
|
||||
I49["#49 Structured validation reports"]:::done
|
||||
I50["#50 False-positive diagnostic triage"]:::done
|
||||
I51["#51 Bad-YAML mutation suite"]:::todo
|
||||
I55["#55 Corpus fuzz and property gates"]:::done
|
||||
I44["#44 Humongous YAML corpus gate"]:::done
|
||||
I45["#45 Yams execution and diagnostics comparison"]:::done
|
||||
I47["#47 Yams-parseable OpenAPI gaps"]:::done
|
||||
I52["#52 OpenAPIDoctor integration dry run"]:::todo
|
||||
I53["#53 Yams differential regression gate"]:::done
|
||||
I43["#43 True chunked input streaming"]:::todo
|
||||
I48["#48 Parser throughput toward libyaml parity"]:::todo
|
||||
E54 --> I49
|
||||
I49 --> I50
|
||||
I50 --> I51
|
||||
I51 --> I55
|
||||
I55 --> I44
|
||||
I44 --> I45
|
||||
I45 --> I47
|
||||
I47 --> I53
|
||||
I53 --> I52
|
||||
I52 --> I43
|
||||
I43 --> I48
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
The current release is 0.2.0. It includes block mappings, block sequences,
|
||||
@ -111,6 +48,18 @@ Unsupported gaps are pinned by executable tests so they produce exact errors,
|
||||
exact validation issues, or exact fallback value trees instead of silent parser
|
||||
drift.
|
||||
|
||||
PureYAML now has checked-corpus Yams parse-success parity, structured
|
||||
diagnostics and validation, JSON compatibility proof gates, internal
|
||||
token/event streaming, real-corpus property tests, and repeatable performance
|
||||
comparison artifacts. Remaining work is tracked under the
|
||||
[production hardening and validation readiness](https://git.aleahim.com/NodetteHQ/PureYAML/issues/8)
|
||||
epic:
|
||||
[true chunked input streaming](https://git.aleahim.com/NodetteHQ/PureYAML/issues/4),
|
||||
[throughput toward libyaml parity](https://git.aleahim.com/NodetteHQ/PureYAML/issues/5),
|
||||
[a broader bad-YAML mutation suite](https://git.aleahim.com/NodetteHQ/PureYAML/issues/6),
|
||||
and an
|
||||
[OpenAPIDoctor integration dry run](https://git.aleahim.com/NodetteHQ/PureYAML/issues/7).
|
||||
|
||||
## Attribution
|
||||
|
||||
PureYAML is informed by Yams and its bundled `CYaml` / libyaml-derived parser,
|
||||
@ -308,7 +257,6 @@ bash scripts/check-style.sh
|
||||
bash scripts/check-namespacing.sh
|
||||
bash scripts/check-forbidden-patterns.sh
|
||||
bash scripts/check-changelog-touched.sh
|
||||
bash scripts/check-roadmap.sh
|
||||
swiftformat . --config .swiftformat --lint
|
||||
swiftlint --config .swiftlint.yml --strict
|
||||
swift build
|
||||
|
||||
@ -36,13 +36,23 @@ public extension PureYAML {
|
||||
Emitting.Dumper(options: options).dump(documents)
|
||||
}
|
||||
|
||||
/// Serializes a tag-preserving node tree into YAML, re-emitting anchors,
|
||||
/// aliases (as `*name` references, not expanded copies), explicit tags,
|
||||
/// and per-node presentation styles.
|
||||
static func dump(
|
||||
_ node: Tagged.Node,
|
||||
options: Emitting.Options = .default,
|
||||
) -> String {
|
||||
Tagged.Dumper(options: options).dump(node)
|
||||
}
|
||||
|
||||
/// Validates a parsed YAML value with the default validation rules.
|
||||
@discardableResult
|
||||
static func validate(
|
||||
_ value: Model.Value,
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
strict: Bool = true,
|
||||
) throws -> [Validation.Issue] {
|
||||
) throws -> [Validation.ValidationError] {
|
||||
try validator.validate(value, strict: strict)
|
||||
}
|
||||
|
||||
@ -50,7 +60,7 @@ public extension PureYAML {
|
||||
@discardableResult
|
||||
static func validate(
|
||||
_ documents: [Stream.Document],
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
strict: Bool = true,
|
||||
) throws -> [Stream.Issue] {
|
||||
try validator.validate(documents, strict: strict)
|
||||
|
||||
@ -79,20 +79,20 @@ extension PureYAML.Decoding {
|
||||
_ value: PureYAML.Model.Value,
|
||||
codingPath: [any CodingKey],
|
||||
) throws {
|
||||
let result = PureYAML.Validation.Validator().collect(value)
|
||||
guard !result.issues.isEmpty else {
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
guard !result.values.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let pathPrefix = PureYAML.Validation.Path(codingPath: codingPath)
|
||||
let issues = result.issues.map { issue in
|
||||
PureYAML.Validation.Issue(
|
||||
severity: issue.severity,
|
||||
reason: issue.reason,
|
||||
path: .init(pathPrefix.components + issue.path.components),
|
||||
let errors = result.values.map { error in
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: error.severity,
|
||||
reason: error.reason,
|
||||
path: .init(pathPrefix.components + error.path.components),
|
||||
)
|
||||
}
|
||||
throw PureYAML.Validation.Issue.Collection(issues)
|
||||
throw PureYAML.Validation.ValidationErrorCollection(errors)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
Sources/Emitting/Dumper.Canonical.swift
Normal file
93
Sources/Emitting/Dumper.Canonical.swift
Normal file
@ -0,0 +1,93 @@
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
/// The YAML-specification canonical form, matching libyaml's canonical
|
||||
/// emitter layout: an explicit `---` document start, an explicit tag on
|
||||
/// every node, double-quoted scalars, and flow collections with one
|
||||
/// entry per line, mapping entries in the explicit `? key : value,`
|
||||
/// form. Tags derive from each value's case, the same resolution the
|
||||
/// parser applies in reverse.
|
||||
func canonicalDocument(_ value: PureYAML.Model.Value) -> String {
|
||||
"---\n" + canonicalBody(value)
|
||||
}
|
||||
|
||||
/// The canonical body without the leading `---` marker, so the marker is
|
||||
/// owned by the single-document and stream paths, not baked into the body.
|
||||
func canonicalBody(_ value: PureYAML.Model.Value) -> String {
|
||||
canonicalLines(value, indent: 0).joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
func canonicalLines(
|
||||
_ value: PureYAML.Model.Value,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch value {
|
||||
case let .sequence(values):
|
||||
return canonicalSequenceLines(values, indent: indent)
|
||||
case let .mapping(mapping):
|
||||
return canonicalMappingLines(mapping, indent: indent)
|
||||
default:
|
||||
return [prefix + canonicalScalar(value)]
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSequenceLines(
|
||||
_ values: [PureYAML.Model.Value],
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !values.isEmpty else {
|
||||
return [prefix + "!!seq []"]
|
||||
}
|
||||
var lines = [prefix + "!!seq ["]
|
||||
for value in values {
|
||||
var entry = canonicalLines(value, indent: indent + options.indentWidth)
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "]")
|
||||
return lines
|
||||
}
|
||||
|
||||
func canonicalMappingLines(
|
||||
_ mapping: PureYAML.Model.Mapping,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + "!!map {}"]
|
||||
}
|
||||
let inner = indent + options.indentWidth
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + "!!map {"]
|
||||
for pair in orderedPairs(mapping) {
|
||||
var keyLines = canonicalLines(pair.keyNode.value, indent: inner)
|
||||
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
|
||||
lines.append(contentsOf: keyLines)
|
||||
var valueLines = canonicalLines(pair.value, indent: inner)
|
||||
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
|
||||
valueLines[valueLines.count - 1] += ","
|
||||
lines.append(contentsOf: valueLines)
|
||||
}
|
||||
lines.append(prefix + "}")
|
||||
return lines
|
||||
}
|
||||
|
||||
/// A canonical scalar: the value's implicit tag made explicit, content
|
||||
/// double-quoted so no resolution ambiguity survives.
|
||||
func canonicalScalar(_ value: PureYAML.Model.Value) -> String {
|
||||
switch value {
|
||||
case .null:
|
||||
"!!null \"null\""
|
||||
case let .bool(flag):
|
||||
"!!bool \"\(flag ? "true" : "false")\""
|
||||
case let .int(number):
|
||||
"!!int \"\(number)\""
|
||||
case let .double(number):
|
||||
"!!float \"\(number)\""
|
||||
case let .string(string):
|
||||
"!!str " + quote(string)
|
||||
case .sequence, .mapping:
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,30 @@ extension PureYAML.Emitting.Dumper {
|
||||
return [prefix + "{}"]
|
||||
}
|
||||
|
||||
return mapping.pairs.flatMap { pair in
|
||||
return orderedPairs(mapping).flatMap { pair in
|
||||
renderMappingPair(pair, indent: indent)
|
||||
}
|
||||
}
|
||||
|
||||
/// `mapping.pairs` in insertion order, or sorted by their rendered
|
||||
/// string-keyed form when `options.sortKeys` is set. Only string keys
|
||||
/// are sortable this way; a mapping with any non-string key is left in
|
||||
/// insertion order regardless of the option, since there is no
|
||||
/// well-defined lexicographic ordering across mixed key kinds
|
||||
/// (`Model.Key.mapping`/`.sequence`) to sort by.
|
||||
func orderedPairs(_ mapping: PureYAML.Model.Mapping) -> [PureYAML.Model.Pair] {
|
||||
guard options.sortKeys, mapping.pairs.allSatisfy({ pair in
|
||||
if case .string = pair.keyNode {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) else {
|
||||
return mapping.pairs
|
||||
}
|
||||
return mapping.pairs.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
func renderMappingPair(
|
||||
_ pair: PureYAML.Model.Pair,
|
||||
indent: Int,
|
||||
@ -36,7 +55,7 @@ extension PureYAML.Emitting.Dumper {
|
||||
|
||||
switch pair.value {
|
||||
case .mapping, .sequence:
|
||||
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
|
||||
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + options.indentWidth)
|
||||
default:
|
||||
return renderMappingScalarPair(pair, indent: indent)
|
||||
}
|
||||
@ -54,11 +73,11 @@ extension PureYAML.Emitting.Dumper {
|
||||
|
||||
return [prefix + "? " + pair.keyNode.flowDescription]
|
||||
+ [prefix + ":"]
|
||||
+ render(pair.value, indent: indent + 2)
|
||||
+ render(pair.value, indent: indent + options.indentWidth)
|
||||
}
|
||||
|
||||
func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String {
|
||||
let pairs = mapping.pairs
|
||||
let pairs = orderedPairs(mapping)
|
||||
.map { pair in
|
||||
"\(renderFlowKey(pair.keyNode)): \(renderFlow(pair.value))"
|
||||
}
|
||||
|
||||
77
Sources/Emitting/Dumper.NestingLimit.swift
Normal file
77
Sources/Emitting/Dumper.NestingLimit.swift
Normal file
@ -0,0 +1,77 @@
|
||||
extension PureYAML.Emitting {
|
||||
/// The deepest collection nesting the recursive renderers walk before a
|
||||
/// value is refused. It matches the parser's ``PureYAML/Parsing/Parser/maxNestingDepth``
|
||||
/// exactly: a value nested deeper than the parser accepts can never round-trip
|
||||
/// through PureYAML, so emitting it would produce YAML the paired parser
|
||||
/// rejects. Parsed trees are always within this bound, so real round-trips
|
||||
/// are unaffected.
|
||||
static var maxNestingDepth: Int {
|
||||
PureYAML.Parsing.Parser.maxNestingDepth
|
||||
}
|
||||
|
||||
/// The reparseable marker returned for a value nested past ``maxNestingDepth``.
|
||||
/// It is a valid YAML document that resolves to `null`, with a comment that
|
||||
/// names the refused limit, so a caller never receives a crash or silent
|
||||
/// truncation.
|
||||
static var nestingLimitDocument: String {
|
||||
"# value nesting exceeds the emitter limit of \(maxNestingDepth); not serialized\nnull\n"
|
||||
}
|
||||
|
||||
/// Iteratively (no recursion, so this cannot itself overflow) reports whether
|
||||
/// a model value nests collections deeper than ``maxNestingDepth``. Complex
|
||||
/// keys count toward depth like values; scalars are leaves.
|
||||
static func exceedsNestingLimit(_ value: PureYAML.Model.Value) -> Bool {
|
||||
var stack: [(value: PureYAML.Model.Value, depth: Int)] = [(value, 1)]
|
||||
while let (node, depth) = stack.popLast() {
|
||||
switch node {
|
||||
case let .sequence(values):
|
||||
if depth > maxNestingDepth {
|
||||
return true
|
||||
}
|
||||
for child in values {
|
||||
stack.append((child, depth + 1))
|
||||
}
|
||||
case let .mapping(mapping):
|
||||
if depth > maxNestingDepth {
|
||||
return true
|
||||
}
|
||||
for pair in mapping.pairs {
|
||||
stack.append((pair.value, depth + 1))
|
||||
stack.append((pair.keyNode.value, depth + 1))
|
||||
}
|
||||
case .null, .bool, .int, .double, .string:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Iteratively reports whether a tagged node nests collections deeper than
|
||||
/// ``maxNestingDepth``. An alias renders as a `*name` reference, not an
|
||||
/// expanded subtree, so it is a leaf here and its aliased node is not walked.
|
||||
static func exceedsNestingLimit(_ node: PureYAML.Tagged.Node) -> Bool {
|
||||
var stack: [(node: PureYAML.Tagged.Node, depth: Int)] = [(node, 1)]
|
||||
while let (current, depth) = stack.popLast() {
|
||||
switch current {
|
||||
case let .sequence(sequence):
|
||||
if depth > maxNestingDepth {
|
||||
return true
|
||||
}
|
||||
for child in sequence.values {
|
||||
stack.append((child, depth + 1))
|
||||
}
|
||||
case let .mapping(mapping):
|
||||
if depth > maxNestingDepth {
|
||||
return true
|
||||
}
|
||||
for pair in mapping.pairs {
|
||||
stack.append((pair.value, depth + 1))
|
||||
stack.append((pair.keyNode, depth + 1))
|
||||
}
|
||||
case .scalar, .alias:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
188
Sources/Emitting/Dumper.ScalarAnalysis.swift
Normal file
188
Sources/Emitting/Dumper.ScalarAnalysis.swift
Normal file
@ -0,0 +1,188 @@
|
||||
// MARK: - Scalar analysis
|
||||
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
func canRenderLiteralBlockString(_ value: String) -> Bool {
|
||||
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.allSatisfy(canRenderLiteralBlockLine)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
|
||||
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
let value = String(line)
|
||||
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = line.first else {
|
||||
return false
|
||||
}
|
||||
return canRenderLiteralBlockLineStarting(with: first, line: line)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLineStarting(
|
||||
with first: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
switch first {
|
||||
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
|
||||
false
|
||||
case "-":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case "?":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case ":":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
default:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
func hasOnlyIndicator(
|
||||
_ indicator: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
line.count == 1 && line.first == indicator
|
||||
}
|
||||
|
||||
func containsCommentStart(_ value: String) -> Bool {
|
||||
var previousWasWhitespace = true
|
||||
for character in value {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMappingSeparator(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, isMappingSeparatorBoundary(character) {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return previousWasColon
|
||||
}
|
||||
|
||||
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
|
||||
character.isWhitespace
|
||||
|| character == ","
|
||||
|| character == "]"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func canRenderPlainString(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
// By unicode scalar: CR+LF is one Character in Swift, so `contains("\n")` is false for it and a string with a CRLF break would be written raw.
|
||||
guard !value.unicodeScalars.contains(where: { $0 == "\n" || $0 == "\r" || $0 == "\t" }) else {
|
||||
return false
|
||||
}
|
||||
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = value.first, !isPlainScalarIndicator(first) else {
|
||||
return false
|
||||
}
|
||||
// A trailing `:` reparses as a mapping key with an empty value, so a
|
||||
// scalar that ends in one cannot render plain even though no `:` is
|
||||
// followed by a space inside it.
|
||||
guard value.last != ":" else {
|
||||
return false
|
||||
}
|
||||
// `<<` as a key is the merge key, and `...` at the start of a line ends the document.
|
||||
guard value != "<<", value != "...", !value.hasPrefix("... ") else {
|
||||
return false
|
||||
}
|
||||
return !containsColonSpace(value) && !value.contains("#")
|
||||
}
|
||||
|
||||
func canRenderFlowPlainString(_ value: String) -> Bool {
|
||||
canRenderPlainString(value) && !containsFlowDelimiter(value)
|
||||
}
|
||||
|
||||
func containsFlowDelimiter(_ value: String) -> Bool {
|
||||
value.contains(",")
|
||||
|| value.contains("[")
|
||||
|| value.contains("]")
|
||||
|| value.contains("{")
|
||||
|| value.contains("}")
|
||||
}
|
||||
|
||||
func containsColonSpace(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, character == " " {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// A word the parser reads as something other than a string (null, a boolean, a number): written plain, a string of it would come back changed.
|
||||
func isReservedPlainScalar(_ value: String) -> Bool {
|
||||
!PureYAML.Parsing.PlainScalar.readsAsString(value)
|
||||
}
|
||||
|
||||
func isNumberLikePlainScalar(_ value: String) -> Bool {
|
||||
if Int(value) != nil, !value.contains(".") {
|
||||
return true
|
||||
}
|
||||
if value.contains(".") || value.contains("e") || value.contains("E") {
|
||||
return Double(value) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPlainScalarIndicator(_ character: Character) -> Bool {
|
||||
"-?:,[]{}#&*!|>'\"%@`".contains(character)
|
||||
}
|
||||
|
||||
func escapeKey(_ key: String) -> String {
|
||||
canRenderPlainString(key) ? key : quote(key)
|
||||
}
|
||||
|
||||
func quote(_ value: String) -> String {
|
||||
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
|
||||
// single extended grapheme cluster in Swift, so a Character-level switch
|
||||
// would match neither "\r" nor "\n" and emit the line break unescaped,
|
||||
// which does not round-trip through the folding parser.
|
||||
var output = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
output += "\""
|
||||
return output
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
public extension PureYAML.Emitting.Dumper {
|
||||
/// Serializes indexed YAML stream documents with explicit document starts.
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
/// Serializes a stream of documents. The stream path owns the document
|
||||
/// markers: it emits one `---` start per document and, when
|
||||
/// `explicitDocumentEnd` is set, one `...` end per document. Each document
|
||||
/// body is rendered marker-free so `explicitDocumentStart` and `canonical`
|
||||
/// cannot stack a second `---` onto the separator, which previously turned
|
||||
/// every document into two (an empty one plus the real one) on reparse.
|
||||
func dump(_ documents: [PureYAML.Stream.Document]) -> String {
|
||||
documents
|
||||
.map { document in
|
||||
"---\n" + dump(document.value)
|
||||
let body = PureYAML.Emitting.exceedsNestingLimit(document.value)
|
||||
? PureYAML.Emitting.nestingLimitDocument
|
||||
: documentBody(document.value)
|
||||
let end = options.explicitDocumentEnd ? "...\n" : ""
|
||||
return "---\n" + body + end
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
|
||||
147
Sources/Emitting/Dumper.Wrap.swift
Normal file
147
Sources/Emitting/Dumper.Wrap.swift
Normal file
@ -0,0 +1,147 @@
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
/// Width-aware flow rendering: when a flow collection's single-line
|
||||
/// form (with its indent) overflows `lineWidth`, it re-renders with one
|
||||
/// entry per line, recursively, which is equally valid flow YAML. With
|
||||
/// wrapping disabled or content that fits, the single-line form is
|
||||
/// unchanged.
|
||||
func renderFlowWrappedDocument(_ value: PureYAML.Model.Value) -> String {
|
||||
renderFlowWrappedLines(value, indent: 0).joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
func renderFlowWrappedLines(
|
||||
_ value: PureYAML.Model.Value,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let single = renderFlow(value)
|
||||
if indent + single.count <= options.lineWidth {
|
||||
return [prefix + single]
|
||||
}
|
||||
switch value {
|
||||
case let .sequence(values):
|
||||
guard !values.isEmpty else {
|
||||
return [prefix + "[]"]
|
||||
}
|
||||
var lines = [prefix + "["]
|
||||
for item in values {
|
||||
var entry = renderFlowWrappedLines(item, indent: indent + options.indentWidth)
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "]")
|
||||
return lines
|
||||
case let .mapping(mapping):
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + "{}"]
|
||||
}
|
||||
let inner = indent + options.indentWidth
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + "{"]
|
||||
for pair in orderedPairs(mapping) {
|
||||
let key = renderFlowKey(pair.keyNode)
|
||||
var entry = renderFlowWrappedLines(pair.value, indent: inner)
|
||||
entry[0] = innerPrefix + key + ": " + String(entry[0].dropFirst(innerPrefix.count))
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "}")
|
||||
return lines
|
||||
default:
|
||||
// A scalar has no flow break points; an overlong one stays on
|
||||
// its single line.
|
||||
return [prefix + single]
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds an overlong plain scalar line at single-space boundaries onto
|
||||
/// more-indented continuation lines. Parsing folds each break back to
|
||||
/// exactly one space, so the value is unchanged. Returns `nil` when
|
||||
/// wrapping is off, the line fits, the scalar did not render plain, or
|
||||
/// no safe break point exists.
|
||||
func foldedPlainScalarLine(
|
||||
head: String,
|
||||
value: PureYAML.Model.Value,
|
||||
rendered: String,
|
||||
indent: Int,
|
||||
) -> [String]? {
|
||||
guard options.lineWidth > 0, head.count + rendered.count > options.lineWidth else {
|
||||
return nil
|
||||
}
|
||||
guard case let .string(string) = value, rendered == string else {
|
||||
return nil
|
||||
}
|
||||
let continuationIndent = indent + options.indentWidth
|
||||
let continuationPrefix = String(repeating: " ", count: continuationIndent)
|
||||
let segments = foldSegments(
|
||||
string,
|
||||
firstBudget: options.lineWidth - head.count,
|
||||
continuationBudget: options.lineWidth - continuationIndent,
|
||||
)
|
||||
guard segments.count > 1, foldSegmentsAreSafe(segments) else {
|
||||
return nil
|
||||
}
|
||||
var lines = [head + segments[0]]
|
||||
for segment in segments.dropFirst() {
|
||||
lines.append(continuationPrefix + segment)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/// A fold boundary must not manufacture YAML syntax: a segment ending
|
||||
/// in `:` would put a colon before a line break (a value indicator), a
|
||||
/// continuation line starting with `#` would open a comment (the fold
|
||||
/// break is whitespace), and one starting with `-`, `?`, or `:` before
|
||||
/// a space (or alone) would read as a block indicator. Any unsafe
|
||||
/// boundary vetoes the whole fold; the line stays long.
|
||||
func foldSegmentsAreSafe(_ segments: [String]) -> Bool {
|
||||
for (index, segment) in segments.enumerated() {
|
||||
if index < segments.count - 1, segment.hasSuffix(":") {
|
||||
return false
|
||||
}
|
||||
guard index > 0 else {
|
||||
continue
|
||||
}
|
||||
if segment.hasPrefix("#") {
|
||||
return false
|
||||
}
|
||||
if let first = segment.first, "-?:".contains(first) {
|
||||
let second = segment.dropFirst().first
|
||||
if second == nil || second == " " {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Splits at spaces whose neighbors are non-space, so no segment gains
|
||||
/// a leading or trailing space and every fold restores to exactly one
|
||||
/// space on reparse. A word longer than the budget stays whole.
|
||||
func foldSegments(
|
||||
_ string: String,
|
||||
firstBudget: Int,
|
||||
continuationBudget: Int,
|
||||
) -> [String] {
|
||||
var segments: [String] = []
|
||||
var current = ""
|
||||
var budget = firstBudget
|
||||
for word in string.split(separator: " ", omittingEmptySubsequences: false) {
|
||||
if current.isEmpty {
|
||||
current = String(word)
|
||||
} else if current.count + 1 + word.count <= budget || word.isEmpty || current.hasSuffix(" ") {
|
||||
// Empty words mark runs of consecutive spaces: those spaces
|
||||
// must stay literal (a fold would swallow them), so they
|
||||
// glue onto the current segment and forbid breaking here.
|
||||
current += " " + word
|
||||
} else {
|
||||
segments.append(current)
|
||||
current = String(word)
|
||||
budget = continuationBudget
|
||||
}
|
||||
}
|
||||
if !current.isEmpty {
|
||||
segments.append(current)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,36 @@ public extension PureYAML.Emitting {
|
||||
self.options = options
|
||||
}
|
||||
|
||||
/// Serializes one document. A value nested deeper than the parser's own
|
||||
/// ``PureYAML/Parsing/Parser/maxNestingDepth`` cannot round-trip through
|
||||
/// PureYAML (the parser rejects it), and the recursive renderers would
|
||||
/// overflow the stack on it, so such a value is refused with a
|
||||
/// documented, reparseable marker document instead of crashing.
|
||||
public func dump(_ value: PureYAML.Model.Value) -> String {
|
||||
switch options.collectionStyle {
|
||||
guard !PureYAML.Emitting.exceedsNestingLimit(value) else {
|
||||
return PureYAML.Emitting.nestingLimitDocument
|
||||
}
|
||||
var body = documentBody(value)
|
||||
if options.canonical || options.explicitDocumentStart {
|
||||
body = "---\n" + body
|
||||
}
|
||||
if options.explicitDocumentEnd {
|
||||
body += "...\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/// The document body with no leading `---` or trailing `...` markers, so
|
||||
/// the single-document and stream paths can each own marker emission.
|
||||
func documentBody(_ value: PureYAML.Model.Value) -> String {
|
||||
if options.canonical {
|
||||
return canonicalBody(value)
|
||||
}
|
||||
return switch options.collectionStyle {
|
||||
case .block:
|
||||
render(value, indent: 0).joined(separator: "\n") + "\n"
|
||||
case .flow where options.lineWidth > 0:
|
||||
renderFlowWrappedDocument(value)
|
||||
case .flow:
|
||||
renderFlowDocument(value)
|
||||
}
|
||||
@ -49,7 +75,7 @@ extension PureYAML.Emitting.Dumper {
|
||||
|
||||
switch value {
|
||||
case .mapping, .sequence:
|
||||
return [prefix + "-"] + render(value, indent: indent + 2)
|
||||
return [prefix + "-"] + render(value, indent: indent + options.indentWidth)
|
||||
default:
|
||||
return renderSequenceScalarItem(value, indent: indent)
|
||||
}
|
||||
@ -116,10 +142,14 @@ extension PureYAML.Emitting.Dumper {
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + renderScalar(value)]
|
||||
let rendered = renderScalar(value)
|
||||
if let folded = foldedPlainScalarLine(head: prefix, value: value, rendered: rendered, indent: indent) {
|
||||
return folded
|
||||
}
|
||||
return [prefix + rendered]
|
||||
}
|
||||
|
||||
func renderMappingScalarPair(
|
||||
@ -128,10 +158,15 @@ extension PureYAML.Emitting.Dumper {
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let key = escapeKey(pair.key)
|
||||
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + 2) {
|
||||
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + key + ": " + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + key + ": " + renderScalar(pair.value)]
|
||||
let rendered = renderScalar(pair.value)
|
||||
let head = prefix + key + ": "
|
||||
if let folded = foldedPlainScalarLine(head: head, value: pair.value, rendered: rendered, indent: indent) {
|
||||
return folded
|
||||
}
|
||||
return [head + rendered]
|
||||
}
|
||||
|
||||
func renderSequenceScalarItem(
|
||||
@ -139,10 +174,14 @@ extension PureYAML.Emitting.Dumper {
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) {
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + "- " + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + "- " + renderScalar(value)]
|
||||
let rendered = renderScalar(value)
|
||||
if let folded = foldedPlainScalarLine(head: prefix + "- ", value: value, rendered: rendered, indent: indent) {
|
||||
return folded
|
||||
}
|
||||
return [prefix + "- " + rendered]
|
||||
}
|
||||
|
||||
func renderScalar(_ value: PureYAML.Model.Value) -> String {
|
||||
@ -202,182 +241,4 @@ extension PureYAML.Emitting.Dumper {
|
||||
.map { prefix + String($0) }
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockString(_ value: String) -> Bool {
|
||||
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.allSatisfy(canRenderLiteralBlockLine)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
|
||||
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
let value = String(line)
|
||||
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = line.first else {
|
||||
return false
|
||||
}
|
||||
return canRenderLiteralBlockLineStarting(with: first, line: line)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLineStarting(
|
||||
with first: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
switch first {
|
||||
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
|
||||
false
|
||||
case "-":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case "?":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case ":":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
default:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
func hasOnlyIndicator(
|
||||
_ indicator: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
line.count == 1 && line.first == indicator
|
||||
}
|
||||
|
||||
func containsCommentStart(_ value: String) -> Bool {
|
||||
var previousWasWhitespace = true
|
||||
for character in value {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMappingSeparator(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, isMappingSeparatorBoundary(character) {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return previousWasColon
|
||||
}
|
||||
|
||||
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
|
||||
character.isWhitespace
|
||||
|| character == ","
|
||||
|| character == "]"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func canRenderPlainString(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
guard !value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = value.first, !isPlainScalarIndicator(first) else {
|
||||
return false
|
||||
}
|
||||
return !containsColonSpace(value) && !value.contains("#")
|
||||
}
|
||||
|
||||
func canRenderFlowPlainString(_ value: String) -> Bool {
|
||||
canRenderPlainString(value) && !containsFlowDelimiter(value)
|
||||
}
|
||||
|
||||
func containsFlowDelimiter(_ value: String) -> Bool {
|
||||
value.contains(",")
|
||||
|| value.contains("[")
|
||||
|| value.contains("]")
|
||||
|| value.contains("{")
|
||||
|| value.contains("}")
|
||||
}
|
||||
|
||||
func containsColonSpace(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, character == " " {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReservedPlainScalar(_ value: String) -> Bool {
|
||||
switch value {
|
||||
case "~", "null", "Null", "NULL", "true", "True", "TRUE", "false", "False", "FALSE":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumberLikePlainScalar(_ value: String) -> Bool {
|
||||
if Int(value) != nil, !value.contains(".") {
|
||||
return true
|
||||
}
|
||||
if value.contains(".") || value.contains("e") || value.contains("E") {
|
||||
return Double(value) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPlainScalarIndicator(_ character: Character) -> Bool {
|
||||
"-?:,[]{}#&*!|>'\"%@`".contains(character)
|
||||
}
|
||||
|
||||
func escapeKey(_ key: String) -> String {
|
||||
canRenderPlainString(key) ? key : quote(key)
|
||||
}
|
||||
|
||||
func quote(_ value: String) -> String {
|
||||
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
|
||||
// single extended grapheme cluster in Swift, so a Character-level switch
|
||||
// would match neither "\r" nor "\n" and emit the line break unescaped,
|
||||
// which does not round-trip through the folding parser.
|
||||
var output = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
output += "\""
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,62 @@ public extension PureYAML.Emitting {
|
||||
|
||||
public var scalarStyle: ScalarStyle
|
||||
public var collectionStyle: CollectionStyle
|
||||
/// Spaces per nesting level in block style. Has no effect on flow
|
||||
/// style, which has no indentation to control. Must be at least 1:
|
||||
/// block YAML distinguishes nesting only by indentation, so 0 would
|
||||
/// make sibling and child content indistinguishable and produce
|
||||
/// output that cannot round-trip back through the parser. Not
|
||||
/// enforced at construction (a plain `Int`, matching this struct's
|
||||
/// existing options), so passing 0 or negative is a caller error
|
||||
/// that surfaces as unparseable output, not a thrown error.
|
||||
public var indentWidth: Int
|
||||
/// Sort mapping keys lexicographically by their rendered (already
|
||||
/// quoted/escaped) form before emitting, instead of preserving
|
||||
/// `Model.Mapping`'s insertion order. Off by default: insertion
|
||||
/// order is what makes round-tripping a duplicate-key-preserving
|
||||
/// `Mapping` back to itself meaningful, so sorting is opt-in, not
|
||||
/// the default.
|
||||
public var sortKeys: Bool
|
||||
/// Emit the YAML-specification canonical form: an explicit `---`
|
||||
/// document start, an explicit `!!tag` on every node, double-quoted
|
||||
/// scalars, and flow collections with one `? key : value,` entry
|
||||
/// per line. When set, `scalarStyle`, `collectionStyle`, and
|
||||
/// `lineWidth` are ignored; `indentWidth` and `sortKeys` still
|
||||
/// apply. Off by default.
|
||||
public var canonical: Bool
|
||||
/// Emit an explicit `---` document start marker. Implied by
|
||||
/// `canonical`. Off by default.
|
||||
public var explicitDocumentStart: Bool
|
||||
/// Emit an explicit `...` document end marker. Off by default.
|
||||
public var explicitDocumentEnd: Bool
|
||||
/// The preferred maximum line width. `0` (the default) disables
|
||||
/// wrapping. When positive, a flow collection whose single-line
|
||||
/// form would overflow the width re-renders with one entry per
|
||||
/// line, and a plain block scalar folds at spaces onto
|
||||
/// more-indented continuation lines; folding restores each break
|
||||
/// to the same single space on reparse, so the value is unchanged.
|
||||
/// Content with no legal break point (a single overlong word, a
|
||||
/// quoted or block scalar) stays on one line.
|
||||
public var lineWidth: Int
|
||||
|
||||
public init(
|
||||
scalarStyle: ScalarStyle = .quoted,
|
||||
collectionStyle: CollectionStyle = .block,
|
||||
indentWidth: Int = 2,
|
||||
sortKeys: Bool = false,
|
||||
canonical: Bool = false,
|
||||
explicitDocumentStart: Bool = false,
|
||||
explicitDocumentEnd: Bool = false,
|
||||
lineWidth: Int = 0,
|
||||
) {
|
||||
self.scalarStyle = scalarStyle
|
||||
self.collectionStyle = collectionStyle
|
||||
self.indentWidth = indentWidth
|
||||
self.sortKeys = sortKeys
|
||||
self.canonical = canonical
|
||||
self.explicitDocumentStart = explicitDocumentStart
|
||||
self.explicitDocumentEnd = explicitDocumentEnd
|
||||
self.lineWidth = lineWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
Sources/Model/Value.Traversable.swift
Normal file
26
Sources/Model/Value.Traversable.swift
Normal file
@ -0,0 +1,26 @@
|
||||
extension PureYAML.Model.Value: PureYAML.Validation.Traversable {
|
||||
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this), and each pair's
|
||||
/// key node at its own path (so a key-typed check can inspect it, e.g. "is this key a string").
|
||||
/// Nothing extra for any other case.
|
||||
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
|
||||
guard case let .mapping(mapping) = self else {
|
||||
return []
|
||||
}
|
||||
return [(nil, mapping)] + mapping.pairs.map { pair in
|
||||
(component: pair.keyNode.pathComponent, value: pair.keyNode)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mapping's children are its pairs' values, keyed by their key node's path component; a
|
||||
/// sequence's children are its values, keyed by index. A scalar has none.
|
||||
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Model.Value)] {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
mapping.pairs.map { pair in (component: pair.keyNode.pathComponent, value: pair.value) }
|
||||
case let .sequence(values):
|
||||
values.indices.map { index in (component: .index(index), value: values[index]) }
|
||||
case .null, .bool, .int, .double, .string:
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/Parsing/EventStreamComposer.ExpansionBudget.swift
Normal file
26
Sources/Parsing/EventStreamComposer.ExpansionBudget.swift
Normal file
@ -0,0 +1,26 @@
|
||||
extension PureYAML.Parsing.EventStreamComposer {
|
||||
/// Charges `cost` new expanded nodes against the document's remaining
|
||||
/// ``EventStreamComposer/expandedNodeBudget``, throwing `expansionTooLarge` the moment it
|
||||
/// runs out.
|
||||
///
|
||||
/// `enforceNestingLimit` (`Parser.Events.swift`) bounds PHYSICAL collection depth in the raw
|
||||
/// event stream; it does nothing against an anchor-expansion ("billion laughs") bomb, where a
|
||||
/// handful of shallow, small anchors are each aliased many times, each alias nested inside the
|
||||
/// next, so the LOGICAL tree an alias resolves to is exponential in the number of alias levels
|
||||
/// while the physical document stays kilobytes -- a 25-level, fan-out-9 document already
|
||||
/// expands past 9^25 logical nodes.
|
||||
///
|
||||
/// Called once per scalar, alias resolution, or completed container -- never re-derived from
|
||||
/// a walk -- so a bomb is rejected within a few alias levels, long before its true size could
|
||||
/// ever be materialized: each charge is the MARGINAL cost this one node adds, with any nested
|
||||
/// alias expansion it contains already folded into a prior charge (cached in
|
||||
/// ``EventStreamComposer/anchorSizes``) and looked up in O(1), never re-counted here.
|
||||
/// ``EventStreamComposer/expandedNodeBudget``'s initial 5,000,000 is far past any real
|
||||
/// document's node count.
|
||||
mutating func chargeExpansion(_ cost: Int, mark: PureYAML.Parsing.Mark) throws {
|
||||
expandedNodeBudget -= cost
|
||||
guard expandedNodeBudget >= 0 else {
|
||||
throw PureYAML.Parsing.ParseError.expansionTooLarge(line: mark.line, column: mark.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,10 @@ extension PureYAML.Parsing {
|
||||
// default value path never sets this, so it stays byte-identical to before.
|
||||
var tracksLocations = false
|
||||
var anchors: [String: PureYAML.Model.Value] = [:]
|
||||
/// Each anchor's cached EXPANDED node count; see `chargeExpansion` for why.
|
||||
var anchorSizes: [String: Int] = [:]
|
||||
// The expanded-node budget remaining for this document; see `chargeExpansion`.
|
||||
var expandedNodeBudget = 5_000_000
|
||||
var containers: [EventStreamContainer] = []
|
||||
var documentValues: [PureYAML.Model.Value] = []
|
||||
var documentLocated: [PureYAML.Model.LocatedValue] = []
|
||||
@ -103,6 +107,8 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line)
|
||||
}
|
||||
anchors = [:]
|
||||
anchorSizes = [:]
|
||||
expandedNodeBudget = 5_000_000
|
||||
currentDocumentValue = nil
|
||||
}
|
||||
|
||||
@ -126,8 +132,10 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
marks: (start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark),
|
||||
) throws {
|
||||
let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: marks.start)
|
||||
try chargeExpansion(1, mark: marks.start)
|
||||
if let anchor {
|
||||
anchors[anchor] = model
|
||||
anchorSizes[anchor] = 1
|
||||
}
|
||||
try complete(.init(
|
||||
value: model,
|
||||
@ -139,7 +147,7 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
),
|
||||
mark: marks.start,
|
||||
located: tracksLocations ? .scalar(model, range(start: marks.start, end: marks.end)) : nil,
|
||||
))
|
||||
), cost: 1)
|
||||
}
|
||||
|
||||
mutating func consumeAlias(
|
||||
@ -153,6 +161,12 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
// The target's own cached expanded size (itself plus every descendant, its own nested
|
||||
// aliases already resolved): an alias to a value built from other aliases charges the
|
||||
// FULL compounded cost here, in O(1), which is exactly what makes a nested bomb's true
|
||||
// size visible without ever materializing it.
|
||||
let cost = anchorSizes[anchor] ?? 1
|
||||
try chargeExpansion(cost, mark: mark)
|
||||
try complete(.init(
|
||||
value: value,
|
||||
mappingKey: .init(
|
||||
@ -163,15 +177,20 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
),
|
||||
mark: mark,
|
||||
located: tracksLocations ? .synthesized(from: value, at: range(at: mark)) : nil,
|
||||
))
|
||||
), cost: cost)
|
||||
}
|
||||
|
||||
mutating func completeSequence(mark: PureYAML.Parsing.Mark) throws {
|
||||
guard let container = containers.popLast(), container.kind == .sequence else {
|
||||
throw unexpectedEvent(expected: "sequence start", mark: mark)
|
||||
}
|
||||
// Only the container's own wrapper is a NEW charge here; every child already charged its
|
||||
// own cost (including any alias expansion) as it completed, accumulated into
|
||||
// `logicalSize`. The cache for a later alias to THIS container needs the full total.
|
||||
try chargeExpansion(1, mark: container.mark)
|
||||
let totalCost = container.logicalSize + 1
|
||||
let value = PureYAML.Model.Value.sequence(container.values.map(\.value))
|
||||
store(value, anchor: container.anchor)
|
||||
store(value, anchor: container.anchor, cost: totalCost)
|
||||
try complete(.init(
|
||||
value: value,
|
||||
mappingKey: .init(
|
||||
@ -185,7 +204,7 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
located: tracksLocations
|
||||
? .sequence(container.locatedValues, range(start: container.mark, end: mark))
|
||||
: nil,
|
||||
))
|
||||
), cost: totalCost)
|
||||
}
|
||||
|
||||
mutating func completeMapping(mark: PureYAML.Parsing.Mark) throws {
|
||||
@ -195,12 +214,14 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
guard container.pendingKey == nil else {
|
||||
throw unexpectedEvent(expected: "mapping value", mark: mark)
|
||||
}
|
||||
try chargeExpansion(1, mark: container.mark)
|
||||
let totalCost = container.logicalSize + 1
|
||||
let pairs = mergedPairs(
|
||||
from: container.mergeSources,
|
||||
localPairs: container.localPairs,
|
||||
)
|
||||
let value = PureYAML.Model.Value.mapping(.init(pairs))
|
||||
store(value, anchor: container.anchor)
|
||||
store(value, anchor: container.anchor, cost: totalCost)
|
||||
let located: PureYAML.Model.LocatedValue? = tracksLocations
|
||||
? .mapping(
|
||||
mergedLocatedPairs(from: container.mergeLocatedSources, localPairs: container.localLocatedPairs),
|
||||
@ -217,10 +238,10 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
),
|
||||
mark: container.mark,
|
||||
located: located,
|
||||
))
|
||||
), cost: totalCost)
|
||||
}
|
||||
|
||||
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode) throws {
|
||||
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode, cost: Int) throws {
|
||||
guard !containers.isEmpty else {
|
||||
currentDocumentValue = node.value
|
||||
if tracksLocations {
|
||||
@ -232,11 +253,13 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
switch containers[containers.count - 1].kind {
|
||||
case .sequence:
|
||||
containers[containers.count - 1].values.append(node)
|
||||
containers[containers.count - 1].logicalSize += cost
|
||||
if tracksLocations {
|
||||
containers[containers.count - 1].locatedValues.append(locatedNode(node))
|
||||
}
|
||||
case .mapping:
|
||||
try completeMappingNode(node)
|
||||
containers[containers.count - 1].logicalSize += cost
|
||||
}
|
||||
}
|
||||
|
||||
@ -277,11 +300,13 @@ private extension PureYAML.Parsing.EventStreamComposer {
|
||||
mutating func store(
|
||||
_ value: PureYAML.Model.Value,
|
||||
anchor: String?,
|
||||
cost: Int,
|
||||
) {
|
||||
guard let anchor else {
|
||||
return
|
||||
}
|
||||
anchors[anchor] = value
|
||||
anchorSizes[anchor] = cost
|
||||
}
|
||||
|
||||
func mergeSources(
|
||||
|
||||
@ -7,6 +7,11 @@ extension PureYAML.Parsing {
|
||||
var localPairs: [PureYAML.Model.Pair] = []
|
||||
var pendingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey?
|
||||
var mark: PureYAML.Parsing.Mark
|
||||
/// The running sum of every child's own expanded-node cost (see
|
||||
/// `EventStreamComposer.expandedNodeBudget`), so this container's total logical size --
|
||||
/// itself plus every descendant, alias expansion already accounted for -- is available in
|
||||
/// O(1) the moment it completes, without re-walking anything.
|
||||
var logicalSize = 0
|
||||
// Location tracking (parallel to the above, only when the composer tracks locations).
|
||||
var locatedValues: [PureYAML.Model.LocatedValue] = []
|
||||
var mergeLocatedSources: [[PureYAML.Model.LocatedPair]] = []
|
||||
|
||||
@ -19,6 +19,15 @@ extension PureYAML.Parsing.LibYAML {
|
||||
/// `parser->tokens_parsed`, the count already dequeued, used to turn an
|
||||
/// absolute token number into an index into the live queue.
|
||||
var tokensParsed = 0
|
||||
/// The most recently enqueued or spliced token, kept in sync with
|
||||
/// `tokens.last` after every mutation below. Batch scanning never dequeues
|
||||
/// (`tokensParsed` stays 0, `dequeue()` unused), so `tokens.last` alone
|
||||
/// would suffice there; this field exists for a resumable scanner
|
||||
/// (`Scanner.scanStep()`), which releases and discards tokens as soon as
|
||||
/// they are safe to hand out, so `tokens` can be empty exactly when a check
|
||||
/// like `lastTokenForbidsInlineSequence()` needs to know what the last
|
||||
/// token was.
|
||||
private(set) var lastToken: Token?
|
||||
|
||||
/// Port of `yaml_parser_roll_indent`. In flow context it does nothing.
|
||||
/// When the column is greater than the current indent, it pushes the
|
||||
@ -44,6 +53,7 @@ extension PureYAML.Parsing.LibYAML {
|
||||
} else {
|
||||
tokens.insert(token, at: number - tokensParsed)
|
||||
}
|
||||
lastToken = tokens.last
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,17 +69,20 @@ extension PureYAML.Parsing.LibYAML {
|
||||
tokens.append(Token(type: .blockEnd, startMark: mark, endMark: mark))
|
||||
indent = indents.removeLast()
|
||||
}
|
||||
lastToken = tokens.last ?? lastToken
|
||||
}
|
||||
|
||||
/// `ENQUEUE`: append a token to the queue.
|
||||
mutating func enqueue(_ token: Token) {
|
||||
tokens.append(token)
|
||||
lastToken = token
|
||||
}
|
||||
|
||||
/// `QUEUE_INSERT`: insert a token at an absolute token number, used to
|
||||
/// splice a `KEY` token in front of a node already queued.
|
||||
mutating func insert(_ token: Token, atTokenNumber number: Int) {
|
||||
tokens.insert(token, at: number - tokensParsed)
|
||||
lastToken = tokens.last
|
||||
}
|
||||
|
||||
/// Dequeue the front token, advancing `tokensParsed` like libyaml's
|
||||
|
||||
@ -6,20 +6,71 @@ extension PureYAML.Parsing.LibYAML {
|
||||
/// libyaml's 0-based line and column convention; conversion to the package's
|
||||
/// 1-based `Mark` happens at token emission during integration.
|
||||
struct Reader {
|
||||
private let scalars: [Unicode.Scalar]
|
||||
private var scalars: [Unicode.Scalar]
|
||||
private(set) var index = 0
|
||||
private(set) var line = 0
|
||||
private(set) var column = 0
|
||||
/// Whether the buffered `scalars` are the complete input. True for every
|
||||
/// batch caller (the default), so `ranDry` can never be set and this type's
|
||||
/// behavior is unchanged for `scanAll()`. A resumable caller passes `false`
|
||||
/// and later calls `markFinal()` once the last chunk has been appended.
|
||||
private(set) var isFinal: Bool
|
||||
/// Set by `peek(_:)` when it was asked for a position past the buffered
|
||||
/// scalars while `!isFinal`: the query is inconclusive, not a real end of
|
||||
/// input, and the caller must suspend and retry after `append(_:)`. Batch
|
||||
/// callers (`isFinal == true`) never set this, since the guard below is
|
||||
/// unreachable when `isFinal` is true.
|
||||
private(set) var ranDry = false
|
||||
|
||||
init(_ string: String) {
|
||||
/// The index of the first non-`c-printable` scalar found in `scalars`
|
||||
/// (libyaml's reader-level check, `yaml_parser_update_buffer`'s printable
|
||||
/// validation), or nil if none has been found yet. Set once, by whichever
|
||||
/// of `init`/`append(_:)` first buffers the offending scalar; never
|
||||
/// cleared. `fetchNextToken()` checks this once per token and throws,
|
||||
/// mirroring libyaml rejecting raw control characters (anything outside
|
||||
/// `c-printable`, i.e. not a plain tab/CR/LF and not in the printable
|
||||
/// ranges `CharacterClass.isPrintable` implements) wherever they occur in
|
||||
/// the stream, not only inside specific constructs like anchor names.
|
||||
private(set) var invalidControlCharacterIndex: Int?
|
||||
|
||||
init(_ string: String = "", isFinal: Bool = true) {
|
||||
scalars = Array(string.unicodeScalars)
|
||||
self.isFinal = isFinal
|
||||
recordFirstInvalidControlCharacter(in: 0 ..< scalars.count)
|
||||
}
|
||||
|
||||
/// `PEEK_TOKEN` / `CHECK_AT`: the scalar `offset` ahead, or nil past the
|
||||
/// end (libyaml's `\0`).
|
||||
func peek(_ offset: Int = 0) -> Unicode.Scalar? {
|
||||
/// end (libyaml's `\0`). Past the buffered end while more chunks may still
|
||||
/// arrive, this flags `ranDry` instead of asserting true end of input.
|
||||
mutating func peek(_ offset: Int = 0) -> Unicode.Scalar? {
|
||||
let position = index + offset
|
||||
return position < scalars.count ? scalars[position] : nil
|
||||
if position < scalars.count {
|
||||
return scalars[position]
|
||||
}
|
||||
if !isFinal {
|
||||
ranDry = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Append a chunk of newly arrived input. Only meaningful before
|
||||
/// `markFinal()`. Clears `ranDry`: new data may satisfy whatever `peek(_:)`
|
||||
/// call set it, and nothing else ever clears it. The existing
|
||||
/// checkpoint-restore path gets this for free (the restored `Reader` is a
|
||||
/// copy from before the dry read), but a resumed scan that keeps its
|
||||
/// reader position instead of restoring a snapshot (`PlainScalarSuspended`)
|
||||
/// has no other point where a stale `true` would ever become false again.
|
||||
mutating func append(_ chunk: String) {
|
||||
let previousCount = scalars.count
|
||||
scalars.append(contentsOf: chunk.unicodeScalars)
|
||||
ranDry = false
|
||||
recordFirstInvalidControlCharacter(in: previousCount ..< scalars.count)
|
||||
}
|
||||
|
||||
/// Declare that no further chunks are coming: a `peek` past the buffered
|
||||
/// end is now a real end of input, matching the batch reader's behavior.
|
||||
mutating func markFinal() {
|
||||
isFinal = true
|
||||
}
|
||||
|
||||
var isAtEnd: Bool {
|
||||
@ -53,5 +104,49 @@ extension PureYAML.Parsing.LibYAML {
|
||||
line += 1
|
||||
column = 0
|
||||
}
|
||||
|
||||
/// The 0-based line/column of `scalars[index]`, computed by counting line
|
||||
/// breaks from the start of the buffer. Not the hot `mark` path (that
|
||||
/// tracks `line`/`column` incrementally as scanning consumes characters);
|
||||
/// used only once, lazily, when `fetchNextToken()` actually needs to report
|
||||
/// `invalidControlCharacterIndex` as a `Mark`, since that index can be
|
||||
/// anywhere in the buffer, not necessarily at the reader's current
|
||||
/// scanning position.
|
||||
func mark(atIndex target: Int) -> PureYAML.Parsing.Mark {
|
||||
var line = 0
|
||||
var column = 0
|
||||
var cursor = 0
|
||||
while cursor < target, cursor < scalars.count {
|
||||
if scalars[cursor] == "\r", cursor + 1 < scalars.count, scalars[cursor + 1] == "\n" {
|
||||
line += 1
|
||||
column = 0
|
||||
cursor += 2
|
||||
} else if CharacterClass.isBreak(scalars[cursor]) {
|
||||
line += 1
|
||||
column = 0
|
||||
cursor += 1
|
||||
} else {
|
||||
column += 1
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
return PureYAML.Parsing.Mark(line: line, column: column, index: target)
|
||||
}
|
||||
|
||||
/// Scans `range` (a slice of `scalars` just buffered by `init`/`append(_:)`)
|
||||
/// for the first scalar outside `CharacterClass.isPrintable`, recording its
|
||||
/// index if one is found and none was already recorded. Only the first
|
||||
/// occurrence overall matters (matching libyaml, which reports the first
|
||||
/// invalid byte and stops), so once `invalidControlCharacterIndex` is set
|
||||
/// this is a no-op.
|
||||
private mutating func recordFirstInvalidControlCharacter(in range: Range<Int>) {
|
||||
guard invalidControlCharacterIndex == nil else {
|
||||
return
|
||||
}
|
||||
for index in range where !CharacterClass.isPrintable(scalars[index]) {
|
||||
invalidControlCharacterIndex = index
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +161,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
/// Fold the pending breaks at the start of a content line (folded style joins a
|
||||
/// single break between non-blank lines into a space; otherwise breaks are
|
||||
/// kept).
|
||||
private func foldBlockLine(_ state: inout BlockState, literal: Bool) {
|
||||
private mutating func foldBlockLine(_ state: inout BlockState, literal: Bool) {
|
||||
let trailingBlank = Classify.isBlank(reader.peek())
|
||||
if !literal, state.leadingBreak.first == "\n", !state.leadingBlank, !trailingBlank {
|
||||
if state.trailingBreaks.isEmpty {
|
||||
@ -184,7 +184,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
|
||||
/// Whether a `---` or `...` document marker (three repeats then a blank, break,
|
||||
/// or end of input) starts at the current position.
|
||||
private func documentMarkerAhead() -> Bool {
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
|
||||
@ -23,6 +23,25 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
fetchStreamStart()
|
||||
return
|
||||
}
|
||||
// Gated on `reader.index >= badIndex`, not just "is one set", so a real
|
||||
// syntax error earlier in the document (which normal scanning would reach
|
||||
// and throw first) is not hidden behind a control character that only
|
||||
// occurs later: this only fires once the reader has genuinely scanned up
|
||||
// to the offending position, matching the order libyaml's own byte-by-byte
|
||||
// reader would hit them in. `resumePlainScalar`'s own suspend/resume path
|
||||
// (`scanStep()`, the streaming API) does not route through
|
||||
// `fetchNextToken()` and so is not covered by this check; a token
|
||||
// containing a control character can be returned to a streaming caller one
|
||||
// token before this fires. `scanAll()`/`PureYAML.parse()`, the primary
|
||||
// target, always calls `fetchNextToken()` again before returning, so this
|
||||
// still surfaces before a batch parse completes.
|
||||
if let badIndex = reader.invalidControlCharacterIndex, reader.index >= badIndex {
|
||||
throw ScannerError(
|
||||
context: "while scanning for the next token",
|
||||
problem: "control characters are not allowed",
|
||||
mark: reader.mark(atIndex: badIndex),
|
||||
)
|
||||
}
|
||||
|
||||
try scanToNextToken()
|
||||
try checkFlowContinuationIndent()
|
||||
@ -352,7 +371,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
private func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
|
||||
private mutating func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
|
||||
reader.peek(0) == character
|
||||
&& reader.peek(1) == character
|
||||
&& reader.peek(2) == character
|
||||
|
||||
@ -215,7 +215,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a quoted scalar", problem: problem, mark: mark)
|
||||
}
|
||||
|
||||
private func documentMarkerAhead() -> Bool {
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
|
||||
@ -29,7 +29,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
/// same line: a mapping value indicator or a node property (anchor/tag). After
|
||||
/// another block entry a compact sequence is allowed, so that is not barred.
|
||||
func lastTokenForbidsInlineSequence() -> Bool {
|
||||
switch indentEngine.tokens.last?.type {
|
||||
switch indentEngine.lastToken?.type {
|
||||
case .anchor, .tag:
|
||||
true
|
||||
case .value:
|
||||
|
||||
@ -1,10 +1,58 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
/// Which step of `runPlainScalarLoop`'s outer iteration a suspend happened
|
||||
/// at. Resuming must land back on the step that was actually interrupted,
|
||||
/// not on whatever step normally follows it, and in particular must never
|
||||
/// redo a decision (document marker, comment, or "does a blank/break run
|
||||
/// follow") that a *previous, already-passed* check already answered:
|
||||
/// `.start`'s document-marker/`#` checks and `.afterContent`'s blank/break
|
||||
/// guard are only meant to run once, at a genuine entry point, not at an
|
||||
/// arbitrary position inside an already-in-progress content or break run.
|
||||
/// A first version of this type resumed a dry `consumePlainContent` via
|
||||
/// `.start` on the (wrong) assumption that redoing its checks was harmless;
|
||||
/// it is not; if the resume position happens to land on a literal `#`
|
||||
/// that is legitimate mid-scalar content (e.g. a value containing every
|
||||
/// printable character), `.start`'s comment check wrongly ends the scalar
|
||||
/// right there. `consumePlainContent` and `consumePlainBreaks` each get
|
||||
/// their own dedicated "continue this same call, no pre-check" case
|
||||
/// (`.consumingContent`, `.consumingBreaks`) for exactly this reason.
|
||||
enum PlainScalarPhase: Equatable {
|
||||
/// Check for a document marker or comment, then consume content.
|
||||
case start
|
||||
/// Inside consuming an already-started content run (the document
|
||||
/// marker/comment checks that gated entry into it do not apply here).
|
||||
case consumingContent
|
||||
/// Content just finished; decide whether a blank/break run follows.
|
||||
case afterContent
|
||||
/// Inside consuming an already-confirmed blank/break run.
|
||||
case consumingBreaks
|
||||
/// A blank/break run just finished; check whether the scalar dedents.
|
||||
case afterBreaks
|
||||
}
|
||||
|
||||
/// Thrown by `runPlainScalarLoop` when accumulating a plain scalar's content
|
||||
/// runs dry (`Reader.ranDry`) while `!Reader.isFinal`. Carries everything
|
||||
/// `resumePlainScalar(_:)` needs to continue from exactly this point once more
|
||||
/// input is appended, rather than rescanning the scalar's content from its
|
||||
/// start. `scanStep()` catches this specifically and stores it on
|
||||
/// `Scanner.suspendedPlainScalar`, distinct from a generic dry-run catch: no
|
||||
/// checkpoint restore happens for this error, since the whole point is to keep
|
||||
/// the reader position and accumulated content rather than discard them.
|
||||
struct PlainScalarSuspended: Swift.Error {
|
||||
var state: PlainState
|
||||
var indent: Int
|
||||
var startMark: PureYAML.Parsing.Mark
|
||||
var phase: PlainScalarPhase
|
||||
}
|
||||
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// The folding state carried through `scan_plain_scalar`: the accumulated
|
||||
/// value plus the pending leading break, trailing breaks, and whitespace that
|
||||
/// are folded in before the next content character.
|
||||
private struct PlainState {
|
||||
/// are folded in before the next content character. Internal rather than
|
||||
/// private: `PlainScalarSuspended` carries one across a `scanStep()` suspend
|
||||
/// point, so `Scanner`'s own file needs to name the type for its stored
|
||||
/// `suspendedPlainScalar` property.
|
||||
struct PlainState {
|
||||
var string = ""
|
||||
var leadingBreak = ""
|
||||
var trailingBreaks = ""
|
||||
@ -22,24 +70,146 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
let indent = indentEngine.indent + 1
|
||||
let startMark = reader.mark
|
||||
var state = PlainState(endMark: reader.mark)
|
||||
try runPlainScalarLoop(&state, indent: indent, startMark: startMark)
|
||||
return finishPlainScalar(state, startMark: startMark)
|
||||
}
|
||||
|
||||
/// Resumes a plain scalar suspended by a previous `PlainScalarSuspended`,
|
||||
/// continuing from its saved state, phase, and reader position rather than
|
||||
/// rescanning the content already accumulated before the suspend.
|
||||
///
|
||||
/// Takes `state` `inout` rather than the whole `PlainScalarSuspended`, on
|
||||
/// purpose: the caller (`scanStep()`) must disalias `state` from the
|
||||
/// suspended value it came from *before* this call, since a `let
|
||||
/// suspended = ...; resumePlainScalar(suspended)` call would keep the
|
||||
/// caller's own `suspended.state` reference to the same string buffer
|
||||
/// alive for this whole call's duration regardless of anything done
|
||||
/// inside this function's own body (a value parameter is borrowed for
|
||||
/// the call, not owned, so a local disalias inside the callee cannot
|
||||
/// remove a reference the caller itself is still holding). With `state`
|
||||
/// passed by direct reference and no wrapping struct crossing this call
|
||||
/// boundary at all, there is exactly one owner of its string buffer
|
||||
/// throughout, so appending inside the loop below never triggers a
|
||||
/// spurious copy-on-write copy of the whole accumulated scalar. See
|
||||
/// `scanStep()`'s resume branch for where the disalias happens.
|
||||
mutating func resumePlainScalar(
|
||||
_ state: inout PlainState,
|
||||
indent: Int,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
phase: PlainScalarPhase,
|
||||
) throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
try runPlainScalarLoop(&state, indent: indent, startMark: startMark, from: phase)
|
||||
return finishPlainScalar(state, startMark: startMark)
|
||||
}
|
||||
|
||||
/// The scan loop shared by a fresh attempt and a resume, restructured as an
|
||||
/// explicit `PlainScalarPhase` state machine (see its own doc comment for
|
||||
/// why) with a `suspendIfDry` check after every step that peeked. Checking
|
||||
/// only once, after the whole loop exits, is not enough: a dry read
|
||||
/// (`reader.ranDry`, see `Reader.peek(_:)`) inside a lookahead
|
||||
/// (`documentMarkerAhead()`, `consumePlainContent`'s `:`-followed-by-blank
|
||||
/// check, `consumePlainBreaks`'s `blankLineAhead()` guard) reads as "blank or
|
||||
/// end" (`CharacterClass.isBlankZ`/`isBreakZ` treat a missing scalar as
|
||||
/// true), not as "not enough data yet", so the *next* step can reach a
|
||||
/// wrong-but-plausible decision (e.g. `documentMarkerAhead` wrongly says
|
||||
/// "not a marker", so `consumePlainContent` appends `---` as scalar content)
|
||||
/// before a single end-of-loop check would ever see the taint that caused
|
||||
/// it. Checking right after each peeking step, before its result is used for
|
||||
/// anything further, catches the taint before it can influence another
|
||||
/// decision. Batch parsing (`isFinal: true`) is unaffected: every check can
|
||||
/// only throw when `!reader.isFinal`, which batch callers never set, so the
|
||||
/// loop's own logic is otherwise identical to the original.
|
||||
///
|
||||
/// `state` and the reader position are always resumable at a suspend point:
|
||||
/// nothing here calls `reader.skip()` on a dry `nil`, so accumulated content
|
||||
/// and position both stop exactly at the last successfully read scalar.
|
||||
private mutating func runPlainScalarLoop(
|
||||
_ state: inout PlainState,
|
||||
indent: Int,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
from initialPhase: PlainScalarPhase = .start,
|
||||
) throws {
|
||||
var phase = initialPhase
|
||||
while true {
|
||||
if reader.column == 0, documentMarkerAhead() {
|
||||
break
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
break
|
||||
}
|
||||
try consumePlainContent(&state, startMark: startMark)
|
||||
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
|
||||
break
|
||||
}
|
||||
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
||||
if flowLevel == 0, reader.column < indent {
|
||||
break
|
||||
switch phase {
|
||||
case .start:
|
||||
let atDocumentMarker = reader.column == 0 && documentMarkerAhead()
|
||||
// A dry lookahead inside `documentMarkerAhead()` must resume by
|
||||
// redoing this whole step, not by trusting `atDocumentMarker`
|
||||
// (which `isBlankZ`'s nil-is-true bias could have gotten wrong)
|
||||
// and moving on: hence `phase: .start` here, not `.consumingContent`.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .start)
|
||||
if atDocumentMarker {
|
||||
return
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
return
|
||||
}
|
||||
try consumePlainContent(&state, startMark: startMark)
|
||||
// consumePlainContent stopping is not yet known to be a real
|
||||
// answer (terminator found) versus a dry cutoff: check before
|
||||
// trusting it enough to move to `.afterContent`'s guard. On dry,
|
||||
// resume via `.consumingContent`, not `.start`: the document-marker
|
||||
// and `#` checks above only apply at a genuine entry point, and
|
||||
// the reader position where a content run was interrupted can
|
||||
// land on a literal `#` or `-` that is legitimate content, not a
|
||||
// fresh comment or marker.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
||||
phase = .afterContent
|
||||
case .consumingContent:
|
||||
try consumePlainContent(&state, startMark: startMark)
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
||||
phase = .afterContent
|
||||
case .afterContent:
|
||||
// Reached only once consumePlainContent is known to have stopped
|
||||
// for a real reason, so this peek cannot itself be dry: it
|
||||
// re-reads the exact position consumePlainContent's own last
|
||||
// (already non-dry) peek confirmed, and nothing advanced between.
|
||||
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
|
||||
return
|
||||
}
|
||||
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
||||
// Same reasoning as consumePlainContent above, but resuming here
|
||||
// must not redo `.afterContent`'s guard against new data: that
|
||||
// guard gates *entry* into a blank/break run, and re-asking it
|
||||
// after the run is already underway would wrongly treat "the run
|
||||
// continues" as "the run never started" (or vice versa) instead
|
||||
// of just continuing the same run. `.consumingBreaks` re-enters
|
||||
// consumePlainBreaks directly.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
phase = .afterBreaks
|
||||
case .consumingBreaks:
|
||||
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
phase = .afterBreaks
|
||||
case .afterBreaks:
|
||||
// `reader.column` is a plain stored field, never read via
|
||||
// `peek(_:)`, so this check cannot itself observe a dry read; by
|
||||
// construction every path reaching this case already confirmed
|
||||
// `!ranDry` before transitioning here.
|
||||
if flowLevel == 0, reader.column < indent {
|
||||
return
|
||||
}
|
||||
phase = .start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func suspendIfDry(
|
||||
_ state: PlainState,
|
||||
indent: Int,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
phase: PlainScalarPhase,
|
||||
) throws {
|
||||
if !reader.isFinal, reader.ranDry {
|
||||
throw PlainScalarSuspended(state: state, indent: indent, startMark: startMark, phase: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func finishPlainScalar(
|
||||
_ state: PlainState,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
) -> PureYAML.Parsing.LibYAML.Token {
|
||||
if state.leadingBlanks {
|
||||
simpleKeyAllowed = true
|
||||
}
|
||||
@ -106,11 +276,22 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private mutating func consumePlainBreaks(_ state: inout PlainState, indent: Int, startMark: PureYAML.Parsing.Mark) throws {
|
||||
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
|
||||
if Classify.isBlank(reader.peek()) {
|
||||
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()), !blankLineAhead() {
|
||||
// A tab below the indentation is illegal only when it indents
|
||||
// real content; on an otherwise-blank line it is just a blank
|
||||
// line the scalar folds over. libyaml rejects it either way.
|
||||
throw scannerError("found a tab character that violates indentation", at: startMark)
|
||||
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()) {
|
||||
// `blankLineAhead()` is its own multi-character lookahead (not
|
||||
// just the single already-confirmed-real `peek()` above), so it
|
||||
// can independently run dry: check immediately, before the
|
||||
// tab-violation decision below trusts its answer. Unchecked,
|
||||
// `isBreakZ`'s nil-is-true bias makes an incomplete lookahead
|
||||
// read as "blank line", suppressing a real violation until more
|
||||
// data arrives proves otherwise.
|
||||
let onBlankLine = blankLineAhead()
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
if !onBlankLine {
|
||||
// A tab below the indentation is illegal only when it indents
|
||||
// real content; on an otherwise-blank line it is just a blank
|
||||
// line the scalar folds over. libyaml rejects it either way.
|
||||
throw scannerError("found a tab character that violates indentation", at: startMark)
|
||||
}
|
||||
}
|
||||
if !state.leadingBlanks, let blank = reader.peek() {
|
||||
state.whitespaces.unicodeScalars.append(blank)
|
||||
@ -132,7 +313,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a plain scalar", problem: problem, mark: mark)
|
||||
}
|
||||
|
||||
private func documentMarkerAhead() -> Bool {
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
|
||||
@ -78,7 +78,7 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
|
||||
/// Whether the rest of the current line, from the current position, is only
|
||||
/// spaces and tabs up to a line break or end of stream (a blank line).
|
||||
func blankLineAhead() -> Bool {
|
||||
mutating func blankLineAhead() -> Bool {
|
||||
var offset = 0
|
||||
while let character = reader.peek(offset), character == " " || Classify.isTab(character) {
|
||||
offset += 1
|
||||
|
||||
165
Sources/Parsing/LibYAML.Scanner.Streaming.swift
Normal file
165
Sources/Parsing/LibYAML.Scanner.Streaming.swift
Normal file
@ -0,0 +1,165 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
/// The result of one `scanStep()` call.
|
||||
enum ScanOutcome: Equatable {
|
||||
/// A token is safe to release to the caller now.
|
||||
case token(PureYAML.Parsing.LibYAML.Token)
|
||||
/// The buffered input was exhausted mid-fetch; append more input (via
|
||||
/// `reader.append(_:)`) and call `scanStep()` again. The scanner's state is
|
||||
/// unchanged from before this call, as if it had not been called.
|
||||
case needMoreInput
|
||||
/// STREAM-END has been released and no further tokens remain.
|
||||
case done
|
||||
}
|
||||
|
||||
/// Resumable, one-token-at-a-time counterpart to `scanAll()`. Where `scanAll()`
|
||||
/// assumes the full input is already buffered, `scanStep()` may be driven across
|
||||
/// a `Reader` still receiving chunks (`isFinal == false`): a fetch that runs off
|
||||
/// the buffered end is rolled back in full (as if it had not been attempted)
|
||||
/// rather than mistaken for true end of input.
|
||||
///
|
||||
/// A token already queued is not always safe to hand out immediately: libyaml's
|
||||
/// simple-key mechanism can still splice a KEY (and a BLOCK-MAPPING-START) in
|
||||
/// front of it once a later `:` is found (`saveSimpleKey`/`insert(_:atTokenNumber:)`
|
||||
/// in `LibYAML.Scanner.swift`/`LibYAML.IndentEngine.swift`). Port of libyaml's
|
||||
/// `yaml_parser_fetch_more_tokens` release condition: the token at the queue
|
||||
/// front (position `tokensParsed`) is safe to release only when no currently
|
||||
/// possible simple key (at any flow level) is still recorded at that exact
|
||||
/// position; such a key could still resolve into a splice targeting that slot.
|
||||
mutating func scanStep() throws -> ScanOutcome {
|
||||
while true {
|
||||
if isFrontSafeToRelease, let token = indentEngine.dequeue() {
|
||||
return .token(token)
|
||||
}
|
||||
if streamEndProduced {
|
||||
return .done
|
||||
}
|
||||
// A plain scalar in progress resumes from exactly where it stopped,
|
||||
// with no checkpoint at all: `runPlainScalarLoop` never leaves `reader`
|
||||
// or `state` in a state that needs rolling back (see
|
||||
// `PlainScalarSuspended`), so there is nothing to restore if it runs
|
||||
// dry again.
|
||||
switch try resumeSuspendedPlainScalarIfNeeded() {
|
||||
case .notSuspended:
|
||||
break
|
||||
case .tokenReady:
|
||||
continue
|
||||
case .stillDry:
|
||||
return .needMoreInput
|
||||
}
|
||||
if let outcome = try fetchNextTokenCheckpointed() {
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dry run can only ever be reported while `!reader.isFinal` (see
|
||||
/// `Reader.peek(_:)`), so once the caller has appended every chunk and
|
||||
/// called `markFinal()`, no fetch can ever need rolling back. Skipping
|
||||
/// the snapshot in that case matters: it is the entire cost difference
|
||||
/// between `scanStep()` and `scanAll()` once the whole input is already
|
||||
/// buffered (measured in StreamingScannerPerformanceTests), since neither
|
||||
/// reads nor mutations inside `fetchNextToken()` become any cheaper by
|
||||
/// being wrapped in an unused checkpoint. Returns `nil` to mean "keep
|
||||
/// looping in `scanStep()`", matching a fetch that produced no directly
|
||||
/// returnable outcome (a non-plain-scalar token was merely enqueued, or a
|
||||
/// document/directive token dispatched with no scalar involved).
|
||||
private mutating func fetchNextTokenCheckpointed() throws -> ScanOutcome? {
|
||||
guard !reader.isFinal else {
|
||||
try fetchNextToken()
|
||||
return nil
|
||||
}
|
||||
let checkpoint = self
|
||||
do {
|
||||
try fetchNextToken()
|
||||
} catch let suspended as PlainScalarSuspended {
|
||||
// `fetchPlainScalar()` calls `saveSimpleKey()` and sets
|
||||
// `simpleKeyAllowed = false` before ever calling `scanPlainScalar()`,
|
||||
// and a plain-scalar-only resume has no way to undo those: it is
|
||||
// only safe to keep them applied and resume past this suspend if
|
||||
// real progress (content) exists to preserve. `phase == .start`
|
||||
// with an empty `state` means the very first check inside the loop
|
||||
// (before `consumePlainContent` ever ran, e.g. `documentMarkerAhead()`'s
|
||||
// own lookahead went dry) suspended: nothing would be saved by
|
||||
// resuming instead of retrying from scratch, and retrying from
|
||||
// scratch is what correctly re-derives whether this token should
|
||||
// have been dispatched as a plain scalar at all. `dispatchToken()`'s
|
||||
// own `isDocumentMarker` check, called before `fetchPlainScalar()`
|
||||
// is ever reached, can suffer the exact same dry-lookahead problem
|
||||
// `runPlainScalarLoop` guards against; only a full retry re-checks it
|
||||
// with more data, since nothing here can undo a wrong dispatch.
|
||||
if suspended.phase == .start, suspended.state.string.isEmpty {
|
||||
self = checkpoint
|
||||
return .needMoreInput
|
||||
}
|
||||
suspendedPlainScalar = suspended
|
||||
return .needMoreInput
|
||||
} catch {
|
||||
if reader.ranDry {
|
||||
self = checkpoint
|
||||
return .needMoreInput
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if reader.ranDry {
|
||||
self = checkpoint
|
||||
return .needMoreInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var isFrontSafeToRelease: Bool {
|
||||
guard !indentEngine.tokens.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return !simpleKeys.contains { $0.possible && $0.tokenNumber == indentEngine.tokensParsed }
|
||||
}
|
||||
|
||||
private enum ResumeOutcome {
|
||||
/// No plain scalar was suspended; `scanStep()` should fall through
|
||||
/// to its normal fetch path.
|
||||
case notSuspended
|
||||
/// The suspended scalar was resumed to completion and its token was
|
||||
/// enqueued; `scanStep()` should loop back to the top and try to
|
||||
/// release it, not fall through to a fresh fetch.
|
||||
case tokenReady
|
||||
/// The resume itself ran dry again; `scanStep()` should report
|
||||
/// `.needMoreInput`.
|
||||
case stillDry
|
||||
}
|
||||
|
||||
/// Extracted out of `scanStep()` (which was tripping the cyclomatic
|
||||
/// complexity limit) rather than inlined, this also fixes a real O(N^2)
|
||||
/// bug the inline version had: extracting `state` and immediately
|
||||
/// overwriting `suspended.state` with a fresh, unrelated value before
|
||||
/// calling `resumePlainScalar` is required, not optional. A plain
|
||||
/// `resumePlainScalar(suspended)` call, passing the whole suspended
|
||||
/// value, would keep a reference to it alive (as an ordinary borrowed
|
||||
/// value parameter) for this call's entire duration regardless of what
|
||||
/// the callee does internally, leaving `state`'s string buffer with two
|
||||
/// live owners throughout `runPlainScalarLoop` and forcing copy-on-write
|
||||
/// to fully copy the accumulated scalar on every single append.
|
||||
/// Overwriting `suspended.state` here, before the call, drops that
|
||||
/// reference for good, so `state` is the sole owner by the time
|
||||
/// `resumePlainScalar` starts mutating it (see
|
||||
/// `StreamingScannerPerformanceTests.test_measureLongPlainScalar`, which
|
||||
/// caught this at a measured 29x versus the ~3-4x this fix gives).
|
||||
private mutating func resumeSuspendedPlainScalarIfNeeded() throws -> ResumeOutcome {
|
||||
guard var suspended = suspendedPlainScalar else {
|
||||
return .notSuspended
|
||||
}
|
||||
suspendedPlainScalar = nil
|
||||
var state = suspended.state
|
||||
suspended.state = PlainState(endMark: state.endMark)
|
||||
let indent = suspended.indent
|
||||
let startMark = suspended.startMark
|
||||
let phase = suspended.phase
|
||||
do {
|
||||
let token = try resumePlainScalar(&state, indent: indent, startMark: startMark, phase: phase)
|
||||
indentEngine.enqueue(token)
|
||||
return .tokenReady
|
||||
} catch let resuspended as PlainScalarSuspended {
|
||||
suspendedPlainScalar = resuspended
|
||||
return .stillDry
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -120,10 +120,18 @@ extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
}
|
||||
|
||||
private func utf8Width(of octet: UInt8) -> Int {
|
||||
if octet & 0x80 == 0x00 { return 1 }
|
||||
if octet & 0xE0 == 0xC0 { return 2 }
|
||||
if octet & 0xF0 == 0xE0 { return 3 }
|
||||
if octet & 0xF8 == 0xF0 { return 4 }
|
||||
if octet & 0x80 == 0x00 {
|
||||
return 1
|
||||
}
|
||||
if octet & 0xE0 == 0xC0 {
|
||||
return 2
|
||||
}
|
||||
if octet & 0xF0 == 0xE0 {
|
||||
return 3
|
||||
}
|
||||
if octet & 0xF8 == 0xF0 {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@ -42,9 +42,13 @@ extension PureYAML.Parsing.LibYAML {
|
||||
var streamStartProduced = false
|
||||
/// `parser->stream_end_produced`.
|
||||
var streamEndProduced = false
|
||||
/// A plain scalar fetch that ran dry mid-content (`scanStep()` only, batch
|
||||
/// scanning never sets this): resumed from here on the next `scanStep()`
|
||||
/// call instead of restarting the scalar from its first scalar.
|
||||
var suspendedPlainScalar: PlainScalarSuspended?
|
||||
|
||||
init(_ input: String = "") {
|
||||
reader = Reader(input)
|
||||
init(_ input: String = "", isFinal: Bool = true) {
|
||||
reader = Reader(input, isFinal: isFinal)
|
||||
}
|
||||
|
||||
/// `parser->flow_level`, kept on the indent engine.
|
||||
|
||||
@ -8,6 +8,8 @@ extension PureYAML.Parsing.ParseError {
|
||||
let context = error.context ?? ""
|
||||
if error.problem.hasPrefix("found a tab") {
|
||||
self = .tabIndentation(line: line)
|
||||
} else if error.problem == "control characters are not allowed" {
|
||||
self = .invalidControlCharacter(line: line, column: column)
|
||||
} else if context == "while scanning a quoted scalar", error.problem.hasSuffix("end of stream") {
|
||||
self = .unterminatedQuotedString(line: line)
|
||||
} else if context.hasPrefix("while scanning a tag") || context.hasSuffix("TAG directive") {
|
||||
|
||||
@ -3,6 +3,7 @@ public extension PureYAML.Parsing {
|
||||
enum ParseError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
case emptyDocument
|
||||
case tabIndentation(line: Int)
|
||||
case invalidControlCharacter(line: Int, column: Int)
|
||||
case unexpectedIndentation(line: Int)
|
||||
case mixedCollectionStyles(line: Int)
|
||||
case expectedMappingKey(line: Int)
|
||||
@ -22,6 +23,13 @@ public extension PureYAML.Parsing {
|
||||
case unexpectedEvent(expected: String, actual: String, line: Int, column: Int)
|
||||
case unexpectedToken(expected: String, actual: String, line: Int, column: Int)
|
||||
case nestingTooDeep(line: Int, column: Int)
|
||||
/// The document's total EXPANDED node count (after resolving every `*alias` to its
|
||||
/// target) exceeds the budget. `nestingTooDeep` bounds physical collection depth in the
|
||||
/// event stream; it does nothing against an anchor-expansion ("billion laughs") bomb,
|
||||
/// where a handful of shallow, small anchors are each aliased many times, each alias
|
||||
/// nested inside the next, so the LOGICAL tree an alias resolves to is exponential in
|
||||
/// the number of alias levels while the physical document stays tiny.
|
||||
case expansionTooLarge(line: Int, column: Int)
|
||||
case malformed(reason: String, line: Int, column: Int)
|
||||
|
||||
public var description: String {
|
||||
@ -30,6 +38,8 @@ public extension PureYAML.Parsing {
|
||||
"document is empty"
|
||||
case let .tabIndentation(line):
|
||||
"tabs are not allowed for indentation at line \(line)"
|
||||
case let .invalidControlCharacter(line, column):
|
||||
"invalid control character at line \(line), column \(column)"
|
||||
case let .unexpectedIndentation(line):
|
||||
"unexpected indentation at line \(line)"
|
||||
case let .mixedCollectionStyles(line):
|
||||
@ -68,6 +78,8 @@ public extension PureYAML.Parsing {
|
||||
"expected \(expected) at line \(line), column \(column), found \(actual)"
|
||||
case let .nestingTooDeep(line, column):
|
||||
"nesting is too deep at line \(line), column \(column)"
|
||||
case let .expansionTooLarge(line, column):
|
||||
"the document's expanded size (after resolving aliases) is too large at line \(line), column \(column)"
|
||||
case let .malformed(reason, line, column):
|
||||
"\(reason) at line \(line), column \(column)"
|
||||
}
|
||||
@ -79,6 +91,8 @@ public extension PureYAML.Parsing {
|
||||
"emptyDocument"
|
||||
case .tabIndentation:
|
||||
"tabIndentation"
|
||||
case .invalidControlCharacter:
|
||||
"invalidControlCharacter"
|
||||
case .unexpectedIndentation:
|
||||
"unexpectedIndentation"
|
||||
case .mixedCollectionStyles:
|
||||
@ -117,6 +131,8 @@ public extension PureYAML.Parsing {
|
||||
"unexpectedToken"
|
||||
case .nestingTooDeep:
|
||||
"nestingTooDeep"
|
||||
case .expansionTooLarge:
|
||||
"expansionTooLarge"
|
||||
case .malformed:
|
||||
"malformed"
|
||||
}
|
||||
@ -147,6 +163,8 @@ public extension PureYAML.Parsing {
|
||||
let .unexpectedEvent(_, _, line, _),
|
||||
let .unexpectedToken(_, _, line, _),
|
||||
let .nestingTooDeep(line, _),
|
||||
let .expansionTooLarge(line, _),
|
||||
let .invalidControlCharacter(line, _),
|
||||
let .malformed(_, line, _):
|
||||
line
|
||||
}
|
||||
@ -176,6 +194,8 @@ public extension PureYAML.Parsing {
|
||||
let .unexpectedEvent(_, _, _, column),
|
||||
let .unexpectedToken(_, _, _, column),
|
||||
let .nestingTooDeep(_, column),
|
||||
let .expansionTooLarge(_, column),
|
||||
let .invalidControlCharacter(_, column),
|
||||
let .malformed(_, _, column):
|
||||
column
|
||||
}
|
||||
|
||||
99
Sources/Parsing/Parsing.PlainScalar.swift
Normal file
99
Sources/Parsing/Parsing.PlainScalar.swift
Normal file
@ -0,0 +1,99 @@
|
||||
extension PureYAML.Parsing {
|
||||
/// How a plain (unquoted) scalar reads: as null, a boolean, an integer, a floating-point number, or else a string.
|
||||
///
|
||||
/// This is the one definition of that reading. The parser takes the value of every plain scalar from it, and the emitter asks it whether a string can be
|
||||
/// written without quotes, since a string that reads as anything else would come back changed. Two lists of the same words kept apart drift apart: the
|
||||
/// emitter once wrote the string `yes` plain while the parser read `yes` as a boolean.
|
||||
enum PlainScalar {
|
||||
/// The value `text` has as a plain scalar. `text` has no surrounding whitespace.
|
||||
static func resolve(_ text: String) -> PureYAML.Model.Value {
|
||||
switch text {
|
||||
case "", "~", "null", "Null", "NULL":
|
||||
return .null
|
||||
default:
|
||||
break
|
||||
}
|
||||
if let bool = bool(text) {
|
||||
return .bool(bool)
|
||||
}
|
||||
if let int = integer(text), !text.contains(".") {
|
||||
return .int(int)
|
||||
}
|
||||
if text.contains(".") || text.contains("e") || text.contains("E"), let double = double(text) {
|
||||
return .double(double)
|
||||
}
|
||||
return .string(text)
|
||||
}
|
||||
|
||||
/// Whether `text` written plain reads back as the same string.
|
||||
static func readsAsString(_ text: String) -> Bool {
|
||||
if case .string = resolve(text) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func bool(_ text: String) -> Bool? {
|
||||
switch text {
|
||||
case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON":
|
||||
true
|
||||
case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF":
|
||||
false
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
static func integer(_ text: String) -> Int? {
|
||||
let value = removingNumericSeparators(from: text)
|
||||
let negative = value.hasPrefix("-")
|
||||
let hasSign = negative || value.hasPrefix("+")
|
||||
let unsigned = hasSign ? String(value.dropFirst()) : value
|
||||
|
||||
guard !unsigned.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard unsigned != "0" else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let radixPrefixes: [(prefix: String, radix: Int)] = [
|
||||
("0x", 16),
|
||||
("0X", 16),
|
||||
("0b", 2),
|
||||
("0B", 2),
|
||||
("0o", 8),
|
||||
("0O", 8),
|
||||
("0", 8),
|
||||
]
|
||||
|
||||
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
|
||||
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
|
||||
// `Int(_, radix:)` accepts a sign, so `0-3` would read as minus three: after the prefix only digits may follow
|
||||
guard let first = digits.first, first != "-", first != "+", let magnitude = Int(digits, radix: radixPrefix.radix) else {
|
||||
return nil
|
||||
}
|
||||
return negative ? -magnitude : magnitude
|
||||
}
|
||||
|
||||
return Int(value)
|
||||
}
|
||||
|
||||
static func double(_ text: String) -> Double? {
|
||||
switch text {
|
||||
case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF":
|
||||
.infinity
|
||||
case "-.inf", "-.Inf", "-.INF":
|
||||
-.infinity
|
||||
case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN":
|
||||
.nan
|
||||
default:
|
||||
Double(removingNumericSeparators(from: text))
|
||||
}
|
||||
}
|
||||
|
||||
private static func removingNumericSeparators(from text: String) -> String {
|
||||
text.filter { $0 != "_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,93 +14,25 @@ extension PureYAML.Parsing.Parser {
|
||||
line: Int,
|
||||
) throws -> PureYAML.Model.Value {
|
||||
let value = trim(text)
|
||||
switch value {
|
||||
case "", "~", "null", "Null", "NULL":
|
||||
return .null
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if let bool = parseBool(value) {
|
||||
return .bool(bool)
|
||||
}
|
||||
if value.hasPrefix("\"") {
|
||||
return try .string(parseDoubleQuoted(value, line: line))
|
||||
}
|
||||
if value.hasPrefix("'") {
|
||||
return try .string(parseSingleQuoted(value, line: line))
|
||||
}
|
||||
if let int = parseInteger(value), !value.contains(".") {
|
||||
return .int(int)
|
||||
}
|
||||
if value.contains(".") || value.contains("e") || value.contains("E") {
|
||||
if let double = parseDouble(value) {
|
||||
return .double(double)
|
||||
}
|
||||
}
|
||||
return .string(value)
|
||||
return PureYAML.Parsing.PlainScalar.resolve(value)
|
||||
}
|
||||
|
||||
func parseBool(_ text: String) -> Bool? {
|
||||
switch text {
|
||||
case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON":
|
||||
true
|
||||
case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF":
|
||||
false
|
||||
default:
|
||||
nil
|
||||
}
|
||||
PureYAML.Parsing.PlainScalar.bool(text)
|
||||
}
|
||||
|
||||
func parseInteger(_ text: String) -> Int? {
|
||||
let value = removingNumericSeparators(from: text)
|
||||
let negative = value.hasPrefix("-")
|
||||
let hasSign = negative || value.hasPrefix("+")
|
||||
let unsigned = hasSign ? String(value.dropFirst()) : value
|
||||
|
||||
guard !unsigned.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard unsigned != "0" else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let radixPrefixes: [(prefix: String, radix: Int)] = [
|
||||
("0x", 16),
|
||||
("0X", 16),
|
||||
("0b", 2),
|
||||
("0B", 2),
|
||||
("0o", 8),
|
||||
("0O", 8),
|
||||
("0", 8),
|
||||
]
|
||||
|
||||
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
|
||||
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
|
||||
guard !digits.isEmpty, let magnitude = Int(digits, radix: radixPrefix.radix) else {
|
||||
return nil
|
||||
}
|
||||
return negative ? -magnitude : magnitude
|
||||
}
|
||||
|
||||
return Int(value)
|
||||
PureYAML.Parsing.PlainScalar.integer(text)
|
||||
}
|
||||
|
||||
func parseDouble(_ text: String) -> Double? {
|
||||
switch text {
|
||||
case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF":
|
||||
.infinity
|
||||
case "-.inf", "-.Inf", "-.INF":
|
||||
-.infinity
|
||||
case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN":
|
||||
.nan
|
||||
default:
|
||||
Double(removingNumericSeparators(from: text))
|
||||
}
|
||||
}
|
||||
|
||||
func removingNumericSeparators(from text: String) -> String {
|
||||
text.filter { $0 != "_" }
|
||||
PureYAML.Parsing.PlainScalar.double(text)
|
||||
}
|
||||
|
||||
func parseSingleQuoted(
|
||||
|
||||
@ -21,6 +21,9 @@ public enum PureYAML {
|
||||
/// YAML stream types.
|
||||
public enum Stream {}
|
||||
|
||||
/// Resumable, chunked YAML scanning types.
|
||||
public enum Streaming {}
|
||||
|
||||
/// Tag-preserving YAML document model types.
|
||||
public enum Tagged {}
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import PureYAML
|
||||
|
||||
public extension PureYAML.Validation.BuiltinCheck {
|
||||
public extension PureYAML.Validation.BuiltinValidation {
|
||||
/// Reports any mapping key that is not a string. JSON object names must be
|
||||
/// strings, so a YAML complex key (a sequence or mapping used as a key) has no
|
||||
/// JSON form. PureYAML core already ships ``mappingKeysAreUnique``; this check and
|
||||
/// ``numbersAreFinite`` complete the JSON-representability set.
|
||||
static var mappingKeysAreStrings: PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
|
||||
PureYAML.Validation.Check(description: "Mapping keys are strings") { context in
|
||||
static var mappingKeysAreStrings: PureYAML.Validation.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
|
||||
PureYAML.Validation.Validation(description: "Mapping keys are strings") { context in
|
||||
context.subject.pairs.compactMap { pair in
|
||||
guard pair.keyNode.stringValue == nil else {
|
||||
return nil
|
||||
}
|
||||
return PureYAML.Validation.Issue(
|
||||
return PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Mapping key '\(pair.keyNode.description)' is not a string",
|
||||
path: context.path.appending(.complexKey(pair.keyNode)),
|
||||
@ -22,13 +22,13 @@ public extension PureYAML.Validation.BuiltinCheck {
|
||||
|
||||
/// Reports any non-finite number (NaN or infinity). JSON has no literal for
|
||||
/// these, so a value tree carrying one is not representable as JSON.
|
||||
static var numbersAreFinite: PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
|
||||
PureYAML.Validation.Check(description: "Numbers are finite") { context in
|
||||
static var numbersAreFinite: PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
|
||||
PureYAML.Validation.Validation(description: "Numbers are finite") { context in
|
||||
guard case let .double(number) = context.subject, !number.isFinite else {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
PureYAML.Validation.Issue(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Number '\(number)' is not finite",
|
||||
path: context.path,
|
||||
@ -6,7 +6,7 @@ public extension PureYAML.JSON.Convert {
|
||||
/// The source text did not parse.
|
||||
case sourceInvalid(String)
|
||||
/// The value cannot be represented in the target format.
|
||||
case notRepresentable([PureYAML.Validation.Issue])
|
||||
case notRepresentable([PureYAML.Validation.ValidationError])
|
||||
/// The emitted output did not parse back to the source value.
|
||||
case roundTripMismatch(PureYAML.JSON.Format)
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ public extension PureYAML.JSON {
|
||||
/// representability first; the round-trip check is not run here (use
|
||||
/// ``convert(_:to:pretty:)`` for the fully validated path).
|
||||
public static func emit(_ value: PureYAML.Model.Value, as target: Format, pretty: Bool = true) throws -> String {
|
||||
let issues = validator(for: target).collect(value).issues
|
||||
let issues = validator(for: target).collect(value).values
|
||||
guard issues.isEmpty else {
|
||||
throw Failure.notRepresentable(issues)
|
||||
}
|
||||
@ -44,7 +44,7 @@ public extension PureYAML.JSON {
|
||||
|
||||
/// The validator for a target format. JSON additionally requires string keys
|
||||
/// and finite numbers; YAML allows complex keys but still forbids duplicates.
|
||||
public static func validator(for target: Format) -> PureYAML.Validation.Validator {
|
||||
public static func validator(for target: Format) -> PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
|
||||
switch target {
|
||||
case .json:
|
||||
PureYAML.JSON.Validator.representability
|
||||
|
||||
@ -9,22 +9,25 @@ public extension PureYAML.JSON {
|
||||
///
|
||||
/// JSON the format tolerates duplicate object names, but a tree that carries two
|
||||
/// pairs with the same key emits an ambiguous document, so the representability
|
||||
/// validator rejects duplicates for determinism. Drop ``unique`` from the rule
|
||||
/// set if you want the permissive reading.
|
||||
/// validator rejects duplicates for determinism. Drop ``PureYAML/Validation/BuiltinValidation/mappingKeysAreUnique``
|
||||
/// from the rule set if you want the permissive reading.
|
||||
enum Validator {
|
||||
/// The full JSON-representability rule set: unique keys, string keys, finite
|
||||
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`.
|
||||
public static var representability: PureYAML.Validation.Validator {
|
||||
PureYAML.Validation.Validator(checks: [
|
||||
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique),
|
||||
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreStrings),
|
||||
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.numbersAreFinite),
|
||||
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`. Built with
|
||||
/// the same tiered `Validator` shape every other layer uses, so it carries the
|
||||
/// same `.blank`/`.validating`/`.withoutValidating`/`.validationDescriptions`
|
||||
/// machinery (validation-rules.md, principle 10).
|
||||
public static var representability: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
|
||||
PureYAML.Validation.Validator(nonReferenceDefaultValidations: [
|
||||
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique),
|
||||
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings),
|
||||
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.numbersAreFinite),
|
||||
])
|
||||
}
|
||||
|
||||
/// Every reason the value cannot be emitted as strict JSON, in document order.
|
||||
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.Issue] {
|
||||
representability.collect(value).issues
|
||||
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.ValidationError] {
|
||||
representability.collect(value).values
|
||||
}
|
||||
|
||||
/// Whether the value can be emitted as strict JSON.
|
||||
|
||||
@ -2,11 +2,11 @@ public extension PureYAML.Stream {
|
||||
/// Validation issue with the document index preserved.
|
||||
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public var documentIndex: Int
|
||||
public var issue: PureYAML.Validation.Issue
|
||||
public var issue: PureYAML.Validation.ValidationError
|
||||
|
||||
public init(
|
||||
documentIndex: Int,
|
||||
issue: PureYAML.Validation.Issue,
|
||||
issue: PureYAML.Validation.ValidationError,
|
||||
) {
|
||||
self.documentIndex = documentIndex
|
||||
self.issue = issue
|
||||
|
||||
13
Sources/Streaming/Streaming.Outcome.swift
Normal file
13
Sources/Streaming/Streaming.Outcome.swift
Normal file
@ -0,0 +1,13 @@
|
||||
public extension PureYAML.Streaming {
|
||||
/// The result of one ``Scanner/next()`` call.
|
||||
enum Outcome: Equatable, Sendable {
|
||||
/// A token is ready.
|
||||
case token(Token)
|
||||
/// The buffered input was exhausted mid-scan; call ``Scanner/feed(_:)``
|
||||
/// with more input (or ``Scanner/finish()`` if there is no more) and
|
||||
/// call ``Scanner/next()`` again.
|
||||
case needMoreInput
|
||||
/// The stream has ended; no further tokens remain.
|
||||
case done
|
||||
}
|
||||
}
|
||||
102
Sources/Streaming/Streaming.Scanner.swift
Normal file
102
Sources/Streaming/Streaming.Scanner.swift
Normal file
@ -0,0 +1,102 @@
|
||||
public extension PureYAML.Streaming {
|
||||
/// A resumable, chunked YAML token scanner: the public counterpart to the
|
||||
/// internal libyaml-ported scanner's `scanStep()`, which this wraps
|
||||
/// directly with no independent logic of its own. Proven to match batch
|
||||
/// scanning byte-for-byte across the whole yaml-test-suite corpus and at
|
||||
/// several chunk sizes (`StreamingScannerDifferentialTests` in the test
|
||||
/// suite exercises the identical internal `scanStep()` this type calls).
|
||||
struct Scanner: Sendable {
|
||||
private var scanner: PureYAML.Parsing.LibYAML.Scanner
|
||||
|
||||
/// Creates a scanner with no input yet. Feed input with ``feed(_:)``
|
||||
/// and mark the end of input with ``finish()``.
|
||||
public init() {
|
||||
scanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false)
|
||||
}
|
||||
|
||||
/// Appends a chunk of YAML source. Chunks are concatenated in the
|
||||
/// order fed; a chunk need not align with any YAML grammar boundary.
|
||||
public mutating func feed(_ chunk: String) {
|
||||
scanner.reader.append(chunk)
|
||||
}
|
||||
|
||||
/// Marks that no further input will be fed. After this, ``next()``
|
||||
/// never returns ``Outcome/needMoreInput``: a fetch that would have
|
||||
/// needed more input instead surfaces as a real scanning error, since
|
||||
/// there is no more input coming that could resolve it.
|
||||
public mutating func finish() {
|
||||
scanner.reader.markFinal()
|
||||
}
|
||||
|
||||
/// Advances the scan by one token. Call in a loop, feeding more input
|
||||
/// on ``Outcome/needMoreInput`` and stopping on ``Outcome/done``.
|
||||
public mutating func next() throws -> Outcome {
|
||||
switch try scanner.scanStep() {
|
||||
case let .token(token):
|
||||
.token(Token(token))
|
||||
case .needMoreInput:
|
||||
.needMoreInput
|
||||
case .done:
|
||||
.done
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Streaming.Token {
|
||||
init(_ token: PureYAML.Parsing.LibYAML.Token) {
|
||||
self.init(
|
||||
kind: Kind(token.type),
|
||||
range: PureYAML.Model.SourceRange(
|
||||
start: PureYAML.Model.SourcePosition(line: token.startMark.line, column: token.startMark.column),
|
||||
end: PureYAML.Model.SourcePosition(line: token.endMark.line, column: token.endMark.column),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Streaming.Token.Kind {
|
||||
/// A mechanical 1:1 mapping over all 22 `TokenType` cases in one
|
||||
/// exhaustive switch: no `default`, so adding a `TokenType` case is a
|
||||
/// compile error here instead of a runtime trap. The lint's complexity
|
||||
/// metric ignores case statements precisely for closed-enum mappings
|
||||
/// like this one.
|
||||
init(_ type: PureYAML.Parsing.LibYAML.TokenType) {
|
||||
switch type {
|
||||
case .streamStart: self = .streamStart
|
||||
case .streamEnd: self = .streamEnd
|
||||
case .documentStart: self = .documentStart
|
||||
case .documentEnd: self = .documentEnd
|
||||
case .blockSequenceStart: self = .blockSequenceStart
|
||||
case .blockMappingStart: self = .blockMappingStart
|
||||
case .blockEnd: self = .blockEnd
|
||||
case .blockEntry: self = .blockEntry
|
||||
case .flowSequenceStart: self = .flowSequenceStart
|
||||
case .flowMappingStart: self = .flowMappingStart
|
||||
case .flowSequenceEnd: self = .flowSequenceEnd
|
||||
case .flowMappingEnd: self = .flowMappingEnd
|
||||
case .flowEntry: self = .flowEntry
|
||||
case .key: self = .key
|
||||
case .value: self = .value
|
||||
case .reservedDirective: self = .reservedDirective
|
||||
case let .versionDirective(major, minor): self = .versionDirective(major: major, minor: minor)
|
||||
case let .tagDirective(handle, prefix): self = .tagDirective(handle: handle, prefix: prefix)
|
||||
case let .anchor(name): self = .anchor(name: name)
|
||||
case let .alias(name): self = .alias(name: name)
|
||||
case let .tag(handle, suffix): self = .tag(handle: handle, suffix: suffix)
|
||||
case let .scalar(value, style): self = .scalar(value: value, style: PureYAML.Streaming.Token.ScalarStyle(style))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Streaming.Token.ScalarStyle {
|
||||
init(_ style: PureYAML.Parsing.ScalarStyle) {
|
||||
switch style {
|
||||
case .plain: self = .plain
|
||||
case .singleQuoted: self = .singleQuoted
|
||||
case .doubleQuoted: self = .doubleQuoted
|
||||
case .literal: self = .literal
|
||||
case .folded: self = .folded
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Sources/Streaming/Streaming.Token.Kind.swift
Normal file
29
Sources/Streaming/Streaming.Token.Kind.swift
Normal file
@ -0,0 +1,29 @@
|
||||
public extension PureYAML.Streaming.Token {
|
||||
/// A scanner token's kind, mirroring the internal libyaml-ported scanner's
|
||||
/// own token kinds (`yaml_token_type_t`) one for one, under public naming
|
||||
/// that does not leak the port's internal type names.
|
||||
enum Kind: Equatable, Sendable {
|
||||
case streamStart
|
||||
case streamEnd
|
||||
case documentStart
|
||||
case documentEnd
|
||||
case blockSequenceStart
|
||||
case blockMappingStart
|
||||
case blockEnd
|
||||
case blockEntry
|
||||
case flowSequenceStart
|
||||
case flowMappingStart
|
||||
case flowSequenceEnd
|
||||
case flowMappingEnd
|
||||
case flowEntry
|
||||
case key
|
||||
case value
|
||||
case versionDirective(major: Int, minor: Int)
|
||||
case tagDirective(handle: String, prefix: String)
|
||||
case reservedDirective
|
||||
case anchor(name: String)
|
||||
case alias(name: String)
|
||||
case tag(handle: String, suffix: String)
|
||||
case scalar(value: String, style: PureYAML.Streaming.Token.ScalarStyle)
|
||||
}
|
||||
}
|
||||
12
Sources/Streaming/Streaming.Token.ScalarStyle.swift
Normal file
12
Sources/Streaming/Streaming.Token.ScalarStyle.swift
Normal file
@ -0,0 +1,12 @@
|
||||
public extension PureYAML.Streaming.Token {
|
||||
/// The quoting or block style a scalar token was scanned with. Distinct
|
||||
/// from ``PureYAML/Emitting/ScalarStyle``, which is an output rendering
|
||||
/// policy, not the style a scanned scalar was actually written in.
|
||||
enum ScalarStyle: Equatable, Sendable {
|
||||
case plain
|
||||
case singleQuoted
|
||||
case doubleQuoted
|
||||
case literal
|
||||
case folded
|
||||
}
|
||||
}
|
||||
14
Sources/Streaming/Streaming.Token.swift
Normal file
14
Sources/Streaming/Streaming.Token.swift
Normal file
@ -0,0 +1,14 @@
|
||||
public extension PureYAML.Streaming {
|
||||
/// A scanner token: a kind plus the source span it covers. Produced by
|
||||
/// ``Scanner/next()`` one at a time, in the same order ``PureYAML/parse(_:)``
|
||||
/// would see them internally.
|
||||
struct Token: Equatable, Sendable {
|
||||
public var kind: Kind
|
||||
public var range: PureYAML.Model.SourceRange
|
||||
|
||||
public init(kind: Kind, range: PureYAML.Model.SourceRange) {
|
||||
self.kind = kind
|
||||
self.range = range
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,55 +1,85 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The caseless namespace of built-in type-directed checks for the tagged-node tree, the tagged
|
||||
/// layer's counterpart of `Validation.BuiltinCheck`. Each is a `Check<Node, Node>` on the one
|
||||
/// layer's counterpart of `Validation.BuiltinValidation`. Each is a `Validation<_, Node>` on the one
|
||||
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
|
||||
enum BuiltinCheck {
|
||||
enum BuiltinValidation {
|
||||
/// Reports each duplicate key within a single tagged mapping. The tagged-layer counterpart of
|
||||
/// `Validation.BuiltinCheck.mappingKeysAreUnique`; duplicate keys are as invalid in a
|
||||
/// `Validation.BuiltinValidation.mappingKeysAreUnique`; duplicate keys are as invalid in a
|
||||
/// tag-preserving tree as in the plain model, and `Tagged.Mapping` keeps every parsed pair.
|
||||
public static var mappingKeysAreUnique: PureYAML.Validation.Check<Mapping, Node> {
|
||||
PureYAML.Validation.Check(description: "Mapping keys are unique") { context in
|
||||
var seen = Set<String>()
|
||||
var issues: [PureYAML.Validation.Issue] = []
|
||||
for pair in context.subject.pairs where !seen.insert(pair.key).inserted {
|
||||
issues.append(PureYAML.Validation.Issue(
|
||||
public static var mappingKeysAreUnique: PureYAML.Validation.Validation<Mapping, Node> {
|
||||
PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in
|
||||
var seen = Set<PureYAML.Tagged.MappingKeyIdentity>()
|
||||
var errors: [PureYAML.Validation.ValidationError] = []
|
||||
for pair in context.subject.pairs where !seen.insert(keyIdentity(of: pair)).inserted {
|
||||
errors.append(PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '\(pair.key)'",
|
||||
path: context.path.appending(.key(pair.key)),
|
||||
))
|
||||
}
|
||||
return issues
|
||||
return errors
|
||||
}
|
||||
}
|
||||
|
||||
static func keyIdentity(of pair: PureYAML.Tagged.Pair) -> PureYAML.Tagged.MappingKeyIdentity {
|
||||
let node = pair.keyNode.resolved
|
||||
guard case let .scalar(scalar) = node else {
|
||||
// Collection keys do not arise from parsing; fall back to the
|
||||
// resolved key text so equal spellings still collide.
|
||||
return PureYAML.Tagged.MappingKeyIdentity(value: .string(pair.key), tag: node.tag?.rawValue)
|
||||
}
|
||||
return PureYAML.Tagged.MappingKeyIdentity(value: scalar.value, tag: effectiveTag(of: scalar)?.rawValue)
|
||||
}
|
||||
|
||||
static func effectiveTag(of scalar: PureYAML.Tagged.Scalar) -> PureYAML.Tagged.Tag? {
|
||||
if let tag = scalar.tag {
|
||||
return tag
|
||||
}
|
||||
switch scalar.value {
|
||||
case .null:
|
||||
return .null
|
||||
case .bool:
|
||||
return .bool
|
||||
case .int:
|
||||
return .int
|
||||
case .double:
|
||||
return .float
|
||||
case .string:
|
||||
return .string
|
||||
case .sequence, .mapping:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind, on the
|
||||
/// node itself and on each mapping key.
|
||||
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Check<Node, Node> {
|
||||
PureYAML.Validation.Check(description: "Built-in tags are supported and kind-consistent") { context in
|
||||
var issues: [PureYAML.Validation.Issue] = []
|
||||
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Validation<Node, Node> {
|
||||
PureYAML.Validation.Validation(description: "Built-in tags are supported and kind-consistent") { context in
|
||||
var errors: [PureYAML.Validation.ValidationError] = []
|
||||
if case let .mapping(mapping) = context.subject {
|
||||
issues.append(contentsOf: mapping.pairs.compactMap { pair in
|
||||
errors.append(contentsOf: mapping.pairs.compactMap { pair in
|
||||
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
|
||||
})
|
||||
}
|
||||
|
||||
guard let tag = context.subject.tag, tag.isBuiltIn else {
|
||||
return issues
|
||||
return errors
|
||||
}
|
||||
|
||||
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
errors.append(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
|
||||
path: context.path,
|
||||
),
|
||||
)
|
||||
return issues
|
||||
return errors
|
||||
}
|
||||
|
||||
if !tag.isSupportedBuiltInTag {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
errors.append(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)'",
|
||||
path: context.path,
|
||||
@ -57,7 +87,7 @@ public extension PureYAML.Tagged {
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
return errors
|
||||
} when: { context in
|
||||
if context.subject.tag?.isBuiltIn == true {
|
||||
return true
|
||||
@ -72,7 +102,7 @@ public extension PureYAML.Tagged {
|
||||
static func keyTagIssue(
|
||||
for pair: PureYAML.Tagged.Pair,
|
||||
at path: PureYAML.Validation.Path,
|
||||
) -> PureYAML.Validation.Issue? {
|
||||
) -> PureYAML.Validation.ValidationError? {
|
||||
guard let tag = pair.keyTag, tag.isBuiltIn else {
|
||||
return nil
|
||||
}
|
||||
@ -80,21 +110,21 @@ public extension PureYAML.Tagged {
|
||||
return nil
|
||||
}
|
||||
if tag == .merge {
|
||||
return PureYAML.Validation.Issue(
|
||||
return PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
|
||||
return PureYAML.Validation.Issue(
|
||||
return PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
guard tag.isSupportedBuiltInTag else {
|
||||
return PureYAML.Validation.Issue(
|
||||
return PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
|
||||
path: path,
|
||||
23
Sources/Tagged/Tagged.CollectionStyle.swift
Normal file
23
Sources/Tagged/Tagged.CollectionStyle.swift
Normal file
@ -0,0 +1,23 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The presentation style a collection had in the source document, or the
|
||||
/// style a caller wants a collection to take on output. `nil` on a node
|
||||
/// means "no per-node preference": the emitter's document-wide policy
|
||||
/// decides.
|
||||
enum CollectionStyle: String, Equatable, Sendable, CustomStringConvertible {
|
||||
case block
|
||||
case flow
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.CollectionStyle {
|
||||
init(_ style: PureYAML.Parsing.CollectionStyle) {
|
||||
self = switch style {
|
||||
case .block: .block
|
||||
case .flow: .flow
|
||||
}
|
||||
}
|
||||
}
|
||||
132
Sources/Tagged/Tagged.Dumper.Canonical.swift
Normal file
132
Sources/Tagged/Tagged.Dumper.Canonical.swift
Normal file
@ -0,0 +1,132 @@
|
||||
// MARK: - Canonical form
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// The YAML-specification canonical form over a tagged tree: an
|
||||
/// explicit `---` document start, every node's tag explicit (the
|
||||
/// carried tag when present, the value's implicit tag otherwise),
|
||||
/// double-quoted scalar content, flow collections one entry per line,
|
||||
/// and anchors and alias references preserved. Canonical normalizes
|
||||
/// scalar spellings: content renders from the resolved value, not the
|
||||
/// source rawValue.
|
||||
func canonicalDocument(_ node: PureYAML.Tagged.Node) -> String {
|
||||
"---\n" + canonicalLines(node, indent: 0).joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
func canonicalLines(
|
||||
_ node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch node {
|
||||
case let .alias(anchor, _):
|
||||
return [prefix + "*" + anchor]
|
||||
case let .scalar(scalar):
|
||||
return [prefix + canonicalProperties(node) + canonicalScalarContent(scalar)]
|
||||
case let .sequence(sequence):
|
||||
return canonicalSequenceLines(sequence, node: node, indent: indent)
|
||||
case let .mapping(mapping):
|
||||
return canonicalMappingLines(mapping, node: node, indent: indent)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSequenceLines(
|
||||
_ sequence: PureYAML.Tagged.Sequence,
|
||||
node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = canonicalProperties(node, fallback: .sequence)
|
||||
guard !sequence.values.isEmpty else {
|
||||
return [prefix + props + "[]"]
|
||||
}
|
||||
var lines = [prefix + props + "["]
|
||||
for value in sequence.values {
|
||||
var entry = canonicalLines(value, indent: indent + options.indentWidth)
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "]")
|
||||
return lines
|
||||
}
|
||||
|
||||
func canonicalMappingLines(
|
||||
_ mapping: PureYAML.Tagged.Mapping,
|
||||
node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = canonicalProperties(node, fallback: .mapping)
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + props + "{}"]
|
||||
}
|
||||
let inner = indent + options.indentWidth
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + props + "{"]
|
||||
for pair in orderedPairs(mapping) {
|
||||
var keyLines = canonicalLines(pair.keyNode, indent: inner)
|
||||
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
|
||||
lines.append(contentsOf: keyLines)
|
||||
var valueLines = canonicalLines(pair.value, indent: inner)
|
||||
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
|
||||
valueLines[valueLines.count - 1] += ","
|
||||
lines.append(contentsOf: valueLines)
|
||||
}
|
||||
lines.append(prefix + "}")
|
||||
return lines
|
||||
}
|
||||
|
||||
/// The canonical `&anchor !tag ` prefix: anchor first, then the carried
|
||||
/// tag, or the value's implicit tag made explicit when none is carried.
|
||||
func canonicalProperties(
|
||||
_ node: PureYAML.Tagged.Node,
|
||||
fallback: PureYAML.Tagged.Tag? = nil,
|
||||
) -> String {
|
||||
var parts: [String] = []
|
||||
if let anchor = node.anchor, node.kind != .alias {
|
||||
parts.append("&" + anchor)
|
||||
}
|
||||
if let tag = node.tag ?? fallback ?? implicitTag(of: node) {
|
||||
parts.append(renderTag(tag))
|
||||
}
|
||||
return parts.joined(separator: " ") + " "
|
||||
}
|
||||
|
||||
func implicitTag(of node: PureYAML.Tagged.Node) -> PureYAML.Tagged.Tag? {
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return nil
|
||||
}
|
||||
switch scalar.value {
|
||||
case .null:
|
||||
return .null
|
||||
case .bool:
|
||||
return .bool
|
||||
case .int:
|
||||
return .int
|
||||
case .double:
|
||||
return .float
|
||||
case .string:
|
||||
return .string
|
||||
case .sequence, .mapping:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical scalar content: always double-quoted, rendered from the
|
||||
/// resolved value so `NO` normalizes to `"false"` under its bool tag.
|
||||
func canonicalScalarContent(_ scalar: PureYAML.Tagged.Scalar) -> String {
|
||||
switch scalar.value {
|
||||
case .null:
|
||||
"\"null\""
|
||||
case let .bool(flag):
|
||||
"\"\(flag ? "true" : "false")\""
|
||||
case let .int(number):
|
||||
"\"\(number)\""
|
||||
case let .double(number):
|
||||
"\"\(number)\""
|
||||
case let .string(string):
|
||||
PureYAML.Emitting.Dumper(options: options).quote(string)
|
||||
case .sequence, .mapping:
|
||||
"\"\""
|
||||
}
|
||||
}
|
||||
}
|
||||
294
Sources/Tagged/Tagged.Dumper.Scalars.swift
Normal file
294
Sources/Tagged/Tagged.Dumper.Scalars.swift
Normal file
@ -0,0 +1,294 @@
|
||||
// MARK: - Scalar styles
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// Renders a scalar honoring its per-node style where the value can
|
||||
/// legally take it, falling back to double quoting otherwise. Values
|
||||
/// with a plain source style re-emit their source spelling when that
|
||||
/// spelling is syntactically safe.
|
||||
func renderScalarValue(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
if scalar.value == .null, scalar.style == .plain, isPlainNullForm(scalar.rawValue) {
|
||||
// A plain null re-emits its source spelling: `key:` with an empty
|
||||
// value, `~`, and the null word forms all reparse to null, so the
|
||||
// source form is preserved instead of normalizing to "null".
|
||||
return scalar.rawValue
|
||||
}
|
||||
guard case let .string(string) = scalar.value else {
|
||||
return renderNonString(scalar, flow: flow, renderer: renderer)
|
||||
}
|
||||
switch scalar.style {
|
||||
case .plain:
|
||||
return renderPlainString(string, tag: scalar.tag, flow: flow, renderer: renderer)
|
||||
case .singleQuoted:
|
||||
return renderSingleQuoted(string, renderer: renderer)
|
||||
case .doubleQuoted:
|
||||
return renderer.quote(string)
|
||||
case .literal, .folded:
|
||||
// Multi-line handling happens in renderLiteralBlock; reaching
|
||||
// here means the value cannot take a block form (single line, or
|
||||
// flow context, or block-unsafe content), so quote it.
|
||||
return renderer.quote(string)
|
||||
case nil:
|
||||
return flow ? renderer.renderFlowScalar(scalar.value) : renderer.renderScalar(scalar.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain non-string re-emits its source spelling when that spelling
|
||||
/// is syntactically safe: `NO` resolved to a bool and `1_000` to an int
|
||||
/// on parse, so the same resolver maps the same text back to the same
|
||||
/// value, and normalizing the spelling (`NO` to `false`) would lose the
|
||||
/// source form for nothing.
|
||||
func renderNonString(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
if scalar.style == .plain, isSyntacticallyPlainSafe(scalar.rawValue, flow: flow, renderer: renderer) {
|
||||
return scalar.rawValue
|
||||
}
|
||||
return renderer.renderScalar(scalar.value)
|
||||
}
|
||||
|
||||
/// A plain string must stay a string on reparse: without an explicit
|
||||
/// tag, content that resolves as a number, bool, or null must be
|
||||
/// quoted; with one, the tag governs resolution (`!!str 42` reparses as
|
||||
/// the string "42") and only syntax matters.
|
||||
func renderPlainString(
|
||||
_ string: String,
|
||||
tag: PureYAML.Tagged.Tag?,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
let stringSafe = tag != nil
|
||||
|| (!renderer.isReservedPlainScalar(string) && !renderer.isNumberLikePlainScalar(string))
|
||||
return stringSafe && isSyntacticallyPlainSafe(string, flow: flow, renderer: renderer)
|
||||
? string
|
||||
: renderer.quote(string)
|
||||
}
|
||||
|
||||
/// Single quoting escapes only the quote itself (by doubling); content
|
||||
/// with line breaks has no exact single-quoted form (quoted folding
|
||||
/// would rewrite the breaks), so it degrades to double quoting.
|
||||
func renderSingleQuoted(
|
||||
_ string: String,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
guard !string.contains("\n"), !string.contains("\r") else {
|
||||
return renderer.quote(string)
|
||||
}
|
||||
var escaped = ""
|
||||
for character in string {
|
||||
escaped.append(character)
|
||||
if character == "'" {
|
||||
escaped.append("'")
|
||||
}
|
||||
}
|
||||
return "'" + escaped + "'"
|
||||
}
|
||||
|
||||
/// The plain source spellings that reparse to null: an empty value, `~`,
|
||||
/// and the three null word forms.
|
||||
func isPlainNullForm(_ rawValue: String) -> Bool {
|
||||
switch rawValue {
|
||||
case "", "~", "null", "Null", "NULL":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Syntactic plain safety, following the spec's actual rules rather than
|
||||
/// the emitter policy path's coarser ones: a leading `-`, `?`, or `:` is
|
||||
/// safe unless followed by a space (or alone), and `#` only opens a
|
||||
/// comment after whitespace, so a URL fragment stays plain. Resolution
|
||||
/// safety (a string that would re-resolve as a number or bool) is the
|
||||
/// caller's concern: it applies to untagged strings but is exactly wrong
|
||||
/// for non-strings re-emitting their source spelling.
|
||||
func isSyntacticallyPlainSafe(
|
||||
_ string: String,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> Bool {
|
||||
guard !string.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard string.first?.isWhitespace == false, string.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
guard !string.contains("\n"), !string.contains("\r"), !string.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
// A trailing `:` reparses as a mapping key with an empty value, so a
|
||||
// scalar that ends in one is not plain-safe even when no interior `:`
|
||||
// is followed by a space.
|
||||
guard string.last != ":" else {
|
||||
return false
|
||||
}
|
||||
guard let first = string.first else {
|
||||
return false
|
||||
}
|
||||
let second = string.dropFirst().first
|
||||
switch first {
|
||||
case "-", "?", ":":
|
||||
if second == nil || second == " " {
|
||||
return false
|
||||
}
|
||||
case ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", "\"", "%", "@", "`":
|
||||
return false
|
||||
default:
|
||||
break
|
||||
}
|
||||
guard !renderer.containsColonSpace(string), !containsCommentAfterSpace(string) else {
|
||||
return false
|
||||
}
|
||||
return !flow || !renderer.containsFlowDelimiter(string)
|
||||
}
|
||||
|
||||
/// `#` starts a comment only when preceded by whitespace; a `#` embedded
|
||||
/// in a word (a URL fragment, an IP:port#tag form) is plain content.
|
||||
func containsCommentAfterSpace(_ string: String) -> Bool {
|
||||
var previousWasWhitespace = false
|
||||
for character in string {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block scalars
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// Block-scalar rendering for `literal` and `folded` styles. A folded
|
||||
/// scalar re-emits as `>` when the value's shape allows an exact
|
||||
/// inversion of the folding rules, otherwise it degrades to a literal
|
||||
/// block (`|`), which still reproduces the value exactly; only the fold
|
||||
/// marks differ. Returns `nil` when the value cannot take any block
|
||||
/// form, in which case the scalar falls back to double quoting.
|
||||
func renderLiteralBlock(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
props: String,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
guard
|
||||
case let .string(string) = scalar.value,
|
||||
scalar.style == .literal || scalar.style == .folded
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if scalar.style == .folded, let folded = renderFoldedBlock(string, props: props, contentIndent: contentIndent) {
|
||||
return folded
|
||||
}
|
||||
guard canRenderLiteralBlockContent(string) else {
|
||||
return nil
|
||||
}
|
||||
let trailingNewlines = countTrailingNewlines(string)
|
||||
let chomping: String
|
||||
let contentSource: String
|
||||
switch trailingNewlines {
|
||||
case 0:
|
||||
chomping = "-"
|
||||
contentSource = string
|
||||
case 1:
|
||||
chomping = ""
|
||||
contentSource = String(string.dropLast())
|
||||
default:
|
||||
// Two or more trailing newlines need keep chomping: clip (`|`)
|
||||
// would collapse them to one.
|
||||
chomping = "+"
|
||||
contentSource = String(string.dropLast())
|
||||
}
|
||||
// A whitespace-led first content line defeats indentation
|
||||
// auto-detection, so the header carries an explicit indentation
|
||||
// indicator and the leading whitespace becomes content.
|
||||
let needsIndicator = firstContentLine(of: contentSource)?.first?.isWhitespace == true
|
||||
if needsIndicator, !(1 ... 9).contains(options.indentWidth) {
|
||||
return nil
|
||||
}
|
||||
let indicator = "|" + (needsIndicator ? String(options.indentWidth) : "") + chomping
|
||||
let header = props.isEmpty ? indicator : props + " " + indicator
|
||||
let prefix = String(repeating: " ", count: contentIndent)
|
||||
let content = contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.map { $0.isEmpty ? "" : prefix + String($0) }
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
/// Exact inversion of folding for the shapes that allow one: every
|
||||
/// logical line separated by single newlines, no empty logical lines, no
|
||||
/// leading or trailing whitespace per line. Emitting each logical line
|
||||
/// as its own paragraph (blank line between) folds back to exactly one
|
||||
/// newline per boundary.
|
||||
func renderFoldedBlock(
|
||||
_ string: String,
|
||||
props: String,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
let trailingNewlines = countTrailingNewlines(string)
|
||||
guard trailingNewlines <= 1 else {
|
||||
return nil
|
||||
}
|
||||
let contentSource = trailingNewlines == 1 ? String(string.dropLast()) : string
|
||||
guard !contentSource.isEmpty, !contentSource.contains("\r"), !contentSource.contains("\t") else {
|
||||
return nil
|
||||
}
|
||||
let lines = contentSource.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let safe = lines.allSatisfy { line in
|
||||
!line.isEmpty && line.first?.isWhitespace == false && line.last?.isWhitespace == false
|
||||
}
|
||||
guard safe else {
|
||||
return nil
|
||||
}
|
||||
let indicator = trailingNewlines == 1 ? ">" : ">-"
|
||||
let header = props.isEmpty ? indicator : props + " " + indicator
|
||||
let prefix = String(repeating: " ", count: contentIndent)
|
||||
var content: [String] = []
|
||||
for (index, line) in lines.enumerated() {
|
||||
if index > 0 {
|
||||
content.append("")
|
||||
}
|
||||
content.append(prefix + String(line))
|
||||
}
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
func countTrailingNewlines(_ string: String) -> Int {
|
||||
var count = 0
|
||||
for character in string.reversed() {
|
||||
guard character == "\n" else {
|
||||
break
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/// A literal block can hold any string with at least one non-empty line
|
||||
/// and no carriage returns (single-line content emits as `|-`, a
|
||||
/// whitespace-led first line gets an explicit indentation indicator).
|
||||
/// Trailing spaces, tab-led or whitespace-only interior lines, leading
|
||||
/// blank lines, and more deeply indented interior lines are all
|
||||
/// preserved content: the block's own indent is stripped and the rest
|
||||
/// survives verbatim.
|
||||
func canRenderLiteralBlockContent(_ value: String) -> Bool {
|
||||
guard !value.contains("\r") else {
|
||||
return false
|
||||
}
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return firstContentLine(of: contentSource) != nil
|
||||
}
|
||||
|
||||
func firstContentLine(of contentSource: String) -> Substring? {
|
||||
contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.first { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
395
Sources/Tagged/Tagged.Dumper.swift
Normal file
395
Sources/Tagged/Tagged.Dumper.swift
Normal file
@ -0,0 +1,395 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// Serializes a tag-preserving node tree back to YAML, re-emitting
|
||||
/// anchors as `&name`, aliases as `*name` references (not expanded
|
||||
/// copies), explicit tags, and per-node presentation styles.
|
||||
///
|
||||
/// Per-node styles win over the document-wide policy in `options`; a
|
||||
/// node with a `nil` style falls back to that policy. A requested style
|
||||
/// the value cannot legally take (a plain-unsafe string asked to be
|
||||
/// plain, a single-line string asked to be literal) falls back to double
|
||||
/// quoting, which can represent every string.
|
||||
struct Dumper: Sendable {
|
||||
public var options: PureYAML.Emitting.Options
|
||||
|
||||
public init(options: PureYAML.Emitting.Options = .default) {
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public func dump(_ node: PureYAML.Tagged.Node) -> String {
|
||||
guard !PureYAML.Emitting.exceedsNestingLimit(node) else {
|
||||
return PureYAML.Emitting.nestingLimitDocument
|
||||
}
|
||||
if options.canonical {
|
||||
var body = canonicalDocument(node)
|
||||
if options.explicitDocumentEnd {
|
||||
body += "...\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
let scalarRenderer = PureYAML.Emitting.Dumper(options: options)
|
||||
var body = if isFlowRendered(node) {
|
||||
renderFlow(node, renderer: scalarRenderer) + "\n"
|
||||
} else {
|
||||
renderBlock(node, indent: 0, renderer: scalarRenderer)
|
||||
.joined(separator: "\n") + "\n"
|
||||
}
|
||||
if options.explicitDocumentStart {
|
||||
body = "---\n" + body
|
||||
}
|
||||
if options.explicitDocumentEnd {
|
||||
body += "...\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
typealias Node = PureYAML.Tagged.Node
|
||||
|
||||
/// The `&anchor !tag` prefix for a node, empty when the node has neither.
|
||||
/// Anchor precedes tag, matching libyaml's canonical property order.
|
||||
func properties(_ node: Node) -> String {
|
||||
var parts: [String] = []
|
||||
if let anchor = node.anchor, node.kind != .alias {
|
||||
parts.append("&" + anchor)
|
||||
}
|
||||
if let tag = node.tag {
|
||||
parts.append(renderTag(tag))
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Built-in tags render in `!!suffix` shorthand; everything else renders
|
||||
/// verbatim so unknown tag URIs and local tags survive unchanged.
|
||||
func renderTag(_ tag: PureYAML.Tagged.Tag) -> String {
|
||||
if tag.isBuiltIn {
|
||||
return "!!" + tag.rawValue.dropFirst("tag:yaml.org,2002:".count)
|
||||
}
|
||||
return "!<" + tag.rawValue + ">"
|
||||
}
|
||||
|
||||
func isFlowRendered(_ node: Node) -> Bool {
|
||||
switch node {
|
||||
case let .mapping(mapping):
|
||||
(mapping.style ?? collectionPolicy) == .flow
|
||||
case let .sequence(sequence):
|
||||
(sequence.style ?? collectionPolicy) == .flow
|
||||
case .scalar, .alias:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var collectionPolicy: PureYAML.Tagged.CollectionStyle {
|
||||
switch options.collectionStyle {
|
||||
case .block: .block
|
||||
case .flow: .flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
func renderBlock(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch node {
|
||||
case .alias:
|
||||
return [prefix + renderInline(node, renderer: renderer)]
|
||||
case .scalar:
|
||||
return renderBlockScalar(node, indent: indent, renderer: renderer)
|
||||
case let .sequence(sequence):
|
||||
return renderBlockSequence(sequence, node: node, indent: indent, renderer: renderer)
|
||||
case let .mapping(mapping):
|
||||
return renderBlockMapping(mapping, node: node, indent: indent, renderer: renderer)
|
||||
}
|
||||
}
|
||||
|
||||
func renderBlockScalar(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return [prefix + renderInline(node, renderer: renderer)]
|
||||
}
|
||||
if let block = renderLiteralBlock(
|
||||
scalar,
|
||||
props: props,
|
||||
contentIndent: indent + options.indentWidth,
|
||||
) {
|
||||
return [prefix + block.header] + block.content
|
||||
}
|
||||
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
|
||||
return [prefix + (props.isEmpty ? rendered : props + " " + rendered)]
|
||||
}
|
||||
|
||||
func renderBlockSequence(
|
||||
_ sequence: PureYAML.Tagged.Sequence,
|
||||
node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
var lines: [String] = []
|
||||
if !props.isEmpty {
|
||||
lines.append(prefix + props)
|
||||
}
|
||||
guard !sequence.values.isEmpty else {
|
||||
return props.isEmpty ? [prefix + "[]"] : [prefix + props + " []"]
|
||||
}
|
||||
for value in sequence.values {
|
||||
lines.append(contentsOf: renderBlockSequenceItem(value, indent: indent, renderer: renderer))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockSequenceItem(
|
||||
_ value: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let block = literalBlock(for: value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + "- " + block.header] + block.content
|
||||
}
|
||||
if let inline = renderInlineIfSimple(value, renderer: renderer) {
|
||||
return [inline.isEmpty ? prefix + "-" : prefix + "- " + inline]
|
||||
}
|
||||
let childProps = properties(value)
|
||||
var lines: [String] = []
|
||||
if childProps.isEmpty {
|
||||
lines.append(prefix + "-")
|
||||
} else {
|
||||
lines.append(prefix + "- " + childProps)
|
||||
}
|
||||
lines.append(contentsOf: renderBlockBody(value, indent: indent + options.indentWidth, renderer: renderer))
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockMapping(
|
||||
_ mapping: PureYAML.Tagged.Mapping,
|
||||
node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
var lines: [String] = []
|
||||
if !props.isEmpty {
|
||||
lines.append(prefix + props)
|
||||
}
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return props.isEmpty ? [prefix + "{}"] : [prefix + props + " {}"]
|
||||
}
|
||||
for pair in orderedPairs(mapping) {
|
||||
lines.append(contentsOf: renderBlockPair(pair, indent: indent, renderer: renderer))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockPair(
|
||||
_ pair: PureYAML.Tagged.Pair,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let key = renderKey(pair.keyNode, flow: false, renderer: renderer)
|
||||
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + key + ": " + block.header] + block.content
|
||||
}
|
||||
if let inline = renderInlineIfSimple(pair.value, renderer: renderer) {
|
||||
return [inline.isEmpty ? prefix + key + ":" : prefix + key + ": " + inline]
|
||||
}
|
||||
let childProps = properties(pair.value)
|
||||
var lines: [String] = []
|
||||
if childProps.isEmpty {
|
||||
lines.append(prefix + key + ":")
|
||||
} else {
|
||||
lines.append(prefix + key + ": " + childProps)
|
||||
}
|
||||
lines.append(contentsOf: renderBlockBody(pair.value, indent: indent + options.indentWidth, renderer: renderer))
|
||||
return lines
|
||||
}
|
||||
|
||||
/// The node's content lines with its properties already emitted by the
|
||||
/// caller (on the `-` or `key:` line), so collections render bare here.
|
||||
func renderBlockBody(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
switch node {
|
||||
case let .sequence(sequence):
|
||||
renderBlockSequence(
|
||||
sequence,
|
||||
node: .sequence(.init(values: sequence.values)),
|
||||
indent: indent,
|
||||
renderer: renderer,
|
||||
)
|
||||
case let .mapping(mapping):
|
||||
renderBlockMapping(
|
||||
mapping,
|
||||
node: .mapping(.init(pairs: mapping.pairs)),
|
||||
indent: indent,
|
||||
renderer: renderer,
|
||||
)
|
||||
case .scalar, .alias:
|
||||
renderBlock(node, indent: indent, renderer: renderer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mapping key rendered from its node: an alias key emits `*name `
|
||||
/// with a trailing space (`:` is a legal anchor character, so the colon
|
||||
/// must not touch the alias name), a scalar key emits its properties
|
||||
/// and its style-honoring single-line form (block styles on a key fall
|
||||
/// back to quoting inside `renderScalarValue`).
|
||||
func renderKey(
|
||||
_ keyNode: Node,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
switch keyNode {
|
||||
case let .alias(anchor, _):
|
||||
return "*" + anchor + " "
|
||||
case let .scalar(scalar):
|
||||
let props = properties(keyNode)
|
||||
// A style-less key keeps the traditional key convention (plain
|
||||
// when safe, quoted otherwise) instead of inheriting the
|
||||
// document's scalar policy, which would quote every key under
|
||||
// the default options. In a flow mapping the safety check must
|
||||
// also reject flow delimiters (`,{}[]`), or a key like `a,b`
|
||||
// would emit unquoted and reparse as two entries.
|
||||
let rendered: String
|
||||
if scalar.style == nil {
|
||||
let plainSafe = flow
|
||||
? renderer.canRenderFlowPlainString(scalar.rawValue)
|
||||
: renderer.canRenderPlainString(scalar.rawValue)
|
||||
rendered = plainSafe ? scalar.rawValue : renderer.quote(scalar.rawValue)
|
||||
} else {
|
||||
rendered = renderScalarValue(scalar, flow: flow, renderer: renderer)
|
||||
}
|
||||
return props.isEmpty ? rendered : props + " " + rendered
|
||||
case .mapping, .sequence:
|
||||
// Unreachable from parsing (keys compose as scalars or aliases);
|
||||
// a hand-built collection key renders as its resolved string.
|
||||
return renderer.escapeKey("")
|
||||
}
|
||||
}
|
||||
|
||||
/// The block-scalar form for a scalar node whose style asks for one,
|
||||
/// with the node's properties folded into the header; `nil` for
|
||||
/// non-scalars and for scalars that render inline.
|
||||
func literalBlock(
|
||||
for node: Node,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return nil
|
||||
}
|
||||
return renderLiteralBlock(scalar, props: properties(node), contentIndent: contentIndent)
|
||||
}
|
||||
|
||||
/// One-line rendering when the node fits on the parent's line: aliases,
|
||||
/// scalars without a literal block, flow-styled collections, and empty
|
||||
/// collections. Returns `nil` when the node needs its own block lines.
|
||||
func renderInlineIfSimple(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String? {
|
||||
let props = properties(node)
|
||||
switch node {
|
||||
case .alias:
|
||||
return renderInline(node, renderer: renderer)
|
||||
case let .scalar(scalar):
|
||||
if renderLiteralBlock(scalar, props: props, contentIndent: 0) != nil {
|
||||
return nil
|
||||
}
|
||||
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
|
||||
return props.isEmpty ? rendered : props + " " + rendered
|
||||
case let .sequence(sequence):
|
||||
if isFlowRendered(node) || sequence.values.isEmpty {
|
||||
return renderInline(node, renderer: renderer)
|
||||
}
|
||||
return nil
|
||||
case let .mapping(mapping):
|
||||
if isFlowRendered(node) || mapping.pairs.isEmpty {
|
||||
return renderInline(node, renderer: renderer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow and inline rendering
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
func renderInline(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
let props = properties(node)
|
||||
let body: String
|
||||
switch node {
|
||||
case let .alias(anchor, _):
|
||||
return "*" + anchor
|
||||
case let .scalar(scalar):
|
||||
body = renderScalarValue(scalar, flow: true, renderer: renderer)
|
||||
case let .sequence(sequence):
|
||||
body = "[" + sequence.values
|
||||
.map { renderFlow($0, renderer: renderer) }
|
||||
.joined(separator: ", ") + "]"
|
||||
case let .mapping(mapping):
|
||||
body = "{" + orderedPairs(mapping)
|
||||
.map { pair in
|
||||
renderKey(pair.keyNode, flow: true, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
|
||||
}
|
||||
.joined(separator: ", ") + "}"
|
||||
}
|
||||
return props.isEmpty ? body : props + " " + body
|
||||
}
|
||||
|
||||
func renderFlow(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
renderInline(node, renderer: renderer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key ordering
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// `mapping.pairs` in insertion order, or sorted by their plain string key
|
||||
/// when `options.sortKeys` is set and every key is a plain, untagged,
|
||||
/// unanchored string scalar. Any anchored, tagged, styled, alias, or
|
||||
/// non-string key leaves the mapping in insertion order: there is no
|
||||
/// well-defined lexicographic order across those, and reordering an
|
||||
/// anchored key would move an anchor definition relative to its scalar.
|
||||
/// Mirrors the model dumper's `orderedPairs` rule.
|
||||
func orderedPairs(_ mapping: PureYAML.Tagged.Mapping) -> [PureYAML.Tagged.Pair] {
|
||||
guard options.sortKeys, mapping.pairs.allSatisfy(isPlainStringKey) else {
|
||||
return mapping.pairs
|
||||
}
|
||||
return mapping.pairs.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
func isPlainStringKey(_ pair: PureYAML.Tagged.Pair) -> Bool {
|
||||
guard case let .scalar(scalar) = pair.keyNode else {
|
||||
return false
|
||||
}
|
||||
guard case .string = scalar.value else {
|
||||
return false
|
||||
}
|
||||
return scalar.anchor == nil
|
||||
&& scalar.tag == nil
|
||||
&& (scalar.style == .plain || scalar.style == nil)
|
||||
}
|
||||
}
|
||||
@ -101,9 +101,9 @@ extension PureYAML.Tagged.EventComposer {
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
return value
|
||||
case let .mappingStart(anchor, tag, _, _):
|
||||
let value = try composeMapping(tag: tag)
|
||||
return .alias(anchor: anchor, node: value)
|
||||
case let .mappingStart(anchor, tag, style, _):
|
||||
let value = try composeMapping(tag: tag, anchor: anchor, style: style)
|
||||
store(value, anchor: anchor)
|
||||
return value
|
||||
case let .scalar(value, anchor, tag, style, mark, _):
|
||||
@ -117,12 +117,14 @@ extension PureYAML.Tagged.EventComposer {
|
||||
mark: mark,
|
||||
),
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
)
|
||||
let node = PureYAML.Tagged.Node.scalar(scalar)
|
||||
store(node, anchor: anchor)
|
||||
return node
|
||||
case let .sequenceStart(anchor, tag, _, _):
|
||||
let value = try composeSequence(tag: tag)
|
||||
case let .sequenceStart(anchor, tag, style, _):
|
||||
let value = try composeSequence(tag: tag, anchor: anchor, style: style)
|
||||
store(value, anchor: anchor)
|
||||
return value
|
||||
default:
|
||||
@ -130,7 +132,11 @@ extension PureYAML.Tagged.EventComposer {
|
||||
}
|
||||
}
|
||||
|
||||
mutating func composeSequence(tag: String?) throws -> PureYAML.Tagged.Node {
|
||||
mutating func composeSequence(
|
||||
tag: String?,
|
||||
anchor: String?,
|
||||
style: PureYAML.Parsing.CollectionStyle,
|
||||
) throws -> PureYAML.Tagged.Node {
|
||||
_ = try expect("sequence start") { event in
|
||||
if case .sequenceStart = event {
|
||||
return true
|
||||
@ -142,10 +148,19 @@ extension PureYAML.Tagged.EventComposer {
|
||||
try values.append(composeNode())
|
||||
}
|
||||
_ = try expect("sequence end") { $0.isSequenceEnd }
|
||||
return .sequence(.init(values: values, tag: .normalized(tag)))
|
||||
return .sequence(.init(
|
||||
values: values,
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
))
|
||||
}
|
||||
|
||||
mutating func composeMapping(tag: String?) throws -> PureYAML.Tagged.Node {
|
||||
mutating func composeMapping(
|
||||
tag: String?,
|
||||
anchor: String?,
|
||||
style: PureYAML.Parsing.CollectionStyle,
|
||||
) throws -> PureYAML.Tagged.Node {
|
||||
_ = try expect("mapping start") { event in
|
||||
if case .mappingStart = event {
|
||||
return true
|
||||
@ -154,18 +169,40 @@ extension PureYAML.Tagged.EventComposer {
|
||||
}
|
||||
var pairs: [PureYAML.Tagged.Pair] = []
|
||||
while current?.isMappingEnd != true {
|
||||
let key = try composeMappingKey()
|
||||
let keyNode = try composeMappingKey()
|
||||
let value = try composeNode()
|
||||
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
|
||||
pairs.append(.init(keyNode: keyNode, value: value))
|
||||
}
|
||||
_ = try expect("mapping end") { $0.isMappingEnd }
|
||||
return .mapping(.init(pairs: pairs, tag: .normalized(tag)))
|
||||
return .mapping(.init(
|
||||
pairs: pairs,
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
))
|
||||
}
|
||||
|
||||
mutating func composeMappingKey() throws -> ComposedMappingKey {
|
||||
mutating func composeMappingKey() throws -> PureYAML.Tagged.Node {
|
||||
guard let event = current else {
|
||||
throw unexpectedEvent(expected: "mapping key")
|
||||
}
|
||||
if case let .alias(anchor, mark) = event {
|
||||
index += 1
|
||||
guard let resolved = anchors[anchor] else {
|
||||
throw PureYAML.Parsing.ParseError.undefinedAlias(
|
||||
anchor: anchor,
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
// A defined anchor that resolves to a collection cannot serve as a
|
||||
// mapping key here (this layer keys mappings on scalars), so it is
|
||||
// an expected-scalar-key error, not an undefined-alias error.
|
||||
guard case .scalar = resolved.resolved else {
|
||||
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
|
||||
}
|
||||
return .alias(anchor: anchor, node: resolved)
|
||||
}
|
||||
guard case let .scalar(value, anchor, tag, style, mark, _) = event else {
|
||||
let mark = event.mark
|
||||
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
|
||||
@ -175,18 +212,15 @@ extension PureYAML.Tagged.EventComposer {
|
||||
rawValue: value,
|
||||
value: scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark),
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
)
|
||||
store(.scalar(scalar), anchor: anchor)
|
||||
return ComposedMappingKey(value: value, tag: .normalized(tag))
|
||||
return .scalar(scalar)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.EventComposer {
|
||||
struct ComposedMappingKey {
|
||||
var value: String
|
||||
var tag: PureYAML.Tagged.Tag?
|
||||
}
|
||||
|
||||
var current: PureYAML.Parsing.Event? {
|
||||
guard events.indices.contains(index) else {
|
||||
return nil
|
||||
|
||||
@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
|
||||
struct Mapping: Equatable, Sendable {
|
||||
public var pairs: [Pair]
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this mapping in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The mapping's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: CollectionStyle?
|
||||
|
||||
public init(
|
||||
pairs: [Pair],
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: CollectionStyle? = nil,
|
||||
) {
|
||||
self.pairs = pairs
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
|
||||
public subscript(_ key: String) -> Node? {
|
||||
|
||||
12
Sources/Tagged/Tagged.MappingKeyIdentity.swift
Normal file
12
Sources/Tagged/Tagged.MappingKeyIdentity.swift
Normal file
@ -0,0 +1,12 @@
|
||||
extension PureYAML.Tagged {
|
||||
/// Key identity for duplicate detection: the resolved scalar value plus the
|
||||
/// key's effective tag (its carried tag, or the implicit tag of the resolved
|
||||
/// value). Two keys are the same key iff both match. Using the resolved value
|
||||
/// rather than the source spelling makes `0x1` and `1` (both `int(1)`)
|
||||
/// collide while `1` (`int`) and `"1"` (`str`) do not, and keeps
|
||||
/// custom-tagged keys (`!foo 1` vs `!bar 1`) distinct.
|
||||
struct MappingKeyIdentity: Hashable {
|
||||
var value: PureYAML.Model.Value
|
||||
var tag: String?
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
public extension PureYAML.Tagged.Node {
|
||||
/// Converts a tagged node into a model value while explicitly erasing tags.
|
||||
/// An alias converts to its resolved node's value, matching `parse(_:)`'s
|
||||
/// alias-expanding semantics.
|
||||
var modelValueErasingTags: PureYAML.Model.Value {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
@ -10,6 +12,8 @@ public extension PureYAML.Tagged.Node {
|
||||
scalar.value
|
||||
case let .sequence(sequence):
|
||||
.sequence(sequence.values.map(\.modelValueErasingTags))
|
||||
case let .alias(_, node):
|
||||
node.modelValueErasingTags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
Sources/Tagged/Tagged.Node.Traversable.swift
Normal file
26
Sources/Tagged/Tagged.Node.Traversable.swift
Normal file
@ -0,0 +1,26 @@
|
||||
extension PureYAML.Tagged.Node: PureYAML.Validation.Traversable {
|
||||
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this). Unlike the model
|
||||
/// layer, key nodes are not offered separately: a tagged mapping's keys are plain `String`, not a
|
||||
/// `Validatable` key type of their own.
|
||||
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
|
||||
guard case let .mapping(mapping) = self else {
|
||||
return []
|
||||
}
|
||||
return [(nil, mapping)]
|
||||
}
|
||||
|
||||
/// A mapping's children are its pairs' values, keyed by their string key; a sequence's children are
|
||||
/// its values, keyed by index. An alias's aliased subtree was already walked at its anchor's
|
||||
/// definition site, so recursing here would report every error in that subtree once per reference;
|
||||
/// a scalar has no children either.
|
||||
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Tagged.Node)] {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
mapping.pairs.map { pair in (component: .key(pair.key), value: pair.value) }
|
||||
case let .sequence(sequence):
|
||||
sequence.values.indices.map { index in (component: .index(index), value: sequence.values[index]) }
|
||||
case .scalar, .alias:
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// YAML node tree that preserves explicit source tags.
|
||||
enum Node: Equatable, Sendable {
|
||||
/// YAML node tree that preserves explicit source tags, anchors, aliases,
|
||||
/// and per-node presentation styles.
|
||||
///
|
||||
/// An `alias` case carries both the `*name` anchor it referenced and the
|
||||
/// node it resolved to, so value-oriented consumers can read through the
|
||||
/// alias without a separate resolution step while emitters can re-emit
|
||||
/// the reference instead of expanding it. Resolution cannot cycle: the
|
||||
/// composer registers an anchor only after its node is fully composed, so
|
||||
/// a self-referential alias is an `undefinedAlias` parse error.
|
||||
indirect enum Node: Equatable, Sendable {
|
||||
case scalar(Scalar)
|
||||
case sequence(Sequence)
|
||||
case mapping(Mapping)
|
||||
case alias(anchor: String, node: Node)
|
||||
|
||||
public var tag: Tag? {
|
||||
switch self {
|
||||
@ -13,6 +22,8 @@ public extension PureYAML.Tagged {
|
||||
scalar.tag
|
||||
case let .sequence(sequence):
|
||||
sequence.tag
|
||||
case .alias:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +35,33 @@ public extension PureYAML.Tagged {
|
||||
.scalar
|
||||
case .sequence:
|
||||
.sequence
|
||||
case .alias:
|
||||
.alias
|
||||
}
|
||||
}
|
||||
|
||||
/// The `&name` anchor attached to this node, `nil` when unanchored.
|
||||
/// An alias reports the anchor it references.
|
||||
public var anchor: String? {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
mapping.anchor
|
||||
case let .scalar(scalar):
|
||||
scalar.anchor
|
||||
case let .sequence(sequence):
|
||||
sequence.anchor
|
||||
case let .alias(anchor, _):
|
||||
anchor
|
||||
}
|
||||
}
|
||||
|
||||
/// The node an alias resolved to; any other node returns itself.
|
||||
public var resolved: Node {
|
||||
switch self {
|
||||
case .mapping, .scalar, .sequence:
|
||||
self
|
||||
case let .alias(_, node):
|
||||
node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ public extension PureYAML.Tagged {
|
||||
case scalar
|
||||
case sequence
|
||||
case mapping
|
||||
case alias
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
|
||||
@ -1,17 +1,44 @@
|
||||
public extension PureYAML.Tagged {
|
||||
struct Pair: Equatable, Sendable {
|
||||
public var key: String
|
||||
public var keyTag: Tag?
|
||||
/// The key as a full node, carrying its tag, anchor, presentation
|
||||
/// style, and alias-ness, so anchored, styled, and alias keys
|
||||
/// round-trip instead of collapsing to a bare string (issues #12
|
||||
/// and #13).
|
||||
public var keyNode: Node
|
||||
public var value: Node
|
||||
|
||||
public init(
|
||||
keyNode: Node,
|
||||
value: Node,
|
||||
) {
|
||||
self.keyNode = keyNode
|
||||
self.value = value
|
||||
}
|
||||
|
||||
/// Convenience for the overwhelmingly common plain string key.
|
||||
public init(
|
||||
key: String,
|
||||
keyTag: Tag? = nil,
|
||||
value: Node,
|
||||
) {
|
||||
self.key = key
|
||||
self.keyTag = keyTag
|
||||
self.value = value
|
||||
self.init(
|
||||
keyNode: .scalar(.init(rawValue: key, value: .string(key), tag: keyTag)),
|
||||
value: value,
|
||||
)
|
||||
}
|
||||
|
||||
/// The key's resolved raw text: an alias key reads through to its
|
||||
/// anchored scalar, matching `parse(_:)`'s alias-key semantics.
|
||||
public var key: String {
|
||||
guard case let .scalar(scalar) = keyNode.resolved else {
|
||||
return ""
|
||||
}
|
||||
return scalar.rawValue
|
||||
}
|
||||
|
||||
/// The key's tag, read through an alias to its resolution.
|
||||
public var keyTag: Tag? {
|
||||
keyNode.resolved.tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
84
Sources/Tagged/Tagged.Scalar.Builtins.swift
Normal file
84
Sources/Tagged/Tagged.Scalar.Builtins.swift
Normal file
@ -0,0 +1,84 @@
|
||||
public extension PureYAML.Tagged.Scalar {
|
||||
/// The scalar's `!!timestamp` value: non-`nil` when the scalar carries
|
||||
/// the standard timestamp tag and its raw content parses under the
|
||||
/// YAML 1.1 timestamp grammar. Bridge to a Foundation date with
|
||||
/// `Date(timeIntervalSince1970: timestampValue!.secondsSinceEpoch)`;
|
||||
/// the package itself stays Foundation-free.
|
||||
var timestampValue: PureYAML.Tagged.Timestamp? {
|
||||
guard tag == .timestamp else {
|
||||
return nil
|
||||
}
|
||||
return PureYAML.Tagged.Timestamp(yaml: rawValue)
|
||||
}
|
||||
|
||||
/// The scalar's `!!binary` value: non-`nil` when the scalar carries the
|
||||
/// standard binary tag and its raw content is valid base64 (whitespace
|
||||
/// and line breaks between groups are ignored, matching base64 content
|
||||
/// spread over a block scalar). Bridge to Foundation with
|
||||
/// `Data(binaryValue!)`; the package itself stays Foundation-free.
|
||||
var binaryValue: [UInt8]? {
|
||||
guard tag == .binary else {
|
||||
return nil
|
||||
}
|
||||
return PureYAML.Tagged.Scalar.decodeBase64(rawValue)
|
||||
}
|
||||
|
||||
/// Pure-Swift base64 decoding over the standard alphabet with `=`
|
||||
/// padding. Whitespace is skipped; any other non-alphabet character
|
||||
/// rejects the whole input rather than decoding a prefix.
|
||||
static func decodeBase64(_ text: String) -> [UInt8]? {
|
||||
var bytes: [UInt8] = []
|
||||
var buffer = 0
|
||||
var bitCount = 0
|
||||
var padding = 0
|
||||
for character in text {
|
||||
if character == " " || character == "\n" || character == "\t" || character == "\r" {
|
||||
continue
|
||||
}
|
||||
if character == "=" {
|
||||
padding += 1
|
||||
continue
|
||||
}
|
||||
guard padding == 0, let value = base64Value(of: character) else {
|
||||
return nil
|
||||
}
|
||||
buffer = (buffer << 6) | value
|
||||
bitCount += 6
|
||||
if bitCount >= 8 {
|
||||
bitCount -= 8
|
||||
bytes.append(UInt8((buffer >> bitCount) & 0xFF))
|
||||
}
|
||||
}
|
||||
// Leftover bits must be zero-padding from the final group, and the
|
||||
// group structure must be consistent: 8 leftover bits (two missing
|
||||
// characters) pairs with two `=`, 4 bits with one, 0 with none.
|
||||
let expectedPadding = bitCount == 4 ? 2 : bitCount == 2 ? 1 : 0
|
||||
guard bitCount != 6, padding == expectedPadding || padding == 0 else {
|
||||
return nil
|
||||
}
|
||||
guard buffer & ((1 << bitCount) - 1) == 0 || bitCount == 0 else {
|
||||
return nil
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private static func base64Value(of character: Character) -> Int? {
|
||||
guard let ascii = character.asciiValue else {
|
||||
return nil
|
||||
}
|
||||
switch ascii {
|
||||
case 65 ... 90:
|
||||
return Int(ascii - 65)
|
||||
case 97 ... 122:
|
||||
return Int(ascii - 97) + 26
|
||||
case 48 ... 57:
|
||||
return Int(ascii - 48) + 52
|
||||
case 43:
|
||||
return 62
|
||||
case 47:
|
||||
return 63
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,15 +3,25 @@ public extension PureYAML.Tagged {
|
||||
public var rawValue: String
|
||||
public var value: PureYAML.Model.Value
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this scalar in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The scalar's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: ScalarStyle?
|
||||
|
||||
public init(
|
||||
rawValue: String,
|
||||
value: PureYAML.Model.Value,
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: ScalarStyle? = nil,
|
||||
) {
|
||||
self.rawValue = rawValue
|
||||
self.value = value
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
Sources/Tagged/Tagged.ScalarStyle.swift
Normal file
28
Sources/Tagged/Tagged.ScalarStyle.swift
Normal file
@ -0,0 +1,28 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The presentation style a scalar had in the source document, or the
|
||||
/// style a caller wants a scalar to take on output. `nil` on a node means
|
||||
/// "no per-node preference": the emitter's document-wide policy decides.
|
||||
enum ScalarStyle: String, Equatable, Sendable, CustomStringConvertible {
|
||||
case plain
|
||||
case singleQuoted
|
||||
case doubleQuoted
|
||||
case literal
|
||||
case folded
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.ScalarStyle {
|
||||
init(_ style: PureYAML.Parsing.ScalarStyle) {
|
||||
self = switch style {
|
||||
case .plain: .plain
|
||||
case .singleQuoted: .singleQuoted
|
||||
case .doubleQuoted: .doubleQuoted
|
||||
case .literal: .literal
|
||||
case .folded: .folded
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
|
||||
struct Sequence: Equatable, Sendable {
|
||||
public var values: [Node]
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this sequence in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The sequence's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: CollectionStyle?
|
||||
|
||||
public init(
|
||||
values: [Node],
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: CollectionStyle? = nil,
|
||||
) {
|
||||
self.values = values
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
241
Sources/Tagged/Tagged.Timestamp.swift
Normal file
241
Sources/Tagged/Tagged.Timestamp.swift
Normal file
@ -0,0 +1,241 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// A parsed YAML 1.1 `!!timestamp` value, held as calendar components
|
||||
/// plus a timezone offset, with `secondsSinceEpoch` for the callers that
|
||||
/// want an absolute instant (`Date(timeIntervalSince1970:)` bridges it
|
||||
/// in one line; the package itself stays Foundation-free).
|
||||
///
|
||||
/// Accepted source forms are the YAML 1.1 timestamp grammar: the date
|
||||
/// alone (`2002-12-14`), or date and time separated by `T`/`t` or
|
||||
/// whitespace, with an optional fractional second and an optional
|
||||
/// timezone (`Z`, `+5`, `-05:30`). A missing timezone means UTC per the
|
||||
/// YAML 1.1 timestamp specification's treatment of unlabeled times.
|
||||
struct Timestamp: Equatable, Hashable, Sendable {
|
||||
public var year: Int
|
||||
public var month: Int
|
||||
public var day: Int
|
||||
public var hour: Int
|
||||
public var minute: Int
|
||||
public var second: Int
|
||||
/// Fraction of a second in the half-open range `0..<1`.
|
||||
public var fraction: Double
|
||||
/// Offset from UTC in seconds; `0` for `Z` or an absent timezone.
|
||||
public var offsetSeconds: Int
|
||||
|
||||
public init(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
hour: Int = 0,
|
||||
minute: Int = 0,
|
||||
second: Int = 0,
|
||||
fraction: Double = 0,
|
||||
offsetSeconds: Int = 0,
|
||||
) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
self.hour = hour
|
||||
self.minute = minute
|
||||
self.second = second
|
||||
self.fraction = fraction
|
||||
self.offsetSeconds = offsetSeconds
|
||||
}
|
||||
|
||||
/// Seconds since 1970-01-01T00:00:00Z, using the proleptic Gregorian
|
||||
/// calendar (the days-from-civil closed form), with the timezone
|
||||
/// offset applied.
|
||||
public var secondsSinceEpoch: Double {
|
||||
let shiftedYear = month <= 2 ? year - 1 : year
|
||||
let era = (shiftedYear >= 0 ? shiftedYear : shiftedYear - 399) / 400
|
||||
let yearOfEra = shiftedYear - era * 400
|
||||
let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
|
||||
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
|
||||
let days = era * 146_097 + dayOfEra - 719_468
|
||||
let seconds = days * 86400 + hour * 3600 + minute * 60 + second - offsetSeconds
|
||||
return Double(seconds) + fraction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.Timestamp {
|
||||
/// Parses the YAML 1.1 timestamp grammar. Returns `nil` for anything
|
||||
/// outside it, including calendar-invalid dates like February 30.
|
||||
init?(yaml raw: String) {
|
||||
var cursor = Substring(raw)
|
||||
guard let date = Self.takeDate(&cursor) else {
|
||||
return nil
|
||||
}
|
||||
if cursor.isEmpty {
|
||||
self.init(year: date.year, month: date.month, day: date.day)
|
||||
return
|
||||
}
|
||||
guard
|
||||
cursor.takeTimeSeparator(),
|
||||
let time = Self.takeTime(&cursor),
|
||||
let offsetSeconds = Self.takeOffset(&cursor),
|
||||
cursor.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
year: date.year,
|
||||
month: date.month,
|
||||
day: date.day,
|
||||
hour: time.hour,
|
||||
minute: time.minute,
|
||||
second: time.second,
|
||||
fraction: time.fraction,
|
||||
offsetSeconds: offsetSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
struct DateParts {
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
}
|
||||
|
||||
struct TimeParts {
|
||||
var hour: Int
|
||||
var minute: Int
|
||||
var second: Int
|
||||
var fraction: Double
|
||||
}
|
||||
|
||||
static func takeDate(_ cursor: inout Substring) -> DateParts? {
|
||||
guard
|
||||
let year = cursor.takeDigits(exactly: 4),
|
||||
cursor.take("-"),
|
||||
let month = cursor.takeDigits(upTo: 2),
|
||||
cursor.take("-"),
|
||||
let day = cursor.takeDigits(upTo: 2),
|
||||
isValidDate(year: year, month: month, day: day)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return DateParts(year: year, month: month, day: day)
|
||||
}
|
||||
|
||||
static func takeTime(_ cursor: inout Substring) -> TimeParts? {
|
||||
guard
|
||||
let hour = cursor.takeDigits(upTo: 2), hour <= 23,
|
||||
cursor.take(":"),
|
||||
let minute = cursor.takeDigits(exactly: 2), minute <= 59,
|
||||
cursor.take(":"),
|
||||
let second = cursor.takeDigits(exactly: 2), second <= 60
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var fraction = 0.0
|
||||
if cursor.take(".") {
|
||||
var scale = 0.1
|
||||
while let digit = cursor.takeDigit() {
|
||||
fraction += Double(digit) * scale
|
||||
scale /= 10
|
||||
}
|
||||
}
|
||||
return TimeParts(hour: hour, minute: minute, second: second, fraction: fraction)
|
||||
}
|
||||
|
||||
/// The timezone part: `Z`, a signed hour with optional `:minutes`, or
|
||||
/// nothing (UTC). Returns the offset in seconds, or `nil` on a
|
||||
/// malformed offset (which fails the whole parse; absence is `0`).
|
||||
static func takeOffset(_ cursor: inout Substring) -> Int? {
|
||||
cursor.takeWhitespace()
|
||||
if cursor.take("Z") {
|
||||
return 0
|
||||
}
|
||||
guard let sign = cursor.takeSign() else {
|
||||
return 0
|
||||
}
|
||||
guard let offsetHour = cursor.takeDigits(upTo: 2), offsetHour <= 23 else {
|
||||
return nil
|
||||
}
|
||||
var offsetMinute = 0
|
||||
if cursor.take(":") {
|
||||
guard let parsed = cursor.takeDigits(exactly: 2), parsed <= 59 else {
|
||||
return nil
|
||||
}
|
||||
offsetMinute = parsed
|
||||
}
|
||||
return sign * (offsetHour * 3600 + offsetMinute * 60)
|
||||
}
|
||||
|
||||
static func isValidDate(year: Int, month: Int, day: Int) -> Bool {
|
||||
guard (1 ... 12).contains(month), day >= 1 else {
|
||||
return false
|
||||
}
|
||||
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
let lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
return day <= lengths[month - 1]
|
||||
}
|
||||
}
|
||||
|
||||
private extension Substring {
|
||||
mutating func take(_ character: Character) -> Bool {
|
||||
guard first == character else {
|
||||
return false
|
||||
}
|
||||
removeFirst()
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func takeDigit() -> Int? {
|
||||
guard let first, let digit = first.wholeNumberValue, first.isASCII, (0 ... 9).contains(digit) else {
|
||||
return nil
|
||||
}
|
||||
removeFirst()
|
||||
return digit
|
||||
}
|
||||
|
||||
/// Between one and `upTo` digits.
|
||||
mutating func takeDigits(upTo limit: Int) -> Int? {
|
||||
var value: Int?
|
||||
for _ in 0 ..< limit {
|
||||
guard let digit = takeDigit() else {
|
||||
break
|
||||
}
|
||||
value = (value ?? 0) * 10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
mutating func takeDigits(exactly count: Int) -> Int? {
|
||||
var value = 0
|
||||
for _ in 0 ..< count {
|
||||
guard let digit = takeDigit() else {
|
||||
return nil
|
||||
}
|
||||
value = value * 10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// `T`, `t`, or one-or-more spaces/tabs between date and time.
|
||||
mutating func takeTimeSeparator() -> Bool {
|
||||
if take("T") || take("t") {
|
||||
return true
|
||||
}
|
||||
guard first == " " || first == "\t" else {
|
||||
return false
|
||||
}
|
||||
takeWhitespace()
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func takeWhitespace() {
|
||||
while first == " " || first == "\t" {
|
||||
removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
mutating func takeSign() -> Int? {
|
||||
if take("+") {
|
||||
return 1
|
||||
}
|
||||
if take("-") {
|
||||
return -1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,131 +1,44 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// Traverses a tag-preserving YAML node tree and applies type-directed checks. The checks, their
|
||||
/// erasure, and their context all come from the one validation framework: this layer adds only its
|
||||
/// own document type (`Node`), its built-in check, and its traversal (validation-rules.md,
|
||||
/// principle 10).
|
||||
struct Validator: Sendable {
|
||||
public var checks: [PureYAML.Validation.AnyCheck<Node>]
|
||||
|
||||
public init(checks: [PureYAML.Validation.AnyCheck<Node>] = Self.defaultChecks) {
|
||||
self.checks = checks
|
||||
}
|
||||
|
||||
public static var blank: Self {
|
||||
Self(checks: [])
|
||||
}
|
||||
|
||||
public static var defaultChecks: [PureYAML.Validation.AnyCheck<Node>] {
|
||||
[
|
||||
PureYAML.Validation.AnyCheck(BuiltinCheck.builtInTagsAreSupportedAndKindConsistent),
|
||||
PureYAML.Validation.AnyCheck(BuiltinCheck.mappingKeysAreUnique),
|
||||
]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func validate(
|
||||
_ node: Node,
|
||||
strict: Bool = true,
|
||||
) throws -> [PureYAML.Validation.Issue] {
|
||||
let result = collect(node)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw PureYAML.Validation.Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
public func collect(_ node: Node) -> PureYAML.Validation.Result {
|
||||
var issues: [PureYAML.Validation.Issue] = []
|
||||
walk(root: node, subject: node, path: .root, issues: &issues)
|
||||
return PureYAML.Validation.Result(issues)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func validate(
|
||||
_ documents: [Document],
|
||||
strict: Bool = true,
|
||||
) throws -> [PureYAML.Stream.Issue] {
|
||||
let result = collect(documents)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw PureYAML.Stream.Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
public func collect(_ documents: [Document]) -> PureYAML.Stream.Result {
|
||||
var issues: [PureYAML.Stream.Issue] = []
|
||||
for document in documents {
|
||||
let result = collect(document.node)
|
||||
issues.append(contentsOf: result.issues.map { issue in
|
||||
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
|
||||
})
|
||||
}
|
||||
return PureYAML.Stream.Result(issues)
|
||||
}
|
||||
|
||||
/// Adds a type-directed check, which fires only on the `Node` values it specialises on
|
||||
/// wherever they occur in the tree.
|
||||
public func validating(_ check: PureYAML.Validation.Check<Node, Node>) -> Self {
|
||||
var copy = self
|
||||
copy.checks.append(PureYAML.Validation.AnyCheck(check))
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The descriptions of the active checks, in application order: the configuration fixed point.
|
||||
public var validationDescriptions: [String] {
|
||||
checks.map(\.description)
|
||||
}
|
||||
}
|
||||
/// The tagged layer's validator: the same shared `PureYAML.Validation.Validator` traversal engine
|
||||
/// as every other layer (validation-rules.md, principle 10), specialised on `Node` for both the
|
||||
/// walked subject and the cross-cutting document.
|
||||
typealias Validator = PureYAML.Validation.Validator<Node, Node>
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.Validator {
|
||||
func walk(
|
||||
root: PureYAML.Tagged.Node,
|
||||
subject: PureYAML.Tagged.Node,
|
||||
path: PureYAML.Validation.Path,
|
||||
issues: inout [PureYAML.Validation.Issue],
|
||||
) {
|
||||
// The document is the whole tagged tree; each check filters by runtime type. Offer the node,
|
||||
// and for a mapping the mapping too, so a `Check<Node, Node>` and a `Check<Mapping, Node>`
|
||||
// each fire on what they specialise on.
|
||||
offer(subject, at: path, root: root, issues: &issues)
|
||||
if case let .mapping(mapping) = subject {
|
||||
offer(mapping, at: path, root: root, issues: &issues)
|
||||
}
|
||||
|
||||
switch subject {
|
||||
case let .mapping(mapping):
|
||||
for pair in mapping.pairs {
|
||||
walk(
|
||||
root: root,
|
||||
subject: pair.value,
|
||||
path: path.appending(.key(pair.key)),
|
||||
issues: &issues,
|
||||
)
|
||||
}
|
||||
case let .sequence(sequence):
|
||||
for index in sequence.values.indices {
|
||||
walk(
|
||||
root: root,
|
||||
subject: sequence.values[index],
|
||||
path: path.appending(.index(index)),
|
||||
issues: &issues,
|
||||
)
|
||||
}
|
||||
case .scalar:
|
||||
break
|
||||
}
|
||||
public extension PureYAML.Validation.Validator where Subject == PureYAML.Tagged.Node, Document == PureYAML.Tagged.Node {
|
||||
/// The tagged layer's default checks.
|
||||
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Tagged.Node>] {
|
||||
[
|
||||
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.builtInTagsAreSupportedAndKindConsistent),
|
||||
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.mappingKeysAreUnique),
|
||||
]
|
||||
}
|
||||
|
||||
func offer(
|
||||
_ subject: any PureYAML.Validation.Validatable,
|
||||
at path: PureYAML.Validation.Path,
|
||||
root: PureYAML.Tagged.Node,
|
||||
issues: inout [PureYAML.Validation.Issue],
|
||||
) {
|
||||
for check in checks {
|
||||
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
|
||||
convenience init() {
|
||||
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func validate(
|
||||
_ documents: [PureYAML.Tagged.Document],
|
||||
strict: Bool = true,
|
||||
) throws -> [PureYAML.Stream.Issue] {
|
||||
let result = collect(documents)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw PureYAML.Stream.Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
func collect(_ documents: [PureYAML.Tagged.Document]) -> PureYAML.Stream.Result {
|
||||
var issues: [PureYAML.Stream.Issue] = []
|
||||
for document in documents {
|
||||
let result = collect(document.node)
|
||||
issues.append(contentsOf: result.values.map { error in
|
||||
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
|
||||
})
|
||||
}
|
||||
return PureYAML.Stream.Result(issues)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-erased `Check`, so checks specialised on different subject types live in one list, all
|
||||
/// A type-erased `Validation`, so checks specialised on different subject types live in one list, all
|
||||
/// sharing one `Document` (the validated tree's root type). It filters by runtime type: a
|
||||
/// `Check<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
|
||||
/// `Validation<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
|
||||
/// `type(of:)` guard) or a value of any other type.
|
||||
struct AnyCheck<Document: Sendable>: Sendable {
|
||||
struct AnyValidation<Document: Sendable>: Sendable {
|
||||
public let description: String
|
||||
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
|
||||
private let applyErased: @Sendable (any Validatable, Path, Document) -> [ValidationError]
|
||||
|
||||
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
|
||||
description = check.description
|
||||
public init<Subject: Validatable>(_ validation: Validation<Subject, Document>) {
|
||||
description = validation.description
|
||||
applyErased = { subject, path, document in
|
||||
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
|
||||
return []
|
||||
}
|
||||
return check.apply(SubjectContext(document: document, subject: typed, path: path))
|
||||
return validation.apply(ValidationContext(document: document, subject: typed, path: path))
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ public extension PureYAML.Validation {
|
||||
to subject: any Validatable,
|
||||
at path: Path,
|
||||
in document: Document,
|
||||
) -> [Issue] {
|
||||
) -> [ValidationError] {
|
||||
applyErased(subject, path, document)
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,28 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// The caseless namespace of built-in type-directed checks, referenceable by name.
|
||||
enum BuiltinCheck {
|
||||
enum BuiltinValidation {
|
||||
/// Reports each duplicate key within a single mapping. The type-directed counterpart of the
|
||||
/// legacy `Rule.mappingKeysAreUnique`; fires on `Model.Mapping` values directly rather than
|
||||
/// switching on a `Model.Value`.
|
||||
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
|
||||
Check(description: "Mapping keys are unique") { context in
|
||||
public static var mappingKeysAreUnique: Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
|
||||
Validation(description: "Mapping keys are unique") { context in
|
||||
var seen = Set<PureYAML.Model.Key>()
|
||||
var issues: [Issue] = []
|
||||
var errors: [ValidationError] = []
|
||||
for pair in context.subject.pairs where !seen.insert(pair.keyNode).inserted {
|
||||
issues.append(Issue(
|
||||
errors.append(ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
|
||||
path: context.path.appending(pair.keyNode.pathComponent),
|
||||
))
|
||||
}
|
||||
return issues
|
||||
return errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Model.Key {
|
||||
/// The path component a duplicate-key issue points at: a plain `.key` for a string key, a
|
||||
/// The path component a duplicate-key error points at: a plain `.key` for a string key, a
|
||||
/// `.complexKey` for a sequence or mapping key (which has no string spelling).
|
||||
var pathComponent: PureYAML.Validation.Path.Component {
|
||||
switch self {
|
||||
@ -1,49 +0,0 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-directed validation value (the `Validation<Subject>` of the validation idiom): a
|
||||
/// positive `description`, a `predicate` that gates application, and a multi-error `check`. It is
|
||||
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree. `Document`
|
||||
/// is the type of the validated tree's root, the same for every check a given validator runs.
|
||||
struct Check<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
public let description: String
|
||||
public let predicate: @Sendable (SubjectContext<Subject, Document>) -> Bool
|
||||
public let check: @Sendable (SubjectContext<Subject, Document>) -> [Issue]
|
||||
|
||||
/// Multi-error form: return one issue per problem. Use when a subject can fail several ways.
|
||||
public init(
|
||||
description: String,
|
||||
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> [Issue],
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.description = description
|
||||
self.predicate = predicate
|
||||
self.check = check
|
||||
}
|
||||
|
||||
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
|
||||
/// one issue `"Failed to satisfy: <description>"`. The dominant form.
|
||||
public init(
|
||||
description: String,
|
||||
severity: Severity = .error,
|
||||
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool,
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.init(
|
||||
description: description,
|
||||
check: { context in
|
||||
check(context)
|
||||
? []
|
||||
: [Issue(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
|
||||
},
|
||||
when: predicate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Gates on the predicate, then runs the check.
|
||||
public func apply(_ context: SubjectContext<Subject, Document>) -> [Issue] {
|
||||
guard predicate(context) else {
|
||||
return []
|
||||
}
|
||||
return check(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ public extension PureYAML {
|
||||
static func diagnosticValidationReport(
|
||||
_ yaml: String,
|
||||
file: String? = nil,
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
preflightScanner: Validation.PreflightScanner = .init(),
|
||||
) -> Validation.Report {
|
||||
diagnosticValidationResult(
|
||||
@ -17,7 +17,7 @@ public extension PureYAML {
|
||||
/// Validates many YAML inputs with preflight diagnostics for damaged input.
|
||||
static func diagnosticValidationReports(
|
||||
_ sources: [Validation.Source],
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
failOnWarnings: Bool = false,
|
||||
preflightScanner: Validation.PreflightScanner = .init(),
|
||||
) -> Validation.BatchReport {
|
||||
@ -41,7 +41,7 @@ public extension PureYAML {
|
||||
static func parseValidated(
|
||||
_ yaml: String,
|
||||
file: String? = nil,
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
preflightScanner: Validation.PreflightScanner = .init(),
|
||||
) throws -> Model.Value {
|
||||
let documents = try parseValidatedStream(
|
||||
@ -57,7 +57,7 @@ public extension PureYAML {
|
||||
static func parseValidatedStream(
|
||||
_ yaml: String,
|
||||
file: String? = nil,
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
preflightScanner: Validation.PreflightScanner = .init(),
|
||||
) throws -> [Stream.Document] {
|
||||
let result = diagnosticValidationResult(
|
||||
@ -82,7 +82,7 @@ private extension PureYAML {
|
||||
static func diagnosticValidationResult(
|
||||
_ yaml: String,
|
||||
file: String?,
|
||||
using validator: Validation.Validator,
|
||||
using validator: Validation.Validator<Model.Value, Model.Value>,
|
||||
preflightScanner: Validation.PreflightScanner,
|
||||
) -> DiagnosticValidationResult {
|
||||
var diagnostics = preflightScanner.diagnostics(in: yaml, file: file)
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
public extension PureYAML.Validation.Issue {
|
||||
/// Error thrown when validation reports failing issues.
|
||||
struct Collection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public var issues: [PureYAML.Validation.Issue]
|
||||
|
||||
public init(_ issues: [PureYAML.Validation.Issue]) {
|
||||
self.issues = issues
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
issues.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Path-aware validation issue.
|
||||
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public var severity: Severity
|
||||
public var reason: String
|
||||
public var path: Path
|
||||
|
||||
public init(
|
||||
severity: Severity,
|
||||
reason: String,
|
||||
path: Path = .root,
|
||||
) {
|
||||
self.severity = severity
|
||||
self.reason = reason
|
||||
self.path = path
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
let location = path.isRoot ? "root" : path.description
|
||||
return "\(severity.description): \(reason) at \(location)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,19 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Collected validation issues.
|
||||
/// Collected validation errors, the non-throwing outcome accessor beside the throwing
|
||||
/// `Validator.validate(_:strict:)` gate.
|
||||
struct Result: Equatable, Sendable {
|
||||
public var issues: [Issue]
|
||||
public var values: [ValidationError]
|
||||
|
||||
public init(_ issues: [Issue] = []) {
|
||||
self.issues = issues
|
||||
public init(_ values: [ValidationError] = []) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
public var errors: [Issue] {
|
||||
issues.filter { $0.severity == .error }
|
||||
public var errors: [ValidationError] {
|
||||
values.filter { $0.severity == .error }
|
||||
}
|
||||
|
||||
public var warnings: [Issue] {
|
||||
issues.filter { $0.severity == .warning }
|
||||
public var warnings: [ValidationError] {
|
||||
values.filter { $0.severity == .warning }
|
||||
}
|
||||
|
||||
public var isValid: Bool {
|
||||
|
||||
15
Sources/Validation/Traversable.swift
Normal file
15
Sources/Validation/Traversable.swift
Normal file
@ -0,0 +1,15 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A tree node a `Validator` can walk. `Validator`'s traversal engine is shared by every layer
|
||||
/// (validation-rules.md, principle 10); each `Traversable` conformance supplies only that layer's
|
||||
/// own traversal shape: "each layer defines only its own document, its rules, and its traversal".
|
||||
protocol Traversable: Validatable {
|
||||
/// Extra values to offer to type-directed checks at this node, beyond the node itself (e.g. a
|
||||
/// mapping's own type, or a mapping's key nodes). A `nil` path component offers at this node's
|
||||
/// own path; a non-nil one offers at that child's path (e.g. a mapping key's location).
|
||||
var extraOffers: [(component: Path.Component?, value: any Validatable)] { get }
|
||||
|
||||
/// This node's children to recurse into, each paired with the path component that reaches it
|
||||
/// from this node.
|
||||
var children: [(component: Path.Component, value: Self)] { get }
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Marker for a value a type-directed validation can be applied to. A `Check<Subject>` fires on
|
||||
/// Marker for a value a type-directed validation can be applied to. A `Validation<Subject>` fires on
|
||||
/// `Subject` values wherever they appear in the traversed tree; this protocol constrains the
|
||||
/// subjects so the erased dispatch can filter by runtime type.
|
||||
protocol Validatable: Sendable {}
|
||||
|
||||
@ -198,7 +198,7 @@ public extension PureYAML {
|
||||
static func validationReport(
|
||||
_ yaml: String,
|
||||
file: String? = nil,
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
) -> Validation.Report {
|
||||
do {
|
||||
let documents = try parseStream(yaml)
|
||||
@ -241,7 +241,7 @@ public extension PureYAML {
|
||||
/// Validates many YAML inputs without throwing or stopping at the first failure.
|
||||
static func validationReports(
|
||||
_ sources: [Validation.Source],
|
||||
using validator: Validation.Validator = .init(),
|
||||
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
|
||||
failOnWarnings: Bool = false,
|
||||
) -> Validation.BatchReport {
|
||||
let reports = sources.map { source in
|
||||
|
||||
49
Sources/Validation/Validation.swift
Normal file
49
Sources/Validation/Validation.swift
Normal file
@ -0,0 +1,49 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-directed validation value: a positive `description`, a `predicate` that gates
|
||||
/// application, and a multi-error `check`. It is applied only to values of type `Subject`, anywhere
|
||||
/// they occur in the traversed tree. `Document` is the type of the validated tree's root, the same
|
||||
/// for every check a given validator runs.
|
||||
struct Validation<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
public let description: String
|
||||
public let predicate: @Sendable (ValidationContext<Subject, Document>) -> Bool
|
||||
public let check: @Sendable (ValidationContext<Subject, Document>) -> [ValidationError]
|
||||
|
||||
/// Multi-error form: return one error per problem. Use when a subject can fail several ways.
|
||||
public init(
|
||||
description: String,
|
||||
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> [ValidationError],
|
||||
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.description = description
|
||||
self.predicate = predicate
|
||||
self.check = check
|
||||
}
|
||||
|
||||
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
|
||||
/// one error `"Failed to satisfy: <description>"`. The dominant form.
|
||||
public init(
|
||||
description: String,
|
||||
severity: Severity = .error,
|
||||
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool,
|
||||
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.init(
|
||||
description: description,
|
||||
check: { context in
|
||||
check(context)
|
||||
? []
|
||||
: [ValidationError(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
|
||||
},
|
||||
when: predicate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Gates on the predicate, then runs the check.
|
||||
public func apply(_ context: ValidationContext<Subject, Document>) -> [ValidationError] {
|
||||
guard predicate(context) else {
|
||||
return []
|
||||
}
|
||||
return check(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
// The combinator algebra for type-directed checks (validation-rules.md, "The combinator algebra").
|
||||
// Declarative building blocks composed by value: comparison operators and `take` lift a subject or
|
||||
// context value into a Bool predicate; `&&` / `||` compose checks (concatenating or short-circuiting
|
||||
// their errors); `all` and `lift` apply many checks to one or a child value. `lookup` is omitted: it
|
||||
// resolves references against a component store, which the generic model does not carry; a consumer
|
||||
// with references supplies it over its own document.
|
||||
// their errors); `all` and `lift` apply many checks to one or a child value; `unwrap` errors on a nil
|
||||
// optional, else runs child checks. `lookup` is omitted: it resolves references against a component
|
||||
// store, which the generic model does not carry; a consumer with references supplies it over its own
|
||||
// document.
|
||||
//
|
||||
// Each combinator is generic over both the subject `S` and the document `D` (the validated tree's
|
||||
// root type), so the same algebra serves the model layer (`D == Model.Value`) and the tagged layer
|
||||
@ -18,49 +19,49 @@ private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
|
||||
// MARK: comparison operators lifting a key path and a literal into a predicate
|
||||
|
||||
public func == <S, D, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] == rhs }
|
||||
}
|
||||
|
||||
public func != <S, D, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] != rhs }
|
||||
}
|
||||
|
||||
public func > <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] > rhs }
|
||||
}
|
||||
|
||||
public func >= <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] >= rhs }
|
||||
}
|
||||
|
||||
public func < <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] < rhs }
|
||||
}
|
||||
|
||||
public func <= <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] <= rhs }
|
||||
}
|
||||
@ -69,9 +70,9 @@ public extension PureYAML.Validation {
|
||||
/// Digs to a value through a key path on the context and runs arbitrary logic, the escape hatch
|
||||
/// beyond equality.
|
||||
static func take<S, D, U: Sendable>(
|
||||
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
|
||||
_ keyPath: KeyPath<ValidationContext<S, D>, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return { predicate($0[keyPath: boxed.keyPath]) }
|
||||
}
|
||||
@ -80,15 +81,15 @@ public extension PureYAML.Validation {
|
||||
static func take<S, D, U: Sendable>(
|
||||
_ keyPath: KeyPath<S, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S, D>) -> Bool {
|
||||
) -> @Sendable (ValidationContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return { predicate($0.subject[keyPath: boxed.keyPath]) }
|
||||
}
|
||||
|
||||
/// Applies every check to the same context, concatenating their errors.
|
||||
static func all<S, D>(_ checks: Check<S, D>...) -> Check<S, D> {
|
||||
let list = checks
|
||||
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
static func all<S, D>(_ validations: Validation<S, D>...) -> Validation<S, D> {
|
||||
let list = validations
|
||||
return Validation(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
list.flatMap { $0.apply(context) }
|
||||
}
|
||||
}
|
||||
@ -97,12 +98,12 @@ public extension PureYAML.Validation {
|
||||
/// and document.
|
||||
static func lift<S, Child: Validatable, D>(
|
||||
_ keyPath: KeyPath<S, Child>,
|
||||
into checks: Check<Child, D>...,
|
||||
) -> Check<S, D> {
|
||||
let list = checks
|
||||
into validations: Validation<Child, D>...,
|
||||
) -> Validation<S, D> {
|
||||
let list = validations
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
let childContext = SubjectContext(
|
||||
return Validation(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
let childContext = ValidationContext(
|
||||
document: context.document,
|
||||
subject: context.subject[keyPath: boxed.keyPath],
|
||||
path: context.path,
|
||||
@ -110,24 +111,43 @@ public extension PureYAML.Validation {
|
||||
return list.flatMap { $0.apply(childContext) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps an optional reached through a key path, erroring if nil, else running child checks
|
||||
/// against the unwrapped value.
|
||||
static func unwrap<S, Child: Validatable, D>(
|
||||
_ keyPath: KeyPath<S, Child?>,
|
||||
into validations: Validation<Child, D>...,
|
||||
description: String? = nil,
|
||||
) -> Validation<S, D> {
|
||||
let list = validations
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return Validation(description: description ?? "unwraps: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
guard let child = context.subject[keyPath: boxed.keyPath] else {
|
||||
let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil"
|
||||
return [ValidationError(severity: .error, reason: reason, path: context.path)]
|
||||
}
|
||||
let childContext = ValidationContext(document: context.document, subject: child, path: context.path)
|
||||
return list.flatMap { $0.apply(childContext) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: composing two checks (&& concatenates errors, || short-circuits)
|
||||
|
||||
public func && <S, D>(
|
||||
lhs: PureYAML.Validation.Check<S, D>,
|
||||
rhs: PureYAML.Validation.Check<S, D>,
|
||||
) -> PureYAML.Validation.Check<S, D> {
|
||||
PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in
|
||||
lhs: PureYAML.Validation.Validation<S, D>,
|
||||
rhs: PureYAML.Validation.Validation<S, D>,
|
||||
) -> PureYAML.Validation.Validation<S, D> {
|
||||
PureYAML.Validation.Validation(description: "\(lhs.description) and \(rhs.description)") { context in
|
||||
lhs.apply(context) + rhs.apply(context)
|
||||
}
|
||||
}
|
||||
|
||||
public func || <S, D>(
|
||||
lhs: PureYAML.Validation.Check<S, D>,
|
||||
rhs: PureYAML.Validation.Check<S, D>,
|
||||
) -> PureYAML.Validation.Check<S, D> {
|
||||
PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in
|
||||
lhs: PureYAML.Validation.Validation<S, D>,
|
||||
rhs: PureYAML.Validation.Validation<S, D>,
|
||||
) -> PureYAML.Validation.Validation<S, D> {
|
||||
PureYAML.Validation.Validation(description: "\(lhs.description) or \(rhs.description)") { context in
|
||||
let left = lhs.apply(context)
|
||||
guard !left.isEmpty else {
|
||||
return []
|
||||
@ -3,7 +3,7 @@ public extension PureYAML.Validation {
|
||||
/// checks), the current `subject` of the check's type, and the `path` to where it sits. `Document`
|
||||
/// is the type of the validated tree's root, so each layer carries a truthful document: `Model.Value`
|
||||
/// for the model tree, `Tagged.Node` for the tagged tree.
|
||||
struct SubjectContext<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
struct ValidationContext<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
public var document: Document
|
||||
public var subject: Subject
|
||||
public var path: Path
|
||||
26
Sources/Validation/ValidationError.swift
Normal file
26
Sources/Validation/ValidationError.swift
Normal file
@ -0,0 +1,26 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Path-aware validation error.
|
||||
struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public var severity: Severity
|
||||
public var reason: String
|
||||
public var path: Path
|
||||
|
||||
public init(
|
||||
severity: Severity,
|
||||
reason: String,
|
||||
path: Path = .root,
|
||||
) {
|
||||
self.severity = severity
|
||||
self.reason = reason
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// Strips a trailing period from `reason` and renders the location consistently:
|
||||
/// `"<reason> at root of document"` at the root, `"<reason> at path: <path>"` elsewhere.
|
||||
/// `severity` is a separate stored property (see `Result.errors`/`.warnings`), not rendered here.
|
||||
public var description: String {
|
||||
let reasonText = reason.hasSuffix(".") ? String(reason.dropLast()) : reason
|
||||
return path.isRoot ? "\(reasonText) at root of document" : "\(reasonText) at path: \(path.description)"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Sources/Validation/ValidationErrorCollection.swift
Normal file
14
Sources/Validation/ValidationErrorCollection.swift
Normal file
@ -0,0 +1,14 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Error thrown when validation reports failing errors.
|
||||
struct ValidationErrorCollection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public var values: [ValidationError]
|
||||
|
||||
public init(_ values: [ValidationError]) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
values.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,142 +1,166 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Traverses a YAML value tree and applies type-directed checks at each node. The check value, its
|
||||
/// erasure, and its context all come from the one validation framework (validation-rules.md,
|
||||
/// principle 10); this layer adds only its document type (`Model.Value`), its built-in checks, and
|
||||
/// its traversal.
|
||||
struct Validator: Sendable {
|
||||
public var checks: [AnyCheck<PureYAML.Model.Value>]
|
||||
/// Traverses a `Subject` tree and applies type-directed checks at each node. The validation value,
|
||||
/// its erasure, its context, the combinators, and this traversal engine are all the one shared
|
||||
/// framework (validation-rules.md, principle 10); each layer supplies only its own `Subject`/
|
||||
/// `Document` types (through `Traversable`), its built-in checks, and its own traversal shape.
|
||||
///
|
||||
/// `@unchecked Sendable`: immutable after `init` (every stored validation list is a `let`), so
|
||||
/// concurrent reads race on nothing; the erased `AnyValidation` closures are simply not provably
|
||||
/// `Sendable` to the compiler.
|
||||
final class Validator<Subject: Traversable, Document: Sendable>: @unchecked Sendable {
|
||||
fileprivate let nonReferenceDefaultValidations: [AnyValidation<Document>]
|
||||
fileprivate let referenceDefaultValidations: [AnyValidation<Document>]
|
||||
fileprivate let customValidations: [AnyValidation<Document>]
|
||||
|
||||
public init(checks: [AnyCheck<PureYAML.Model.Value>] = Self.defaultChecks) {
|
||||
self.checks = checks
|
||||
public init(
|
||||
nonReferenceDefaultValidations: [AnyValidation<Document>] = [],
|
||||
referenceDefaultValidations: [AnyValidation<Document>] = [],
|
||||
customValidations: [AnyValidation<Document>] = [],
|
||||
) {
|
||||
self.nonReferenceDefaultValidations = nonReferenceDefaultValidations
|
||||
self.referenceDefaultValidations = referenceDefaultValidations
|
||||
self.customValidations = customValidations
|
||||
}
|
||||
|
||||
public static var blank: Self {
|
||||
Self(checks: [])
|
||||
/// A validator with no checks; add your own with `validating(_:)`.
|
||||
public static var blank: Validator {
|
||||
Validator()
|
||||
}
|
||||
|
||||
public static var defaultChecks: [AnyCheck<PureYAML.Model.Value>] {
|
||||
[AnyCheck(BuiltinCheck.mappingKeysAreUnique)]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func validate(
|
||||
_ value: PureYAML.Model.Value,
|
||||
strict: Bool = true,
|
||||
) throws -> [Issue] {
|
||||
let result = collect(value)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
public func collect(_ value: PureYAML.Model.Value) -> Result {
|
||||
var issues: [Issue] = []
|
||||
walk(root: value, subject: value, path: .root, issues: &issues)
|
||||
return Result(issues)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func validate(
|
||||
_ documents: [PureYAML.Stream.Document],
|
||||
strict: Bool = true,
|
||||
) throws -> [PureYAML.Stream.Issue] {
|
||||
let result = collect(documents)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw PureYAML.Stream.Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
public func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
|
||||
var issues: [PureYAML.Stream.Issue] = []
|
||||
for document in documents {
|
||||
let result = collect(document.value)
|
||||
issues.append(contentsOf: result.issues.map { issue in
|
||||
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
|
||||
})
|
||||
}
|
||||
return PureYAML.Stream.Result(issues)
|
||||
}
|
||||
|
||||
/// Adds a type-directed check, which fires only on values of its `Subject` type wherever they
|
||||
/// occur in the tree.
|
||||
public func validating(_ check: Check<some Validatable, PureYAML.Model.Value>) -> Self {
|
||||
var copy = self
|
||||
copy.checks.append(AnyCheck(check))
|
||||
return copy
|
||||
}
|
||||
|
||||
/// Removes every check whose description matches, the identity the validation idiom uses for
|
||||
/// removal. Descriptions are therefore required to be unique and stable.
|
||||
public func withoutValidating(_ description: String) -> Self {
|
||||
var copy = self
|
||||
copy.checks.removeAll { $0.description == description }
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The descriptions of the active checks, in application order: the fixed point a
|
||||
/// configuration-pin test asserts against, so adding, removing, or rewording one changes it
|
||||
/// deliberately.
|
||||
/// The descriptions of every active check, in application order.
|
||||
public var validationDescriptions: [String] {
|
||||
checks.map(\.description)
|
||||
validations.map(\.description)
|
||||
}
|
||||
|
||||
/// Descriptions of the non-reference default tier, in order.
|
||||
public var nonReferenceValidationDescriptions: [String] {
|
||||
nonReferenceDefaultValidations.map(\.description)
|
||||
}
|
||||
|
||||
/// Descriptions of the reference default tier, in order.
|
||||
public var referenceValidationDescriptions: [String] {
|
||||
referenceDefaultValidations.map(\.description)
|
||||
}
|
||||
|
||||
/// Descriptions of the custom tier, in order.
|
||||
public var customValidationDescriptions: [String] {
|
||||
customValidations.map(\.description)
|
||||
}
|
||||
|
||||
fileprivate var validations: [AnyValidation<Document>] {
|
||||
nonReferenceDefaultValidations + referenceDefaultValidations + customValidations
|
||||
}
|
||||
|
||||
/// Returns a validator with `validation` appended to the custom tier.
|
||||
@discardableResult
|
||||
public func validating(_ validation: Validation<some Validatable, Document>) -> Validator {
|
||||
Validator(
|
||||
nonReferenceDefaultValidations: nonReferenceDefaultValidations,
|
||||
referenceDefaultValidations: referenceDefaultValidations,
|
||||
customValidations: customValidations + [AnyValidation(validation)],
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a validator with the checks matching `descriptions` removed from every tier, the
|
||||
/// identity the validation idiom uses for removal. Descriptions are therefore required to be
|
||||
/// unique and stable.
|
||||
@discardableResult
|
||||
public func withoutValidating(_ descriptions: String...) -> Validator {
|
||||
let drop = Set(descriptions)
|
||||
func filter(_ tier: [AnyValidation<Document>]) -> [AnyValidation<Document>] {
|
||||
tier.filter { !drop.contains($0.description) }
|
||||
}
|
||||
return Validator(
|
||||
nonReferenceDefaultValidations: filter(nonReferenceDefaultValidations),
|
||||
referenceDefaultValidations: filter(referenceDefaultValidations),
|
||||
customValidations: filter(customValidations),
|
||||
)
|
||||
}
|
||||
|
||||
public func collect(_ subject: Subject, in document: Document) -> Result {
|
||||
var errors: [ValidationError] = []
|
||||
walk(subject, at: .root, in: document, into: &errors)
|
||||
return Result(errors)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func validate(
|
||||
_ subject: Subject,
|
||||
in document: Document,
|
||||
strict: Bool = true,
|
||||
) throws -> [ValidationError] {
|
||||
let result = collect(subject, in: document)
|
||||
let failures = strict ? result.values : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw ValidationErrorCollection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
private func walk(_ subject: Subject, at path: Path, in document: Document, into errors: inout [ValidationError]) {
|
||||
// Offer the node itself, then any extra values this layer's Traversable conformance wants
|
||||
// offered here (a mapping's own type, its key nodes), then recurse into its children.
|
||||
offer(subject, at: path, in: document, into: &errors)
|
||||
for (component, value) in subject.extraOffers {
|
||||
offer(value, at: component.map(path.appending) ?? path, in: document, into: &errors)
|
||||
}
|
||||
for (component, child) in subject.children {
|
||||
walk(child, at: path.appending(component), in: document, into: &errors)
|
||||
}
|
||||
}
|
||||
|
||||
private func offer(_ value: any Validatable, at path: Path, in document: Document, into errors: inout [ValidationError]) {
|
||||
for validation in validations {
|
||||
errors.append(contentsOf: validation.apply(to: value, at: path, in: document))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Validation.Validator {
|
||||
func walk(
|
||||
root: PureYAML.Model.Value,
|
||||
subject: PureYAML.Model.Value,
|
||||
path: PureYAML.Validation.Path,
|
||||
issues: inout [PureYAML.Validation.Issue],
|
||||
) {
|
||||
// Offer each type-directed check the values it may specialise on at this node: the value
|
||||
// itself, and, for a mapping, the mapping and each of its keys. The erased check filters by
|
||||
// runtime type, so a `Check<Model.Mapping>` ignores the value and key offers, and a
|
||||
// `Check<Model.Value>` (a whole-tree rule) fires on the value offer.
|
||||
offer(subject, at: path, root: root, issues: &issues)
|
||||
if case let .mapping(mapping) = subject {
|
||||
offer(mapping, at: path, root: root, issues: &issues)
|
||||
for pair in mapping.pairs {
|
||||
offer(pair.keyNode, at: path.appending(pair.keyNode.pathComponent), root: root, issues: &issues)
|
||||
}
|
||||
}
|
||||
|
||||
switch subject {
|
||||
case let .mapping(mapping):
|
||||
for pair in mapping.pairs {
|
||||
walk(
|
||||
root: root,
|
||||
subject: pair.value,
|
||||
path: path.appending(pair.keyNode.pathComponent),
|
||||
issues: &issues,
|
||||
)
|
||||
}
|
||||
case let .sequence(values):
|
||||
for index in values.indices {
|
||||
walk(
|
||||
root: root,
|
||||
subject: values[index],
|
||||
path: path.appending(.index(index)),
|
||||
issues: &issues,
|
||||
)
|
||||
}
|
||||
case .null, .bool, .int, .double, .string:
|
||||
break
|
||||
}
|
||||
public extension PureYAML.Validation.Validator where Subject == Document {
|
||||
/// Convenience for the common case where the walked tree's root doubles as the cross-cutting
|
||||
/// document (every PureYAML layer, today).
|
||||
func collect(_ subject: Subject) -> PureYAML.Validation.Result {
|
||||
collect(subject, in: subject)
|
||||
}
|
||||
|
||||
func offer(
|
||||
_ subject: any PureYAML.Validation.Validatable,
|
||||
at path: PureYAML.Validation.Path,
|
||||
root: PureYAML.Model.Value,
|
||||
issues: inout [PureYAML.Validation.Issue],
|
||||
) {
|
||||
for check in checks {
|
||||
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
|
||||
}
|
||||
@discardableResult
|
||||
func validate(_ subject: Subject, strict: Bool = true) throws -> [PureYAML.Validation.ValidationError] {
|
||||
try validate(subject, in: subject, strict: strict)
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureYAML.Validation.Validator where Subject == PureYAML.Model.Value, Document == PureYAML.Model.Value {
|
||||
/// The model layer's default checks: schema-independent structural rules.
|
||||
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Model.Value>] {
|
||||
[PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique)]
|
||||
}
|
||||
|
||||
convenience init() {
|
||||
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func validate(
|
||||
_ documents: [PureYAML.Stream.Document],
|
||||
strict: Bool = true,
|
||||
) throws -> [PureYAML.Stream.Issue] {
|
||||
let result = collect(documents)
|
||||
let failures = strict ? result.issues : result.errors
|
||||
if !failures.isEmpty {
|
||||
throw PureYAML.Stream.Issue.Collection(failures)
|
||||
}
|
||||
return strict ? [] : result.warnings
|
||||
}
|
||||
|
||||
func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
|
||||
var issues: [PureYAML.Stream.Issue] = []
|
||||
for document in documents {
|
||||
let result = collect(document.value)
|
||||
issues.append(contentsOf: result.values.map { error in
|
||||
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
|
||||
})
|
||||
}
|
||||
return PureYAML.Stream.Result(issues)
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,12 +20,12 @@ struct ArbitraryAnyDecisionTests {
|
||||
mapping.pairs.map { ($0.key, $0.value) },
|
||||
uniquingKeysWith: { _, second in second },
|
||||
)
|
||||
let validationResult = PureYAML.Validation.Validator().collect(value)
|
||||
let validationResult = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
|
||||
#expect(mapping.pairs.map(\.key) == ["title", "title", "slug"])
|
||||
#expect(projectedDictionary.keys.sorted() == ["slug", "title"])
|
||||
#expect(projectedDictionary["title"] == .string("Second"))
|
||||
#expect(validationResult.issues == [
|
||||
#expect(validationResult.values == [
|
||||
.init(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
|
||||
@ -127,8 +127,12 @@ enum CanonicalEventCorpus {
|
||||
|
||||
static func props(_ anchor: String?, _ tag: String?) -> String {
|
||||
var result = ""
|
||||
if let anchor { result += " &" + anchor }
|
||||
if let tag { result += " <" + tag + ">" }
|
||||
if let anchor {
|
||||
result += " &" + anchor
|
||||
}
|
||||
if let tag {
|
||||
result += " <" + tag + ">"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@ -102,14 +102,14 @@ struct CodableCompatibilityTests {
|
||||
.init(key: "/users", value: .int(2)),
|
||||
])))
|
||||
} check: { collection in
|
||||
#expect(collection.issues == [
|
||||
#expect(collection.values == [
|
||||
.init(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '/users'",
|
||||
path: .init([.key("/users")]),
|
||||
),
|
||||
])
|
||||
#expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]")
|
||||
#expect(collection.description == "Duplicate mapping key '/users' at path: $[\"/users\"]")
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,12 +225,12 @@ private func expectEncodingError(
|
||||
|
||||
private func expectValidationError(
|
||||
operation: () throws -> some Any,
|
||||
check: (PureYAML.Validation.Issue.Collection) -> Void,
|
||||
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
|
||||
) {
|
||||
do {
|
||||
_ = try operation()
|
||||
recordIssue("expected validation error")
|
||||
} catch let collection as PureYAML.Validation.Issue.Collection {
|
||||
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
|
||||
check(collection)
|
||||
} catch {
|
||||
recordIssue("unexpected error \(error)")
|
||||
|
||||
@ -38,7 +38,7 @@ enum CollectionCompatibilityFixtures {
|
||||
struct ValidationCase: CustomStringConvertible {
|
||||
var name: String
|
||||
var yaml: String
|
||||
var expectedIssues: [PureYAML.Validation.Issue]
|
||||
var expectedIssues: [PureYAML.Validation.ValidationError]
|
||||
|
||||
var description: String {
|
||||
name
|
||||
|
||||
@ -10,20 +10,20 @@ struct ComplexMappingKeyValidationTests {
|
||||
.init(keyNode: key, value: .string("first")),
|
||||
.init(keyNode: key, value: .string("second")),
|
||||
]))
|
||||
let expectedIssue = PureYAML.Validation.Issue(
|
||||
let expectedIssue = PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '[\"a\", \"b\"]'",
|
||||
path: .init([.complexKey(key)]),
|
||||
)
|
||||
|
||||
let result = PureYAML.Validation.Validator().collect(value)
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
|
||||
#expect(result.issues == [expectedIssue])
|
||||
#expect(result.issues.map(\.description) == [
|
||||
"error: Duplicate mapping key '[\"a\", \"b\"]' at $[?[\"a\", \"b\"]]",
|
||||
#expect(result.values == [expectedIssue])
|
||||
#expect(result.values.map(\.description) == [
|
||||
"Duplicate mapping key '[\"a\", \"b\"]' at path: $[?[\"a\", \"b\"]]",
|
||||
])
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == [expectedIssue])
|
||||
#expect(collection.values == [expectedIssue])
|
||||
#expect(collection.description == expectedIssue.description)
|
||||
}
|
||||
}
|
||||
@ -37,16 +37,16 @@ struct ComplexMappingKeyValidationTests {
|
||||
.init(keyNode: key, value: .string("first")),
|
||||
.init(keyNode: key, value: .string("second")),
|
||||
]))
|
||||
let expectedIssue = PureYAML.Validation.Issue(
|
||||
let expectedIssue = PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '{\"name\": \"service\"}'",
|
||||
path: .init([.complexKey(key)]),
|
||||
)
|
||||
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == [expectedIssue])
|
||||
#expect(collection.values == [expectedIssue])
|
||||
#expect(collection.description == """
|
||||
error: Duplicate mapping key '{"name": "service"}' at $[?{"name": "service"}]
|
||||
Duplicate mapping key '{"name": "service"}' at path: $[?{"name": "service"}]
|
||||
""")
|
||||
}
|
||||
}
|
||||
@ -58,7 +58,7 @@ struct ComplexMappingKeyValidationTests {
|
||||
.init(keyNode: key, value: .string("first")),
|
||||
.init(keyNode: key, value: .string("second")),
|
||||
]))
|
||||
let expectedIssue = PureYAML.Validation.Issue(
|
||||
let expectedIssue = PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '[nan]'",
|
||||
path: .init([.complexKey(key)]),
|
||||
@ -66,8 +66,8 @@ struct ComplexMappingKeyValidationTests {
|
||||
|
||||
#expect(PureYAML.Model.Value.double(.nan) == .double(.nan))
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == [expectedIssue])
|
||||
#expect(collection.description == "error: Duplicate mapping key '[nan]' at $[?[nan]]")
|
||||
#expect(collection.values == [expectedIssue])
|
||||
#expect(collection.description == "Duplicate mapping key '[nan]' at path: $[?[nan]]")
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +82,7 @@ struct ComplexMappingKeyValidationTests {
|
||||
let issues = try PureYAML.validate(value, strict: false)
|
||||
|
||||
#expect(issues.isEmpty)
|
||||
#expect(PureYAML.Validation.Validator().collect(value).issues.isEmpty)
|
||||
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values.isEmpty)
|
||||
#expect(value.rootMapping?["[\"a\"]"] == .string("scalar"))
|
||||
#expect(value.rootMapping?[sequenceKey] == .string("complex"))
|
||||
#expect(value.rootMapping?["a"] == nil)
|
||||
@ -98,19 +98,19 @@ struct ComplexMappingKeyValidationTests {
|
||||
.init(key: "mode", value: .string("legacy")),
|
||||
]))),
|
||||
]))
|
||||
let expectedIssue = PureYAML.Validation.Issue(
|
||||
let expectedIssue = PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Legacy mode is not allowed",
|
||||
path: .init([.complexKey(key), .key("mode")]),
|
||||
)
|
||||
|
||||
let result = PureYAML.Validation.Validator.blank
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
|
||||
.validating(legacyModeRule(severity: .error))
|
||||
.collect(value)
|
||||
|
||||
#expect(result.issues == [expectedIssue])
|
||||
#expect(result.issues.first?.description == """
|
||||
error: Legacy mode is not allowed at $[?{"name": "service"}].mode
|
||||
#expect(result.values == [expectedIssue])
|
||||
#expect(result.values.first?.description == """
|
||||
Legacy mode is not allowed at path: $[?{"name": "service"}].mode
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,14 +110,14 @@ struct DocumentationExampleTests {
|
||||
)
|
||||
let tagIssues = PureYAML.Tagged.Validator().collect(tagged)
|
||||
let expectedIssues = [
|
||||
PureYAML.Validation.Issue(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp'",
|
||||
path: .init([.key("value")]),
|
||||
),
|
||||
]
|
||||
|
||||
#expect(tagIssues.issues == expectedIssues)
|
||||
#expect(tagIssues.values == expectedIssues)
|
||||
}
|
||||
|
||||
@Test("README tagged constructor example stays executable")
|
||||
@ -180,20 +180,20 @@ struct DocumentationExampleTests {
|
||||
.init(key: "title", value: .string("Second")),
|
||||
]))
|
||||
let expectedIssues = [
|
||||
PureYAML.Validation.Issue(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
path: .init([.key("title")]),
|
||||
),
|
||||
]
|
||||
let result = PureYAML.Validation.Validator().collect(document)
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(document)
|
||||
|
||||
#expect(try PureYAML.validate(validDocument, strict: false).isEmpty)
|
||||
#expect(result.errors == expectedIssues)
|
||||
#expect(result.warnings.isEmpty)
|
||||
expectValidationFailure(document) { collection in
|
||||
#expect(collection.issues == expectedIssues)
|
||||
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
|
||||
#expect(collection.values == expectedIssues)
|
||||
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,12 +210,12 @@ struct DocumentationExampleTests {
|
||||
|
||||
func expectValidationFailure(
|
||||
_ value: PureYAML.Model.Value,
|
||||
check: (PureYAML.Validation.Issue.Collection) -> Void,
|
||||
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
|
||||
) {
|
||||
do {
|
||||
_ = try PureYAML.validate(value)
|
||||
recordIssue("expected validation failure")
|
||||
} catch let collection as PureYAML.Validation.Issue.Collection {
|
||||
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
|
||||
check(collection)
|
||||
} catch {
|
||||
recordIssue("unexpected error \(error)")
|
||||
|
||||
@ -116,7 +116,7 @@ struct DownstreamDocumentTests {
|
||||
#expect(api?["name"] == .string("API"))
|
||||
#expect(api?["retries"] == .int(3))
|
||||
#expect(api?["timeoutSeconds"] == .int(30))
|
||||
#expect(PureYAML.Validation.Validator().collect(.mapping(root)) == .init())
|
||||
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root)) == .init())
|
||||
}
|
||||
|
||||
func requireRootMapping(_ yaml: String) throws -> PureYAML.Model.Mapping {
|
||||
|
||||
161
Tests/EmitterCanonicalAndWidthTests.swift
Normal file
161
Tests/EmitterCanonicalAndWidthTests.swift
Normal file
@ -0,0 +1,161 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Canonical mode, document markers, and line width")
|
||||
struct EmitterCanonicalAndWidthTests {
|
||||
@Test("Canonical form emits explicit tags, quotes, and flow entries per line")
|
||||
func test_canonicalGoldenOutput() throws {
|
||||
let value = try PureYAML.parse("name: config\ncount: 3\nitems: [a, b]\n")
|
||||
let dumped = PureYAML.dump(value, options: .init(canonical: true))
|
||||
#expect(dumped == """
|
||||
---
|
||||
!!map {
|
||||
? !!str "name"
|
||||
: !!str "config",
|
||||
? !!str "count"
|
||||
: !!int "3",
|
||||
? !!str "items"
|
||||
: !!seq [
|
||||
!!str "a",
|
||||
!!str "b",
|
||||
],
|
||||
}
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("Canonical output reparses to the identical value")
|
||||
func test_canonicalReparsesToSameValue() throws {
|
||||
let source = """
|
||||
server: web-1
|
||||
replicas: 2
|
||||
healthy: true
|
||||
threshold: 0.75
|
||||
fallback: null
|
||||
tags: [prod, eu]
|
||||
limits:
|
||||
memory: 512
|
||||
"""
|
||||
let value = try PureYAML.parse(source)
|
||||
let canonical = PureYAML.dump(value, options: .init(canonical: true))
|
||||
let reparsed = try PureYAML.parse(canonical)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Canonical scalar document and empty collections")
|
||||
func test_canonicalScalarAndEmpties() {
|
||||
#expect(PureYAML.dump(.string("x"), options: .init(canonical: true)) == "---\n!!str \"x\"\n")
|
||||
#expect(PureYAML.dump(.mapping(.init(pairs: [])), options: .init(canonical: true)) == "---\n!!map {}\n")
|
||||
#expect(PureYAML.dump(.sequence([]), options: .init(canonical: true)) == "---\n!!seq []\n")
|
||||
}
|
||||
|
||||
@Test("Canonical tagged tree keeps anchors, aliases, and carried tags")
|
||||
func test_canonicalTaggedTree() throws {
|
||||
let node = try PureYAML.parseTagged("base: &b !Custom widget\nref: *b\n")
|
||||
let canonical = PureYAML.dump(node, options: .init(canonical: true))
|
||||
#expect(canonical == """
|
||||
---
|
||||
!!map {
|
||||
? !!str "base"
|
||||
: &b !<!Custom> "widget",
|
||||
? !!str "ref"
|
||||
: *b,
|
||||
}
|
||||
|
||||
""")
|
||||
let reparsed = try PureYAML.parseTagged(canonical)
|
||||
#expect(reparsed.modelValueErasingTags == node.modelValueErasingTags)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping["ref"]?.kind == .alias)
|
||||
#expect(mapping["base"]?.tag == .init("!Custom"))
|
||||
}
|
||||
|
||||
@Test("Explicit document markers wrap the body")
|
||||
func test_explicitDocumentMarkers() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "a", value: .int(1))]))
|
||||
let both = PureYAML.dump(value, options: .init(explicitDocumentStart: true, explicitDocumentEnd: true))
|
||||
#expect(both == "---\na: 1\n...\n")
|
||||
let startOnly = PureYAML.dump(value, options: .init(explicitDocumentStart: true))
|
||||
#expect(startOnly == "---\na: 1\n")
|
||||
let reparsed = try PureYAML.parse(both)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width folds long plain scalars and the value survives")
|
||||
func test_lineWidthFoldsPlainScalars() throws {
|
||||
let long = "the quick brown fox jumps over the lazy dog and keeps going well past any reasonable width"
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "sentence", value: .string(long))]))
|
||||
let options = PureYAML.Emitting.Options(scalarStyle: .plainWhenSafe, lineWidth: 40)
|
||||
let dumped = PureYAML.dump(value, options: options)
|
||||
#expect(dumped.split(separator: "\n").count > 1)
|
||||
#expect(dumped.split(separator: "\n").allSatisfy { $0.count <= 40 })
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width leaves quoted scalars and unbreakable words alone")
|
||||
func test_lineWidthLeavesUnsafeContentAlone() {
|
||||
let word = String(repeating: "x", count: 90)
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "blob", value: .string(word))]))
|
||||
let plain = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 40))
|
||||
#expect(plain == "blob: \(word)\n")
|
||||
let quoted = PureYAML.dump(value, options: .init(scalarStyle: .quoted, lineWidth: 40))
|
||||
#expect(quoted == "blob: \"\(word)\"\n")
|
||||
}
|
||||
|
||||
@Test("A fold that would manufacture syntax is vetoed")
|
||||
func test_unsafeFoldBoundariesVetoed() throws {
|
||||
let risky = "watch the colon: placement and the - dash and the # mark carefully now"
|
||||
let value = PureYAML.Model.Value.string(risky)
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width wraps flow collections one entry per line")
|
||||
func test_lineWidthWrapsFlow() throws {
|
||||
let value = try PureYAML.parse("list: [alpha, beta, gamma, delta, epsilon, zeta, eta, theta]\n")
|
||||
let options = PureYAML.Emitting.Options(
|
||||
scalarStyle: .plainWhenSafe,
|
||||
collectionStyle: .flow,
|
||||
lineWidth: 30,
|
||||
)
|
||||
let dumped = PureYAML.dump(value, options: options)
|
||||
#expect(dumped.split(separator: "\n").count > 1)
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Sequence items fold with safe continuation indentation")
|
||||
func test_sequenceItemFolding() throws {
|
||||
let long = "one two three four five six seven eight nine ten eleven twelve thirteen"
|
||||
let value = PureYAML.Model.Value.sequence([.string(long), .string("short")])
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Consecutive interior spaces forbid folding at that boundary")
|
||||
func test_consecutiveSpacesSurviveFolding() throws {
|
||||
let doubled = "left middle and then a good number of extra words to overflow the width right"
|
||||
let value = PureYAML.Model.Value.string(doubled)
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 25))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Canonical wins over conflicting style options")
|
||||
func test_canonicalOverridesStyles() throws {
|
||||
let value = try PureYAML.parse("a: 1\n")
|
||||
let styled = PureYAML.dump(value, options: .init(
|
||||
scalarStyle: .plainWhenSafe,
|
||||
collectionStyle: .flow,
|
||||
canonical: true,
|
||||
lineWidth: 10,
|
||||
))
|
||||
#expect(styled == "---\n!!map {\n ? !!str \"a\"\n : !!int \"1\",\n}\n")
|
||||
}
|
||||
}
|
||||
162
Tests/EmitterFixRegressionTests.swift
Normal file
162
Tests/EmitterFixRegressionTests.swift
Normal file
@ -0,0 +1,162 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// Regressions for the round-trip and validation fixes: stream document markers,
|
||||
/// flow-key safety, trailing-colon plain safety, alias-key error classification,
|
||||
/// duplicate-key identity, the emitter nesting guard, leap seconds, and tagged
|
||||
/// `sortKeys`.
|
||||
@Suite("Emitter and tagged fix regressions")
|
||||
struct EmitterFixRegressionTests {
|
||||
// MARK: - Item 1: stream document markers
|
||||
|
||||
@Test("A two-document stream stays two documents with explicitDocumentStart")
|
||||
func test_streamExplicitStartStaysTwoDocuments() throws {
|
||||
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
|
||||
let dumped = PureYAML.dump(docs, options: .init(explicitDocumentStart: true))
|
||||
let reparsed = try PureYAML.parseStream(dumped)
|
||||
#expect(reparsed.count == 2)
|
||||
#expect(reparsed.map(\.value) == docs.map(\.value))
|
||||
}
|
||||
|
||||
@Test("A two-document stream stays two documents in canonical mode")
|
||||
func test_streamCanonicalStaysTwoDocuments() throws {
|
||||
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
|
||||
let dumped = PureYAML.dump(docs, options: .init(canonical: true))
|
||||
let reparsed = try PureYAML.parseStream(dumped)
|
||||
#expect(reparsed.count == 2)
|
||||
#expect(reparsed.map(\.value) == docs.map(\.value))
|
||||
}
|
||||
|
||||
// MARK: - Item 2: flow-key safety
|
||||
|
||||
@Test("A flow mapping key containing a comma round-trips instead of splitting")
|
||||
func test_flowCommaKeyRoundTrips() throws {
|
||||
let node = PureYAML.Tagged.Node.mapping(.init(
|
||||
pairs: [.init(key: "a,b", value: .scalar(.init(rawValue: "1", value: .int(1))))],
|
||||
style: .flow,
|
||||
))
|
||||
let dumped = PureYAML.dump(node)
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == .mapping(.init([.init(key: "a,b", value: .int(1))])))
|
||||
}
|
||||
|
||||
// MARK: - Item 3: trailing-colon plain safety
|
||||
|
||||
@Test("A model scalar ending in a colon falls back to quoting and reparses")
|
||||
func test_modelTrailingColonQuotes() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "k", value: .string("foo:"))]))
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("A tagged plain scalar ending in a colon falls back to quoting and reparses")
|
||||
func test_taggedTrailingColonQuotes() throws {
|
||||
let node = PureYAML.Tagged.Node.mapping(.init(pairs: [
|
||||
.init(key: "k", value: .scalar(.init(rawValue: "foo:", value: .string("foo:"), style: .plain))),
|
||||
]))
|
||||
let dumped = PureYAML.dump(node)
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == .mapping(.init([.init(key: "k", value: .string("foo:"))])))
|
||||
}
|
||||
|
||||
// MARK: - Item 4: alias-key error classification
|
||||
|
||||
@Test("An undefined alias as a mapping key is an undefinedAlias error")
|
||||
func test_undefinedAliasKey() throws {
|
||||
#expect(throws: PureYAML.Parsing.ParseError.self) {
|
||||
try PureYAML.parseTagged("a: 1\n*missing : v\n")
|
||||
}
|
||||
do {
|
||||
_ = try PureYAML.parseTagged("a: 1\n*missing : v\n")
|
||||
} catch let error as PureYAML.Parsing.ParseError {
|
||||
#expect(error.diagnosticCode == "undefinedAlias")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A defined non-scalar alias as a mapping key is an expectedScalarKey error")
|
||||
func test_nonScalarAliasKey() throws {
|
||||
do {
|
||||
_ = try PureYAML.parseTagged("a: &s [1, 2]\n*s : v\n")
|
||||
Issue.record("expected a parse error")
|
||||
} catch let error as PureYAML.Parsing.ParseError {
|
||||
#expect(error.diagnosticCode == "expectedScalarKey")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item 5: duplicate-key identity
|
||||
|
||||
@Test("An int key and a string key spelled the same are not duplicates")
|
||||
func test_intVersusStringKeyNotDuplicate() throws {
|
||||
let node = try PureYAML.parseTagged("1: a\n\"1\": b\n")
|
||||
#expect(PureYAML.Tagged.Validator().collect(node).values.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Two keys resolving to the same int are duplicates")
|
||||
func test_sameResolvedIntKeysAreDuplicate() throws {
|
||||
let node = try PureYAML.parseTagged("0x1: a\n1: b\n")
|
||||
#expect(PureYAML.Tagged.Validator().collect(node).values.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Item 6: emitter nesting guard
|
||||
|
||||
@Test("A model tree nested past the limit yields a reparseable marker, not a crash")
|
||||
func test_modelDeepTreeMarker() throws {
|
||||
var value = PureYAML.Model.Value.string("x")
|
||||
for _ in 0 ..< 200 {
|
||||
value = .sequence([value])
|
||||
}
|
||||
let dumped = PureYAML.dump(value)
|
||||
#expect(dumped.contains("nesting exceeds"))
|
||||
#expect(try PureYAML.parse(dumped) == .null)
|
||||
}
|
||||
|
||||
@Test("A tagged tree nested past the limit yields a reparseable marker, not a crash")
|
||||
func test_taggedDeepTreeMarker() throws {
|
||||
var node = PureYAML.Tagged.Node.scalar(.init(rawValue: "x", value: .string("x")))
|
||||
for _ in 0 ..< 200 {
|
||||
node = .sequence(.init(values: [node]))
|
||||
}
|
||||
let dumped = PureYAML.dump(node)
|
||||
#expect(dumped.contains("nesting exceeds"))
|
||||
#expect(try PureYAML.parse(dumped) == .null)
|
||||
}
|
||||
|
||||
@Test("A tree at the limit still renders normally")
|
||||
func test_treeAtLimitStillRenders() throws {
|
||||
var value = PureYAML.Model.Value.string("x")
|
||||
for _ in 0 ..< (PureYAML.Emitting.maxNestingDepth - 1) {
|
||||
value = .sequence([value])
|
||||
}
|
||||
let dumped = PureYAML.dump(value)
|
||||
#expect(!dumped.contains("nesting exceeds"))
|
||||
#expect(try PureYAML.parse(dumped) == value)
|
||||
}
|
||||
|
||||
// MARK: - Item 7a: leap second
|
||||
|
||||
@Test("A leap second value of 60 parses and adds 60 elapsed seconds")
|
||||
func test_leapSecondParses() throws {
|
||||
let stamp = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:60Z"))
|
||||
#expect(stamp.second == 60)
|
||||
let base = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:00Z"))
|
||||
#expect(stamp.secondsSinceEpoch == base.secondsSinceEpoch + 60)
|
||||
}
|
||||
|
||||
// MARK: - Item 7b: tagged sortKeys
|
||||
|
||||
@Test("Tagged block dump honors sortKeys for plain string keys")
|
||||
func test_taggedBlockSortKeys() throws {
|
||||
let node = try PureYAML.parseTagged("b: 1\na: 2\nc: 3\n")
|
||||
let dumped = PureYAML.dump(node, options: .init(sortKeys: true))
|
||||
#expect(dumped == "a: 2\nb: 1\nc: 3\n")
|
||||
}
|
||||
|
||||
@Test("Tagged canonical dump honors sortKeys for plain string keys")
|
||||
func test_taggedCanonicalSortKeys() throws {
|
||||
let node = try PureYAML.parseTagged("b: 1\na: 2\n")
|
||||
let dumped = PureYAML.dump(node, options: .init(sortKeys: true, canonical: true))
|
||||
let firstKeyLine = dumped.split(separator: "\n").first { $0.contains("? ") }
|
||||
#expect(firstKeyLine?.contains("\"a\"") == true)
|
||||
}
|
||||
}
|
||||
117
Tests/EmitterIndentAndSortKeysTests.swift
Normal file
117
Tests/EmitterIndentAndSortKeysTests.swift
Normal file
@ -0,0 +1,117 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// Coverage for `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's
|
||||
/// first slice: `width`-based line wrapping and canonical mode are separate,
|
||||
/// larger correctness surfaces of their own and remain open, not silently
|
||||
/// dropped).
|
||||
@Suite("Emitter indent width and sortKeys")
|
||||
struct EmitterIndentAndSortKeysTests {
|
||||
@Test("A custom indent width nests block sequences and mappings consistently")
|
||||
func test_customIndentWidth() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "servers", value: .sequence([
|
||||
.mapping(.init([
|
||||
.init(key: "url", value: .string("/")),
|
||||
])),
|
||||
])),
|
||||
]))
|
||||
|
||||
let dumped = PureYAML.dump(value, options: .init(indentWidth: 4))
|
||||
#expect(dumped == """
|
||||
servers:
|
||||
-
|
||||
url: "/"
|
||||
|
||||
""")
|
||||
|
||||
// Indentation is purely a rendering choice: reparsing must recover
|
||||
// the identical value regardless of indent width.
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Indent width 1 is the tightest that still round-trips")
|
||||
func test_indentWidthOne() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "a", value: .mapping(.init([
|
||||
.init(key: "b", value: .int(1)),
|
||||
]))),
|
||||
]))
|
||||
|
||||
let dumped = PureYAML.dump(value, options: .init(indentWidth: 1))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("sortKeys orders string-keyed mapping pairs lexicographically")
|
||||
func test_sortKeysOrdersStringKeys() {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "zebra", value: .int(1)),
|
||||
.init(key: "apple", value: .int(2)),
|
||||
.init(key: "mango", value: .int(3)),
|
||||
]))
|
||||
|
||||
#expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """
|
||||
apple: 2
|
||||
mango: 3
|
||||
zebra: 1
|
||||
|
||||
""")
|
||||
// Off by default: insertion order is preserved unless opted in.
|
||||
#expect(PureYAML.dump(value) == """
|
||||
zebra: 1
|
||||
apple: 2
|
||||
mango: 3
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("sortKeys applies to nested mappings and flow mappings too")
|
||||
func test_sortKeysAppliesToNestedAndFlowMappings() {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "outer", value: .mapping(.init([
|
||||
.init(key: "z", value: .int(1)),
|
||||
.init(key: "a", value: .int(2)),
|
||||
]))),
|
||||
]))
|
||||
|
||||
#expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """
|
||||
outer:
|
||||
a: 2
|
||||
z: 1
|
||||
|
||||
""")
|
||||
|
||||
let flowOptions = PureYAML.Emitting.Options(collectionStyle: .flow, sortKeys: true)
|
||||
#expect(PureYAML.dump(.mapping(.init([
|
||||
.init(key: "z", value: .int(1)),
|
||||
.init(key: "a", value: .int(2)),
|
||||
])), options: flowOptions) == "{\"a\": 2, \"z\": 1}\n")
|
||||
}
|
||||
|
||||
@Test("sortKeys leaves a mapping with any non-string key in insertion order")
|
||||
func test_sortKeysSkipsMixedKeyMappings() {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(keyNode: .string("zebra"), value: .int(1)),
|
||||
.init(keyNode: .sequence([.int(1), .int(2)]), value: .int(2)),
|
||||
.init(keyNode: .string("apple"), value: .int(3)),
|
||||
]))
|
||||
|
||||
let sorted = PureYAML.dump(value, options: .init(sortKeys: true))
|
||||
let unsorted = PureYAML.dump(value)
|
||||
#expect(sorted == unsorted)
|
||||
}
|
||||
|
||||
@Test("sortKeys still round-trips a mapping with duplicate keys")
|
||||
func test_sortKeysRoundTripsDuplicateKeys() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "a", value: .int(1)),
|
||||
.init(key: "a", value: .int(2)),
|
||||
]))
|
||||
|
||||
let dumped = PureYAML.dump(value, options: .init(sortKeys: true))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
}
|
||||
@ -36,11 +36,11 @@ struct EventStreamingParserTests {
|
||||
flow: {id: one, id: two}
|
||||
""")
|
||||
|
||||
let issues = PureYAML.Validation.Validator().collect(value).issues
|
||||
let issues = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values
|
||||
|
||||
#expect(issues.map(\.description) == [
|
||||
"error: Duplicate mapping key 'title' at $.root.title",
|
||||
"error: Duplicate mapping key 'id' at $.flow.id",
|
||||
"Duplicate mapping key 'title' at path: $.root.title",
|
||||
"Duplicate mapping key 'id' at path: $.flow.id",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
55
Tests/InvalidControlCharacterTests.swift
Normal file
55
Tests/InvalidControlCharacterTests.swift
Normal file
@ -0,0 +1,55 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// The reader-level control-character check (`Reader.recordFirstInvalidControlCharacter`,
|
||||
/// `fetchNextToken()`'s check in `LibYAML.Scanner.Fetch.swift`) added to close a real gap
|
||||
/// found comparing PureYAML against Yams on a real-world OpenAPI corpus: Yams's underlying
|
||||
/// libyaml rejects raw control characters (`c-printable` violations) anywhere in the input,
|
||||
/// while `PureYAML.parse()` previously accepted them silently (the only existing check,
|
||||
/// `PreflightScanner.invalidControlCharacterColumn`, is opt-in via `validate()`, not part of
|
||||
/// the core scanner `parse()` uses).
|
||||
@Suite("Invalid control character rejection")
|
||||
struct InvalidControlCharacterTests {
|
||||
@Test("A raw control character in scalar content is rejected with the correct position")
|
||||
func test_rawControlCharacterRejected() {
|
||||
expectParseError(
|
||||
"key: value\u{01}with control\n",
|
||||
.invalidControlCharacter(line: 1, column: 11),
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A tab in scalar content, not indentation, is still legal")
|
||||
func test_tabInContentStillAccepted() throws {
|
||||
let value = try PureYAML.parse("key: value\twith tab\n")
|
||||
let mapping = requireMapping(value)
|
||||
#expect(mapping?["key"] == .string("value\twith tab"))
|
||||
}
|
||||
|
||||
@Test("CR and LF are still legal")
|
||||
func test_crAndLFStillAccepted() throws {
|
||||
let value = try PureYAML.parse("key: |\n line one\n line two\n")
|
||||
let mapping = requireMapping(value)
|
||||
#expect(mapping?["key"] == .string("line one\nline two\n"))
|
||||
}
|
||||
|
||||
@Test("A control character deep inside a large document is still caught")
|
||||
func test_controlCharacterFarIntoDocumentIsCaught() {
|
||||
let padding = String(repeating: "line: value\n", count: 5000)
|
||||
expectParseError(
|
||||
padding + "bad: value\u{02}here\n",
|
||||
.invalidControlCharacter(line: 5001, column: 11),
|
||||
)
|
||||
}
|
||||
|
||||
@Test("An earlier real syntax error is not hidden behind a later control character")
|
||||
func test_earlierSyntaxErrorNotHiddenByLaterControlCharacter() {
|
||||
do {
|
||||
_ = try PureYAML.parse("key: \"unterminated\nbad: value\u{02}here\n")
|
||||
recordIssue("expected parse to throw")
|
||||
} catch let error as PureYAML.Parsing.ParseError {
|
||||
#expect(error.diagnosticCode != "invalidControlCharacter")
|
||||
} catch {
|
||||
recordIssue("expected a ParseError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,7 +73,7 @@ struct KeyedCodingTests {
|
||||
.init(key: "title", value: .string("Second")),
|
||||
]))
|
||||
let expectedIssues = [
|
||||
PureYAML.Validation.Issue(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
path: .init([.key("title")]),
|
||||
@ -81,8 +81,8 @@ struct KeyedCodingTests {
|
||||
]
|
||||
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == expectedIssues)
|
||||
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
|
||||
#expect(collection.values == expectedIssues)
|
||||
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ struct KeyedCodingTests {
|
||||
]))
|
||||
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == [
|
||||
#expect(collection.values == [
|
||||
.init(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
@ -114,14 +114,14 @@ struct KeyedCodingTests {
|
||||
]))
|
||||
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == [
|
||||
#expect(collection.values == [
|
||||
.init(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
path: .init([.key("metadata"), .key("title")]),
|
||||
),
|
||||
])
|
||||
#expect(collection.description == "error: Duplicate mapping key 'title' at $.metadata.title")
|
||||
#expect(collection.description == "Duplicate mapping key 'title' at path: $.metadata.title")
|
||||
} operation: {
|
||||
_ = try TitleOnly(from: PureYAML.Decoding.Decoder(
|
||||
value: value,
|
||||
@ -312,13 +312,13 @@ private func expectEncodingError(
|
||||
|
||||
private func expectValidationError(
|
||||
_: PureYAML.Model.Value,
|
||||
check: (PureYAML.Validation.Issue.Collection) -> Void,
|
||||
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
|
||||
operation: () throws -> some Any,
|
||||
) {
|
||||
do {
|
||||
_ = try operation()
|
||||
recordIssue("expected validation error")
|
||||
} catch let collection as PureYAML.Validation.Issue.Collection {
|
||||
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
|
||||
check(collection)
|
||||
} catch {
|
||||
recordIssue("unexpected error \(error)")
|
||||
@ -327,7 +327,7 @@ private func expectValidationError(
|
||||
|
||||
private func expectValidationError(
|
||||
_ value: PureYAML.Model.Value,
|
||||
check: (PureYAML.Validation.Issue.Collection) -> Void,
|
||||
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
|
||||
) {
|
||||
expectValidationError(value, check: check) {
|
||||
try PureYAML.decode(TitleOnly.self, from: value)
|
||||
|
||||
@ -24,7 +24,7 @@ struct MergeKeyCompatibilityTests {
|
||||
#expect(service?["retries"] == .int(3))
|
||||
#expect(service?["name"] == .string("API"))
|
||||
#expect(service?["<<"] == nil)
|
||||
#expect(PureYAML.Validation.Validator().collect(.mapping(root ?? .init())) == .init())
|
||||
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root ?? .init())) == .init())
|
||||
}
|
||||
|
||||
@Test("Expands merge sequence values with first source precedence and local overrides")
|
||||
@ -86,20 +86,20 @@ struct MergeKeyCompatibilityTests {
|
||||
name: Backend
|
||||
""",
|
||||
)
|
||||
let result = PureYAML.Validation.Validator().collect(value)
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
let expectedIssues = [
|
||||
PureYAML.Validation.Issue(
|
||||
PureYAML.Validation.ValidationError(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key 'name'",
|
||||
path: .init([.key("service"), .key("name")]),
|
||||
),
|
||||
]
|
||||
|
||||
#expect(result.issues == expectedIssues)
|
||||
#expect(result.values == expectedIssues)
|
||||
#expect(result.errors == expectedIssues)
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == expectedIssues)
|
||||
#expect(collection.description == "error: Duplicate mapping key 'name' at $.service.name")
|
||||
#expect(collection.values == expectedIssues)
|
||||
#expect(collection.description == "Duplicate mapping key 'name' at path: $.service.name")
|
||||
}
|
||||
|
||||
let root = requireMapping(value, "expected root mapping")
|
||||
|
||||
@ -19,4 +19,46 @@ struct ParsingAliasTests {
|
||||
}
|
||||
#expect(second["name"] == .string("Example"))
|
||||
}
|
||||
|
||||
/// The regression this guards: `enforceNestingLimit` (Parser.Events.swift) bounds PHYSICAL
|
||||
/// collection depth in the raw event stream, which does nothing against an anchor-expansion
|
||||
/// ("billion laughs") bomb -- a handful of shallow, small anchors each aliased many times,
|
||||
/// each alias nested inside the next, so the LOGICAL tree an alias resolves to is exponential
|
||||
/// in the number of alias levels while the physical document stays kilobytes. A 25-level,
|
||||
/// fan-out-9 document like this one expands past 9^25 logical nodes; before this fix,
|
||||
/// `PureYAML.parse` accepted it in under 2ms (the nesting cap never fires, since raw event
|
||||
/// depth per line stays at 2) and any full-tree walk downstream (JSON representability
|
||||
/// checking, JSON writing, validation) never terminated.
|
||||
@Test("Rejects a nested anchor-expansion bomb instead of accepting an astronomically large tree")
|
||||
func test_rejectsAnchorExpansionBomb() {
|
||||
var yaml = "a0: &a0 [x, x, x]\n"
|
||||
for level in 1 ... 25 {
|
||||
let previous = "*a\(level - 1)"
|
||||
let refs = Array(repeating: previous, count: 9).joined(separator: ", ")
|
||||
yaml += "a\(level): &a\(level) [\(refs)]\n"
|
||||
}
|
||||
yaml += "bomb: *a25\n"
|
||||
|
||||
#expect {
|
||||
try PureYAML.parse(yaml)
|
||||
} throws: { error in
|
||||
guard case .expansionTooLarge = error as? PureYAML.Parsing.ParseError else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The fix must not reject ordinary, non-exponential alias reuse: the same small anchor
|
||||
/// referenced many times at ONE level (no nesting) stays comfortably inside the budget.
|
||||
@Test("A large but non-exponential alias fan-out still parses")
|
||||
func test_wideNonExponentialFanOutStillParses() throws {
|
||||
let refs = Array(repeating: "*item", count: 5000).joined(separator: ", ")
|
||||
let yaml = "item: &item {name: Example}\nlist: [\(refs)]\n"
|
||||
|
||||
let root = try requireMapping(PureYAML.parse(yaml))
|
||||
guard case let .sequence(list)? = root?["list"] else {
|
||||
recordIssue("expected a sequence")
|
||||
return
|
||||
}
|
||||
#expect(list.count == 5000)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ struct ParsingCompatibilityTests {
|
||||
|
||||
#expect(value == testCase.expected)
|
||||
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
|
||||
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
|
||||
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
|
||||
}
|
||||
|
||||
@Test(
|
||||
@ -30,13 +30,13 @@ struct ParsingCompatibilityTests {
|
||||
)
|
||||
func test_collectionDuplicateKeyValidation(testCase: CollectionCompatibilityFixtures.ValidationCase) throws {
|
||||
let value = try PureYAML.parse(testCase.yaml)
|
||||
let result = PureYAML.Validation.Validator().collect(value)
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
|
||||
#expect(result.issues == testCase.expectedIssues)
|
||||
#expect(result.values == testCase.expectedIssues)
|
||||
#expect(result.errors == testCase.expectedIssues)
|
||||
#expect(result.warnings.isEmpty)
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == testCase.expectedIssues)
|
||||
#expect(collection.values == testCase.expectedIssues)
|
||||
#expect(collection.description == testCase.expectedIssues.map(\.description).joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
@ -59,13 +59,13 @@ struct ParsingCompatibilityTests {
|
||||
|
||||
#expect(value == testCase.expected)
|
||||
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
|
||||
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
|
||||
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
|
||||
}
|
||||
|
||||
@Test("Validates direct values for gaps ordinary loaders may collapse")
|
||||
func test_directUnsupportedGapValueValidation() {
|
||||
let value = UnsupportedYAMLGapsFixtures.directPreservedValue
|
||||
let result = PureYAML.Validation.Validator().collect(value)
|
||||
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
|
||||
|
||||
guard case let .mapping(mapping) = value else {
|
||||
recordIssue("expected direct mapping value")
|
||||
@ -76,13 +76,13 @@ struct ParsingCompatibilityTests {
|
||||
#expect(mapping["<<"] == .mapping(.init([
|
||||
.init(key: "enabled", value: .bool(true)),
|
||||
])))
|
||||
#expect(result.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
|
||||
#expect(result.issues.map(\.description) == [
|
||||
"error: Duplicate mapping key 'title' at $.title",
|
||||
#expect(result.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
|
||||
#expect(result.values.map(\.description) == [
|
||||
"Duplicate mapping key 'title' at path: $.title",
|
||||
])
|
||||
expectValidationError(value) { collection in
|
||||
#expect(collection.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
|
||||
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
|
||||
#expect(collection.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
|
||||
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
Tests/PlainScalarRoundTripTests.swift
Normal file
56
Tests/PlainScalarRoundTripTests.swift
Normal file
@ -0,0 +1,56 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// A string the emitter writes without quotes must be read back as that same string. The parser decides what a plain word means (a boolean, a number, null,
|
||||
/// or a string), so the emitter quotes every string the parser would read as anything else.
|
||||
@Suite("Plain scalar round trip")
|
||||
struct PlainScalarRoundTripTests {
|
||||
/// Words and shapes that a reader may take for something other than a string, and shapes that break plain syntax.
|
||||
static let corpus: [String] = [
|
||||
"yes", "Yes", "YES", "no", "No", "NO", "on", "On", "ON", "off", "Off", "OFF", "y", "n", "Y", "N", "true", "True", "TRUE", "false", "False", "FALSE",
|
||||
"null", "Null", "NULL", "~", "0", "-0", "+0", "1", "+1", "-1", "017", "0123", "0o17", "0O17", "0x1F", "0X1f", "0b101", "0B1", "-0x1F", "+0x1F", "1_000",
|
||||
"1__0", "0_1", "_1", "1_", "1e3", "1E3", "1e+3", "1.5", "1.", ".5", "-.5", "+.5", ".inf", ".Inf", ".INF", "-.inf", "+.inf", ".nan", ".NaN", ".NAN", "inf",
|
||||
"nan", "Infinity", "1e400", "0x", "0b", "0o", "9223372036854775807", "9223372036854775808", "-9223372036854775808", "12345678901234567890",
|
||||
"2001-01-01", "12:30:45", "1:30", "<<", "=", "---", "...", "?", "- x", "-", "a: b", "a:", "a :", ":a", "#c", "a #c", "x#c", "!x", "&a", "*a", "|", ">",
|
||||
"%x", "@x", "`x", "'x", "\"x", " x", "x ", "a\tb", "a\nb", "a\r\nb", "héllo", "✓", "a,b", "[a]", "{a}", "a]", "k: v", "x:y", "x: ", "", " ", "word",
|
||||
"two words", "a-b", "a?b", "?x", "-x", "- ", "1 2", "1e", "e1", ".", "..", "+", "0.", "00", "0e0", "1_0.5", "١٢٣", "٣.٥",
|
||||
]
|
||||
|
||||
private static let styles: [PureYAML.Emitting.CollectionStyle] = [.block, .flow]
|
||||
|
||||
private func roundTrip(_ value: PureYAML.Model.Value, _ style: PureYAML.Emitting.CollectionStyle) throws -> PureYAML.Model.Value {
|
||||
let yaml = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, collectionStyle: style))
|
||||
return try PureYAML.parse(yaml)
|
||||
}
|
||||
|
||||
@Test("A string at the root reads back as itself", arguments: corpus)
|
||||
func root(text: String) throws {
|
||||
for style in Self.styles {
|
||||
#expect(try roundTrip(.string(text), style) == .string(text), "\(style): \(text.debugDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A string as a mapping value reads back as itself", arguments: corpus)
|
||||
func mappingValue(text: String) throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "k", value: .string(text))]))
|
||||
for style in Self.styles {
|
||||
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A string as a mapping key reads back as itself", arguments: corpus)
|
||||
func mappingKey(text: String) throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: text, value: .int(1))]))
|
||||
for style in Self.styles {
|
||||
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A string in a sequence reads back as itself", arguments: corpus)
|
||||
func sequenceItem(text: String) throws {
|
||||
let value = PureYAML.Model.Value.sequence([.string(text), .string("after")])
|
||||
for style in Self.styles {
|
||||
#expect(try roundTrip(value, style) == value, "\(style): \(text.debugDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
113
Tests/PublicStreamingScannerTests.swift
Normal file
113
Tests/PublicStreamingScannerTests.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// Differential proof for the public `PureYAML.Streaming.Scanner`, the thin
|
||||
/// translation wrapper around the internal `LibYAML.Scanner.scanStep()`
|
||||
/// already proven by `StreamingScannerDifferentialTests`. That existing proof
|
||||
/// covers `scanStep()` itself; this one covers the translation layer on top
|
||||
/// of it (`Streaming.Token`/`Streaming.Token.Kind`/`Streaming.Token.ScalarStyle`
|
||||
/// mapped from the internal `LibYAML.Token`/`TokenType`/`ScalarStyle`), since a
|
||||
/// wrong case mapping or a dropped field would not be caught by a proof that
|
||||
/// never looks at the public types at all.
|
||||
@Suite("Public streaming scanner")
|
||||
struct PublicStreamingScannerTests {
|
||||
@Test("Public Scanner matches the internal scanAll() reference, translated, at several chunk sizes", arguments: [
|
||||
"title: Example API",
|
||||
"""
|
||||
title: Example
|
||||
servers:
|
||||
- url: /
|
||||
""",
|
||||
"flow: {a: 1, b: [2, 3]}",
|
||||
"""
|
||||
base: &anchor
|
||||
value: 1
|
||||
derived: *anchor
|
||||
""",
|
||||
"block: |\n line one\n line two\n",
|
||||
"&a a: b\nc: &d d\n",
|
||||
])
|
||||
func test_publicScannerMatchesTranslatedReference(yaml: String) throws {
|
||||
try assertPublicScannerMatchesReference(yaml)
|
||||
}
|
||||
|
||||
@Test("Public Scanner matches the internal scanAll() reference over the whole yaml-test-suite corpus")
|
||||
func test_publicScannerMatchesReferenceOverWholeSuite() throws {
|
||||
guard ProcessInfo.processInfo.environment["PUREYAML_RUN_YAML_TEST_SUITE"] == "1" else {
|
||||
return
|
||||
}
|
||||
let manifest = try YAMLTestSuiteManifest.load()
|
||||
var mismatches: [String] = []
|
||||
for testCase in manifest.cases {
|
||||
let yaml = try testCase.loadInput()
|
||||
do {
|
||||
try assertPublicScannerMatchesReference(yaml)
|
||||
} catch {
|
||||
mismatches.append("\(testCase.id): \(error)")
|
||||
}
|
||||
}
|
||||
#expect(mismatches.isEmpty, "public/internal mismatches (\(mismatches.count)): \(mismatches.prefix(10).joined(separator: "; "))")
|
||||
}
|
||||
}
|
||||
|
||||
private func assertPublicScannerMatchesReference(_ yaml: String) throws {
|
||||
let reference = translatedBatchReference(yaml)
|
||||
for chunkSize in [1, 2, 3, 7, 64, yaml.unicodeScalars.count + 1] {
|
||||
let observed = try publicScan(yaml, chunkSize: chunkSize)
|
||||
#expect(observed == reference, "chunk size \(chunkSize) diverged from the translated reference for: \(yaml.debugDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private enum PublicScanResult: Equatable {
|
||||
case tokens([PureYAML.Streaming.Token])
|
||||
case threw(String)
|
||||
}
|
||||
|
||||
/// The reference: the batch scanner's own tokens, run through the exact same
|
||||
/// `Streaming.Token.init(_:)` translator the public `Scanner` uses, so this
|
||||
/// proof isolates "does the public wrapper preserve what the internal scanner
|
||||
/// already produces" from "is the internal scanner itself correct" (the
|
||||
/// latter is `StreamingScannerDifferentialTests`'s job, not this one's).
|
||||
private func translatedBatchReference(_ yaml: String) -> PublicScanResult {
|
||||
var scanner = PureYAML.Parsing.LibYAML.Scanner(yaml)
|
||||
do {
|
||||
return try .tokens(scanner.scanAll().map(PureYAML.Streaming.Token.init))
|
||||
} catch {
|
||||
return .threw("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func publicScan(_ yaml: String, chunkSize: Int) throws -> PublicScanResult {
|
||||
let scalars = Array(yaml.unicodeScalars)
|
||||
var chunks: [String] = []
|
||||
var index = 0
|
||||
while index < scalars.count {
|
||||
let end = min(index + chunkSize, scalars.count)
|
||||
chunks.append(String(String.UnicodeScalarView(scalars[index ..< end])))
|
||||
index = end
|
||||
}
|
||||
|
||||
var scanner = PureYAML.Streaming.Scanner()
|
||||
var nextChunk = 0
|
||||
var tokens: [PureYAML.Streaming.Token] = []
|
||||
do {
|
||||
while true {
|
||||
switch try scanner.next() {
|
||||
case let .token(token):
|
||||
tokens.append(token)
|
||||
case .done:
|
||||
return .tokens(tokens)
|
||||
case .needMoreInput:
|
||||
if nextChunk < chunks.count {
|
||||
scanner.feed(chunks[nextChunk])
|
||||
nextChunk += 1
|
||||
} else {
|
||||
scanner.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return .threw("\(error)")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user