Compare commits
235 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 | |||
| 630849e67a | |||
| 26bf4a0e00 | |||
| a8950e118c | |||
| e76472da11 | |||
| 9b60f415ac | |||
|
|
f5fefc7d21 | ||
| ebf2e2477c | |||
| e64bff7cdf | |||
| d1d5b1a37c | |||
| a8c5de540a | |||
| 5e5e568e2c | |||
| b35e2a04f0 | |||
| c1bb5aa8d6 | |||
| 93621b8c84 | |||
| af4a00f229 | |||
| ba000d90fa | |||
| 9620122759 | |||
| 990a2d2e42 | |||
| 8112954f4c | |||
| ae29059530 | |||
| 056095ca3d | |||
| 8d245ae335 | |||
| df3d43804c | |||
| 941c3f948a | |||
| d4ba594012 | |||
| 5a3bf6518c | |||
| 942a7ff2bc | |||
| 5ea6fa668b | |||
| 4dcedbd6aa | |||
| 5528ce9aa4 | |||
| b45b7a4b7d | |||
| c858339307 | |||
| e3caa5be0d | |||
| 7879818ce5 | |||
| 4b06f6e5fc | |||
| 3b504a9c3a | |||
| 31da488055 | |||
| 5c99086a38 | |||
| 95c5ff3966 | |||
| 143624be08 | |||
| 8bc855d7a0 | |||
| a33be7c37a | |||
| 2b3d95d214 | |||
| 64f3f71211 | |||
| 6c322fc5ed | |||
| 37ac4d862d | |||
| 246e6b1891 | |||
| 4b0dc9375e | |||
| 67d72c8b15 | |||
| 738408d458 | |||
| 37e84b5eb4 | |||
| c1610f1eec | |||
| 956f42bbb7 | |||
| 5a0e6c732e | |||
| a5b8986d5e | |||
| 8f87a1ecd0 | |||
| 47960a85c8 | |||
| d195dd3fa1 | |||
| fc1a6db1dc | |||
| f901a3f04c | |||
| 1915a00fde | |||
| 2e45ecaffe | |||
| d3b4fab0d9 | |||
| 85663682a2 | |||
| 2501cacd19 | |||
| 3291a3701f | |||
| 4941eac711 | |||
| 7e89ba1877 | |||
| 25b1388dbb | |||
| d79df8c4bc | |||
| ecf634fdf7 | |||
| 30dbd9316b | |||
| f1db1344da | |||
| c1dda8bbab | |||
| 28f549311f | |||
| 779ebe596d | |||
| 8649f1722a | |||
| b964fea138 | |||
| b0dc31922b | |||
| 735767ced9 | |||
| fcba4e1f57 | |||
| 27c423dbb9 | |||
| 4c77b17f9f | |||
| 97584dc57c | |||
| b7bcfe1a01 | |||
| 1d07fce635 | |||
| b80e9c1c63 | |||
| ceec6cca84 | |||
| 0ae88855da | |||
| 58f30b4343 | |||
| 82d5b57fd6 | |||
| 688f0d5c12 | |||
| 27e8defac6 | |||
| 08e1979085 | |||
| 455cd07f4a | |||
| c888a113f2 | |||
| d90a091ab0 | |||
| eaf6a01a0e | |||
| 9298536e52 | |||
| 985bd7baac | |||
| 2f6de52654 | |||
| ceb12295a4 | |||
| 234e0382e6 | |||
| 4358cf02cc | |||
| d3ff3823a5 | |||
| 934c9eafa3 | |||
| fadde56a05 | |||
| 44abc12e80 | |||
| 7270359d14 | |||
| c4f7f9dccc | |||
| 9ae6c3baff | |||
| 87e3e5cbab | |||
| ea31bb5864 | |||
| 9acfd57acc | |||
| 5f3892f2c1 | |||
| f7500400ee | |||
| 3f3ce310b1 | |||
| bf069fad39 | |||
| 5c34ebd8b6 | |||
| 98ca76e7af | |||
| 52b62ba390 | |||
| 24bb2ea4dd | |||
| c236945323 | |||
| 57adb14f15 | |||
| eee7079efe | |||
| 4a6b168565 | |||
| fca1441a27 | |||
| 1b67030ed5 | |||
| 2e9975e977 | |||
| b5129d71ee | |||
| 7898dcd1a9 | |||
| d222b8cc53 | |||
| 825084dbff | |||
| b2d2d2f595 | |||
| 8092897612 | |||
| ff199516ac | |||
| c3a1e6aaa4 | |||
| 26e09103b4 | |||
| 254905fe67 | |||
| 3fc3072544 | |||
| 70c3da85a3 | |||
| 884ff7d9a1 | |||
| 18101b8ddc | |||
| df3aa0b5d4 | |||
| 0588996506 | |||
| d9bf2bda26 | |||
| c906993f00 | |||
| 6ce1f906f9 | |||
| 561921df42 | |||
| 6eddb64dcd | |||
| 521062d974 | |||
| ab05580518 | |||
| ed1904534c | |||
| 7918eefe14 | |||
| 526abe9998 | |||
| 4e7ca8fcb2 | |||
| 27514466d0 | |||
| 43b20ab7bc | |||
| 7f94e17e2f | |||
| 348af8b77b | |||
| 9ce57e86e5 | |||
| c3033c356d | |||
| 190b80316b | |||
| 71e53b7a4c | |||
| e8987e4cfd | |||
| ce0585f77e | |||
| 16da7248c7 | |||
| 1875d66d46 | |||
| 318913baeb | |||
| 1de1fcf2a8 | |||
| b426c04bfa | |||
| cd05976fac | |||
| dfb498a89c | |||
| cdf5f082f8 | |||
| 88b67ff0b2 | |||
| 3db3e87d39 | |||
| 13a3859e21 | |||
| 16a47df118 | |||
| 64a171bff4 | |||
| 5d76d184cb | |||
| c13b7c97fd | |||
| dc8d977f78 | |||
| 28cfd3839d | |||
| 7006d1eb52 | |||
| ca96772207 | |||
| b9215d4015 | |||
| aa74cf4332 | |||
| 7c069dce66 | |||
| 8184470cd6 | |||
| 6f76f31c88 | |||
| 208b892019 | |||
| 9f68671c16 | |||
| 5f420cdf02 | |||
| 1bcec9593b | |||
| 0a649c4d55 | |||
| 36364cfcac | |||
| b47ca4fc31 | |||
| 4bc772d807 | |||
| 37239fe613 |
1
.claude/commands/exp
Symbolic link
1
.claude/commands/exp
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../private/mihaela-agents/skills/exp
|
||||
1
.claude/commands/openapi
Symbolic link
1
.claude/commands/openapi
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../private/mihaela-agents/skills/openapi
|
||||
1
.claude/commands/swift
Symbolic link
1
.claude/commands/swift
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../private/mihaela-agents/skills/swift
|
||||
1
.claude/commands/ui
Symbolic link
1
.claude/commands/ui
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../private/mihaela-agents/skills/ui
|
||||
24
.gitea/workflows/rules-freshness.yml
Normal file
24
.gitea/workflows/rules-freshness.yml
Normal file
@ -0,0 +1,24 @@
|
||||
# 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: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Shared-rules freshness marker
|
||||
run: |
|
||||
BASE=https://codeberg.org/Mihaela/rules-swift/raw/branch/main
|
||||
canon=$(curl -fsSL "$BASE/VERSION" | tr -d '[:space:]')
|
||||
mine=$(tr -d '[:space:]' < .rules-version 2>/dev/null || echo MISSING)
|
||||
if [ "$mine" != "$canon" ]; then
|
||||
echo "STALE: shared-rules baseline is v$canon, this repo tracks v$mine."
|
||||
echo "Review the baseline diff, fold it into this repo's rules, then set .rules-version to $canon."
|
||||
exit 1
|
||||
fi
|
||||
echo "rules baseline FRESH at v$canon (repo-specific rules preserved, not checked)"
|
||||
@ -88,4 +88,61 @@ for p in "${PREAMBLE_PATTERNS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Rule 5.1: Cursor attribution. Reject Cursor co-author trailers, the agent
|
||||
# email, any @cursor.com address, and "generated with Cursor" style tells.
|
||||
# Case-insensitive on purpose: cursoragent@cursor.com, Co-authored-by: Cursor.
|
||||
CURSOR_LITERALS=(
|
||||
'Co-Authored-By: Cursor'
|
||||
'Co-authored-by: Cursor'
|
||||
'Generated with Cursor'
|
||||
'Generated by Cursor'
|
||||
'with help from Cursor'
|
||||
'Powered by Cursor'
|
||||
)
|
||||
for p in "${CURSOR_LITERALS[@]}"; do
|
||||
if printf '%s' "$CONTENT" | grep -qiF -- "$p"; then
|
||||
printf 'commit-msg: forbidden Cursor attribution: %s\n' "$p" >&2
|
||||
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
if printf '%s' "$CONTENT" | grep -qiE -- '@cursor\.com'; then
|
||||
printf 'commit-msg: forbidden Cursor email (@cursor.com) in commit message.\n' >&2
|
||||
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Author / committer identity must not be Cursor. git var resolves the exact
|
||||
# identity (including --author / GIT_*_IDENT overrides) this commit will record.
|
||||
for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do
|
||||
[ -z "$ident" ] && continue
|
||||
if printf '%s' "$ident" | grep -qiE -- '@cursor\.com|(^|[^[:alpha:]])cursor(agent)?([^[:alpha:]]|$)'; then
|
||||
printf 'commit-msg: forbidden Cursor identity as author/committer: %s\n' "$ident" >&2
|
||||
printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
# commits.md Rule 1/2: subject must follow Conventional Commits (lowercase type
|
||||
# from the allowed set, optional scope, optional ! for breaking) and be at most
|
||||
# 72 characters. Merge/revert/fixup/squash subjects are tooling-generated and
|
||||
# exempt.
|
||||
SUBJECT=$(printf '%s\n' "$CONTENT" | sed '/^[[:space:]]*$/d' | head -1)
|
||||
case "$SUBJECT" in
|
||||
"Merge "*|"Revert "*|"fixup! "*|"squash! "*|"") ;;
|
||||
*)
|
||||
if [ "${#SUBJECT}" -gt 72 ]; then
|
||||
printf 'commit-msg: subject is %s chars (max 72).\n' "${#SUBJECT}" >&2
|
||||
printf ' Rule: commits.md Rule 1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
if ! printf '%s' "$SUBJECT" | grep -qE '^(feat|fix|critic-fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9._-]+\))?!?: .+'; then
|
||||
printf 'commit-msg: subject must be Conventional Commits: "type(scope): summary".\n' >&2
|
||||
printf ' Allowed types: feat fix critic-fix docs style refactor perf test build ci chore\n' >&2
|
||||
printf ' Rule: commits.md Rule 1, Rule 2\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit $FAIL
|
||||
|
||||
@ -62,6 +62,12 @@ SKIP_PATTERNS=(
|
||||
'^third_party/'
|
||||
'^node_modules/'
|
||||
'^_archive/'
|
||||
'^Tests/Fixtures/acme/'
|
||||
'^Tests/Fixtures/real-yaml/'
|
||||
# Vendored upstream rules canon (mirror of rules-swift). Its enforcement docs
|
||||
# legitimately quote the forbidden phrases. See docs/rules-swift/PROVENANCE.md.
|
||||
'^docs/rules-swift/'
|
||||
'^Tests/Fixtures/yaml-test-suite/'
|
||||
# Rule files that DEFINE the prohibitions; they MUST be able to cite
|
||||
# the forbidden strings to document them.
|
||||
'/github-discipline\.md$'
|
||||
@ -71,8 +77,12 @@ SKIP_PATTERNS=(
|
||||
# .githooks/pre-commit) rather than the canonical *-style-check names.
|
||||
'/commit-msg$'
|
||||
'/pre-commit$'
|
||||
'/pre-push$'
|
||||
'/check-style\.sh$'
|
||||
'/check-staged-style\.sh$'
|
||||
# The CI workflow that enforces the same Cursor prohibition server-side; it
|
||||
# must quote the forbidden tokens to scan for them.
|
||||
'^\.github/workflows/no-cursor-attribution\.yml$'
|
||||
)
|
||||
|
||||
# Filter STAGED_FILES through the skip list.
|
||||
@ -145,6 +155,16 @@ LITERAL_PATTERNS=(
|
||||
'This change was generated'
|
||||
)
|
||||
|
||||
# Cursor-attribution literal tells (case-insensitive at the call site).
|
||||
CURSOR_LITERALS=(
|
||||
'Co-Authored-By: Cursor'
|
||||
'Co-authored-by: Cursor'
|
||||
'Generated with Cursor'
|
||||
'Generated by Cursor'
|
||||
'with help from Cursor'
|
||||
'Powered by Cursor'
|
||||
)
|
||||
|
||||
# AI-tell signature emojis (UTF-8 byte sequences kept in a variable so the
|
||||
# script source stays clean of the glyphs themselves).
|
||||
EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d')
|
||||
@ -178,6 +198,21 @@ while IFS= read -r file; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Cursor-attribution check (mirrors commit-msg). Specific tells only, never
|
||||
# the bare word "cursor" (a legitimate programming term: text/db cursor).
|
||||
for p in "${CURSOR_LITERALS[@]}"; do
|
||||
if printf '%s' "$ADDED" | grep -qiF -- "$p"; then
|
||||
printf 'pre-commit: forbidden Cursor attribution in staged %s: %s\n' "$file" "$p" >&2
|
||||
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
if printf '%s' "$ADDED" | grep -qiE -- '@cursor\.com'; then
|
||||
printf 'pre-commit: forbidden Cursor email (@cursor.com) in staged %s.\n' "$file" >&2
|
||||
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# AI-signature emoji check.
|
||||
while IFS= read -r emoji; do
|
||||
[ -z "$emoji" ] && continue
|
||||
@ -190,6 +225,32 @@ while IFS= read -r file; do
|
||||
done <<<"$EMOJI_TELLS"
|
||||
done <<<"$SCAN_FILES"
|
||||
|
||||
# SwiftFormat + SwiftLint on staged Swift files (formatting-and-linting.md
|
||||
# acceptance item 2). Run only when the tool is installed; CI re-runs both as a
|
||||
# required gate (gates.yml), so a machine lacking the tools cannot land
|
||||
# unformatted or lint-dirty code regardless.
|
||||
STAGED_SWIFT=$(printf '%s\n' "$STAGED_FILES" | grep -E '\.swift$' || true)
|
||||
if [ -n "$STAGED_SWIFT" ]; then
|
||||
if command -v swiftformat >/dev/null 2>&1; then
|
||||
if ! printf '%s\n' "$STAGED_SWIFT" | xargs swiftformat --lint --config .swiftformat >/dev/null 2>&1; then
|
||||
printf 'pre-commit: SwiftFormat found unformatted staged Swift.\n' >&2
|
||||
printf ' Fix: swiftformat . --config .swiftformat\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
else
|
||||
printf 'pre-commit: swiftformat not installed; format gate skipped (CI enforces).\n' >&2
|
||||
fi
|
||||
if command -v swiftlint >/dev/null 2>&1; then
|
||||
if ! printf '%s\n' "$STAGED_SWIFT" | xargs swiftlint lint --strict --quiet --config .swiftlint.yml >/dev/null 2>&1; then
|
||||
printf 'pre-commit: SwiftLint violations in staged Swift.\n' >&2
|
||||
printf ' Fix: swiftlint --config .swiftlint.yml --strict\n' >&2
|
||||
FAIL=1
|
||||
fi
|
||||
else
|
||||
printf 'pre-commit: swiftlint not installed; lint gate skipped (CI enforces).\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf '\n' >&2
|
||||
printf 'Hook scope: only ADDED lines in the staged diff are gated.\n' >&2
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: run the mechanical gates locally before a push reaches GitHub.
|
||||
# These mirror the CI workflow (.github/workflows/ci.yml) so red is caught here
|
||||
# These mirror the CI workflows (.github/workflows/) so red is caught here
|
||||
# first. PureYAML is a root Swift package, so Swift gates run at repo root.
|
||||
#
|
||||
# Install (per repo): git config core.hooksPath .githooks
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -52,6 +52,17 @@ body:
|
||||
- High
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: complexity
|
||||
attributes:
|
||||
label: Complexity
|
||||
description: A rough effort estimate. Small is a focused fix; Large spans many files or needs design.
|
||||
options:
|
||||
- Small
|
||||
- Medium
|
||||
- Large
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
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://github.com/mihaelamj/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://github.com/mihaelamj/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.
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
11
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -41,3 +41,14 @@ body:
|
||||
- High
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: complexity
|
||||
attributes:
|
||||
label: Complexity
|
||||
description: A rough effort estimate. Small is a focused change; Large spans many files or needs design.
|
||||
options:
|
||||
- Small
|
||||
- Medium
|
||||
- Large
|
||||
validations:
|
||||
required: true
|
||||
|
||||
86
.github/workflows/ci.yml
vendored
86
.github/workflows/ci.yml
vendored
@ -1,86 +0,0 @@
|
||||
name: CI
|
||||
|
||||
# PureYAML is a root Swift package. CI must prove macOS, Linux, Windows, and WASM.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install format and lint tools
|
||||
run: brew install swiftformat swiftlint
|
||||
- name: Repository gates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/check-style.sh
|
||||
bash scripts/check-namespacing.sh
|
||||
bash scripts/check-forbidden-patterns.sh
|
||||
git fetch origin main --depth=1 || true
|
||||
BASE_REF=origin/main bash scripts/check-changelog-touched.sh
|
||||
- name: Format and lint
|
||||
run: |
|
||||
swiftformat . --config .swiftformat --lint
|
||||
swiftlint --config .swiftlint.yml --strict
|
||||
- name: Build and test
|
||||
run: |
|
||||
swift build
|
||||
swift test
|
||||
|
||||
linux:
|
||||
name: Linux
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test
|
||||
run: bash scripts/check-linux.sh
|
||||
|
||||
windows:
|
||||
name: Windows
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Swift
|
||||
uses: compnerd/gha-setup-swift@main
|
||||
with:
|
||||
swift-version: swift-6.3.2-release
|
||||
swift-build: 6.3.2-RELEASE
|
||||
- name: Build and test
|
||||
shell: pwsh
|
||||
run: ./scripts/check-windows.ps1
|
||||
|
||||
wasm:
|
||||
name: WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Swift toolchain dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcurl4-openssl-dev
|
||||
- name: Install swiftly
|
||||
run: |
|
||||
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
|
||||
tar zxf swiftly.tar.gz
|
||||
./swiftly init --quiet-shell-followup --assume-yes --skip-install
|
||||
- name: Install Swift 6.3.2 and Wasm SDK
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
swiftly install --use 6.3.2
|
||||
swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c
|
||||
- name: Build for WASM
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
bash scripts/check-wasm.sh
|
||||
26
.github/workflows/conformance.yml
vendored
Normal file
26
.github/workflows/conformance.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
name: Conformance
|
||||
|
||||
# Canonical YAML test-suite conformance: parse-level, value-level, and the canonical
|
||||
# event-stream gate. The corpus is fetched, not vendored, so this job fetches it and then
|
||||
# runs the opt-in conformance tests as a gate. The event gate's recorded-gap set fails the
|
||||
# build on any regression or any silently-closed gap. One concern, one badge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
conformance:
|
||||
name: Conformance
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Fetch the canonical yaml-test-suite
|
||||
run: bash scripts/fetch-yaml-test-suite.sh
|
||||
- name: Run conformance gates
|
||||
run: PUREYAML_RUN_YAML_TEST_SUITE=1 swift test --filter Conformance
|
||||
35
.github/workflows/corpus.yml
vendored
Normal file
35
.github/workflows/corpus.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Corpus
|
||||
|
||||
# Heavy corpus, differential, and throughput gates. Scheduled and manual only;
|
||||
# not a platform claim, so it carries no README badge.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
corpus:
|
||||
name: Corpus
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Run corpus gates
|
||||
run: |
|
||||
bash scripts/check-json-compatibility.sh
|
||||
bash scripts/check-corpus.sh
|
||||
bash scripts/check-yams-differential.sh
|
||||
bash scripts/check-yams-diagnostics.sh
|
||||
bash scripts/check-yams-validation.sh
|
||||
bash scripts/check-throughput.sh
|
||||
bash scripts/check-performance-phases.sh
|
||||
- name: Upload corpus artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: pureyaml-corpus-${{ runner.os }}-${{ github.sha }}
|
||||
path: .build/pureyaml-artifacts
|
||||
retention-days: 30
|
||||
35
.github/workflows/gates.yml
vendored
Normal file
35
.github/workflows/gates.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Gates
|
||||
|
||||
# Repository style/structure gates plus format and lint. One concern, one badge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Gates
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install format and lint tools
|
||||
run: brew install swiftformat swiftlint
|
||||
- name: Repository gates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/check-style.sh
|
||||
bash scripts/check-namespacing.sh
|
||||
bash scripts/check-forbidden-patterns.sh
|
||||
git fetch origin main --depth=1 || true
|
||||
BASE_REF=origin/main bash scripts/check-changelog-touched.sh
|
||||
- name: Format and lint
|
||||
run: |
|
||||
swiftformat . --config .swiftformat --lint
|
||||
swiftlint --config .swiftlint.yml --strict
|
||||
39
.github/workflows/labeler.yml
vendored
Normal file
39
.github/workflows/labeler.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: Issue labeler
|
||||
|
||||
# Rule 1.6 backstop: issue-form dropdown selections produce body TEXT under
|
||||
# `### <field>` headings, not labels. This translates the Priority dropdown into
|
||||
# the `priority: high` label. Complexity is intentionally a body field only, not
|
||||
# a label (Rule 2.1), so it is not mapped here.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Apply form labels
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply priority label from the issue form
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
# Print the first non-empty line after a "### <heading>" line.
|
||||
value_under() {
|
||||
printf '%s\n' "$BODY" | awk -v h="### $1" '
|
||||
$0 == h { found = 1; next }
|
||||
found && NF { print; exit }
|
||||
'
|
||||
}
|
||||
priority=$(value_under "Priority")
|
||||
if [ "$priority" = "High" ]; then
|
||||
gh issue edit "$NUMBER" --repo "$REPO" --add-label "priority: high"
|
||||
else
|
||||
gh issue edit "$NUMBER" --repo "$REPO" --remove-label "priority: high" || true
|
||||
fi
|
||||
22
.github/workflows/linux.yml
vendored
Normal file
22
.github/workflows/linux.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
name: Linux
|
||||
|
||||
# Linux build and test in the official Swift container. One concern, one badge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and test
|
||||
run: bash scripts/check-linux.sh
|
||||
25
.github/workflows/macos.yml
vendored
Normal file
25
.github/workflows/macos.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
name: macOS
|
||||
|
||||
# macOS build and test (debug and release). One concern, one badge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and test
|
||||
run: |
|
||||
swift build
|
||||
swift test
|
||||
swift build -c release
|
||||
swift test -c release
|
||||
60
.github/workflows/no-cursor-attribution.yml
vendored
Normal file
60
.github/workflows/no-cursor-attribution.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
name: No Cursor attribution
|
||||
|
||||
# Server-side companion to the local .githooks/commit-msg and pre-commit guards.
|
||||
# Fails the check if any commit pushed (or in a PR) carries Cursor attribution:
|
||||
# a Cursor co-author trailer, an @cursor.com email, or a "generated with Cursor"
|
||||
# tell, in the commit message or in the author/committer identity.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan commits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Reject Cursor attribution
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the set of commits to scan.
|
||||
if [ "$EVENT" = "pull_request" ]; then
|
||||
range="${PR_BASE}..${PR_HEAD}"
|
||||
elif [ -n "${PUSH_BEFORE:-}" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ]; then
|
||||
range="${PUSH_BEFORE}..${SHA}"
|
||||
else
|
||||
# New branch / tag push or manual run: scan just the tip commit.
|
||||
range="${SHA}~1..${SHA}"
|
||||
git rev-parse "${SHA}~1" >/dev/null 2>&1 || range="$SHA"
|
||||
fi
|
||||
echo "Scanning commit range: $range"
|
||||
|
||||
# Emit message + author + committer for each commit, then grep.
|
||||
fmt='commit %H%nauthor: %an <%ae>%ncommitter: %cn <%ce>%n%B'
|
||||
if printf '%s' "$range" | grep -q '\.\.'; then
|
||||
log=$(git log --format="$fmt" "$range")
|
||||
else
|
||||
log=$(git log -1 --format="$fmt" "$range")
|
||||
fi
|
||||
|
||||
pattern='cursoragent@cursor\.com|@cursor\.com|Co-authored-by:[[:space:]]*Cursor|Generated (with|by) Cursor|with help from Cursor|Powered by Cursor'
|
||||
if printf '%s\n' "$log" | grep -niE -- "$pattern"; then
|
||||
echo "::error::Cursor attribution found in the commits above. Strip it (rewrite the commits) before pushing."
|
||||
exit 1
|
||||
fi
|
||||
echo "No Cursor attribution found."
|
||||
38
.github/workflows/wasm.yml
vendored
Normal file
38
.github/workflows/wasm.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
name: WASM
|
||||
|
||||
# WASM build gate (Rule 8.3): the WASI SDK compiles the package; it does not run
|
||||
# the test suite. A green badge here means "compiles for WASM", not "tests pass".
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Swift toolchain dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcurl4-openssl-dev
|
||||
- name: Install swiftly
|
||||
run: |
|
||||
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
|
||||
tar zxf swiftly.tar.gz
|
||||
./swiftly init --quiet-shell-followup --assume-yes --skip-install
|
||||
- name: Install Swift 6.3.2 and Wasm SDK
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
swiftly install --use 6.3.2
|
||||
swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c
|
||||
- name: Build for WASM
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
bash scripts/check-wasm.sh
|
||||
28
.github/workflows/windows.yml
vendored
Normal file
28
.github/workflows/windows.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Windows
|
||||
|
||||
# Windows build and test on the Swift 6.3.2 toolchain. One concern, one badge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
name: Windows
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Swift
|
||||
uses: compnerd/gha-setup-swift@main
|
||||
with:
|
||||
swift-version: swift-6.3.2-release
|
||||
swift-build: 6.3.2-RELEASE
|
||||
- name: Build and test
|
||||
shell: pwsh
|
||||
run: ./scripts/check-windows.ps1
|
||||
1
.rules-version
Normal file
1
.rules-version
Normal file
@ -0,0 +1 @@
|
||||
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
|
||||
|
||||
@ -16,8 +16,20 @@ Yams and libyaml source remain available from their upstream projects:
|
||||
- https://github.com/jpsim/Yams
|
||||
- https://pyyaml.org/wiki/LibYAML
|
||||
|
||||
The license file includes a matching attribution notice.
|
||||
This file is the canonical attribution notice for the project; the `LICENSE`
|
||||
file is kept to the standard MIT text so it is correctly detected as MIT.
|
||||
|
||||
The private `PureYAMLResearch` repository contains a full Yams source snapshot
|
||||
for study and compatibility research. That repository is not the implementation
|
||||
source for PureYAML.
|
||||
|
||||
## Vendored test data
|
||||
|
||||
`Tests/Fixtures/yaml-test-suite/` contains input cases vendored from the official
|
||||
[YAML test suite](https://github.com/yaml/yaml-test-suite) (`data` branch, commit
|
||||
`6ad3d2c62885d82fc349026c136ef560838fdf3d`), which is distributed under the MIT
|
||||
license. All 402 cases (numbered sub-cases counted) are vendored, each as its input
|
||||
(`in.yaml`), expected JSON projection (`in.json`), and/or expected-error marker
|
||||
(`error`). `Tests/Fixtures/yaml-test-suite-manifest.yaml` records the provenance and
|
||||
each case's conformance status. These files are test data, not implementation
|
||||
source.
|
||||
|
||||
1615
CHANGELOG.md
1615
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
16
LICENSE
16
LICENSE
@ -19,19 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Attribution Notice
|
||||
------------------
|
||||
|
||||
PureYAML is an independent Swift implementation. It is informed by the behavior
|
||||
and public API surface of Yams, and by the YAML parsing model embodied in Yams'
|
||||
bundled CYaml / libyaml-derived C sources.
|
||||
|
||||
PureYAML does not copy Yams Swift source code, CYaml C source code, or libyaml C
|
||||
source code into this public package.
|
||||
|
||||
Yams:
|
||||
https://github.com/jpsim/Yams
|
||||
|
||||
libyaml:
|
||||
https://pyyaml.org/wiki/LibYAML
|
||||
|
||||
@ -9,20 +9,36 @@ let package = Package(
|
||||
name: "PureYAML",
|
||||
targets: ["PureYAML"],
|
||||
),
|
||||
.library(
|
||||
name: "PureYAMLJSON",
|
||||
targets: ["PureYAMLJSON"],
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "PureYAML",
|
||||
path: "Sources",
|
||||
exclude: ["PureYAMLJSON"],
|
||||
),
|
||||
.target(
|
||||
name: "PureYAMLJSON",
|
||||
dependencies: ["PureYAML"],
|
||||
path: "Sources/PureYAMLJSON",
|
||||
),
|
||||
.testTarget(
|
||||
name: "PureYAMLTests",
|
||||
dependencies: ["PureYAML"],
|
||||
path: "Tests",
|
||||
exclude: ["PureYAMLJSONTests"],
|
||||
resources: [
|
||||
.copy("Fixtures"),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "PureYAMLJSONTests",
|
||||
dependencies: ["PureYAMLJSON"],
|
||||
path: "Tests/PureYAMLJSONTests",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
50
README.md
50
README.md
@ -1,6 +1,12 @@
|
||||
# PureYAML
|
||||
|
||||
[](https://github.com/mihaelamj/PureYAML/actions/workflows/ci.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.
|
||||
|
||||
@ -14,34 +20,9 @@ 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.
|
||||
|
||||
## Status
|
||||
|
||||
The current release is 0.1.0. It includes block mappings, block sequences,
|
||||
The current release is 0.2.0. It includes block mappings, block sequences,
|
||||
ordered mappings, common scalars, quoted strings, comments, flow collections,
|
||||
literal and folded block scalars, anchors, aliases, YAML directives, document
|
||||
markers, explicit built-in scalar tags, merge-key expansion, complex mapping keys,
|
||||
@ -67,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,
|
||||
@ -77,6 +70,8 @@ but it does not copy their implementation into `Sources/`. See
|
||||
|
||||
- [Usage](docs/USAGE.md): public APIs, validation, emitter options, and
|
||||
cross-platform gates.
|
||||
- [Corpus gates](docs/CORPUS.md): real-world YAML seed sources, generated
|
||||
mutations, artifact format, and hardening acceptance criteria.
|
||||
- [Migration and support boundaries](docs/MIGRATION.md): what PureYAML can
|
||||
replace today and which YAML features are deliberately unsupported.
|
||||
- [Release process](docs/RELEASE.md): verification commands, tag commands, and
|
||||
@ -262,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
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@ -5,7 +5,7 @@
|
||||
If you believe you have found a security issue in PureYAML, please report it
|
||||
privately. Do not open a public issue for security problems.
|
||||
|
||||
Email **mihaelamj.com** with:
|
||||
Email **mihaelamj@gmail.com** with:
|
||||
|
||||
- A description of the issue and its impact.
|
||||
- Steps to reproduce, or a proof of concept.
|
||||
@ -23,6 +23,13 @@ supported.
|
||||
|
||||
## Scope
|
||||
|
||||
PureYAML generates static sites. The generated output is plain HTML, CSS, and
|
||||
client-side JavaScript for rich tiles. Reports about generated output (for
|
||||
example, missing output escaping that enables script injection) are in scope.
|
||||
PureYAML parses, validates, and emits YAML. Reports about how the library
|
||||
handles untrusted input are in scope, for example:
|
||||
|
||||
- Malformed or adversarial input that causes a crash, an unbounded loop, stack
|
||||
overflow, or excessive memory or CPU use.
|
||||
- Parser or emitter behavior that misrepresents input in a way a consumer could
|
||||
rely on for a security decision (for example, a value that round-trips to a
|
||||
different meaning).
|
||||
- Incorrect rejection or acceptance of input by the validation layer that a
|
||||
consumer treats as a trust boundary.
|
||||
|
||||
@ -9,6 +9,17 @@ public extension PureYAML {
|
||||
try Parsing.Parser().parseStream(yaml)
|
||||
}
|
||||
|
||||
/// Parses a YAML document into a located value tree, annotating every node with its source
|
||||
/// span. The plain ``parse(_:)`` is unaffected; positions are strictly opt-in.
|
||||
static func parseLocated(_ yaml: String) throws -> Model.LocatedValue {
|
||||
try Parsing.Parser().parseLocated(yaml)
|
||||
}
|
||||
|
||||
/// Parses a YAML stream into one located value tree per document, in document order.
|
||||
static func parseStreamLocated(_ yaml: String) throws -> [Model.LocatedValue] {
|
||||
try Parsing.Parser().parseStreamLocated(yaml)
|
||||
}
|
||||
|
||||
/// Serializes a ``Model/Value`` tree into YAML with the selected options.
|
||||
static func dump(
|
||||
_ value: Model.Value,
|
||||
@ -25,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)
|
||||
}
|
||||
|
||||
@ -39,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,181 +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 {
|
||||
if key.isEmpty || key.contains(":") || key.contains("#") || key.contains("\n") {
|
||||
return quote(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func quote(_ value: String) -> String {
|
||||
var output = "\""
|
||||
for character in value {
|
||||
switch character {
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.append(character)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
Sources/Model/LocatedPair.swift
Normal file
20
Sources/Model/LocatedPair.swift
Normal file
@ -0,0 +1,20 @@
|
||||
public extension PureYAML.Model {
|
||||
/// A key-value pair in a located mapping: the key (as a ``Key``), the source span of the
|
||||
/// key node, and the located value. Parallel to ``Pair`` but carrying source coordinates.
|
||||
struct LocatedPair: Equatable, Sendable {
|
||||
public var keyNode: Key
|
||||
public var keyRange: SourceRange
|
||||
public var value: LocatedValue
|
||||
|
||||
public init(keyNode: Key, keyRange: SourceRange, value: LocatedValue) {
|
||||
self.keyNode = keyNode
|
||||
self.keyRange = keyRange
|
||||
self.value = value
|
||||
}
|
||||
|
||||
/// The position-free pair, projecting the located value back to a plain ``Value``.
|
||||
public var pair: Pair {
|
||||
Pair(keyNode: keyNode, value: value.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Sources/Model/LocatedValue.swift
Normal file
58
Sources/Model/LocatedValue.swift
Normal file
@ -0,0 +1,58 @@
|
||||
public extension PureYAML.Model {
|
||||
/// A value tree annotated with source spans, parallel in shape to ``Value``: every node
|
||||
/// carries its own ``SourceRange`` and its located children. It is produced by the opt-in
|
||||
/// ``PureYAML/parseLocated(_:)`` and ``PureYAML/parseStreamLocated(_:)`` entry points; the
|
||||
/// plain ``Value`` API is unchanged, so source positions never affect value equality, the
|
||||
/// conformance oracle, or any existing consumer.
|
||||
///
|
||||
/// `value` projects the position-free ``Value`` (identical to what a plain parse returns),
|
||||
/// and `range` is the node's own span, so a consumer can keep operating on ``Value`` and
|
||||
/// consult the located tree only when it needs a location.
|
||||
indirect enum LocatedValue: Equatable, Sendable {
|
||||
/// A leaf scalar (`null`, `bool`, `int`, `double`, or `string`) with its start-anchored span.
|
||||
case scalar(Value, SourceRange)
|
||||
/// A sequence: its located items and the span from its opening to its closing.
|
||||
case sequence([LocatedValue], SourceRange)
|
||||
/// A mapping: its located pairs and the span from its opening to its closing.
|
||||
case mapping([LocatedPair], SourceRange)
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureYAML.Model.LocatedValue {
|
||||
/// The span of this node.
|
||||
var range: PureYAML.Model.SourceRange {
|
||||
switch self {
|
||||
case let .scalar(_, range), let .sequence(_, range), let .mapping(_, range):
|
||||
range
|
||||
}
|
||||
}
|
||||
|
||||
/// The position-free value this located node projects to, identical to a plain parse.
|
||||
var value: PureYAML.Model.Value {
|
||||
switch self {
|
||||
case let .scalar(value, _):
|
||||
value
|
||||
case let .sequence(items, _):
|
||||
.sequence(items.map(\.value))
|
||||
case let .mapping(pairs, _):
|
||||
.mapping(PureYAML.Model.Mapping(pairs.map(\.pair)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain ``Value`` so every node shares one span. Used where a real per-node span is
|
||||
/// not available (an alias expansion or a merge-inherited pair, whose content is defined
|
||||
/// elsewhere): the whole subtree honestly points at its single reference site.
|
||||
static func synthesized(from value: PureYAML.Model.Value, at range: PureYAML.Model.SourceRange) -> Self {
|
||||
switch value {
|
||||
case .null, .bool, .int, .double, .string:
|
||||
return .scalar(value, range)
|
||||
case let .sequence(items):
|
||||
return .sequence(items.map { synthesized(from: $0, at: range) }, range)
|
||||
case let .mapping(mapping):
|
||||
let pairs = mapping.pairs.map {
|
||||
PureYAML.Model.LocatedPair(keyNode: $0.keyNode, keyRange: range, value: synthesized(from: $0.value, at: range))
|
||||
}
|
||||
return .mapping(pairs, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Sources/Model/SourcePosition.swift
Normal file
19
Sources/Model/SourcePosition.swift
Normal file
@ -0,0 +1,19 @@
|
||||
public extension PureYAML.Model {
|
||||
/// A 1-based line/column position in the source text. Carried by ``LocatedValue`` so a
|
||||
/// consumer (an editor, a linter, a CI report) can point at exactly where a value-tree
|
||||
/// node was written. Positions live on the parallel located tree, never on ``Value``,
|
||||
/// so the plain value tree and its equality stay free of source coordinates.
|
||||
struct SourcePosition: Equatable, Hashable, Sendable, Comparable {
|
||||
public var line: Int
|
||||
public var column: Int
|
||||
|
||||
public init(line: Int, column: Int) {
|
||||
self.line = line
|
||||
self.column = column
|
||||
}
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
(lhs.line, lhs.column) < (rhs.line, rhs.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Sources/Model/SourceRange.swift
Normal file
20
Sources/Model/SourceRange.swift
Normal file
@ -0,0 +1,20 @@
|
||||
public extension PureYAML.Model {
|
||||
/// A source span for a value-tree node: a start and end ``SourcePosition``. A collection
|
||||
/// spans from its opening to its closing event; a scalar carries a start-anchored span
|
||||
/// (start equal to end), because the event stream records a scalar's start mark only.
|
||||
struct SourceRange: Equatable, Hashable, Sendable {
|
||||
public var start: SourcePosition
|
||||
public var end: SourcePosition
|
||||
|
||||
public init(start: SourcePosition, end: SourcePosition) {
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// A zero-width span at a single position, used when only a point is known.
|
||||
public init(at position: SourcePosition) {
|
||||
start = position
|
||||
end = position
|
||||
}
|
||||
}
|
||||
}
|
||||
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:
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ extension PureYAML.Parsing {
|
||||
enum BlockScalarChomping: String, Equatable, CustomStringConvertible {
|
||||
case clip
|
||||
case strip
|
||||
case keep
|
||||
|
||||
var description: String {
|
||||
rawValue
|
||||
|
||||
@ -2,13 +2,13 @@ extension PureYAML.Parsing {
|
||||
enum Event: Equatable, CustomStringConvertible {
|
||||
case streamStart(mark: Mark)
|
||||
case streamEnd(mark: Mark)
|
||||
case documentStart(mark: Mark)
|
||||
case documentEnd(mark: Mark)
|
||||
case documentStart(explicit: Bool, mark: Mark)
|
||||
case documentEnd(explicit: Bool, mark: Mark)
|
||||
case sequenceStart(anchor: String?, tag: String?, style: CollectionStyle, mark: Mark)
|
||||
case sequenceEnd(mark: Mark)
|
||||
case mappingStart(anchor: String?, tag: String?, style: CollectionStyle, mark: Mark)
|
||||
case mappingEnd(mark: Mark)
|
||||
case scalar(value: String, anchor: String?, tag: String?, style: ScalarStyle, mark: Mark)
|
||||
case scalar(value: String, anchor: String?, tag: String?, style: ScalarStyle, mark: Mark, endMark: Mark)
|
||||
case alias(anchor: String, mark: Mark)
|
||||
|
||||
var description: String {
|
||||
@ -17,10 +17,10 @@ extension PureYAML.Parsing {
|
||||
"streamStart @\(mark)"
|
||||
case let .streamEnd(mark):
|
||||
"streamEnd @\(mark)"
|
||||
case let .documentStart(mark):
|
||||
"documentStart @\(mark)"
|
||||
case let .documentEnd(mark):
|
||||
"documentEnd @\(mark)"
|
||||
case let .documentStart(explicit, mark):
|
||||
"documentStart explicit=\(explicit) @\(mark)"
|
||||
case let .documentEnd(explicit, mark):
|
||||
"documentEnd explicit=\(explicit) @\(mark)"
|
||||
case let .sequenceStart(anchor, tag, style, mark):
|
||||
"sequenceStart anchor=\(describe(anchor)) tag=\(describe(tag)) style=\(style) @\(mark)"
|
||||
case let .sequenceEnd(mark):
|
||||
@ -29,7 +29,7 @@ extension PureYAML.Parsing {
|
||||
"mappingStart anchor=\(describe(anchor)) tag=\(describe(tag)) style=\(style) @\(mark)"
|
||||
case let .mappingEnd(mark):
|
||||
"mappingEnd @\(mark)"
|
||||
case let .scalar(value, anchor, tag, style, mark):
|
||||
case let .scalar(value, anchor, tag, style, mark, _):
|
||||
"scalar value=\"\(escape(value))\" anchor=\(describe(anchor)) tag=\(describe(tag)) style=\(style) @\(mark)"
|
||||
case let .alias(anchor, mark):
|
||||
"alias anchor=\(anchor) @\(mark)"
|
||||
@ -39,6 +39,36 @@ extension PureYAML.Parsing {
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing.Event {
|
||||
/// Re-emit the event with libyaml's 0-based line/column mark converted to the
|
||||
/// package's 1-based convention (the byte index is already 0-based in both).
|
||||
func bumpingMark() -> Self {
|
||||
let source = mark
|
||||
let bumped = PureYAML.Parsing.Mark(line: source.line + 1, column: source.column + 1, index: source.index)
|
||||
switch self {
|
||||
case .streamStart:
|
||||
return .streamStart(mark: bumped)
|
||||
case .streamEnd:
|
||||
return .streamEnd(mark: bumped)
|
||||
case let .documentStart(explicit, _):
|
||||
return .documentStart(explicit: explicit, mark: bumped)
|
||||
case let .documentEnd(explicit, _):
|
||||
return .documentEnd(explicit: explicit, mark: bumped)
|
||||
case let .sequenceStart(anchor, tag, style, _):
|
||||
return .sequenceStart(anchor: anchor, tag: tag, style: style, mark: bumped)
|
||||
case .sequenceEnd:
|
||||
return .sequenceEnd(mark: bumped)
|
||||
case let .mappingStart(anchor, tag, style, _):
|
||||
return .mappingStart(anchor: anchor, tag: tag, style: style, mark: bumped)
|
||||
case .mappingEnd:
|
||||
return .mappingEnd(mark: bumped)
|
||||
case let .scalar(value, anchor, tag, style, _, endMark):
|
||||
let bumpedEnd = PureYAML.Parsing.Mark(line: endMark.line + 1, column: endMark.column + 1, index: endMark.index)
|
||||
return .scalar(value: value, anchor: anchor, tag: tag, style: style, mark: bumped, endMark: bumpedEnd)
|
||||
case let .alias(anchor, _):
|
||||
return .alias(anchor: anchor, mark: bumped)
|
||||
}
|
||||
}
|
||||
|
||||
var isMappingEnd: Bool {
|
||||
if case .mappingEnd = self {
|
||||
return true
|
||||
@ -63,16 +93,17 @@ extension PureYAML.Parsing.Event {
|
||||
var mark: PureYAML.Parsing.Mark {
|
||||
switch self {
|
||||
case let .alias(_, mark),
|
||||
let .documentEnd(mark),
|
||||
let .documentStart(mark),
|
||||
let .mappingEnd(mark),
|
||||
let .mappingStart(_, _, _, mark),
|
||||
let .scalar(_, _, _, _, mark),
|
||||
let .scalar(_, _, _, _, mark, _),
|
||||
let .sequenceEnd(mark),
|
||||
let .sequenceStart(_, _, _, mark),
|
||||
let .streamEnd(mark),
|
||||
let .streamStart(mark):
|
||||
mark
|
||||
case let .documentEnd(_, mark),
|
||||
let .documentStart(_, mark):
|
||||
mark
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -56,12 +56,12 @@ extension PureYAML.Parsing.EventComposer {
|
||||
case let .sequence(values):
|
||||
return try values.map { value in
|
||||
guard case let .mapping(mapping) = value else {
|
||||
throw invalidMergeValue(mark: mark)
|
||||
throw PureYAML.Parsing.MergeResolution.invalidMergeValue(mark: mark)
|
||||
}
|
||||
return mapping.pairs
|
||||
}
|
||||
case .bool, .double, .int, .null, .string:
|
||||
throw invalidMergeValue(mark: mark)
|
||||
throw PureYAML.Parsing.MergeResolution.invalidMergeValue(mark: mark)
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,42 +70,8 @@ extension PureYAML.Parsing.EventComposer {
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
) throws -> [PureYAML.Model.Pair] {
|
||||
guard case let .mapping(mapping) = value else {
|
||||
throw invalidMergeValue(mark: mark)
|
||||
throw PureYAML.Parsing.MergeResolution.invalidMergeValue(mark: mark)
|
||||
}
|
||||
return mapping.pairs
|
||||
}
|
||||
|
||||
func mergedPairs(
|
||||
from mergeSources: [[PureYAML.Model.Pair]],
|
||||
localPairs: [PureYAML.Model.Pair],
|
||||
) -> [PureYAML.Model.Pair] {
|
||||
guard !mergeSources.isEmpty else {
|
||||
return localPairs
|
||||
}
|
||||
|
||||
let localKeys = Set(localPairs.map(\.keyNode))
|
||||
var inheritedKeys = Set<PureYAML.Model.Key>()
|
||||
var inheritedPairs: [PureYAML.Model.Pair] = []
|
||||
for source in mergeSources {
|
||||
var sourceKeys = Set<PureYAML.Model.Key>()
|
||||
for pair in source {
|
||||
guard !localKeys.contains(pair.keyNode),
|
||||
!inheritedKeys.contains(pair.keyNode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
inheritedPairs.append(pair)
|
||||
sourceKeys.insert(pair.keyNode)
|
||||
}
|
||||
inheritedKeys.formUnion(sourceKeys)
|
||||
}
|
||||
return inheritedPairs + localPairs
|
||||
}
|
||||
|
||||
func invalidMergeValue(mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.ParseError {
|
||||
PureYAML.Parsing.ParseError.invalidMergeValue(
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,12 @@ extension PureYAML.Parsing {
|
||||
}
|
||||
return false
|
||||
}
|
||||
// An empty or comment-only stream has no document events; the
|
||||
// single-document value API maps that to null.
|
||||
if current?.isStreamEnd == true {
|
||||
_ = try expectStreamEnd()
|
||||
return .null
|
||||
}
|
||||
_ = try expect("document start") { event in
|
||||
if case .documentStart = event {
|
||||
return true
|
||||
@ -35,7 +41,7 @@ extension PureYAML.Parsing {
|
||||
}
|
||||
return false
|
||||
}
|
||||
if case let .documentStart(mark)? = current {
|
||||
if case let .documentStart(_, mark)? = current {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line)
|
||||
}
|
||||
_ = try expectStreamEnd()
|
||||
@ -96,7 +102,7 @@ extension PureYAML.Parsing.EventComposer {
|
||||
let value = try composeMapping()
|
||||
store(value, anchor: anchor)
|
||||
return value
|
||||
case let .scalar(value, anchor, tag, style, mark):
|
||||
case let .scalar(value, anchor, tag, style, mark, _):
|
||||
index += 1
|
||||
let model = try composeScalar(
|
||||
value,
|
||||
@ -149,7 +155,7 @@ extension PureYAML.Parsing.EventComposer {
|
||||
}
|
||||
}
|
||||
_ = try expect("mapping end") { $0.isMappingEnd }
|
||||
let pairs = mergedPairs(from: mergeSources, localPairs: localPairs)
|
||||
let pairs = PureYAML.Parsing.MergeResolution.mergedPairs(from: mergeSources, localPairs: localPairs)
|
||||
return .mapping(PureYAML.Model.Mapping(pairs))
|
||||
}
|
||||
|
||||
@ -157,7 +163,7 @@ extension PureYAML.Parsing.EventComposer {
|
||||
guard let event = current else {
|
||||
throw unexpectedEvent(expected: "mapping key")
|
||||
}
|
||||
guard case let .scalar(value, anchor, tag, style, _) = event else {
|
||||
guard case let .scalar(value, anchor, tag, style, _, _) = event else {
|
||||
let value = try composeNode()
|
||||
return ComposedMappingKey(
|
||||
keyNode: PureYAML.Model.Key(value: value),
|
||||
@ -167,7 +173,10 @@ extension PureYAML.Parsing.EventComposer {
|
||||
)
|
||||
}
|
||||
index += 1
|
||||
store(.string(value), anchor: anchor)
|
||||
if let anchor {
|
||||
let model = try composeScalar(value, tag: tag, style: style, mark: .start)
|
||||
store(model, anchor: anchor)
|
||||
}
|
||||
return ComposedMappingKey(
|
||||
keyNode: .string(value),
|
||||
scalarValue: value,
|
||||
|
||||
10
Sources/Parsing/EventStreamComposedNode.swift
Normal file
10
Sources/Parsing/EventStreamComposedNode.swift
Normal file
@ -0,0 +1,10 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct EventStreamComposedNode {
|
||||
var value: PureYAML.Model.Value
|
||||
var mappingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey
|
||||
var mark: PureYAML.Parsing.Mark
|
||||
var sequenceItems: [EventStreamComposedNode] = []
|
||||
/// The located projection, populated only when the composer tracks locations.
|
||||
var located: PureYAML.Model.LocatedValue?
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Sources/Parsing/EventStreamComposer.Located.swift
Normal file
63
Sources/Parsing/EventStreamComposer.Located.swift
Normal file
@ -0,0 +1,63 @@
|
||||
extension PureYAML.Parsing.EventStreamComposer {
|
||||
func range(at mark: PureYAML.Parsing.Mark) -> PureYAML.Model.SourceRange {
|
||||
.init(at: .init(line: mark.line, column: mark.column))
|
||||
}
|
||||
|
||||
func range(start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark) -> PureYAML.Model.SourceRange {
|
||||
.init(start: .init(line: start.line, column: start.column), end: .init(line: end.line, column: end.column))
|
||||
}
|
||||
|
||||
/// The node's located projection, falling back to a synthesized one at the node's mark if
|
||||
/// the located field was somehow not populated (never expected while tracking locations).
|
||||
func locatedNode(_ node: PureYAML.Parsing.EventStreamComposedNode) -> PureYAML.Model.LocatedValue {
|
||||
node.located ?? .synthesized(from: node.value, at: range(at: node.mark))
|
||||
}
|
||||
|
||||
/// The located pairs a merge value (`<<`) contributes, mirroring ``mergeSources(from:)``.
|
||||
/// The value-side merge validation has already run, so the located shape is a mapping or a
|
||||
/// sequence of mappings; anything else contributes nothing.
|
||||
func locatedMergeSources(from node: PureYAML.Parsing.EventStreamComposedNode) -> [[PureYAML.Model.LocatedPair]] {
|
||||
switch locatedNode(node) {
|
||||
case let .mapping(pairs, _):
|
||||
[pairs]
|
||||
case let .sequence(items, _):
|
||||
items.compactMap { item in
|
||||
guard case let .mapping(pairs, _) = item else {
|
||||
return nil
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
case .scalar:
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The final located pairs, mirroring ``mergedPairs(from:localPairs:)`` exactly (same dedup,
|
||||
/// same order) so the located mapping has the identical shape to the value mapping.
|
||||
func mergedLocatedPairs(
|
||||
from mergeSources: [[PureYAML.Model.LocatedPair]],
|
||||
localPairs: [PureYAML.Model.LocatedPair],
|
||||
) -> [PureYAML.Model.LocatedPair] {
|
||||
guard !mergeSources.isEmpty else {
|
||||
return localPairs
|
||||
}
|
||||
|
||||
let localKeys = Set(localPairs.map(\.keyNode))
|
||||
var inheritedKeys = Set<PureYAML.Model.Key>()
|
||||
var inheritedPairs: [PureYAML.Model.LocatedPair] = []
|
||||
for source in mergeSources {
|
||||
var sourceKeys = Set<PureYAML.Model.Key>()
|
||||
for pair in source {
|
||||
guard !localKeys.contains(pair.keyNode),
|
||||
!inheritedKeys.contains(pair.keyNode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
inheritedPairs.append(pair)
|
||||
sourceKeys.insert(pair.keyNode)
|
||||
}
|
||||
inheritedKeys.formUnion(sourceKeys)
|
||||
}
|
||||
return inheritedPairs + localPairs
|
||||
}
|
||||
}
|
||||
386
Sources/Parsing/EventStreamComposer.swift
Normal file
386
Sources/Parsing/EventStreamComposer.swift
Normal file
@ -0,0 +1,386 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct EventStreamComposer {
|
||||
let scalarParser: Parser
|
||||
let mode: EventStreamComposerMode
|
||||
// When true, the composer builds a parallel located tree alongside each value. The
|
||||
// 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] = []
|
||||
var currentDocumentValue: PureYAML.Model.Value?
|
||||
var currentDocumentLocated: PureYAML.Model.LocatedValue?
|
||||
var didSeeStreamStart = false
|
||||
var didSeeStreamEnd = false
|
||||
|
||||
init(
|
||||
scalarParser: Parser,
|
||||
mode: EventStreamComposerMode,
|
||||
tracksLocations: Bool = false,
|
||||
) {
|
||||
self.scalarParser = scalarParser
|
||||
self.mode = mode
|
||||
self.tracksLocations = tracksLocations
|
||||
}
|
||||
|
||||
mutating func consume(_ event: PureYAML.Parsing.Event) throws {
|
||||
switch event {
|
||||
case .streamStart:
|
||||
didSeeStreamStart = true
|
||||
case .streamEnd:
|
||||
didSeeStreamEnd = true
|
||||
case let .documentStart(_, mark):
|
||||
try startDocument(mark: mark)
|
||||
case let .documentEnd(_, mark):
|
||||
try endDocument(mark: mark)
|
||||
case let .scalar(value, anchor, tag, style, mark, endMark):
|
||||
try consumeScalar(value, anchor: anchor, tag: tag, style: style, marks: (mark, endMark))
|
||||
case let .alias(anchor, mark):
|
||||
try consumeAlias(anchor, mark: mark)
|
||||
case let .sequenceStart(anchor, _, _, mark):
|
||||
containers.append(.init(kind: .sequence, anchor: anchor, mark: mark))
|
||||
case let .sequenceEnd(mark):
|
||||
try completeSequence(mark: mark)
|
||||
case let .mappingStart(anchor, _, _, mark):
|
||||
containers.append(.init(kind: .mapping, anchor: anchor, mark: mark))
|
||||
case let .mappingEnd(mark):
|
||||
try completeMapping(mark: mark)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func composedValue() throws -> PureYAML.Model.Value {
|
||||
guard didSeeStreamStart, didSeeStreamEnd else {
|
||||
throw unexpectedEvent(expected: "complete YAML stream", mark: .start)
|
||||
}
|
||||
// An empty or comment-only stream has zero documents and maps to null;
|
||||
// a single document yields its value; more than one is not a single
|
||||
// document and is rejected.
|
||||
guard documentValues.count <= 1 else {
|
||||
throw unexpectedEvent(expected: "single YAML document", mark: .start)
|
||||
}
|
||||
return documentValues.first ?? .null
|
||||
}
|
||||
|
||||
mutating func composedStream() throws -> [PureYAML.Stream.Document] {
|
||||
guard didSeeStreamStart, didSeeStreamEnd else {
|
||||
throw unexpectedEvent(expected: "complete YAML stream", mark: .start)
|
||||
}
|
||||
return documentValues.enumerated().map { index, value in
|
||||
PureYAML.Stream.Document(index: index, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single document's located tree (mirrors ``composedValue()``). Only meaningful
|
||||
/// when the composer was created with `tracksLocations: true`.
|
||||
mutating func composedLocated() throws -> PureYAML.Model.LocatedValue {
|
||||
guard didSeeStreamStart, didSeeStreamEnd else {
|
||||
throw unexpectedEvent(expected: "complete YAML stream", mark: .start)
|
||||
}
|
||||
guard documentLocated.count <= 1 else {
|
||||
throw unexpectedEvent(expected: "single YAML document", mark: .start)
|
||||
}
|
||||
return documentLocated.first ?? .scalar(.null, .init(at: .init(line: 1, column: 1)))
|
||||
}
|
||||
|
||||
/// The stream's located trees, one per document (mirrors ``composedStream()``). Only
|
||||
/// meaningful when the composer was created with `tracksLocations: true`.
|
||||
mutating func composedLocatedStream() throws -> [PureYAML.Model.LocatedValue] {
|
||||
guard didSeeStreamStart, didSeeStreamEnd else {
|
||||
throw unexpectedEvent(expected: "complete YAML stream", mark: .start)
|
||||
}
|
||||
return documentLocated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureYAML.Parsing.EventStreamComposer {
|
||||
mutating func startDocument(mark: PureYAML.Parsing.Mark) throws {
|
||||
guard containers.isEmpty else {
|
||||
throw unexpectedEvent(expected: "document content end", mark: mark)
|
||||
}
|
||||
if mode == .singleDocument, !documentValues.isEmpty {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: mark.line)
|
||||
}
|
||||
anchors = [:]
|
||||
anchorSizes = [:]
|
||||
expandedNodeBudget = 5_000_000
|
||||
currentDocumentValue = nil
|
||||
}
|
||||
|
||||
mutating func endDocument(mark: PureYAML.Parsing.Mark) throws {
|
||||
guard containers.isEmpty else {
|
||||
throw unexpectedEvent(expected: "document content end", mark: mark)
|
||||
}
|
||||
documentValues.append(currentDocumentValue ?? .null)
|
||||
currentDocumentValue = nil
|
||||
if tracksLocations {
|
||||
documentLocated.append(currentDocumentLocated ?? .scalar(.null, range(at: mark)))
|
||||
currentDocumentLocated = nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func consumeScalar(
|
||||
_ value: String,
|
||||
anchor: String?,
|
||||
tag: String?,
|
||||
style: PureYAML.Parsing.ScalarStyle,
|
||||
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,
|
||||
mappingKey: .init(
|
||||
keyNode: .string(value),
|
||||
scalarValue: value,
|
||||
tag: scalarParser.normalizedTag(tag),
|
||||
style: style,
|
||||
),
|
||||
mark: marks.start,
|
||||
located: tracksLocations ? .scalar(model, range(start: marks.start, end: marks.end)) : nil,
|
||||
), cost: 1)
|
||||
}
|
||||
|
||||
mutating func consumeAlias(
|
||||
_ anchor: String,
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
) throws {
|
||||
guard let value = anchors[anchor] else {
|
||||
throw PureYAML.Parsing.ParseError.undefinedAlias(
|
||||
anchor: anchor,
|
||||
line: mark.line,
|
||||
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(
|
||||
keyNode: PureYAML.Model.Key(value: value),
|
||||
scalarValue: nil,
|
||||
tag: nil,
|
||||
style: nil,
|
||||
),
|
||||
mark: mark,
|
||||
located: tracksLocations ? .synthesized(from: value, at: range(at: mark)) : nil,
|
||||
), cost: cost)
|
||||
}
|
||||
|
||||
mutating func completeSequence(mark: PureYAML.Parsing.Mark) throws {
|
||||
guard let container = containers.popLast(), container.kind == .sequence else {
|
||||
throw unexpectedEvent(expected: "sequence start", mark: mark)
|
||||
}
|
||||
// Only the container's own wrapper is a NEW charge here; every child already charged its
|
||||
// own cost (including any alias expansion) as it completed, accumulated into
|
||||
// `logicalSize`. The cache for a later alias to THIS container needs the full total.
|
||||
try chargeExpansion(1, mark: container.mark)
|
||||
let totalCost = container.logicalSize + 1
|
||||
let value = PureYAML.Model.Value.sequence(container.values.map(\.value))
|
||||
store(value, anchor: container.anchor, cost: totalCost)
|
||||
try complete(.init(
|
||||
value: value,
|
||||
mappingKey: .init(
|
||||
keyNode: PureYAML.Model.Key(value: value),
|
||||
scalarValue: nil,
|
||||
tag: nil,
|
||||
style: nil,
|
||||
),
|
||||
mark: container.mark,
|
||||
sequenceItems: container.values,
|
||||
located: tracksLocations
|
||||
? .sequence(container.locatedValues, range(start: container.mark, end: mark))
|
||||
: nil,
|
||||
), cost: totalCost)
|
||||
}
|
||||
|
||||
mutating func completeMapping(mark: PureYAML.Parsing.Mark) throws {
|
||||
guard let container = containers.popLast(), container.kind == .mapping else {
|
||||
throw unexpectedEvent(expected: "mapping start", mark: mark)
|
||||
}
|
||||
guard container.pendingKey == nil else {
|
||||
throw unexpectedEvent(expected: "mapping value", mark: mark)
|
||||
}
|
||||
try chargeExpansion(1, mark: container.mark)
|
||||
let totalCost = container.logicalSize + 1
|
||||
let pairs = mergedPairs(
|
||||
from: container.mergeSources,
|
||||
localPairs: container.localPairs,
|
||||
)
|
||||
let value = PureYAML.Model.Value.mapping(.init(pairs))
|
||||
store(value, anchor: container.anchor, cost: totalCost)
|
||||
let located: PureYAML.Model.LocatedValue? = tracksLocations
|
||||
? .mapping(
|
||||
mergedLocatedPairs(from: container.mergeLocatedSources, localPairs: container.localLocatedPairs),
|
||||
range(start: container.mark, end: mark),
|
||||
)
|
||||
: nil
|
||||
try complete(.init(
|
||||
value: value,
|
||||
mappingKey: .init(
|
||||
keyNode: PureYAML.Model.Key(value: value),
|
||||
scalarValue: nil,
|
||||
tag: nil,
|
||||
style: nil,
|
||||
),
|
||||
mark: container.mark,
|
||||
located: located,
|
||||
), cost: totalCost)
|
||||
}
|
||||
|
||||
mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode, cost: Int) throws {
|
||||
guard !containers.isEmpty else {
|
||||
currentDocumentValue = node.value
|
||||
if tracksLocations {
|
||||
currentDocumentLocated = node.located
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
mutating func completeMappingNode(_ node: PureYAML.Parsing.EventStreamComposedNode) throws {
|
||||
let index = containers.count - 1
|
||||
guard let key = containers[index].pendingKey else {
|
||||
containers[index].pendingKey = node.mappingKey
|
||||
if tracksLocations {
|
||||
containers[index].pendingKeyNode = node
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if key.isMergeKey {
|
||||
try containers[index].mergeSources.append(contentsOf: mergeSources(
|
||||
from: node,
|
||||
))
|
||||
if tracksLocations {
|
||||
containers[index].mergeLocatedSources.append(contentsOf: locatedMergeSources(from: node))
|
||||
}
|
||||
} else {
|
||||
containers[index].localPairs.append(.init(keyNode: key.keyNode, value: node.value))
|
||||
if tracksLocations {
|
||||
let keyNode = containers[index].pendingKeyNode
|
||||
containers[index].localLocatedPairs.append(.init(
|
||||
keyNode: key.keyNode,
|
||||
keyRange: keyNode.flatMap(\.located)?.range ?? locatedNode(node).range,
|
||||
value: locatedNode(node),
|
||||
))
|
||||
}
|
||||
}
|
||||
containers[index].pendingKey = nil
|
||||
if tracksLocations {
|
||||
containers[index].pendingKeyNode = nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func store(
|
||||
_ value: PureYAML.Model.Value,
|
||||
anchor: String?,
|
||||
cost: Int,
|
||||
) {
|
||||
guard let anchor else {
|
||||
return
|
||||
}
|
||||
anchors[anchor] = value
|
||||
anchorSizes[anchor] = cost
|
||||
}
|
||||
|
||||
func mergeSources(
|
||||
from node: PureYAML.Parsing.EventStreamComposedNode,
|
||||
) throws -> [[PureYAML.Model.Pair]] {
|
||||
switch node.value {
|
||||
case let .mapping(mapping):
|
||||
return [mapping.pairs]
|
||||
case let .sequence(values):
|
||||
guard !node.sequenceItems.isEmpty else {
|
||||
return try values.map { value in
|
||||
guard case let .mapping(mapping) = value else {
|
||||
throw invalidMergeValue(mark: node.mark)
|
||||
}
|
||||
return mapping.pairs
|
||||
}
|
||||
}
|
||||
return try values.enumerated().map { index, value in
|
||||
let mark = node.sequenceItems[index].mark
|
||||
guard case let .mapping(mapping) = value else {
|
||||
throw invalidMergeValue(mark: mark)
|
||||
}
|
||||
return mapping.pairs
|
||||
}
|
||||
case .bool, .double, .int, .null, .string:
|
||||
throw invalidMergeValue(mark: node.mark)
|
||||
}
|
||||
}
|
||||
|
||||
func mergedPairs(
|
||||
from mergeSources: [[PureYAML.Model.Pair]],
|
||||
localPairs: [PureYAML.Model.Pair],
|
||||
) -> [PureYAML.Model.Pair] {
|
||||
guard !mergeSources.isEmpty else {
|
||||
return localPairs
|
||||
}
|
||||
|
||||
let localKeys = Set(localPairs.map(\.keyNode))
|
||||
var inheritedKeys = Set<PureYAML.Model.Key>()
|
||||
var inheritedPairs: [PureYAML.Model.Pair] = []
|
||||
for source in mergeSources {
|
||||
var sourceKeys = Set<PureYAML.Model.Key>()
|
||||
for pair in source {
|
||||
guard !localKeys.contains(pair.keyNode),
|
||||
!inheritedKeys.contains(pair.keyNode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
inheritedPairs.append(pair)
|
||||
sourceKeys.insert(pair.keyNode)
|
||||
}
|
||||
inheritedKeys.formUnion(sourceKeys)
|
||||
}
|
||||
return inheritedPairs + localPairs
|
||||
}
|
||||
|
||||
func invalidMergeValue(
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
) -> PureYAML.Parsing.ParseError {
|
||||
PureYAML.Parsing.ParseError.invalidMergeValue(
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
|
||||
func unexpectedEvent(
|
||||
expected: String,
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
) -> PureYAML.Parsing.ParseError {
|
||||
PureYAML.Parsing.ParseError.unexpectedEvent(
|
||||
expected: expected,
|
||||
actual: "streaming event",
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
}
|
||||
6
Sources/Parsing/EventStreamComposerMode.swift
Normal file
6
Sources/Parsing/EventStreamComposerMode.swift
Normal file
@ -0,0 +1,6 @@
|
||||
extension PureYAML.Parsing {
|
||||
enum EventStreamComposerMode {
|
||||
case singleDocument
|
||||
case stream
|
||||
}
|
||||
}
|
||||
21
Sources/Parsing/EventStreamContainer.swift
Normal file
21
Sources/Parsing/EventStreamContainer.swift
Normal file
@ -0,0 +1,21 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct EventStreamContainer {
|
||||
var kind: EventStreamContainerKind
|
||||
var anchor: String?
|
||||
var values: [EventStreamComposedNode] = []
|
||||
var mergeSources: [[PureYAML.Model.Pair]] = []
|
||||
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]] = []
|
||||
var localLocatedPairs: [PureYAML.Model.LocatedPair] = []
|
||||
var pendingKeyNode: EventStreamComposedNode?
|
||||
}
|
||||
}
|
||||
6
Sources/Parsing/EventStreamContainerKind.swift
Normal file
6
Sources/Parsing/EventStreamContainerKind.swift
Normal file
@ -0,0 +1,6 @@
|
||||
extension PureYAML.Parsing {
|
||||
enum EventStreamContainerKind {
|
||||
case sequence
|
||||
case mapping
|
||||
}
|
||||
}
|
||||
103
Sources/Parsing/LibYAML.CharacterClass.swift
Normal file
103
Sources/Parsing/LibYAML.CharacterClass.swift
Normal file
@ -0,0 +1,103 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// Port of libyaml's character-class macros (`IS_*` in `yaml_private.h`).
|
||||
/// libyaml inspects UTF-8 bytes; this operates on `Unicode.Scalar`, where the
|
||||
/// multi-byte line breaks NEL (`U+0085`), LS (`U+2028`), and PS (`U+2029`) are
|
||||
/// single scalars. End of input is `nil`, which is libyaml's `\0` sentinel, so
|
||||
/// `isZ(nil)` is true and every other predicate is false for `nil`.
|
||||
enum CharacterClass {
|
||||
/// `IS_Z`: the end of the input (libyaml's `\0`).
|
||||
static func isZ(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
scalar == nil
|
||||
}
|
||||
|
||||
/// `IS_SPACE`.
|
||||
static func isSpace(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
scalar == " "
|
||||
}
|
||||
|
||||
/// `IS_TAB`.
|
||||
static func isTab(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
scalar == "\t"
|
||||
}
|
||||
|
||||
/// `IS_BLANK`: space or tab.
|
||||
static func isBlank(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
isSpace(scalar) || isTab(scalar)
|
||||
}
|
||||
|
||||
/// `IS_BREAK`: CR, LF, NEL, LS, or PS.
|
||||
static func isBreak(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
switch scalar {
|
||||
case "\r", "\n", "\u{85}", "\u{2028}", "\u{2029}": true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// `IS_BREAKZ`: a line break or the end of input.
|
||||
static func isBreakZ(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
isBreak(scalar) || isZ(scalar)
|
||||
}
|
||||
|
||||
/// `IS_SPACEZ`: a space, a line break, or the end of input.
|
||||
static func isSpaceZ(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
isSpace(scalar) || isBreakZ(scalar)
|
||||
}
|
||||
|
||||
/// `IS_BLANKZ`: blank (space or tab), a line break, or the end of input.
|
||||
static func isBlankZ(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
isBlank(scalar) || isBreakZ(scalar)
|
||||
}
|
||||
|
||||
/// A flow collection indicator (`,`, `[`, `]`, `{`, `}`); the YAML
|
||||
/// `c-flow-indicator` set excluded from `ns-plain-safe` in flow context.
|
||||
static func isFlowIndicator(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
switch scalar {
|
||||
case ",", "[", "]", "{", "}": true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// `IS_DIGIT`.
|
||||
static func isDigit(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
guard let scalar else { return false }
|
||||
return scalar >= "0" && scalar <= "9"
|
||||
}
|
||||
|
||||
/// `IS_ALPHA`: a digit, ASCII letter, `_`, or `-` (the directive, anchor,
|
||||
/// and tag-handle name characters).
|
||||
static func isAlpha(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
guard let scalar else { return false }
|
||||
return (scalar >= "0" && scalar <= "9")
|
||||
|| (scalar >= "A" && scalar <= "Z")
|
||||
|| (scalar >= "a" && scalar <= "z")
|
||||
|| scalar == "_"
|
||||
|| scalar == "-"
|
||||
}
|
||||
|
||||
/// `IS_HEX`: a hexadecimal digit.
|
||||
static func isHex(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
guard let scalar else { return false }
|
||||
return (scalar >= "0" && scalar <= "9")
|
||||
|| (scalar >= "A" && scalar <= "F")
|
||||
|| (scalar >= "a" && scalar <= "f")
|
||||
}
|
||||
|
||||
/// `IS_ASCII`.
|
||||
static func isASCII(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
guard let scalar else { return false }
|
||||
return scalar.value <= 0x7F
|
||||
}
|
||||
|
||||
/// `IS_PRINTABLE`: the YAML `c-printable` set.
|
||||
static func isPrintable(_ scalar: Unicode.Scalar?) -> Bool {
|
||||
guard let scalar else { return false }
|
||||
let value = scalar.value
|
||||
return value == 0x09 || value == 0x0A || value == 0x0D
|
||||
|| (value >= 0x20 && value <= 0x7E)
|
||||
|| value == 0x85
|
||||
|| (value >= 0xA0 && value <= 0xD7FF)
|
||||
|| (value >= 0xE000 && value <= 0xFFFD)
|
||||
|| (value >= 0x10000 && value <= 0x10FFFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Sources/Parsing/LibYAML.IndentEngine.swift
Normal file
98
Sources/Parsing/LibYAML.IndentEngine.swift
Normal file
@ -0,0 +1,98 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// Port of the indentation stack from `yaml_parser_t` plus
|
||||
/// `yaml_parser_roll_indent` and `yaml_parser_unroll_indent`. libyaml tracks a
|
||||
/// single `indent` column (the current block collection's indentation, -1 at
|
||||
/// the document root) and a stack of the enclosing levels. A block collection
|
||||
/// is opened only when content is strictly more indented than the current
|
||||
/// level, which is exactly why `- a:` followed by a same-column `b:` keeps `b`
|
||||
/// a sibling and leaves `a` an empty value.
|
||||
struct IndentEngine {
|
||||
/// `parser->indent`. -1 at the document root, matching libyaml.
|
||||
private(set) var indent = -1
|
||||
/// `parser->indents`, the stack of enclosing indentation levels.
|
||||
private(set) var indents: [Int] = []
|
||||
/// `parser->flow_level`; indentation is inert inside flow collections.
|
||||
var flowLevel = 0
|
||||
/// The token queue (`parser->tokens`) the engine appends to or, for
|
||||
/// `roll_indent`, inserts a start token into at a recorded position.
|
||||
private(set) var tokens: [Token] = []
|
||||
/// `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
|
||||
/// current indent, adopts the new column, and emits the block start token.
|
||||
/// `number == -1` appends; otherwise the token is inserted at the recorded
|
||||
/// queue position so a `BLOCK-MAPPING-START` precedes a simple key that was
|
||||
/// already queued.
|
||||
mutating func rollIndent(
|
||||
column: Int,
|
||||
number: Int,
|
||||
type: TokenType,
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
) {
|
||||
if flowLevel != 0 {
|
||||
return
|
||||
}
|
||||
if indent < column {
|
||||
indents.append(indent)
|
||||
indent = column
|
||||
let token = Token(type: type, startMark: mark, endMark: mark)
|
||||
if number == -1 {
|
||||
tokens.append(token)
|
||||
} else {
|
||||
tokens.insert(token, at: number - tokensParsed)
|
||||
}
|
||||
lastToken = tokens.last
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_unroll_indent`. In flow context it does nothing.
|
||||
/// While the current indent is deeper than the column, it emits a
|
||||
/// `BLOCK-END` and pops the enclosing level, closing every block collection
|
||||
/// that the dedent ends.
|
||||
mutating func unrollIndent(column: Int, mark: PureYAML.Parsing.Mark) {
|
||||
if flowLevel != 0 {
|
||||
return
|
||||
}
|
||||
while indent > column {
|
||||
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
|
||||
/// `yaml_parser_scan` does when it hands a token to the parser.
|
||||
mutating func dequeue() -> Token? {
|
||||
guard !tokens.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
tokensParsed += 1
|
||||
return tokens.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Sources/Parsing/LibYAML.NodeProperties.swift
Normal file
12
Sources/Parsing/LibYAML.NodeProperties.swift
Normal file
@ -0,0 +1,12 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// The anchor and tag scanned ahead of a node's content, with the marks needed
|
||||
/// for events and tag-resolution errors. Mirrors the locals in
|
||||
/// `yaml_parser_parse_node`.
|
||||
struct NodeProperties {
|
||||
var anchor: String?
|
||||
var tagHandle: String?
|
||||
var tagSuffix: String?
|
||||
var startMark: PureYAML.Parsing.Mark
|
||||
var tagMark: PureYAML.Parsing.Mark
|
||||
}
|
||||
}
|
||||
109
Sources/Parsing/LibYAML.Parser.Block.swift
Normal file
109
Sources/Parsing/LibYAML.Parser.Block.swift
Normal file
@ -0,0 +1,109 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
/// Port of `yaml_parser_parse_block_sequence_entry`.
|
||||
mutating func parseBlockSequenceEntry(first: Bool) throws -> Event {
|
||||
if first {
|
||||
marks.append(peekToken().startMark)
|
||||
skipToken()
|
||||
}
|
||||
let token = peekToken()
|
||||
if case .blockEntry = token.type {
|
||||
let mark = token.endMark
|
||||
skipToken()
|
||||
let next = peekToken()
|
||||
if !isBlockEntry(next), !isBlockEnd(next) {
|
||||
states.append(.blockSequenceEntry)
|
||||
return try parseNode(block: true, indentlessSequence: false)
|
||||
}
|
||||
state = .blockSequenceEntry
|
||||
return processEmptyScalar(at: mark)
|
||||
}
|
||||
if case .blockEnd = token.type {
|
||||
state = states.removeLast()
|
||||
_ = marks.popLast()
|
||||
skipToken()
|
||||
return .sequenceEnd(mark: token.startMark)
|
||||
}
|
||||
throw ParserError(
|
||||
context: "while parsing a block collection",
|
||||
problem: "did not find expected '-' indicator",
|
||||
mark: marks.popLast() ?? token.startMark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_indentless_sequence_entry`. The closing token
|
||||
/// belongs to the enclosing collection, so the SEQUENCE-END is emitted without
|
||||
/// consuming it.
|
||||
mutating func parseIndentlessSequenceEntry() throws -> Event {
|
||||
let token = peekToken()
|
||||
guard case .blockEntry = token.type else {
|
||||
state = states.removeLast()
|
||||
return .sequenceEnd(mark: token.startMark)
|
||||
}
|
||||
let mark = token.endMark
|
||||
skipToken()
|
||||
let next = peekToken()
|
||||
if !isBlockEntry(next), !isKey(next), !isValue(next), !isBlockEnd(next) {
|
||||
states.append(.indentlessSequenceEntry)
|
||||
return try parseNode(block: true, indentlessSequence: false)
|
||||
}
|
||||
state = .indentlessSequenceEntry
|
||||
return processEmptyScalar(at: mark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_block_mapping_key`.
|
||||
mutating func parseBlockMappingKey(first: Bool) throws -> Event {
|
||||
if first {
|
||||
marks.append(peekToken().startMark)
|
||||
skipToken()
|
||||
}
|
||||
let token = peekToken()
|
||||
if case .key = token.type {
|
||||
let mark = token.endMark
|
||||
skipToken()
|
||||
let next = peekToken()
|
||||
if !isKey(next), !isValue(next), !isBlockEnd(next) {
|
||||
states.append(.blockMappingValue)
|
||||
return try parseNode(block: true, indentlessSequence: true)
|
||||
}
|
||||
state = .blockMappingValue
|
||||
return processEmptyScalar(at: mark)
|
||||
}
|
||||
if case .blockEnd = token.type {
|
||||
state = states.removeLast()
|
||||
_ = marks.popLast()
|
||||
skipToken()
|
||||
return .mappingEnd(mark: token.startMark)
|
||||
}
|
||||
// YAML 1.2 permits an empty (null) key: a VALUE indicator with no
|
||||
// preceding key denotes a mapping entry whose key is empty. libyaml
|
||||
// rejects this here; instead emit the empty scalar key and leave the
|
||||
// VALUE token for `parseBlockMappingValue` to consume.
|
||||
if case .value = token.type {
|
||||
state = .blockMappingValue
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
throw ParserError(
|
||||
context: "while parsing a block mapping",
|
||||
problem: "did not find expected key",
|
||||
mark: marks.popLast() ?? token.startMark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_block_mapping_value`.
|
||||
mutating func parseBlockMappingValue() throws -> Event {
|
||||
let token = peekToken()
|
||||
guard case .value = token.type else {
|
||||
state = .blockMappingKey
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
let mark = token.endMark
|
||||
skipToken()
|
||||
let next = peekToken()
|
||||
if !isKey(next), !isValue(next), !isBlockEnd(next) {
|
||||
states.append(.blockMappingKey)
|
||||
return try parseNode(block: true, indentlessSequence: true)
|
||||
}
|
||||
state = .blockMappingKey
|
||||
return processEmptyScalar(at: mark)
|
||||
}
|
||||
}
|
||||
70
Sources/Parsing/LibYAML.Parser.Directives.swift
Normal file
70
Sources/Parsing/LibYAML.Parser.Directives.swift
Normal file
@ -0,0 +1,70 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
/// The implied tag handles, mirroring libyaml's `default_tag_directives`.
|
||||
private var defaultTagDirectives: [(handle: String, prefix: String)] {
|
||||
[("!", "!"), ("!!", "tag:yaml.org,2002:")]
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_process_directives`: consume the `%YAML` and `%TAG`
|
||||
/// directive tokens at the head of a document, validating the version and
|
||||
/// recording the tag handles, then install the default tag handles. The
|
||||
/// version is checked against a local so a duplicate `%YAML` is rejected.
|
||||
mutating func processDirectives() throws {
|
||||
var version: (major: Int, minor: Int)?
|
||||
var token = peekToken()
|
||||
while true {
|
||||
if case let .versionDirective(major, minor) = token.type {
|
||||
try recordVersion(major: major, minor: minor, into: &version, mark: token.startMark)
|
||||
} else if case let .tagDirective(handle, prefix) = token.type {
|
||||
try appendTagDirective(handle: handle, prefix: prefix, allowDuplicates: false, mark: token.startMark)
|
||||
} else if case .reservedDirective = token.type {
|
||||
// A reserved directive is ignored (YAML 1.2 §6.8.1) but still
|
||||
// consumed here so the explicit-document path requires a `---`.
|
||||
} else {
|
||||
break
|
||||
}
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
}
|
||||
for directive in defaultTagDirectives {
|
||||
try appendTagDirective(handle: directive.handle, prefix: directive.prefix, allowDuplicates: true, mark: token.startMark)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func recordVersion(major: Int, minor: Int, into version: inout (major: Int, minor: Int)?, mark: Mark) throws {
|
||||
guard version == nil else {
|
||||
throw ParserError(context: nil, problem: "found duplicate %YAML directive", mark: mark)
|
||||
}
|
||||
// YAML 1.2 §6.8.1: a document whose major version is 1 is parsed even when
|
||||
// the minor version is newer than 1.2 (with a warning), rather than
|
||||
// libyaml's hard rejection of any minor other than 1 or 2.
|
||||
guard major == 1 else {
|
||||
throw ParserError(context: nil, problem: "found incompatible YAML document", mark: mark)
|
||||
}
|
||||
version = (major, minor)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_append_tag_directive`: append a handle/prefix mapping,
|
||||
/// rejecting a redefinition unless duplicates are allowed (the defaults are).
|
||||
mutating func appendTagDirective(handle: String, prefix: String, allowDuplicates: Bool, mark: Mark) throws {
|
||||
if tagDirectives.contains(where: { $0.handle == handle }) {
|
||||
if allowDuplicates {
|
||||
return
|
||||
}
|
||||
throw ParserError(context: nil, problem: "found duplicate %TAG directive", mark: mark)
|
||||
}
|
||||
tagDirectives.append((handle, prefix))
|
||||
}
|
||||
|
||||
/// Port of the tag-resolution block in `yaml_parser_parse_node`: an empty
|
||||
/// handle means the suffix is a verbatim tag; otherwise substitute the handle's
|
||||
/// registered prefix, failing on an undefined handle.
|
||||
func resolveTag(handle: String, suffix: String, startMark _: Mark, tagMark: Mark) throws -> String {
|
||||
guard !handle.isEmpty else {
|
||||
return suffix
|
||||
}
|
||||
guard let directive = tagDirectives.first(where: { $0.handle == handle }) else {
|
||||
throw ParserError(context: "while parsing a node", problem: "found undefined tag handle", mark: tagMark)
|
||||
}
|
||||
return directive.prefix + suffix
|
||||
}
|
||||
}
|
||||
112
Sources/Parsing/LibYAML.Parser.Document.swift
Normal file
112
Sources/Parsing/LibYAML.Parser.Document.swift
Normal file
@ -0,0 +1,112 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
/// Port of `yaml_parser_parse_stream_start`.
|
||||
mutating func parseStreamStart() throws -> Event {
|
||||
let token = peekToken()
|
||||
guard case .streamStart = token.type else {
|
||||
throw ParserError(context: nil, problem: "did not find expected <stream-start>", mark: token.startMark)
|
||||
}
|
||||
state = .implicitDocumentStart
|
||||
skipToken()
|
||||
return .streamStart(mark: token.startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_document_start`, extended for YAML 1.2: stray
|
||||
/// `...` markers are skipped, then a bare document is opened when one is allowed
|
||||
/// (the first document or after an explicit `...`), an explicit document is
|
||||
/// opened after directives and a `---`, or STREAM-END is emitted. libyaml only
|
||||
/// allows a bare document as the very first one.
|
||||
mutating func parseDocumentStart() throws -> Event {
|
||||
var token = peekToken()
|
||||
while case .documentEnd = token.type {
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
}
|
||||
// Directives apply to the document that follows, so the previous document
|
||||
// must be closed with an explicit `...` end marker before them. After an
|
||||
// implicit end (`allowBareDocument` is false) a directive is an error.
|
||||
if isDirective(token), !allowBareDocument {
|
||||
throw ParserError(
|
||||
context: nil,
|
||||
problem: "directives require a preceding document end marker",
|
||||
mark: token.startMark,
|
||||
)
|
||||
}
|
||||
if allowBareDocument, !isDirectiveOrBoundary(token) {
|
||||
try processDirectives()
|
||||
states.append(.documentEnd)
|
||||
state = .blockNode
|
||||
return .documentStart(explicit: false, mark: token.startMark)
|
||||
}
|
||||
if case .streamEnd = token.type {
|
||||
state = .end
|
||||
skipToken()
|
||||
return .streamEnd(mark: token.startMark)
|
||||
}
|
||||
return try openExplicitDocument(startMark: token.startMark)
|
||||
}
|
||||
|
||||
/// The explicit-document branch: directives then a required `---`.
|
||||
private mutating func openExplicitDocument(startMark: Mark) throws -> Event {
|
||||
try processDirectives()
|
||||
let token = peekToken()
|
||||
guard case .documentStart = token.type else {
|
||||
throw ParserError(context: nil, problem: "did not find expected <document start>", mark: token.startMark)
|
||||
}
|
||||
states.append(.documentEnd)
|
||||
state = .documentContent
|
||||
skipToken()
|
||||
return .documentStart(explicit: true, mark: startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_document_content`: an empty scalar stands in for
|
||||
/// a document with no node, otherwise parse the root node.
|
||||
mutating func parseDocumentContent() throws -> Event {
|
||||
let token = peekToken()
|
||||
if isDirectiveOrBoundary(token) || isDocumentEnd(token) {
|
||||
state = states.removeLast()
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
return try parseNode(block: true, indentlessSequence: false)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_document_end`: reset the per-document tag
|
||||
/// directives and emit DOCUMENT-END, explicit only when `...` was present.
|
||||
mutating func parseDocumentEnd() throws -> Event {
|
||||
let token = peekToken()
|
||||
var explicit = false
|
||||
if case .documentEnd = token.type {
|
||||
skipToken()
|
||||
explicit = true
|
||||
}
|
||||
tagDirectives.removeAll()
|
||||
// A bare document may follow only an explicit `...` end marker.
|
||||
allowBareDocument = explicit
|
||||
state = .documentStart
|
||||
return .documentEnd(explicit: explicit, mark: token.startMark)
|
||||
}
|
||||
|
||||
private func isDirectiveOrBoundary(_ token: Token) -> Bool {
|
||||
switch token.type {
|
||||
case .versionDirective, .tagDirective, .reservedDirective, .documentStart, .streamEnd:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func isDirective(_ token: Token) -> Bool {
|
||||
switch token.type {
|
||||
case .versionDirective, .tagDirective, .reservedDirective:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func isDocumentEnd(_ token: Token) -> Bool {
|
||||
if case .documentEnd = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
157
Sources/Parsing/LibYAML.Parser.Flow.swift
Normal file
157
Sources/Parsing/LibYAML.Parser.Flow.swift
Normal file
@ -0,0 +1,157 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
/// Port of `yaml_parser_parse_flow_sequence_entry`. A `?` key inside a flow
|
||||
/// sequence opens a single-pair flow mapping.
|
||||
mutating func parseFlowSequenceEntry(first: Bool) throws -> Event {
|
||||
if first {
|
||||
marks.append(peekToken().startMark)
|
||||
skipToken()
|
||||
}
|
||||
var token = peekToken()
|
||||
if !isFlowSequenceEnd(token) {
|
||||
if !first {
|
||||
guard isFlowEntry(token) else {
|
||||
throw ParserError(
|
||||
context: "while parsing a flow sequence",
|
||||
problem: "did not find expected ',' or ']'",
|
||||
mark: marks.popLast() ?? token.startMark,
|
||||
)
|
||||
}
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
}
|
||||
if isKey(token) {
|
||||
state = .flowSequenceEntryMappingKey
|
||||
skipToken()
|
||||
return .mappingStart(anchor: nil, tag: nil, style: .flow, mark: token.startMark)
|
||||
}
|
||||
if isValue(token) {
|
||||
// A leading `:` opens a single-pair mapping with an empty key
|
||||
// (`[ : v ]`). Keep the VALUE token for the mapping-key state.
|
||||
state = .flowSequenceEntryMappingKey
|
||||
return .mappingStart(anchor: nil, tag: nil, style: .flow, mark: token.startMark)
|
||||
}
|
||||
if !isFlowSequenceEnd(token) {
|
||||
states.append(.flowSequenceEntry)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
}
|
||||
state = states.removeLast()
|
||||
_ = marks.popLast()
|
||||
skipToken()
|
||||
return .sequenceEnd(mark: token.startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_flow_sequence_entry_mapping_key`.
|
||||
mutating func parseFlowSequenceEntryMappingKey() throws -> Event {
|
||||
let token = peekToken()
|
||||
if !isValue(token), !isFlowEntry(token), !isFlowSequenceEnd(token) {
|
||||
states.append(.flowSequenceEntryMappingValue)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
// An empty key: leave a VALUE token for the value state to consume (so the
|
||||
// value is parsed); a closing `]` ends the pair with an empty value too.
|
||||
if isValue(token) || isFlowSequenceEnd(token) {
|
||||
state = .flowSequenceEntryMappingValue
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
let mark = token.endMark
|
||||
skipToken()
|
||||
state = .flowSequenceEntryMappingValue
|
||||
return processEmptyScalar(at: mark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_flow_sequence_entry_mapping_value`.
|
||||
mutating func parseFlowSequenceEntryMappingValue() throws -> Event {
|
||||
var token = peekToken()
|
||||
if isValue(token) {
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
if !isFlowEntry(token), !isFlowSequenceEnd(token) {
|
||||
states.append(.flowSequenceEntryMappingEnd)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
}
|
||||
state = .flowSequenceEntryMappingEnd
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_flow_sequence_entry_mapping_end`.
|
||||
mutating func parseFlowSequenceEntryMappingEnd() throws -> Event {
|
||||
let token = peekToken()
|
||||
state = .flowSequenceEntry
|
||||
return .mappingEnd(mark: token.startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_flow_mapping_key`.
|
||||
mutating func parseFlowMappingKey(first: Bool) throws -> Event {
|
||||
if first {
|
||||
marks.append(peekToken().startMark)
|
||||
skipToken()
|
||||
}
|
||||
var token = peekToken()
|
||||
if !isFlowMappingEnd(token) {
|
||||
if !first {
|
||||
guard isFlowEntry(token) else {
|
||||
throw ParserError(
|
||||
context: "while parsing a flow mapping",
|
||||
problem: "did not find expected ',' or '}'",
|
||||
mark: marks.popLast() ?? token.startMark,
|
||||
)
|
||||
}
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
}
|
||||
if isKey(token) {
|
||||
return try flowMappingKeyEvent()
|
||||
}
|
||||
// YAML 1.2 permits an empty (null) key in a flow mapping: a `:` with
|
||||
// no preceding key denotes an entry whose key is empty. libyaml hands
|
||||
// the VALUE token to `parse_node` and fails; instead emit the empty
|
||||
// scalar key and let `parseFlowMappingValue` consume the indicator.
|
||||
if isValue(token) {
|
||||
state = .flowMappingValue
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
if !isFlowMappingEnd(token) {
|
||||
states.append(.flowMappingEmptyValue)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
}
|
||||
state = states.removeLast()
|
||||
_ = marks.popLast()
|
||||
skipToken()
|
||||
return .mappingEnd(mark: token.startMark)
|
||||
}
|
||||
|
||||
/// The KEY branch of `parse_flow_mapping_key`: parse the key node, or an empty
|
||||
/// scalar when a value/entry/end follows the `?` immediately.
|
||||
private mutating func flowMappingKeyEvent() throws -> Event {
|
||||
skipToken()
|
||||
let token = peekToken()
|
||||
if !isValue(token), !isFlowEntry(token), !isFlowMappingEnd(token) {
|
||||
states.append(.flowMappingValue)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
state = .flowMappingValue
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_parse_flow_mapping_value`.
|
||||
mutating func parseFlowMappingValue(empty: Bool) throws -> Event {
|
||||
var token = peekToken()
|
||||
if empty {
|
||||
state = .flowMappingKey
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
if isValue(token) {
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
if !isFlowEntry(token), !isFlowMappingEnd(token) {
|
||||
states.append(.flowMappingKey)
|
||||
return try parseNode(block: false, indentlessSequence: false)
|
||||
}
|
||||
}
|
||||
state = .flowMappingKey
|
||||
return processEmptyScalar(at: token.startMark)
|
||||
}
|
||||
}
|
||||
50
Sources/Parsing/LibYAML.Parser.Helpers.swift
Normal file
50
Sources/Parsing/LibYAML.Parser.Helpers.swift
Normal file
@ -0,0 +1,50 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
func isBlockEntry(_ token: Token) -> Bool {
|
||||
if case .blockEntry = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockEnd(_ token: Token) -> Bool {
|
||||
if case .blockEnd = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isKey(_ token: Token) -> Bool {
|
||||
if case .key = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValue(_ token: Token) -> Bool {
|
||||
if case .value = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFlowEntry(_ token: Token) -> Bool {
|
||||
if case .flowEntry = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFlowSequenceEnd(_ token: Token) -> Bool {
|
||||
if case .flowSequenceEnd = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isFlowMappingEnd(_ token: Token) -> Bool {
|
||||
if case .flowMappingEnd = token.type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
99
Sources/Parsing/LibYAML.Parser.Node.swift
Normal file
99
Sources/Parsing/LibYAML.Parser.Node.swift
Normal file
@ -0,0 +1,99 @@
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
typealias NodeProperties = PureYAML.Parsing.LibYAML.NodeProperties
|
||||
|
||||
/// Port of `yaml_parser_parse_node`. An ALIAS is emitted directly; otherwise
|
||||
/// the node's anchor/tag properties are consumed, the tag is resolved, and the
|
||||
/// content token selects a scalar, a flow/block collection start, or an empty
|
||||
/// scalar standing in for a property-only node.
|
||||
mutating func parseNode(block: Bool, indentlessSequence: Bool) throws -> Event {
|
||||
let token = peekToken()
|
||||
if case let .alias(name) = token.type {
|
||||
state = states.removeLast()
|
||||
skipToken()
|
||||
return .alias(anchor: name, mark: token.startMark)
|
||||
}
|
||||
let props = consumeNodeProperties()
|
||||
let tag = try props.tagHandle.map {
|
||||
try resolveTag(handle: $0, suffix: props.tagSuffix ?? "", startMark: props.startMark, tagMark: props.tagMark)
|
||||
}
|
||||
return try dispatchNodeContent(block: block, indentlessSequence: indentlessSequence, props: props, tag: tag)
|
||||
}
|
||||
|
||||
/// Consume `properties ::= TAG ANCHOR? | ANCHOR TAG?` ahead of the content.
|
||||
private mutating func consumeNodeProperties() -> NodeProperties {
|
||||
var token = peekToken()
|
||||
var props = NodeProperties(anchor: nil, tagHandle: nil, tagSuffix: nil, startMark: token.startMark, tagMark: token.startMark)
|
||||
if case let .anchor(name) = token.type {
|
||||
props.anchor = name
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
if case let .tag(handle, suffix) = token.type {
|
||||
props.tagHandle = handle
|
||||
props.tagSuffix = suffix
|
||||
props.tagMark = token.startMark
|
||||
skipToken()
|
||||
}
|
||||
} else if case let .tag(handle, suffix) = token.type {
|
||||
props.tagHandle = handle
|
||||
props.tagSuffix = suffix
|
||||
props.tagMark = token.startMark
|
||||
skipToken()
|
||||
token = peekToken()
|
||||
if case let .anchor(name) = token.type {
|
||||
props.anchor = name
|
||||
skipToken()
|
||||
}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
/// Select the event for the content token following a node's properties.
|
||||
private mutating func dispatchNodeContent(block: Bool, indentlessSequence: Bool, props: NodeProperties, tag: String?) throws -> Event {
|
||||
let token = peekToken()
|
||||
if indentlessSequence, case .blockEntry = token.type {
|
||||
state = .indentlessSequenceEntry
|
||||
return .sequenceStart(anchor: props.anchor, tag: tag, style: .block, mark: props.startMark)
|
||||
}
|
||||
if let event = contentEvent(token: token, block: block, props: props, tag: tag) {
|
||||
return event
|
||||
}
|
||||
return try emptyNodeOrError(token: token, block: block, props: props, tag: tag)
|
||||
}
|
||||
|
||||
/// A scalar or a flow/block collection start, or nil if the token is neither.
|
||||
private mutating func contentEvent(token: Token, block: Bool, props: NodeProperties, tag: String?) -> Event? {
|
||||
switch token.type {
|
||||
case let .scalar(value, style):
|
||||
state = states.removeLast()
|
||||
skipToken()
|
||||
return .scalar(value: value, anchor: props.anchor, tag: tag, style: style, mark: props.startMark, endMark: token.endMark)
|
||||
case .flowSequenceStart:
|
||||
state = .flowSequenceFirstEntry
|
||||
return .sequenceStart(anchor: props.anchor, tag: tag, style: .flow, mark: props.startMark)
|
||||
case .flowMappingStart:
|
||||
state = .flowMappingFirstKey
|
||||
return .mappingStart(anchor: props.anchor, tag: tag, style: .flow, mark: props.startMark)
|
||||
case .blockSequenceStart where block:
|
||||
state = .blockSequenceFirstEntry
|
||||
return .sequenceStart(anchor: props.anchor, tag: tag, style: .block, mark: props.startMark)
|
||||
case .blockMappingStart where block:
|
||||
state = .blockMappingFirstKey
|
||||
return .mappingStart(anchor: props.anchor, tag: tag, style: .block, mark: props.startMark)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty scalar when the node carried only properties, else a parse error.
|
||||
private mutating func emptyNodeOrError(token: Token, block: Bool, props: NodeProperties, tag: String?) throws -> Event {
|
||||
if props.anchor != nil || tag != nil {
|
||||
state = states.removeLast()
|
||||
return .scalar(value: "", anchor: props.anchor, tag: tag, style: .plain, mark: props.startMark, endMark: props.startMark)
|
||||
}
|
||||
throw ParserError(
|
||||
context: block ? "while parsing a block node" : "while parsing a flow node",
|
||||
problem: "did not find expected node content",
|
||||
mark: token.startMark,
|
||||
)
|
||||
}
|
||||
}
|
||||
148
Sources/Parsing/LibYAML.Parser.swift
Normal file
148
Sources/Parsing/LibYAML.Parser.swift
Normal file
@ -0,0 +1,148 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// A faithful port of libyaml's `parser.c`: a one-event-at-a-time state
|
||||
/// machine that consumes the scanner's token stream and produces the YAML
|
||||
/// event stream. The token queue is materialized up front by the scanner, so
|
||||
/// `peekToken`/`skipToken` index into a fixed array rather than pulling lazily,
|
||||
/// which is equivalent because simple-key resolution lives entirely in the
|
||||
/// scanner.
|
||||
struct Parser {
|
||||
let tokens: [Token]
|
||||
var cursor = 0
|
||||
var state: ParserState = .streamStart
|
||||
var states: [ParserState] = []
|
||||
var marks: [Mark] = []
|
||||
var tagDirectives: [(handle: String, prefix: String)] = []
|
||||
/// Whether a bare (implicit) document may begin here: true for the first
|
||||
/// document and after an explicit `...` end marker, false otherwise. YAML
|
||||
/// 1.2 allows a bare document to follow `...`, which libyaml rejects.
|
||||
var allowBareDocument = true
|
||||
|
||||
init(_ input: String = "") throws {
|
||||
var scanner = Scanner(input)
|
||||
tokens = try scanner.scanAll()
|
||||
}
|
||||
|
||||
init(tokens: [Token]) {
|
||||
self.tokens = tokens
|
||||
}
|
||||
|
||||
/// Drive the state machine until STREAM-END, collecting every event.
|
||||
mutating func parse() throws -> [Event] {
|
||||
var events: [Event] = []
|
||||
while state != .end {
|
||||
try events.append(stateMachine())
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// The head of the token queue. STREAM-END is always the final token and
|
||||
/// the machine halts once it has been consumed, so the clamp is never the
|
||||
/// value actually parsed; it only avoids an out-of-range index.
|
||||
func peekToken() -> Token {
|
||||
tokens[min(cursor, tokens.count - 1)]
|
||||
}
|
||||
|
||||
mutating func skipToken() {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_state_machine`: dispatch on the current state. The
|
||||
/// flat 24-way switch is split into document/block/flow groups to keep each
|
||||
/// dispatcher's branching within the complexity budget.
|
||||
mutating func stateMachine() throws -> Event {
|
||||
switch state {
|
||||
case .streamStart, .implicitDocumentStart, .documentStart, .documentContent, .documentEnd:
|
||||
try documentStateMachine()
|
||||
case .blockNode, .blockNodeOrIndentlessSequence, .flowNode,
|
||||
.blockSequenceFirstEntry, .blockSequenceEntry, .indentlessSequenceEntry,
|
||||
.blockMappingFirstKey, .blockMappingKey, .blockMappingValue:
|
||||
try nodeStateMachine()
|
||||
case .flowSequenceFirstEntry, .flowSequenceEntry, .flowSequenceEntryMappingKey,
|
||||
.flowSequenceEntryMappingValue, .flowSequenceEntryMappingEnd,
|
||||
.flowMappingFirstKey, .flowMappingKey, .flowMappingValue, .flowMappingEmptyValue:
|
||||
try flowStateMachine()
|
||||
case .end:
|
||||
throw endError()
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func documentStateMachine() throws -> Event {
|
||||
switch state {
|
||||
case .streamStart:
|
||||
try parseStreamStart()
|
||||
case .implicitDocumentStart:
|
||||
try parseDocumentStart()
|
||||
case .documentStart:
|
||||
try parseDocumentStart()
|
||||
case .documentContent:
|
||||
try parseDocumentContent()
|
||||
default:
|
||||
try parseDocumentEnd()
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func nodeStateMachine() throws -> Event {
|
||||
switch state {
|
||||
case .blockNode:
|
||||
try parseNode(block: true, indentlessSequence: false)
|
||||
case .blockNodeOrIndentlessSequence:
|
||||
try parseNode(block: true, indentlessSequence: true)
|
||||
case .flowNode:
|
||||
try parseNode(block: false, indentlessSequence: false)
|
||||
case .blockSequenceFirstEntry:
|
||||
try parseBlockSequenceEntry(first: true)
|
||||
case .blockSequenceEntry:
|
||||
try parseBlockSequenceEntry(first: false)
|
||||
case .indentlessSequenceEntry:
|
||||
try parseIndentlessSequenceEntry()
|
||||
case .blockMappingFirstKey:
|
||||
try parseBlockMappingKey(first: true)
|
||||
case .blockMappingKey:
|
||||
try parseBlockMappingKey(first: false)
|
||||
default:
|
||||
try parseBlockMappingValue()
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func flowStateMachine() throws -> Event {
|
||||
switch state {
|
||||
case .flowSequenceFirstEntry:
|
||||
try parseFlowSequenceEntry(first: true)
|
||||
case .flowSequenceEntry:
|
||||
try parseFlowSequenceEntry(first: false)
|
||||
case .flowSequenceEntryMappingKey:
|
||||
try parseFlowSequenceEntryMappingKey()
|
||||
case .flowSequenceEntryMappingValue:
|
||||
try parseFlowSequenceEntryMappingValue()
|
||||
case .flowSequenceEntryMappingEnd:
|
||||
try parseFlowSequenceEntryMappingEnd()
|
||||
case .flowMappingFirstKey:
|
||||
try parseFlowMappingKey(first: true)
|
||||
case .flowMappingKey:
|
||||
try parseFlowMappingKey(first: false)
|
||||
case .flowMappingValue:
|
||||
try parseFlowMappingValue(empty: false)
|
||||
default:
|
||||
try parseFlowMappingValue(empty: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func endError() -> ParserError {
|
||||
ParserError(context: nil, problem: "parsed past the end of the stream", mark: peekToken().startMark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_process_empty_scalar`: an empty plain scalar event
|
||||
/// stands in for an absent node.
|
||||
func processEmptyScalar(at mark: Mark) -> Event {
|
||||
.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing.LibYAML.Parser {
|
||||
typealias Token = PureYAML.Parsing.LibYAML.Token
|
||||
typealias Mark = PureYAML.Parsing.Mark
|
||||
typealias Event = PureYAML.Parsing.Event
|
||||
typealias ParserError = PureYAML.Parsing.LibYAML.ParserError
|
||||
typealias ParserState = PureYAML.Parsing.LibYAML.ParserState
|
||||
}
|
||||
10
Sources/Parsing/LibYAML.ParserError.swift
Normal file
10
Sources/Parsing/LibYAML.ParserError.swift
Normal file
@ -0,0 +1,10 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// A parser error, mirroring `yaml_parser_set_parser_error[_context]`: an
|
||||
/// optional context describing the surrounding production, the immediate
|
||||
/// problem, and the mark where it was detected.
|
||||
struct ParserError: Error, Equatable {
|
||||
var context: String?
|
||||
var problem: String
|
||||
var mark: PureYAML.Parsing.Mark
|
||||
}
|
||||
}
|
||||
30
Sources/Parsing/LibYAML.ParserState.swift
Normal file
30
Sources/Parsing/LibYAML.ParserState.swift
Normal file
@ -0,0 +1,30 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// The parser state, mirroring `yaml_parser_state_e`. Each case names the
|
||||
/// production the state machine is in the middle of recognizing.
|
||||
enum ParserState: Equatable {
|
||||
case streamStart
|
||||
case implicitDocumentStart
|
||||
case documentStart
|
||||
case documentContent
|
||||
case documentEnd
|
||||
case blockNode
|
||||
case blockNodeOrIndentlessSequence
|
||||
case flowNode
|
||||
case blockSequenceFirstEntry
|
||||
case blockSequenceEntry
|
||||
case indentlessSequenceEntry
|
||||
case blockMappingFirstKey
|
||||
case blockMappingKey
|
||||
case blockMappingValue
|
||||
case flowSequenceFirstEntry
|
||||
case flowSequenceEntry
|
||||
case flowSequenceEntryMappingKey
|
||||
case flowSequenceEntryMappingValue
|
||||
case flowSequenceEntryMappingEnd
|
||||
case flowMappingFirstKey
|
||||
case flowMappingKey
|
||||
case flowMappingValue
|
||||
case flowMappingEmptyValue
|
||||
case end
|
||||
}
|
||||
}
|
||||
152
Sources/Parsing/LibYAML.Reader.swift
Normal file
152
Sources/Parsing/LibYAML.Reader.swift
Normal file
@ -0,0 +1,152 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// Port of libyaml's input buffer and position tracking (the `buffer`/`mark`
|
||||
/// fields with the `PEEK`, `SKIP`, and `SKIP_LINE` macros). It iterates
|
||||
/// `Unicode.Scalar`s rather than grapheme clusters so a CR and an LF are
|
||||
/// distinct, matching libyaml's byte-oriented scanning, and tracks the mark in
|
||||
/// libyaml's 0-based line and column convention; conversion to the package's
|
||||
/// 1-based `Mark` happens at token emission during integration.
|
||||
struct Reader {
|
||||
private var scalars: [Unicode.Scalar]
|
||||
private(set) var index = 0
|
||||
private(set) var line = 0
|
||||
private(set) var column = 0
|
||||
/// Whether the buffered `scalars` are the complete input. True for every
|
||||
/// batch caller (the default), so `ranDry` can never be set and this type's
|
||||
/// behavior is unchanged for `scanAll()`. A resumable caller passes `false`
|
||||
/// and later calls `markFinal()` once the last chunk has been appended.
|
||||
private(set) var isFinal: Bool
|
||||
/// Set by `peek(_:)` when it was asked for a position past the buffered
|
||||
/// scalars while `!isFinal`: the query is inconclusive, not a real end of
|
||||
/// input, and the caller must suspend and retry after `append(_:)`. Batch
|
||||
/// callers (`isFinal == true`) never set this, since the guard below is
|
||||
/// unreachable when `isFinal` is true.
|
||||
private(set) var ranDry = false
|
||||
|
||||
/// The index of the first non-`c-printable` scalar found in `scalars`
|
||||
/// (libyaml's reader-level check, `yaml_parser_update_buffer`'s printable
|
||||
/// validation), or nil if none has been found yet. Set once, by whichever
|
||||
/// of `init`/`append(_:)` first buffers the offending scalar; never
|
||||
/// cleared. `fetchNextToken()` checks this once per token and throws,
|
||||
/// mirroring libyaml rejecting raw control characters (anything outside
|
||||
/// `c-printable`, i.e. not a plain tab/CR/LF and not in the printable
|
||||
/// ranges `CharacterClass.isPrintable` implements) wherever they occur in
|
||||
/// the stream, not only inside specific constructs like anchor names.
|
||||
private(set) var invalidControlCharacterIndex: Int?
|
||||
|
||||
init(_ string: String = "", isFinal: Bool = true) {
|
||||
scalars = Array(string.unicodeScalars)
|
||||
self.isFinal = isFinal
|
||||
recordFirstInvalidControlCharacter(in: 0 ..< scalars.count)
|
||||
}
|
||||
|
||||
/// `PEEK_TOKEN` / `CHECK_AT`: the scalar `offset` ahead, or nil past the
|
||||
/// end (libyaml's `\0`). Past the buffered end while more chunks may still
|
||||
/// arrive, this flags `ranDry` instead of asserting true end of input.
|
||||
mutating func peek(_ offset: Int = 0) -> Unicode.Scalar? {
|
||||
let position = index + offset
|
||||
if position < scalars.count {
|
||||
return scalars[position]
|
||||
}
|
||||
if !isFinal {
|
||||
ranDry = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Append a chunk of newly arrived input. Only meaningful before
|
||||
/// `markFinal()`. Clears `ranDry`: new data may satisfy whatever `peek(_:)`
|
||||
/// call set it, and nothing else ever clears it. The existing
|
||||
/// checkpoint-restore path gets this for free (the restored `Reader` is a
|
||||
/// copy from before the dry read), but a resumed scan that keeps its
|
||||
/// reader position instead of restoring a snapshot (`PlainScalarSuspended`)
|
||||
/// has no other point where a stale `true` would ever become false again.
|
||||
mutating func append(_ chunk: String) {
|
||||
let previousCount = scalars.count
|
||||
scalars.append(contentsOf: chunk.unicodeScalars)
|
||||
ranDry = false
|
||||
recordFirstInvalidControlCharacter(in: previousCount ..< scalars.count)
|
||||
}
|
||||
|
||||
/// Declare that no further chunks are coming: a `peek` past the buffered
|
||||
/// end is now a real end of input, matching the batch reader's behavior.
|
||||
mutating func markFinal() {
|
||||
isFinal = true
|
||||
}
|
||||
|
||||
var isAtEnd: Bool {
|
||||
index >= scalars.count
|
||||
}
|
||||
|
||||
/// The current position. Column is 0-based, as in libyaml.
|
||||
var mark: PureYAML.Parsing.Mark {
|
||||
PureYAML.Parsing.Mark(line: line, column: column, index: index)
|
||||
}
|
||||
|
||||
/// `SKIP`: consume one non-break scalar, advancing the column.
|
||||
mutating func skip() {
|
||||
guard index < scalars.count else {
|
||||
return
|
||||
}
|
||||
index += 1
|
||||
column += 1
|
||||
}
|
||||
|
||||
/// `SKIP_LINE`: consume a line break and start a new line. A CR+LF pair is
|
||||
/// one break, matching libyaml.
|
||||
mutating func skipLine() {
|
||||
if peek() == "\r", peek(1) == "\n" {
|
||||
index += 2
|
||||
} else if CharacterClass.isBreak(peek()) {
|
||||
index += 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Sources/Parsing/LibYAML.Scanner.Anchor.swift
Normal file
45
Sources/Parsing/LibYAML.Scanner.Anchor.swift
Normal file
@ -0,0 +1,45 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// Port of `yaml_parser_scan_anchor`, widened to YAML 1.2: an anchor (`&name`)
|
||||
/// or alias (`*name`) name is `ns-anchor-char+`, i.e. any printable character
|
||||
/// that is not whitespace, a line break, or a flow indicator (`,[]{}`). libyaml
|
||||
/// restricts the name to alphanumerics, which rejects Unicode and punctuation
|
||||
/// anchor names that YAML 1.2 allows.
|
||||
mutating func scanAnchor(isAnchor: Bool) throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
var name = ""
|
||||
while let character = reader.peek(), isAnchorCharacter(character) {
|
||||
name.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
let endMark = reader.mark
|
||||
guard !name.isEmpty else {
|
||||
throw PureYAML.Parsing.LibYAML.ScannerError(
|
||||
context: isAnchor ? "while scanning an anchor" : "while scanning an alias",
|
||||
problem: "did not find expected alphabetic or numeric character",
|
||||
mark: startMark,
|
||||
)
|
||||
}
|
||||
return PureYAML.Parsing.LibYAML.Token(
|
||||
type: isAnchor ? .anchor(name: name) : .alias(name: name),
|
||||
startMark: startMark,
|
||||
endMark: endMark,
|
||||
)
|
||||
}
|
||||
|
||||
/// `ns-anchor-char`: a printable, non-blank, non-break character that is not a
|
||||
/// flow indicator or the byte-order mark.
|
||||
private func isAnchorCharacter(_ scalar: Unicode.Scalar) -> Bool {
|
||||
guard Classify.isPrintable(scalar), !Classify.isBlankZ(scalar), scalar != "\u{FEFF}" else {
|
||||
return false
|
||||
}
|
||||
switch scalar {
|
||||
case ",", "[", "]", "{", "}":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
205
Sources/Parsing/LibYAML.Scanner.BlockScalar.swift
Normal file
205
Sources/Parsing/LibYAML.Scanner.BlockScalar.swift
Normal file
@ -0,0 +1,205 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// The accumulation state for `scan_block_scalar`.
|
||||
private struct BlockState {
|
||||
var string = ""
|
||||
var leadingBreak = ""
|
||||
var trailingBreaks = ""
|
||||
var leadingBlank = false
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_block_scalar`. Scans a literal (`|`) or folded
|
||||
/// (`>`) block scalar: parses the chomping (`+`/`-`) and indentation indicators
|
||||
/// from the header, auto-detects the indentation if not given, then folds the
|
||||
/// content (literal keeps breaks, folded joins equally-indented non-empty lines
|
||||
/// with a space) and applies clip/strip/keep chomping to the trailing breaks.
|
||||
mutating func scanBlockScalar(literal: Bool) throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
let header = try readBlockScalarHeader(startMark: startMark)
|
||||
try consumeBlockHeaderTail(startMark: startMark)
|
||||
|
||||
var endMark = reader.mark
|
||||
// -1 is the "indentation not yet auto-detected" sentinel. libyaml uses 0,
|
||||
// but a root-level block scalar legitimately detects indentation 0, so 0
|
||||
// must be a representable detected value rather than the sentinel.
|
||||
var indent = -1
|
||||
if header.increment != 0 {
|
||||
indent = indentEngine.indent >= 0 ? indentEngine.indent + header.increment : header.increment
|
||||
}
|
||||
var state = BlockState()
|
||||
try scanBlockScalarBreaks(indent: &indent, state: &state, startMark: startMark, endMark: &endMark)
|
||||
|
||||
while reader.column == indent, reader.peek() != nil {
|
||||
// A root block scalar (indent 0) ends at a `---`/`...` document marker
|
||||
// in column 0. libyaml never needs this because it floors the indent
|
||||
// at 1, but indent 0 lets the loop reach a column-0 marker line.
|
||||
if reader.column == 0, documentMarkerAhead() {
|
||||
break
|
||||
}
|
||||
foldBlockLine(&state, literal: literal)
|
||||
while let character = reader.peek(), !Classify.isBreakZ(character) {
|
||||
state.string.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
state.leadingBreak += readBlockLine()
|
||||
try scanBlockScalarBreaks(indent: &indent, state: &state, startMark: startMark, endMark: &endMark)
|
||||
}
|
||||
|
||||
if header.chomping != -1 {
|
||||
state.string += state.leadingBreak
|
||||
}
|
||||
if header.chomping == 1 {
|
||||
state.string += state.trailingBreaks
|
||||
}
|
||||
return PureYAML.Parsing.LibYAML.Token(
|
||||
type: .scalar(value: state.string, style: literal ? .literal : .folded),
|
||||
startMark: startMark,
|
||||
endMark: endMark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the chomping and indentation indicators in either order.
|
||||
private mutating func readBlockScalarHeader(startMark: PureYAML.Parsing.Mark) throws -> (chomping: Int, increment: Int) {
|
||||
var chomping = 0
|
||||
var increment = 0
|
||||
if reader.peek() == "+" || reader.peek() == "-" {
|
||||
chomping = reader.peek() == "+" ? 1 : -1
|
||||
reader.skip()
|
||||
if Classify.isDigit(reader.peek()) {
|
||||
increment = try blockIncrement(startMark: startMark)
|
||||
}
|
||||
} else if Classify.isDigit(reader.peek()) {
|
||||
increment = try blockIncrement(startMark: startMark)
|
||||
if reader.peek() == "+" || reader.peek() == "-" {
|
||||
chomping = reader.peek() == "+" ? 1 : -1
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
return (chomping, increment)
|
||||
}
|
||||
|
||||
private mutating func blockIncrement(startMark: PureYAML.Parsing.Mark) throws -> Int {
|
||||
if reader.peek() == "0" {
|
||||
throw blockError("found an indentation indicator equal to 0", startMark)
|
||||
}
|
||||
let value = digitValue(reader.peek())
|
||||
reader.skip()
|
||||
return value
|
||||
}
|
||||
|
||||
/// Eat trailing blanks and a comment to the end of the header line, then the
|
||||
/// line break.
|
||||
private mutating func consumeBlockHeaderTail(startMark: PureYAML.Parsing.Mark) throws {
|
||||
while reader.peek() == " " {
|
||||
reader.skip()
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
// A comment needs a preceding space, so `>#` (a `#` adjacent to the
|
||||
// block scalar indicator) is malformed, not a comment. libyaml accepts it.
|
||||
if reader.column > 0, let previous = reader.peek(-1), !Classify.isBlank(previous) {
|
||||
throw blockError("found a comment without a preceding space", startMark)
|
||||
}
|
||||
while !Classify.isBreakZ(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
guard Classify.isBreakZ(reader.peek()) else {
|
||||
throw blockError("did not find expected comment or line break", startMark)
|
||||
}
|
||||
if Classify.isBreak(reader.peek()) {
|
||||
reader.skipLine()
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_block_scalar_breaks`: eat indentation spaces and
|
||||
/// blank lines, auto-detecting the indentation level when none was given.
|
||||
private mutating func scanBlockScalarBreaks(indent: inout Int, state: inout BlockState, startMark: PureYAML.Parsing.Mark, endMark: inout PureYAML.Parsing.Mark) throws {
|
||||
var maxIndent = 0
|
||||
endMark = reader.mark
|
||||
// A tab is content (or separation) once the column reaches the content
|
||||
// indentation; it is illegal only while still inside the required
|
||||
// indentation. During auto-detection the floor is the parent indent + 1.
|
||||
let tabFloor = indent >= 0 ? indent : max(0, indentEngine.indent + 1)
|
||||
while true {
|
||||
let lineStart = reader.column
|
||||
while indent < 0 || reader.column < indent, reader.peek() == " " {
|
||||
reader.skip()
|
||||
}
|
||||
if reader.column > maxIndent {
|
||||
maxIndent = reader.column
|
||||
}
|
||||
if reader.column < tabFloor, Classify.isTab(reader.peek()) {
|
||||
throw blockError("found a tab character where an indentation space is expected", startMark)
|
||||
}
|
||||
if !Classify.isBreak(reader.peek()) {
|
||||
// A trailing whitespace-only line that ends at end of input without
|
||||
// a break still counts as one line break for keep chomping (as the
|
||||
// test-suite reference does), even with no break byte present.
|
||||
if reader.peek() == nil, reader.column > lineStart {
|
||||
state.trailingBreaks += "\n"
|
||||
}
|
||||
break
|
||||
}
|
||||
state.trailingBreaks += readBlockLine()
|
||||
endMark = reader.mark
|
||||
}
|
||||
// A leading empty line may not be more indented than the first content
|
||||
// line; libyaml silently detects the deeper indent and drops the content.
|
||||
if indent < 0, !Classify.isBreakZ(reader.peek()), reader.column < maxIndent {
|
||||
throw blockError("found a leading empty line more indented than the content", startMark)
|
||||
}
|
||||
if indent < 0 {
|
||||
// Auto-detected indentation. A root-level block scalar has a parent
|
||||
// indent of -1, so the floor is 0 (allowing column-0 content), not
|
||||
// libyaml's 1. maxIndent >= 0 keeps the result non-negative.
|
||||
indent = max(maxIndent, indentEngine.indent + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the pending breaks at the start of a content line (folded style joins a
|
||||
/// single break between non-blank lines into a space; otherwise breaks are
|
||||
/// kept).
|
||||
private mutating func foldBlockLine(_ state: inout BlockState, literal: Bool) {
|
||||
let trailingBlank = Classify.isBlank(reader.peek())
|
||||
if !literal, state.leadingBreak.first == "\n", !state.leadingBlank, !trailingBlank {
|
||||
if state.trailingBreaks.isEmpty {
|
||||
state.string += " "
|
||||
}
|
||||
state.leadingBreak = ""
|
||||
} else {
|
||||
state.string += state.leadingBreak
|
||||
state.leadingBreak = ""
|
||||
}
|
||||
state.string += state.trailingBreaks
|
||||
state.trailingBreaks = ""
|
||||
state.leadingBlank = Classify.isBlank(reader.peek())
|
||||
}
|
||||
|
||||
private mutating func readBlockLine() -> String {
|
||||
reader.skipLine()
|
||||
return "\n"
|
||||
}
|
||||
|
||||
/// Whether a `---` or `...` document marker (three repeats then a blank, break,
|
||||
/// or end of input) starts at the current position.
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
}
|
||||
return reader.peek(1) == first && reader.peek(2) == first && Classify.isBlankZ(reader.peek(3))
|
||||
}
|
||||
|
||||
private func digitValue(_ scalar: Unicode.Scalar?) -> Int {
|
||||
guard let scalar, scalar >= "0", scalar <= "9" else {
|
||||
return 0
|
||||
}
|
||||
return Int(scalar.value - Unicode.Scalar("0").value)
|
||||
}
|
||||
|
||||
private func blockError(_ problem: String, _ mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a block scalar", problem: problem, mark: mark)
|
||||
}
|
||||
}
|
||||
153
Sources/Parsing/LibYAML.Scanner.Directive.swift
Normal file
153
Sources/Parsing/LibYAML.Scanner.Directive.swift
Normal file
@ -0,0 +1,153 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
private typealias Token = PureYAML.Parsing.LibYAML.Token
|
||||
private typealias Mark = PureYAML.Parsing.Mark
|
||||
|
||||
/// The longest version number libyaml accepts (`MAX_NUMBER_LENGTH`).
|
||||
private var maxVersionNumberLength: Int {
|
||||
9
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_directive`: reset the indentation and simple keys,
|
||||
/// then scan a `%YAML` or `%TAG` directive token.
|
||||
mutating func fetchDirective() throws {
|
||||
indentEngine.unrollIndent(column: -1, mark: reader.mark)
|
||||
try removeSimpleKey()
|
||||
simpleKeyAllowed = false
|
||||
let token = try scanDirective()
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_directive`, extended to YAML 1.2 §6.8.1: a `%YAML`
|
||||
/// or `%TAG` directive produces its token; any other (reserved) directive is
|
||||
/// ignored with a warning, producing a `reservedDirective` token whose content
|
||||
/// the parser drops, rather than libyaml's hard rejection. The reserved token
|
||||
/// still marks the document as a directives document, so it must be followed by
|
||||
/// a `---`.
|
||||
private mutating func scanDirective() throws -> Token {
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
let name = try scanDirectiveName(startMark: startMark)
|
||||
if name == "YAML" {
|
||||
let version = try scanVersionDirectiveValue(startMark: startMark)
|
||||
let token = Token(type: .versionDirective(major: version.major, minor: version.minor), startMark: startMark, endMark: reader.mark)
|
||||
try consumeDirectiveTail(startMark: startMark)
|
||||
return token
|
||||
}
|
||||
if name == "TAG" {
|
||||
let value = try scanTagDirectiveValue(startMark: startMark)
|
||||
let token = Token(type: .tagDirective(handle: value.handle, prefix: value.prefix), startMark: startMark, endMark: reader.mark)
|
||||
try consumeDirectiveTail(startMark: startMark)
|
||||
return token
|
||||
}
|
||||
skipReservedDirectiveLine()
|
||||
return Token(type: .reservedDirective, startMark: startMark, endMark: reader.mark)
|
||||
}
|
||||
|
||||
/// Consume a reserved directive's parameters through the end of the line; the
|
||||
/// whole line is ignored per YAML 1.2 §6.8.1.
|
||||
private mutating func skipReservedDirectiveLine() {
|
||||
while !Classify.isBreakZ(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
if Classify.isBreak(reader.peek()) {
|
||||
reader.skipLine()
|
||||
}
|
||||
}
|
||||
|
||||
/// Eat trailing blanks and an optional comment, require a line break, eat it.
|
||||
private mutating func consumeDirectiveTail(startMark: Mark) throws {
|
||||
while Classify.isBlank(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
while !Classify.isBreakZ(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
guard Classify.isBreakZ(reader.peek()) else {
|
||||
throw directiveError("while scanning a directive", "did not find expected comment or line break", startMark)
|
||||
}
|
||||
if Classify.isBreak(reader.peek()) {
|
||||
reader.skipLine()
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_directive_name`: the alphanumeric name after `%`,
|
||||
/// which must be non-empty and followed by a blank or line break.
|
||||
private mutating func scanDirectiveName(startMark: Mark) throws -> String {
|
||||
var name = ""
|
||||
while let character = reader.peek(), Classify.isAlpha(character) {
|
||||
name.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
guard !name.isEmpty else {
|
||||
throw directiveError("while scanning a directive", "could not find expected directive name", startMark)
|
||||
}
|
||||
guard Classify.isBlankZ(reader.peek()) else {
|
||||
throw directiveError("while scanning a directive", "found unexpected non-alphabetical character", startMark)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_version_directive_value`: `major.minor`.
|
||||
private mutating func scanVersionDirectiveValue(startMark: Mark) throws -> (major: Int, minor: Int) {
|
||||
while Classify.isBlank(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
let major = try scanVersionDirectiveNumber(startMark: startMark)
|
||||
guard reader.peek() == "." else {
|
||||
throw directiveError("while scanning a %YAML directive", "did not find expected digit or '.' character", startMark)
|
||||
}
|
||||
reader.skip()
|
||||
let minor = try scanVersionDirectiveNumber(startMark: startMark)
|
||||
// The version must be followed by separation; a comment needs a space
|
||||
// before its `#`, so `%YAML 1.1#...` (no space) is malformed. libyaml
|
||||
// accepts it, treating the `#` as an adjacent comment.
|
||||
guard Classify.isBlankZ(reader.peek()) else {
|
||||
throw directiveError("while scanning a %YAML directive", "did not find expected digit or '.' character", startMark)
|
||||
}
|
||||
return (major, minor)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_version_directive_number`: a bounded run of digits.
|
||||
private mutating func scanVersionDirectiveNumber(startMark: Mark) throws -> Int {
|
||||
var value = 0
|
||||
var length = 0
|
||||
while let character = reader.peek(), Classify.isDigit(character) {
|
||||
length += 1
|
||||
guard length <= maxVersionNumberLength else {
|
||||
throw directiveError("while scanning a %YAML directive", "found extremely long version number", startMark)
|
||||
}
|
||||
value = value * 10 + Int(character.value - Unicode.Scalar("0").value)
|
||||
reader.skip()
|
||||
}
|
||||
guard length > 0 else {
|
||||
throw directiveError("while scanning a %YAML directive", "did not find expected version number", startMark)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_tag_directive_value`: `!handle! prefix`.
|
||||
private mutating func scanTagDirectiveValue(startMark: Mark) throws -> (handle: String, prefix: String) {
|
||||
while Classify.isBlank(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
let handle = try scanTagHandle(directive: true, startMark: startMark)
|
||||
guard Classify.isBlank(reader.peek()) else {
|
||||
throw directiveError("while scanning a %TAG directive", "did not find expected whitespace", startMark)
|
||||
}
|
||||
while Classify.isBlank(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
let prefix = try scanTagURI(uriChar: true, directive: true, head: "", startMark: startMark)
|
||||
guard Classify.isBlankZ(reader.peek()) else {
|
||||
throw directiveError("while scanning a %TAG directive", "did not find expected whitespace or line break", startMark)
|
||||
}
|
||||
return (handle, prefix)
|
||||
}
|
||||
|
||||
private func directiveError(_ context: String, _ problem: String, _ mark: Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: context, problem: problem, mark: mark)
|
||||
}
|
||||
}
|
||||
380
Sources/Parsing/LibYAML.Scanner.Fetch.swift
Normal file
380
Sources/Parsing/LibYAML.Scanner.Fetch.swift
Normal file
@ -0,0 +1,380 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
private typealias Token = PureYAML.Parsing.LibYAML.Token
|
||||
private typealias ScannerError = PureYAML.Parsing.LibYAML.ScannerError
|
||||
|
||||
/// Produce every token for the input, mirroring repeated calls to libyaml's
|
||||
/// `yaml_parser_fetch_next_token` until STREAM-END. Tokens are never dequeued,
|
||||
/// so `tokens_parsed` stays 0 and `QUEUE_INSERT` positions are absolute, which
|
||||
/// matches the simple-key splice.
|
||||
mutating func scanAll() throws -> [PureYAML.Parsing.LibYAML.Token] {
|
||||
while !streamEndProduced {
|
||||
try fetchNextToken()
|
||||
}
|
||||
return indentEngine.tokens
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_next_token`: emit STREAM-START first, otherwise
|
||||
/// skip to the next token, expire stale simple keys, unroll the indentation to
|
||||
/// the current column, and dispatch on the leading character. Constructs not
|
||||
/// yet ported throw an explicit error rather than mis-scan.
|
||||
mutating func fetchNextToken() throws {
|
||||
if !streamStartProduced {
|
||||
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()
|
||||
mark = reader.mark
|
||||
try staleSimpleKeys()
|
||||
indentEngine.unrollIndent(column: reader.column, mark: reader.mark)
|
||||
mark = reader.mark
|
||||
try dispatchToken()
|
||||
}
|
||||
|
||||
/// Dispatch on the leading character: stream end, directives and document
|
||||
/// markers at column 0, then the indicators.
|
||||
private mutating func dispatchToken() throws {
|
||||
let character = reader.peek()
|
||||
if Classify.isZ(character) {
|
||||
fetchStreamEnd()
|
||||
return
|
||||
}
|
||||
if reader.column == 0, character == "%" {
|
||||
try fetchDirective()
|
||||
return
|
||||
}
|
||||
if reader.column == 0, isDocumentMarker("-") {
|
||||
try fetchDocumentIndicator(.documentStart)
|
||||
return
|
||||
}
|
||||
if reader.column == 0, isDocumentMarker(".") {
|
||||
try fetchDocumentIndicator(.documentEnd)
|
||||
return
|
||||
}
|
||||
guard let character else {
|
||||
return
|
||||
}
|
||||
try dispatchIndicator(character)
|
||||
}
|
||||
|
||||
/// Dispatch the block-structure indicators and plain scalars; unported
|
||||
/// constructs throw an explicit error.
|
||||
private mutating func dispatchIndicator(_ character: Unicode.Scalar) throws {
|
||||
if try fetchFlowToken(character) {
|
||||
return
|
||||
}
|
||||
if try fetchNodeToken(character) {
|
||||
return
|
||||
}
|
||||
switch character {
|
||||
case "-" where Classify.isBlankZ(reader.peek(1)):
|
||||
try fetchBlockEntry()
|
||||
case "-" where flowLevel > 0 && Classify.isFlowIndicator(reader.peek(1)):
|
||||
// Per `ns-plain-first` a `-` begins a plain scalar in flow only when a
|
||||
// flow-safe character follows; before a flow indicator (`[-]`, `[-,`)
|
||||
// it cannot start a node. libyaml reads it as the scalar `-`.
|
||||
throw ScannerError(
|
||||
context: "while scanning for the next token",
|
||||
problem: "found unexpected '-' indicator",
|
||||
mark: reader.mark,
|
||||
)
|
||||
case "?" where Classify.isBlankZ(reader.peek(1)) || (flowLevel > 0 && Classify.isFlowIndicator(reader.peek(1))):
|
||||
// In flow context libyaml treats every `?` as a key indicator, but per
|
||||
// `ns-plain-first` a `?` followed by a flow-safe character (anything but
|
||||
// whitespace, end, or a flow indicator) begins a plain scalar (`?foo`).
|
||||
try fetchKey()
|
||||
case ":" where Classify.isBlankZ(reader.peek(1))
|
||||
|| (flowLevel > 0 && (Classify.isFlowIndicator(reader.peek(1)) || flowKeyIsJSON)):
|
||||
// In flow context libyaml makes every `:` a value indicator. Per the
|
||||
// spec a `:` only ends a node when followed by whitespace, end of
|
||||
// input, or a flow indicator, except an adjacent `:` after a JSON
|
||||
// (quoted) key. Otherwise the `:` is plain-scalar content (`:x`).
|
||||
try fetchValue()
|
||||
default:
|
||||
// A plain scalar may begin with any non-blank character; a blank (a
|
||||
// tab here, since spaces and breaks were already consumed) cannot
|
||||
// start a token, matching libyaml's final guard in fetch_next_token.
|
||||
guard !Classify.isBlankZ(character) else {
|
||||
throw ScannerError(
|
||||
context: "while scanning for the next token",
|
||||
problem: "found character that cannot start any token",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
try fetchPlainScalar()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a flow-collection indicator (`[` `]` `{` `}` `,`); returns false
|
||||
/// otherwise.
|
||||
private mutating func fetchFlowToken(_ character: Unicode.Scalar) throws -> Bool {
|
||||
switch character {
|
||||
case "[": fetchFlowCollectionStart(.flowSequenceStart)
|
||||
case "{": fetchFlowCollectionStart(.flowMappingStart)
|
||||
case "]": try fetchFlowCollectionEnd(.flowSequenceEnd)
|
||||
case "}": try fetchFlowCollectionEnd(.flowMappingEnd)
|
||||
case ",": try fetchFlowEntry()
|
||||
default: return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fetch a quoted scalar, an anchor/alias, or a block scalar; returns false
|
||||
/// otherwise.
|
||||
private mutating func fetchNodeToken(_ character: Unicode.Scalar) throws -> Bool {
|
||||
switch character {
|
||||
case "'": try fetchFlowScalar(single: true)
|
||||
case "\"": try fetchFlowScalar(single: false)
|
||||
case "&": try fetchAnchor(isAnchor: true)
|
||||
case "*": try fetchAnchor(isAnchor: false)
|
||||
case "!": try fetchTag()
|
||||
case "|" where flowLevel == 0: try fetchBlockScalar(literal: true)
|
||||
case ">" where flowLevel == 0: try fetchBlockScalar(literal: false)
|
||||
default: return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_tag`.
|
||||
private mutating func fetchTag() throws {
|
||||
mark = reader.mark
|
||||
try saveSimpleKey()
|
||||
simpleKeyAllowed = false
|
||||
let token = try scanTag()
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_block_scalar`.
|
||||
private mutating func fetchBlockScalar(literal: Bool) throws {
|
||||
try removeSimpleKey()
|
||||
simpleKeyAllowed = true
|
||||
let token = try scanBlockScalar(literal: literal)
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_flow_scalar`.
|
||||
private mutating func fetchFlowScalar(single: Bool) throws {
|
||||
mark = reader.mark
|
||||
let saved = simpleKeyAllowed
|
||||
try saveSimpleKey()
|
||||
// A quoted scalar that became this level's simple key is a JSON node, so a
|
||||
// later adjacent `:` (no space) still opens its value.
|
||||
if saved {
|
||||
simpleKeys[simpleKeys.count - 1].isJSON = true
|
||||
}
|
||||
simpleKeyAllowed = false
|
||||
let token = try scanFlowScalar(single: single)
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_anchor`.
|
||||
private mutating func fetchAnchor(isAnchor: Bool) throws {
|
||||
mark = reader.mark
|
||||
try saveSimpleKey()
|
||||
simpleKeyAllowed = false
|
||||
let token = try scanAnchor(isAnchor: isAnchor)
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_flow_collection_start`.
|
||||
private mutating func fetchFlowCollectionStart(_ type: PureYAML.Parsing.LibYAML.TokenType) {
|
||||
// saveSimpleKey cannot throw in flow: a required key needs block context.
|
||||
let saved = simpleKeyAllowed
|
||||
try? saveSimpleKey()
|
||||
// A flow collection is a JSON node, so when it is this level's simple key
|
||||
// an adjacent `:` after its closing `]`/`}` still opens its value.
|
||||
if saved {
|
||||
simpleKeys[simpleKeys.count - 1].isJSON = true
|
||||
}
|
||||
increaseFlowLevel(isSequence: type == .flowSequenceStart)
|
||||
simpleKeyAllowed = true
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: type, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_flow_collection_end`.
|
||||
private mutating func fetchFlowCollectionEnd(_ type: PureYAML.Parsing.LibYAML.TokenType) throws {
|
||||
try removeSimpleKey()
|
||||
decreaseFlowLevel()
|
||||
simpleKeyAllowed = false
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: type, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_flow_entry`.
|
||||
private mutating func fetchFlowEntry() throws {
|
||||
try removeSimpleKey()
|
||||
simpleKeyAllowed = true
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: .flowEntry, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_stream_start`.
|
||||
private mutating func fetchStreamStart() {
|
||||
// indent is already -1 and simpleKeys already holds the block-context slot.
|
||||
simpleKeyAllowed = true
|
||||
streamStartProduced = true
|
||||
indentEngine.enqueue(Token(type: .streamStart, startMark: reader.mark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_stream_end`.
|
||||
private mutating func fetchStreamEnd() {
|
||||
if reader.column != 0 {
|
||||
// Force a new line in the mark, as libyaml does.
|
||||
mark = PureYAML.Parsing.Mark(line: reader.line + 1, column: 0, index: reader.index)
|
||||
}
|
||||
indentEngine.unrollIndent(column: -1, mark: mark)
|
||||
// remove_simple_key cannot throw here: a required key would already have
|
||||
// been resolved before stream end in the block + plain core.
|
||||
simpleKeys[simpleKeys.count - 1].possible = false
|
||||
simpleKeyAllowed = false
|
||||
streamEndProduced = true
|
||||
indentEngine.enqueue(Token(type: .streamEnd, startMark: mark, endMark: mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_document_indicator`.
|
||||
private mutating func fetchDocumentIndicator(_ type: PureYAML.Parsing.LibYAML.TokenType) throws {
|
||||
indentEngine.unrollIndent(column: -1, mark: reader.mark)
|
||||
simpleKeys[simpleKeys.count - 1].possible = false
|
||||
simpleKeyAllowed = false
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: type, startMark: startMark, endMark: reader.mark))
|
||||
if case .documentEnd = type {
|
||||
// Only whitespace and a comment may follow `...` on its line; a `--- x`
|
||||
// document start can carry inline content, but `... x` cannot.
|
||||
while reader.peek() == " " || Classify.isTab(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
if let next = reader.peek(), next != "#", !Classify.isBreak(next) {
|
||||
throw ScannerError(
|
||||
context: "while scanning a document end marker",
|
||||
problem: "found content after the document end marker",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_block_entry`.
|
||||
private mutating func fetchBlockEntry() throws {
|
||||
if tabbedSeparation {
|
||||
throw tabIndentationError()
|
||||
}
|
||||
// A block sequence must begin its own line. A `-` mid-line after a mapping
|
||||
// value (`key: - a`) or a node property (`&a - x`) is invalid, but a `-`
|
||||
// after another `-` is a valid compact sequence (`- - a`). libyaml accepts
|
||||
// all three.
|
||||
if flowLevel == 0, !crossedLineBreak, lastTokenForbidsInlineSequence() {
|
||||
throw ScannerError(
|
||||
context: "while scanning a node",
|
||||
problem: "found a block sequence on the same line as an earlier node",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
if flowLevel == 0 {
|
||||
indentEngine.rollIndent(column: reader.column, number: -1, type: .blockSequenceStart, mark: reader.mark)
|
||||
}
|
||||
simpleKeys[simpleKeys.count - 1].possible = false
|
||||
simpleKeyAllowed = true
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: .blockEntry, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_key`.
|
||||
private mutating func fetchKey() throws {
|
||||
if tabbedSeparation {
|
||||
throw tabIndentationError()
|
||||
}
|
||||
if flowLevel == 0 {
|
||||
indentEngine.rollIndent(column: reader.column, number: -1, type: .blockMappingStart, mark: reader.mark)
|
||||
}
|
||||
simpleKeys[simpleKeys.count - 1].possible = false
|
||||
simpleKeyAllowed = flowLevel == 0
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: .key, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_value`, including the simple-key splice: a
|
||||
/// possible simple key inserts its `KEY` token at the recorded position and
|
||||
/// rolls the block mapping indent to the key's column.
|
||||
private mutating func fetchValue() throws {
|
||||
let top = simpleKeys.count - 1
|
||||
// A pending simple key means this `:` closes an inline implicit key (key
|
||||
// and `:` on one line). An explicit `?` key's `:` has no pending simple key
|
||||
// (it went stale across the line break), so its value may open a sequence.
|
||||
lastValueWasInline = simpleKeys[top].possible
|
||||
// A flow-sequence single-pair implicit key must be single-line, so its `:`
|
||||
// must sit on the key's line (`[ "k"\n :v ]` and `[ k\n : v ]` are both
|
||||
// invalid); a flow-mapping adjacent value may span lines (`{ "k"\n :v }`).
|
||||
if simpleKeys[top].possible, flowIsSequence.last == true, simpleKeys[top].mark.line != mark.line {
|
||||
throw ScannerError(
|
||||
context: "while scanning a simple key",
|
||||
problem: "could not find expected ':'",
|
||||
mark: simpleKeys[top].mark,
|
||||
)
|
||||
}
|
||||
if simpleKeys[top].possible {
|
||||
if simpleKeys[top].tabbed {
|
||||
throw tabIndentationError()
|
||||
}
|
||||
let key = simpleKeys[top]
|
||||
indentEngine.insert(Token(type: .key, startMark: key.mark, endMark: key.mark), atTokenNumber: key.tokenNumber)
|
||||
indentEngine.rollIndent(column: key.mark.column, number: key.tokenNumber, type: .blockMappingStart, mark: key.mark)
|
||||
simpleKeys[top].possible = false
|
||||
simpleKeyAllowed = false
|
||||
} else {
|
||||
if flowLevel == 0 {
|
||||
indentEngine.rollIndent(column: reader.column, number: -1, type: .blockMappingStart, mark: reader.mark)
|
||||
}
|
||||
simpleKeyAllowed = flowLevel == 0
|
||||
}
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
indentEngine.enqueue(Token(type: .value, startMark: startMark, endMark: reader.mark))
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_fetch_plain_scalar`.
|
||||
private mutating func fetchPlainScalar() throws {
|
||||
mark = reader.mark
|
||||
try saveSimpleKey()
|
||||
simpleKeyAllowed = false
|
||||
let token = try scanPlainScalar()
|
||||
indentEngine.enqueue(token)
|
||||
}
|
||||
|
||||
private mutating func isDocumentMarker(_ character: Unicode.Scalar) -> Bool {
|
||||
reader.peek(0) == character
|
||||
&& reader.peek(1) == character
|
||||
&& reader.peek(2) == character
|
||||
&& Classify.isBlankZ(reader.peek(3))
|
||||
}
|
||||
}
|
||||
225
Sources/Parsing/LibYAML.Scanner.FlowScalar.swift
Normal file
225
Sources/Parsing/LibYAML.Scanner.FlowScalar.swift
Normal file
@ -0,0 +1,225 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// The folding state carried through `scan_flow_scalar`, the same shape as the
|
||||
/// plain-scalar state.
|
||||
private struct FlowState {
|
||||
var string = ""
|
||||
var leadingBreak = ""
|
||||
var trailingBreaks = ""
|
||||
var whitespaces = ""
|
||||
var leadingBlanks = false
|
||||
}
|
||||
|
||||
/// Decoded single-character escapes for double-quoted scalars (libyaml's escape
|
||||
/// switch). libyaml emits UTF-8 bytes; here each maps to its Unicode scalar.
|
||||
private static let doubleQuotedEscapes: [Unicode.Scalar: Unicode.Scalar] = [
|
||||
"0": "\u{0}", "a": "\u{07}", "b": "\u{08}", "t": "\u{09}", "\t": "\u{09}",
|
||||
"n": "\u{0A}", "v": "\u{0B}", "f": "\u{0C}", "r": "\u{0D}", "e": "\u{1B}",
|
||||
" ": "\u{20}", "\"": "\"", "/": "/", "\\": "\\",
|
||||
"N": "\u{85}", "_": "\u{A0}", "L": "\u{2028}", "P": "\u{2029}",
|
||||
]
|
||||
|
||||
/// Port of `yaml_parser_scan_flow_scalar`. Scans a single- or double-quoted
|
||||
/// scalar, folding line breaks like a plain scalar and, for double quotes,
|
||||
/// decoding escape sequences.
|
||||
mutating func scanFlowScalar(single: Bool) throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
let startMark = reader.mark
|
||||
reader.skip()
|
||||
var state = FlowState()
|
||||
let quote: Unicode.Scalar = single ? "'" : "\""
|
||||
let indent = indentEngine.indent + 1
|
||||
// A continuation indented below `indent` is an error, but it is recorded
|
||||
// and only raised once the quote is known to close; an unterminated quote
|
||||
// reaches end of input first and reports that instead.
|
||||
var indentError: PureYAML.Parsing.LibYAML.ScannerError?
|
||||
while true {
|
||||
if reader.column == 0, documentMarkerAhead() {
|
||||
throw flowError("found unexpected document indicator", startMark)
|
||||
}
|
||||
if reader.peek() == nil {
|
||||
throw flowError("found unexpected end of stream", startMark)
|
||||
}
|
||||
try consumeFlowContent(single: single, &state, startMark: startMark)
|
||||
if reader.peek() == quote {
|
||||
break
|
||||
}
|
||||
consumeFlowBreaks(&state, indent: indent, quote: quote, startMark: startMark, error: &indentError)
|
||||
foldFlow(&state)
|
||||
}
|
||||
reader.skip()
|
||||
if let indentError {
|
||||
throw indentError
|
||||
}
|
||||
return PureYAML.Parsing.LibYAML.Token(
|
||||
type: .scalar(value: state.string, style: single ? .singleQuoted : .doubleQuoted),
|
||||
startMark: startMark,
|
||||
endMark: reader.mark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Consume the run of non-blank characters, decoding escapes and stopping at
|
||||
/// the closing quote or an escaped line break.
|
||||
private mutating func consumeFlowContent(single: Bool, _ state: inout FlowState, startMark: PureYAML.Parsing.Mark) throws {
|
||||
state.leadingBlanks = false
|
||||
let quote: Unicode.Scalar = single ? "'" : "\""
|
||||
while let character = reader.peek(), !Classify.isBlankZ(character) {
|
||||
if single, character == "'", reader.peek(1) == "'" {
|
||||
state.string.unicodeScalars.append("'")
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
} else if character == quote {
|
||||
return
|
||||
} else if !single, character == "\\", Classify.isBreak(reader.peek(1)) {
|
||||
reader.skip()
|
||||
reader.skipLine()
|
||||
state.leadingBlanks = true
|
||||
return
|
||||
} else if !single, character == "\\" {
|
||||
try appendEscape(&state, startMark: startMark)
|
||||
} else {
|
||||
state.string.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a double-quoted escape sequence (`\` already at the reader).
|
||||
private mutating func appendEscape(_ state: inout FlowState, startMark: PureYAML.Parsing.Mark) throws {
|
||||
let escape = reader.peek(1)
|
||||
if let escape, let decoded = Self.doubleQuotedEscapes[escape] {
|
||||
state.string.unicodeScalars.append(decoded)
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
return
|
||||
}
|
||||
let codeLength = try hexEscapeLength(escape, startMark: startMark)
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
var value = try readHexEscape(count: codeLength, startMark: startMark)
|
||||
if codeLength == 4 {
|
||||
value = try combineSurrogateIfNeeded(high: value, startMark: startMark)
|
||||
}
|
||||
guard let scalar = Unicode.Scalar(value) else {
|
||||
throw flowError("found invalid Unicode character escape code", startMark)
|
||||
}
|
||||
state.string.unicodeScalars.append(scalar)
|
||||
}
|
||||
|
||||
/// The hex digit count for `\x` (2), `\u` (4), and `\U` (8) escapes.
|
||||
private func hexEscapeLength(_ escape: Unicode.Scalar?, startMark: PureYAML.Parsing.Mark) throws -> Int {
|
||||
switch escape {
|
||||
case "x": 2
|
||||
case "u": 4
|
||||
case "U": 8
|
||||
default: throw flowError("found unknown escape character", startMark)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and consume `count` hexadecimal digits, returning their value.
|
||||
private mutating func readHexEscape(count: Int, startMark: PureYAML.Parsing.Mark) throws -> UInt32 {
|
||||
var value: UInt32 = 0
|
||||
for _ in 0 ..< count {
|
||||
guard let digit = reader.peek(), Classify.isHex(digit) else {
|
||||
throw flowError("did not find expected hexdecimal number", startMark)
|
||||
}
|
||||
value = (value << 4) + hexValue(digit)
|
||||
reader.skip()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Combine a UTF-16 surrogate pair (a high surrogate `\u` escape immediately
|
||||
/// followed by a low surrogate `\u` escape) into a single astral code point,
|
||||
/// for JSON compatibility. A lone high surrogate is left for the scalar check.
|
||||
private mutating func combineSurrogateIfNeeded(high: UInt32, startMark: PureYAML.Parsing.Mark) throws -> UInt32 {
|
||||
guard (0xD800 ... 0xDBFF).contains(high), reader.peek() == "\\", reader.peek(1) == "u" else {
|
||||
return high
|
||||
}
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
let low = try readHexEscape(count: 4, startMark: startMark)
|
||||
guard (0xDC00 ... 0xDFFF).contains(low) else {
|
||||
throw flowError("found invalid Unicode character escape code", startMark)
|
||||
}
|
||||
return 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
|
||||
}
|
||||
|
||||
/// Consume blanks and line breaks between content, recording pending
|
||||
/// whitespace and folded breaks.
|
||||
private mutating func consumeFlowBreaks(
|
||||
_ state: inout FlowState,
|
||||
indent: Int,
|
||||
quote: Unicode.Scalar,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
error: inout PureYAML.Parsing.LibYAML.ScannerError?,
|
||||
) {
|
||||
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
|
||||
if Classify.isBlank(reader.peek()) {
|
||||
if error == nil, state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()), !blankLineAhead() {
|
||||
error = flowError("found a tab character that violates indentation", startMark)
|
||||
}
|
||||
if !state.leadingBlanks, let blank = reader.peek() {
|
||||
state.whitespaces.unicodeScalars.append(blank)
|
||||
}
|
||||
reader.skip()
|
||||
} else if !state.leadingBlanks {
|
||||
state.whitespaces = ""
|
||||
reader.skipLine()
|
||||
state.leadingBreak = "\n"
|
||||
state.leadingBlanks = true
|
||||
} else {
|
||||
reader.skipLine()
|
||||
state.trailingBreaks += "\n"
|
||||
}
|
||||
}
|
||||
// The continuation content must be indented at least `indent`; a less
|
||||
// indented (non-closing, non-empty) line is malformed.
|
||||
if error == nil, state.leadingBlanks, let next = reader.peek(), next != quote, !Classify.isBreakZ(next), reader.column < indent {
|
||||
error = flowError("found wrong indentation in a multi-line quoted scalar", startMark)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the pending breaks or whitespace into the value.
|
||||
private func foldFlow(_ state: inout FlowState) {
|
||||
guard state.leadingBlanks else {
|
||||
state.string += state.whitespaces
|
||||
state.whitespaces = ""
|
||||
return
|
||||
}
|
||||
if state.leadingBreak.first == "\n" {
|
||||
if state.trailingBreaks.isEmpty {
|
||||
state.string += " "
|
||||
} else {
|
||||
state.string += state.trailingBreaks
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
state.leadingBreak = ""
|
||||
} else {
|
||||
state.string += state.leadingBreak
|
||||
state.string += state.trailingBreaks
|
||||
state.leadingBreak = ""
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func hexValue(_ scalar: Unicode.Scalar) -> UInt32 {
|
||||
switch scalar {
|
||||
case "0" ... "9": scalar.value - Unicode.Scalar("0").value
|
||||
case "A" ... "F": scalar.value - Unicode.Scalar("A").value + 10
|
||||
default: scalar.value - Unicode.Scalar("a").value + 10
|
||||
}
|
||||
}
|
||||
|
||||
private func flowError(_ problem: String, _ mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a quoted scalar", problem: problem, mark: mark)
|
||||
}
|
||||
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
}
|
||||
return reader.peek(1) == first && reader.peek(2) == first && Classify.isBlankZ(reader.peek(3))
|
||||
}
|
||||
}
|
||||
59
Sources/Parsing/LibYAML.Scanner.Guards.swift
Normal file
59
Sources/Parsing/LibYAML.Scanner.Guards.swift
Normal file
@ -0,0 +1,59 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias ScannerError = PureYAML.Parsing.LibYAML.ScannerError
|
||||
|
||||
/// Reject a flow-collection continuation line that is not indented past the
|
||||
/// enclosing block context. YAML requires `s-flow-line-prefix` to be more
|
||||
/// indented than the parent block; libyaml does not enforce it, so multi-line
|
||||
/// flow content in column 0 (`[a,\nb]` under a key) wrongly parses.
|
||||
mutating func checkFlowContinuationIndent() throws {
|
||||
guard flowLevel > 0, crossedLineBreak, let next = reader.peek() else {
|
||||
return
|
||||
}
|
||||
// A closing `]`/`}` (or a `,` separator) may sit at any indentation; only
|
||||
// flow CONTENT must be indented past the parent block.
|
||||
if next == "]" || next == "}" || next == "," {
|
||||
return
|
||||
}
|
||||
// A tab in the leading whitespace is never valid indentation, even where
|
||||
// spaces of the same width would be (the tab inflates the column).
|
||||
if reader.column <= indentEngine.indent || flowLeadingTab {
|
||||
throw ScannerError(
|
||||
context: "while parsing a flow node",
|
||||
problem: "found insufficiently indented line in a flow collection",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last emitted token bars a block sequence from opening on the
|
||||
/// same line: a mapping value indicator or a node property (anchor/tag). After
|
||||
/// another block entry a compact sequence is allowed, so that is not barred.
|
||||
func lastTokenForbidsInlineSequence() -> Bool {
|
||||
switch indentEngine.lastToken?.type {
|
||||
case .anchor, .tag:
|
||||
true
|
||||
case .value:
|
||||
lastValueWasInline
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The error raised when a tab is used where indentation is required (before a
|
||||
/// block indicator or an implicit mapping key). A tab is valid separation but
|
||||
/// never indentation.
|
||||
func tabIndentationError() -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
ScannerError(
|
||||
context: "while scanning for the next token",
|
||||
problem: "found a tab character where an indentation space is expected",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this flow level's pending simple key is a JSON (quoted) node, which
|
||||
/// lets an adjacent `:` open its value without a separating space.
|
||||
var flowKeyIsJSON: Bool {
|
||||
let key = simpleKeys[simpleKeys.count - 1]
|
||||
return key.possible && key.isJSON
|
||||
}
|
||||
}
|
||||
323
Sources/Parsing/LibYAML.Scanner.PlainScalar.swift
Normal file
323
Sources/Parsing/LibYAML.Scanner.PlainScalar.swift
Normal file
@ -0,0 +1,323 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
/// Which step of `runPlainScalarLoop`'s outer iteration a suspend happened
|
||||
/// at. Resuming must land back on the step that was actually interrupted,
|
||||
/// not on whatever step normally follows it, and in particular must never
|
||||
/// redo a decision (document marker, comment, or "does a blank/break run
|
||||
/// follow") that a *previous, already-passed* check already answered:
|
||||
/// `.start`'s document-marker/`#` checks and `.afterContent`'s blank/break
|
||||
/// guard are only meant to run once, at a genuine entry point, not at an
|
||||
/// arbitrary position inside an already-in-progress content or break run.
|
||||
/// A first version of this type resumed a dry `consumePlainContent` via
|
||||
/// `.start` on the (wrong) assumption that redoing its checks was harmless;
|
||||
/// it is not; if the resume position happens to land on a literal `#`
|
||||
/// that is legitimate mid-scalar content (e.g. a value containing every
|
||||
/// printable character), `.start`'s comment check wrongly ends the scalar
|
||||
/// right there. `consumePlainContent` and `consumePlainBreaks` each get
|
||||
/// their own dedicated "continue this same call, no pre-check" case
|
||||
/// (`.consumingContent`, `.consumingBreaks`) for exactly this reason.
|
||||
enum PlainScalarPhase: Equatable {
|
||||
/// Check for a document marker or comment, then consume content.
|
||||
case start
|
||||
/// Inside consuming an already-started content run (the document
|
||||
/// marker/comment checks that gated entry into it do not apply here).
|
||||
case consumingContent
|
||||
/// Content just finished; decide whether a blank/break run follows.
|
||||
case afterContent
|
||||
/// Inside consuming an already-confirmed blank/break run.
|
||||
case consumingBreaks
|
||||
/// A blank/break run just finished; check whether the scalar dedents.
|
||||
case afterBreaks
|
||||
}
|
||||
|
||||
/// Thrown by `runPlainScalarLoop` when accumulating a plain scalar's content
|
||||
/// runs dry (`Reader.ranDry`) while `!Reader.isFinal`. Carries everything
|
||||
/// `resumePlainScalar(_:)` needs to continue from exactly this point once more
|
||||
/// input is appended, rather than rescanning the scalar's content from its
|
||||
/// start. `scanStep()` catches this specifically and stores it on
|
||||
/// `Scanner.suspendedPlainScalar`, distinct from a generic dry-run catch: no
|
||||
/// checkpoint restore happens for this error, since the whole point is to keep
|
||||
/// the reader position and accumulated content rather than discard them.
|
||||
struct PlainScalarSuspended: Swift.Error {
|
||||
var state: PlainState
|
||||
var indent: Int
|
||||
var startMark: PureYAML.Parsing.Mark
|
||||
var phase: PlainScalarPhase
|
||||
}
|
||||
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// The folding state carried through `scan_plain_scalar`: the accumulated
|
||||
/// value plus the pending leading break, trailing breaks, and whitespace that
|
||||
/// are folded in before the next content character. Internal rather than
|
||||
/// private: `PlainScalarSuspended` carries one across a `scanStep()` suspend
|
||||
/// point, so `Scanner`'s own file needs to name the type for its stored
|
||||
/// `suspendedPlainScalar` property.
|
||||
struct PlainState {
|
||||
var string = ""
|
||||
var leadingBreak = ""
|
||||
var trailingBreaks = ""
|
||||
var whitespaces = ""
|
||||
var leadingBlanks = false
|
||||
var endMark: PureYAML.Parsing.Mark
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_plain_scalar`. Consumes a plain scalar, folding
|
||||
/// line breaks: a single break between non-empty lines becomes a space, blank
|
||||
/// lines become literal newlines, and leading/trailing whitespace on folded
|
||||
/// lines is dropped. Ends at a `: ` value indicator, a comment, a less-indented
|
||||
/// line (block context), a flow indicator (flow context), or a document marker.
|
||||
mutating func scanPlainScalar() throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
let indent = indentEngine.indent + 1
|
||||
let startMark = reader.mark
|
||||
var state = PlainState(endMark: reader.mark)
|
||||
try runPlainScalarLoop(&state, indent: indent, startMark: startMark)
|
||||
return finishPlainScalar(state, startMark: startMark)
|
||||
}
|
||||
|
||||
/// Resumes a plain scalar suspended by a previous `PlainScalarSuspended`,
|
||||
/// continuing from its saved state, phase, and reader position rather than
|
||||
/// rescanning the content already accumulated before the suspend.
|
||||
///
|
||||
/// Takes `state` `inout` rather than the whole `PlainScalarSuspended`, on
|
||||
/// purpose: the caller (`scanStep()`) must disalias `state` from the
|
||||
/// suspended value it came from *before* this call, since a `let
|
||||
/// suspended = ...; resumePlainScalar(suspended)` call would keep the
|
||||
/// caller's own `suspended.state` reference to the same string buffer
|
||||
/// alive for this whole call's duration regardless of anything done
|
||||
/// inside this function's own body (a value parameter is borrowed for
|
||||
/// the call, not owned, so a local disalias inside the callee cannot
|
||||
/// remove a reference the caller itself is still holding). With `state`
|
||||
/// passed by direct reference and no wrapping struct crossing this call
|
||||
/// boundary at all, there is exactly one owner of its string buffer
|
||||
/// throughout, so appending inside the loop below never triggers a
|
||||
/// spurious copy-on-write copy of the whole accumulated scalar. See
|
||||
/// `scanStep()`'s resume branch for where the disalias happens.
|
||||
mutating func resumePlainScalar(
|
||||
_ state: inout PlainState,
|
||||
indent: Int,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
phase: PlainScalarPhase,
|
||||
) throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
try runPlainScalarLoop(&state, indent: indent, startMark: startMark, from: phase)
|
||||
return finishPlainScalar(state, startMark: startMark)
|
||||
}
|
||||
|
||||
/// The scan loop shared by a fresh attempt and a resume, restructured as an
|
||||
/// explicit `PlainScalarPhase` state machine (see its own doc comment for
|
||||
/// why) with a `suspendIfDry` check after every step that peeked. Checking
|
||||
/// only once, after the whole loop exits, is not enough: a dry read
|
||||
/// (`reader.ranDry`, see `Reader.peek(_:)`) inside a lookahead
|
||||
/// (`documentMarkerAhead()`, `consumePlainContent`'s `:`-followed-by-blank
|
||||
/// check, `consumePlainBreaks`'s `blankLineAhead()` guard) reads as "blank or
|
||||
/// end" (`CharacterClass.isBlankZ`/`isBreakZ` treat a missing scalar as
|
||||
/// true), not as "not enough data yet", so the *next* step can reach a
|
||||
/// wrong-but-plausible decision (e.g. `documentMarkerAhead` wrongly says
|
||||
/// "not a marker", so `consumePlainContent` appends `---` as scalar content)
|
||||
/// before a single end-of-loop check would ever see the taint that caused
|
||||
/// it. Checking right after each peeking step, before its result is used for
|
||||
/// anything further, catches the taint before it can influence another
|
||||
/// decision. Batch parsing (`isFinal: true`) is unaffected: every check can
|
||||
/// only throw when `!reader.isFinal`, which batch callers never set, so the
|
||||
/// loop's own logic is otherwise identical to the original.
|
||||
///
|
||||
/// `state` and the reader position are always resumable at a suspend point:
|
||||
/// nothing here calls `reader.skip()` on a dry `nil`, so accumulated content
|
||||
/// and position both stop exactly at the last successfully read scalar.
|
||||
private mutating func runPlainScalarLoop(
|
||||
_ state: inout PlainState,
|
||||
indent: Int,
|
||||
startMark: PureYAML.Parsing.Mark,
|
||||
from initialPhase: PlainScalarPhase = .start,
|
||||
) throws {
|
||||
var phase = initialPhase
|
||||
while true {
|
||||
switch phase {
|
||||
case .start:
|
||||
let atDocumentMarker = reader.column == 0 && documentMarkerAhead()
|
||||
// A dry lookahead inside `documentMarkerAhead()` must resume by
|
||||
// redoing this whole step, not by trusting `atDocumentMarker`
|
||||
// (which `isBlankZ`'s nil-is-true bias could have gotten wrong)
|
||||
// and moving on: hence `phase: .start` here, not `.consumingContent`.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .start)
|
||||
if atDocumentMarker {
|
||||
return
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
return
|
||||
}
|
||||
try consumePlainContent(&state, startMark: startMark)
|
||||
// consumePlainContent stopping is not yet known to be a real
|
||||
// answer (terminator found) versus a dry cutoff: check before
|
||||
// trusting it enough to move to `.afterContent`'s guard. On dry,
|
||||
// resume via `.consumingContent`, not `.start`: the document-marker
|
||||
// and `#` checks above only apply at a genuine entry point, and
|
||||
// the reader position where a content run was interrupted can
|
||||
// land on a literal `#` or `-` that is legitimate content, not a
|
||||
// fresh comment or marker.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
||||
phase = .afterContent
|
||||
case .consumingContent:
|
||||
try consumePlainContent(&state, startMark: startMark)
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingContent)
|
||||
phase = .afterContent
|
||||
case .afterContent:
|
||||
// Reached only once consumePlainContent is known to have stopped
|
||||
// for a real reason, so this peek cannot itself be dry: it
|
||||
// re-reads the exact position consumePlainContent's own last
|
||||
// (already non-dry) peek confirmed, and nothing advanced between.
|
||||
guard Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) else {
|
||||
return
|
||||
}
|
||||
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
||||
// Same reasoning as consumePlainContent above, but resuming here
|
||||
// must not redo `.afterContent`'s guard against new data: that
|
||||
// guard gates *entry* into a blank/break run, and re-asking it
|
||||
// after the run is already underway would wrongly treat "the run
|
||||
// continues" as "the run never started" (or vice versa) instead
|
||||
// of just continuing the same run. `.consumingBreaks` re-enters
|
||||
// consumePlainBreaks directly.
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
phase = .afterBreaks
|
||||
case .consumingBreaks:
|
||||
try consumePlainBreaks(&state, indent: indent, startMark: startMark)
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
phase = .afterBreaks
|
||||
case .afterBreaks:
|
||||
// `reader.column` is a plain stored field, never read via
|
||||
// `peek(_:)`, so this check cannot itself observe a dry read; by
|
||||
// construction every path reaching this case already confirmed
|
||||
// `!ranDry` before transitioning here.
|
||||
if flowLevel == 0, reader.column < indent {
|
||||
return
|
||||
}
|
||||
phase = .start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return PureYAML.Parsing.LibYAML.Token(
|
||||
type: .scalar(value: state.string, style: .plain),
|
||||
startMark: startMark,
|
||||
endMark: state.endMark,
|
||||
)
|
||||
}
|
||||
|
||||
/// Consume the run of non-blank characters, folding any pending breaks or
|
||||
/// whitespace ahead of each one.
|
||||
private mutating func consumePlainContent(_ state: inout PlainState, startMark _: PureYAML.Parsing.Mark) throws {
|
||||
while let character = reader.peek(), !Classify.isBlankZ(character) {
|
||||
if character == ":", Classify.isBlankZ(reader.peek(1)) {
|
||||
break
|
||||
}
|
||||
if flowLevel > 0, character == ":", Classify.isFlowIndicator(reader.peek(1)) {
|
||||
// A `:` before a flow indicator ends the key; the value is empty.
|
||||
// libyaml rejects this as an unexpected ':', but it is a valid
|
||||
// value indicator in flow context.
|
||||
break
|
||||
}
|
||||
if flowLevel > 0, Classify.isFlowIndicator(character) {
|
||||
break
|
||||
}
|
||||
foldPending(&state)
|
||||
state.string.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
state.endMark = reader.mark
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the pending leading break, trailing breaks, and whitespace into the
|
||||
/// accumulated value (the join logic from libyaml's inner loop).
|
||||
private func foldPending(_ state: inout PlainState) {
|
||||
guard state.leadingBlanks || !state.whitespaces.isEmpty else {
|
||||
return
|
||||
}
|
||||
guard state.leadingBlanks else {
|
||||
state.string += state.whitespaces
|
||||
state.whitespaces = ""
|
||||
return
|
||||
}
|
||||
if state.leadingBreak.first == "\n" {
|
||||
if state.trailingBreaks.isEmpty {
|
||||
state.string += " "
|
||||
} else {
|
||||
state.string += state.trailingBreaks
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
state.leadingBreak = ""
|
||||
} else {
|
||||
state.string += state.leadingBreak
|
||||
state.string += state.trailingBreaks
|
||||
state.leadingBreak = ""
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
state.leadingBlanks = false
|
||||
}
|
||||
|
||||
/// Consume the run of blanks and line breaks between content, recording them as
|
||||
/// pending whitespace or folded breaks.
|
||||
private mutating func consumePlainBreaks(_ state: inout PlainState, indent: Int, startMark: PureYAML.Parsing.Mark) throws {
|
||||
while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) {
|
||||
if Classify.isBlank(reader.peek()) {
|
||||
if state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()) {
|
||||
// `blankLineAhead()` is its own multi-character lookahead (not
|
||||
// just the single already-confirmed-real `peek()` above), so it
|
||||
// can independently run dry: check immediately, before the
|
||||
// tab-violation decision below trusts its answer. Unchecked,
|
||||
// `isBreakZ`'s nil-is-true bias makes an incomplete lookahead
|
||||
// read as "blank line", suppressing a real violation until more
|
||||
// data arrives proves otherwise.
|
||||
let onBlankLine = blankLineAhead()
|
||||
try suspendIfDry(state, indent: indent, startMark: startMark, phase: .consumingBreaks)
|
||||
if !onBlankLine {
|
||||
// A tab below the indentation is illegal only when it indents
|
||||
// real content; on an otherwise-blank line it is just a blank
|
||||
// line the scalar folds over. libyaml rejects it either way.
|
||||
throw scannerError("found a tab character that violates indentation", at: startMark)
|
||||
}
|
||||
}
|
||||
if !state.leadingBlanks, let blank = reader.peek() {
|
||||
state.whitespaces.unicodeScalars.append(blank)
|
||||
}
|
||||
reader.skip()
|
||||
} else if !state.leadingBlanks {
|
||||
state.whitespaces = ""
|
||||
reader.skipLine()
|
||||
state.leadingBreak = "\n"
|
||||
state.leadingBlanks = true
|
||||
} else {
|
||||
reader.skipLine()
|
||||
state.trailingBreaks += "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scannerError(_ problem: String, at mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a plain scalar", problem: problem, mark: mark)
|
||||
}
|
||||
|
||||
private mutating func documentMarkerAhead() -> Bool {
|
||||
let first = reader.peek()
|
||||
guard first == "-" || first == "." else {
|
||||
return false
|
||||
}
|
||||
return reader.peek(1) == first && reader.peek(2) == first && Classify.isBlankZ(reader.peek(3))
|
||||
}
|
||||
}
|
||||
88
Sources/Parsing/LibYAML.Scanner.SkipToToken.swift
Normal file
88
Sources/Parsing/LibYAML.Scanner.SkipToToken.swift
Normal file
@ -0,0 +1,88 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
private typealias ScannerError = PureYAML.Parsing.LibYAML.ScannerError
|
||||
|
||||
/// Port of `yaml_parser_scan_to_next_token`: skip a leading BOM, spaces and
|
||||
/// tabs, a comment, and a single line break, which re-allows a simple key in
|
||||
/// the block context. A tab is valid in-line separation, so it is skipped
|
||||
/// here; `tabbedSeparation` records when one preceded the token so a block
|
||||
/// indicator or implicit key (where the tab would be indentation) can reject.
|
||||
mutating func scanToNextToken() throws {
|
||||
crossedLineBreak = false
|
||||
flowLeadingTab = false
|
||||
while true {
|
||||
tabbedSeparation = false
|
||||
if reader.column == 0, reader.peek() == "\u{FEFF}" {
|
||||
reader.skip()
|
||||
}
|
||||
while reader.peek() == " " || Classify.isTab(reader.peek()) {
|
||||
if Classify.isTab(reader.peek()) {
|
||||
noteLeadingTab()
|
||||
}
|
||||
reader.skip()
|
||||
}
|
||||
// YAML 1.2: a line containing only whitespace is blank even when that
|
||||
// whitespace includes tabs. libyaml leaves a leading tab in place and
|
||||
// later rejects it; instead consume the trailing blanks so the line
|
||||
// break below is reached and the blank line is skipped.
|
||||
if blankLineAhead() {
|
||||
while reader.peek() == " " || Classify.isTab(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
if reader.peek() == "#" {
|
||||
try consumeComment()
|
||||
}
|
||||
if Classify.isBreak(reader.peek()) {
|
||||
reader.skipLine()
|
||||
crossedLineBreak = true
|
||||
if flowLevel == 0 {
|
||||
simpleKeyAllowed = true
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record what a tab in the leading whitespace implies. In block context with a
|
||||
/// simple key allowed it is potential indentation before an indicator. In flow
|
||||
/// context, below the continuation indent on a non-blank line it is indentation
|
||||
/// abuse (a tab is never indentation); at or above the indent it is separation.
|
||||
private mutating func noteLeadingTab() {
|
||||
if flowLevel == 0, simpleKeyAllowed {
|
||||
tabbedSeparation = true
|
||||
}
|
||||
if flowLevel > 0, crossedLineBreak, reader.column < indentEngine.indent + 1, !blankLineAhead() {
|
||||
flowLeadingTab = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a comment to the end of the line. A comment must be preceded by
|
||||
/// whitespace or start the line; a `#` adjacent to the previous token (e.g.
|
||||
/// `"x"#`, `]#`, `,#`) is not a comment. The plain-scalar scanner consumes its
|
||||
/// own trailing blanks, so the character just before the `#` is the reliable
|
||||
/// signal. libyaml accepts the adjacent form.
|
||||
private mutating func consumeComment() throws {
|
||||
if reader.column > 0, let previous = reader.peek(-1), !Classify.isBlank(previous) {
|
||||
throw ScannerError(
|
||||
context: "while scanning for the next token",
|
||||
problem: "found a comment without a preceding space",
|
||||
mark: reader.mark,
|
||||
)
|
||||
}
|
||||
while !Classify.isBreakZ(reader.peek()) {
|
||||
reader.skip()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the rest of the current line, from the current position, is only
|
||||
/// spaces and tabs up to a line break or end of stream (a blank line).
|
||||
mutating func blankLineAhead() -> Bool {
|
||||
var offset = 0
|
||||
while let character = reader.peek(offset), character == " " || Classify.isTab(character) {
|
||||
offset += 1
|
||||
}
|
||||
return Classify.isBreakZ(reader.peek(offset))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
163
Sources/Parsing/LibYAML.Scanner.Tag.swift
Normal file
163
Sources/Parsing/LibYAML.Scanner.Tag.swift
Normal file
@ -0,0 +1,163 @@
|
||||
extension PureYAML.Parsing.LibYAML.Scanner {
|
||||
private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass
|
||||
|
||||
/// Port of `yaml_parser_scan_tag`. Scans a tag in verbatim `!<uri>`, shorthand
|
||||
/// `!handle!suffix`, or `!suffix` form, producing the handle and suffix; the
|
||||
/// parser resolves them against the tag directives.
|
||||
mutating func scanTag() throws -> PureYAML.Parsing.LibYAML.Token {
|
||||
let startMark = reader.mark
|
||||
var handle = ""
|
||||
var suffix = ""
|
||||
if reader.peek(1) == "<" {
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
suffix = try scanTagURI(uriChar: true, directive: false, head: "", startMark: startMark)
|
||||
guard reader.peek() == ">" else {
|
||||
throw tagError("did not find the expected '>'", startMark)
|
||||
}
|
||||
reader.skip()
|
||||
} else {
|
||||
handle = try scanTagHandle(directive: false, startMark: startMark)
|
||||
if handle.first == "!", handle.count > 1, handle.last == "!" {
|
||||
suffix = try scanTagURI(uriChar: false, directive: false, head: "", startMark: startMark)
|
||||
} else {
|
||||
suffix = try scanTagURI(uriChar: false, directive: false, head: handle, startMark: startMark)
|
||||
handle = "!"
|
||||
if suffix.isEmpty {
|
||||
swap(&handle, &suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard Classify.isBlankZ(reader.peek()) || (flowLevel > 0 && reader.peek() == ",") else {
|
||||
throw tagError("did not find expected whitespace or line break", startMark)
|
||||
}
|
||||
return PureYAML.Parsing.LibYAML.Token(type: .tag(handle: handle, suffix: suffix), startMark: startMark, endMark: reader.mark)
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_tag_handle`. A handle is `!`, optionally followed
|
||||
/// by alphanumeric characters and a closing `!`.
|
||||
mutating func scanTagHandle(directive: Bool, startMark: PureYAML.Parsing.Mark) throws -> String {
|
||||
guard reader.peek() == "!" else {
|
||||
throw tagError("did not find expected '!'", startMark)
|
||||
}
|
||||
var handle = "!"
|
||||
reader.skip()
|
||||
while let character = reader.peek(), Classify.isAlpha(character) {
|
||||
handle.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
if reader.peek() == "!" {
|
||||
handle.unicodeScalars.append("!")
|
||||
reader.skip()
|
||||
} else if directive, handle != "!" {
|
||||
throw tagError("did not find expected '!'", startMark)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_tag_uri`. Consumes the URI character set (plus the
|
||||
/// flow indicators inside a verbatim tag), decoding `%`-escapes.
|
||||
mutating func scanTagURI(uriChar: Bool, directive: Bool, head: String, startMark: PureYAML.Parsing.Mark) throws -> String {
|
||||
var string = head.count > 1 ? String(head.dropFirst()) : ""
|
||||
var length = head.isEmpty ? 0 : head.count
|
||||
while let character = reader.peek(), isURICharacter(character, uriChar: uriChar) {
|
||||
if character == "%" {
|
||||
try scanURIEscapes(directive: directive, startMark: startMark, into: &string)
|
||||
} else {
|
||||
string.unicodeScalars.append(character)
|
||||
reader.skip()
|
||||
}
|
||||
length += 1
|
||||
}
|
||||
guard length > 0 else {
|
||||
throw tagError("did not find expected tag URI", startMark)
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_scan_uri_escapes`. Decodes one UTF-8 character from a
|
||||
/// run of `%XX` octets.
|
||||
private mutating func scanURIEscapes(directive _: Bool, startMark: PureYAML.Parsing.Mark, into string: inout String) throws {
|
||||
var bytes: [UInt8] = []
|
||||
var width = 0
|
||||
repeat {
|
||||
guard reader.peek() == "%", let high = reader.peek(1), Classify.isHex(high), let low = reader.peek(2), Classify.isHex(low) else {
|
||||
throw tagError("did not find URI escaped octet", startMark)
|
||||
}
|
||||
let octet = UInt8((hexDigit(high) << 4) + hexDigit(low))
|
||||
if width == 0 {
|
||||
width = utf8Width(of: octet)
|
||||
guard width > 0 else {
|
||||
throw tagError("found an incorrect leading UTF-8 octet", startMark)
|
||||
}
|
||||
} else if octet & 0xC0 != 0x80 {
|
||||
throw tagError("found an incorrect trailing UTF-8 octet", startMark)
|
||||
}
|
||||
bytes.append(octet)
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
reader.skip()
|
||||
width -= 1
|
||||
} while width > 0
|
||||
guard let scalar = Unicode.Scalar(decodeUTF8(bytes)) else {
|
||||
throw tagError("found invalid UTF-8 in a URI escape", startMark)
|
||||
}
|
||||
string.unicodeScalars.append(scalar)
|
||||
}
|
||||
|
||||
/// Assemble a code point from the validated 1-to-4 UTF-8 octets of one escape.
|
||||
private func decodeUTF8(_ bytes: [UInt8]) -> UInt32 {
|
||||
switch bytes.count {
|
||||
case 1:
|
||||
UInt32(bytes[0])
|
||||
case 2:
|
||||
(UInt32(bytes[0] & 0x1F) << 6) | UInt32(bytes[1] & 0x3F)
|
||||
case 3:
|
||||
(UInt32(bytes[0] & 0x0F) << 12) | (UInt32(bytes[1] & 0x3F) << 6) | UInt32(bytes[2] & 0x3F)
|
||||
default:
|
||||
(UInt32(bytes[0] & 0x07) << 18) | (UInt32(bytes[1] & 0x3F) << 12) | (UInt32(bytes[2] & 0x3F) << 6) | UInt32(bytes[3] & 0x3F)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func isURICharacter(_ scalar: Unicode.Scalar, uriChar: Bool) -> Bool {
|
||||
if Classify.isAlpha(scalar) {
|
||||
return true
|
||||
}
|
||||
switch scalar {
|
||||
case ";", "/", "?", ":", "@", "&", "=", "+", "$", ".", "%", "!", "~", "*", "'", "(", ")":
|
||||
return true
|
||||
case ",", "[", "]":
|
||||
return uriChar
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func hexDigit(_ scalar: Unicode.Scalar) -> Int {
|
||||
switch scalar {
|
||||
case "0" ... "9": Int(scalar.value - Unicode.Scalar("0").value)
|
||||
case "A" ... "F": Int(scalar.value - Unicode.Scalar("A").value) + 10
|
||||
default: Int(scalar.value - Unicode.Scalar("a").value) + 10
|
||||
}
|
||||
}
|
||||
|
||||
private func tagError(_ problem: String, _ mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.LibYAML.ScannerError {
|
||||
PureYAML.Parsing.LibYAML.ScannerError(context: "while scanning a tag", problem: problem, mark: mark)
|
||||
}
|
||||
}
|
||||
143
Sources/Parsing/LibYAML.Scanner.swift
Normal file
143
Sources/Parsing/LibYAML.Scanner.swift
Normal file
@ -0,0 +1,143 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// Aggregate scanner state, mirroring the fields of `yaml_parser_t` that the
|
||||
/// ported functions read and write. It is built up as the port proceeds;
|
||||
/// currently it holds the indentation engine and the simple-key mechanism.
|
||||
/// Column values follow libyaml's 0-based convention internally; conversion to
|
||||
/// the package's 1-based `Mark` happens at token emission during integration.
|
||||
struct Scanner {
|
||||
/// The input buffer and position (`parser->buffer` / `parser->mark`).
|
||||
var reader: Reader
|
||||
/// The indentation stack and `roll_indent` / `unroll_indent` (also the
|
||||
/// owner of `flow_level` and the token queue).
|
||||
var indentEngine = IndentEngine()
|
||||
/// `parser->simple_keys`. Always holds at least the block-context level.
|
||||
var simpleKeys: [SimpleKey] = [.empty]
|
||||
/// `parser->simple_key_allowed`. True at stream start.
|
||||
var simpleKeyAllowed = true
|
||||
/// Whether a tab appeared in the indentation before the pending token. A
|
||||
/// tab is valid in-line separation but never indentation, so a block
|
||||
/// indicator or implicit mapping key reached through one is an error.
|
||||
var tabbedSeparation = false
|
||||
/// Whether reaching the pending token crossed at least one line break. In
|
||||
/// flow context a continuation line must be indented past the parent block,
|
||||
/// so a token on a new line at or below that indent is an error.
|
||||
var crossedLineBreak = false
|
||||
/// Whether a tab appeared in the leading whitespace of a flow continuation
|
||||
/// line. A tab is never indentation, so flow content reached through one is
|
||||
/// an error even though the tab inflates the column.
|
||||
var flowLeadingTab = false
|
||||
/// Whether the most recent `:` value indicator sat on the same line as its
|
||||
/// key (an inline implicit-key value). Such a value may not open a block
|
||||
/// sequence on that line (`key: - a`), whereas an explicit key whose `:` is
|
||||
/// on a later line may (`? k`\n`: - a`).
|
||||
var lastValueWasInline = false
|
||||
/// For each open flow level, whether it is a sequence (`[`) rather than a
|
||||
/// mapping (`{`). A flow-sequence single-pair implicit key is single-line
|
||||
/// (`c-s-implicit-json-key`), so its `:` may not cross a line break, unlike
|
||||
/// a flow-mapping adjacent value (`{ "a"`\n`: b }` is allowed).
|
||||
var flowIsSequence: [Bool] = []
|
||||
/// `parser->mark`, kept in sync with the reader at each fetch step.
|
||||
var mark: PureYAML.Parsing.Mark = .start
|
||||
/// `parser->stream_start_produced`.
|
||||
var streamStartProduced = false
|
||||
/// `parser->stream_end_produced`.
|
||||
var streamEndProduced = false
|
||||
/// A plain scalar fetch that ran dry mid-content (`scanStep()` only, batch
|
||||
/// scanning never sets this): resumed from here on the next `scanStep()`
|
||||
/// call instead of restarting the scalar from its first scalar.
|
||||
var suspendedPlainScalar: PlainScalarSuspended?
|
||||
|
||||
init(_ input: String = "", isFinal: Bool = true) {
|
||||
reader = Reader(input, isFinal: isFinal)
|
||||
}
|
||||
|
||||
/// `parser->flow_level`, kept on the indent engine.
|
||||
var flowLevel: Int {
|
||||
indentEngine.flowLevel
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_stale_simple_keys`. A potential simple key is
|
||||
/// invalid once it is on an earlier line or more than 1024 characters back
|
||||
/// (a simple key is single-line and bounded); a required one going stale is
|
||||
/// an error.
|
||||
mutating func staleSimpleKeys() throws {
|
||||
for index in simpleKeys.indices {
|
||||
let key = simpleKeys[index]
|
||||
guard key.possible else {
|
||||
continue
|
||||
}
|
||||
// A block-context key (index 0) is single-line. A flow-context key
|
||||
// (index >= 1) may be separated from its ':' by line breaks under
|
||||
// YAML 1.2, so a new line alone does not invalidate it; both remain
|
||||
// bounded to 1024 characters. libyaml invalidates every key on a new
|
||||
// line, which wrongly rejects multi-line flow keys.
|
||||
let tooFarBack = key.mark.index + 1024 < mark.index
|
||||
let stale = index == 0 ? (key.mark.line < mark.line || tooFarBack) : tooFarBack
|
||||
guard stale else {
|
||||
continue
|
||||
}
|
||||
if key.required {
|
||||
throw ScannerError(
|
||||
context: "while scanning a simple key",
|
||||
problem: "could not find expected ':'",
|
||||
mark: key.mark,
|
||||
)
|
||||
}
|
||||
simpleKeys[index].possible = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_save_simple_key`. Record the current position as a
|
||||
/// potential simple key for this flow level when one is allowed. A key is
|
||||
/// required (a `:` must follow) when it sits exactly at the block indent.
|
||||
mutating func saveSimpleKey() throws {
|
||||
let required = flowLevel == 0 && indentEngine.indent == mark.column
|
||||
guard simpleKeyAllowed else {
|
||||
return
|
||||
}
|
||||
let key = SimpleKey(
|
||||
possible: true,
|
||||
required: required,
|
||||
tokenNumber: indentEngine.tokensParsed + indentEngine.tokens.count,
|
||||
mark: mark,
|
||||
tabbed: tabbedSeparation,
|
||||
)
|
||||
try removeSimpleKey()
|
||||
simpleKeys[simpleKeys.count - 1] = key
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_remove_simple_key`. Drop the potential key at the
|
||||
/// current flow level; removing a required key is an error.
|
||||
mutating func removeSimpleKey() throws {
|
||||
let top = simpleKeys.count - 1
|
||||
if simpleKeys[top].possible, simpleKeys[top].required {
|
||||
throw ScannerError(
|
||||
context: "while scanning a simple key",
|
||||
problem: "could not find expected ':'",
|
||||
mark: simpleKeys[top].mark,
|
||||
)
|
||||
}
|
||||
simpleKeys[top].possible = false
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_increase_flow_level`. Push an empty simple key for
|
||||
/// the new flow level, recording whether the level is a sequence.
|
||||
mutating func increaseFlowLevel(isSequence: Bool) {
|
||||
simpleKeys.append(.empty)
|
||||
flowIsSequence.append(isSequence)
|
||||
indentEngine.flowLevel += 1
|
||||
}
|
||||
|
||||
/// Port of `yaml_parser_decrease_flow_level`. Pop the flow level's simple
|
||||
/// key.
|
||||
mutating func decreaseFlowLevel() {
|
||||
if indentEngine.flowLevel > 0 {
|
||||
indentEngine.flowLevel -= 1
|
||||
simpleKeys.removeLast()
|
||||
if !flowIsSequence.isEmpty {
|
||||
flowIsSequence.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Sources/Parsing/LibYAML.ScannerError.swift
Normal file
10
Sources/Parsing/LibYAML.ScannerError.swift
Normal file
@ -0,0 +1,10 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// A scanning error, mirroring libyaml's `yaml_parser_set_scanner_error`
|
||||
/// (`context` plus `problem` at a mark). It is mapped to the package's public
|
||||
/// `ParseError` when the port is integrated.
|
||||
struct ScannerError: Swift.Error, Equatable {
|
||||
var context: String
|
||||
var problem: String
|
||||
var mark: PureYAML.Parsing.Mark
|
||||
}
|
||||
}
|
||||
28
Sources/Parsing/LibYAML.SimpleKey.swift
Normal file
28
Sources/Parsing/LibYAML.SimpleKey.swift
Normal file
@ -0,0 +1,28 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// Port of `yaml_simple_key_t`. A potential implicit ("simple") mapping key:
|
||||
/// a node that may turn out to be a key once a `:` is seen on the same line.
|
||||
/// `tokenNumber` records where its `KEY` token must be spliced into the queue.
|
||||
struct SimpleKey: Equatable {
|
||||
/// Whether a simple key is still possible at this flow level.
|
||||
var possible: Bool
|
||||
/// Whether a `:` is required (a key sitting exactly at the block indent).
|
||||
var required: Bool
|
||||
/// The absolute token number where the `KEY` token is inserted.
|
||||
var tokenNumber: Int
|
||||
/// The position the potential key started at.
|
||||
var mark: PureYAML.Parsing.Mark
|
||||
/// Whether this key is a JSON-style node (a quoted flow scalar). A `:`
|
||||
/// adjacent to such a key is a value indicator even without a following
|
||||
/// space, per the YAML flow "adjacent value" rule.
|
||||
var isJSON = false
|
||||
/// Whether a tab preceded this key in its indentation. A tab may be
|
||||
/// separation but never indentation, so promoting such a key to a block
|
||||
/// mapping key (a `:` follows) is an error.
|
||||
var tabbed = false
|
||||
|
||||
/// The empty entry pushed for a new flow level (`{ 0, 0, 0, {0,0,0} }`).
|
||||
static var empty: Self {
|
||||
Self(possible: false, required: false, tokenNumber: 0, mark: .start)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Sources/Parsing/LibYAML.Token.swift
Normal file
10
Sources/Parsing/LibYAML.Token.swift
Normal file
@ -0,0 +1,10 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// A scanner token, mirroring `yaml_token_t`: a kind plus its start and end
|
||||
/// marks. Token-specific payloads (scalar value, anchor name, tag) are added
|
||||
/// as their fetchers are ported.
|
||||
struct Token: Equatable {
|
||||
var type: TokenType
|
||||
var startMark: PureYAML.Parsing.Mark
|
||||
var endMark: PureYAML.Parsing.Mark
|
||||
}
|
||||
}
|
||||
29
Sources/Parsing/LibYAML.TokenType.swift
Normal file
29
Sources/Parsing/LibYAML.TokenType.swift
Normal file
@ -0,0 +1,29 @@
|
||||
extension PureYAML.Parsing.LibYAML {
|
||||
/// A scanner token kind, mirroring `yaml_token_type_t`. The flow-collection,
|
||||
/// anchor, tag, alias, and directive kinds are added as their fetchers are
|
||||
/// ported; the block + plain-scalar core is present.
|
||||
enum TokenType: Equatable {
|
||||
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.Parsing.ScalarStyle)
|
||||
}
|
||||
}
|
||||
10
Sources/Parsing/LibYAML.swift
Normal file
10
Sources/Parsing/LibYAML.swift
Normal file
@ -0,0 +1,10 @@
|
||||
extension PureYAML.Parsing {
|
||||
/// Faithful Swift port of libyaml's scanner (`CYaml/src/scanner.c`). It is
|
||||
/// built as a parallel implementation, verified token-for-token against
|
||||
/// libyaml through the differential harness, to replace the hand-rolled
|
||||
/// approximation whose indentation and simple-key handling the official test
|
||||
/// suite surfaced as incorrect. The port proceeds function by function; each
|
||||
/// piece mirrors its C counterpart so correctness is by construction rather
|
||||
/// than re-derived. Members live under this namespace in their own files.
|
||||
enum LibYAML {}
|
||||
}
|
||||
46
Sources/Parsing/MergeResolution.swift
Normal file
46
Sources/Parsing/MergeResolution.swift
Normal file
@ -0,0 +1,46 @@
|
||||
extension PureYAML.Parsing {
|
||||
/// Shared merge-key (`<<`) resolution used by both the buffered and the
|
||||
/// streaming event composers, so the override semantics live in one place.
|
||||
enum MergeResolution {
|
||||
/// Combine merge sources with local pairs.
|
||||
///
|
||||
/// Local keys win over inherited keys, and earlier merge sources win
|
||||
/// over later ones. Insertion order is inherited pairs first, then the
|
||||
/// document's own local pairs.
|
||||
static func mergedPairs(
|
||||
from mergeSources: [[PureYAML.Model.Pair]],
|
||||
localPairs: [PureYAML.Model.Pair],
|
||||
) -> [PureYAML.Model.Pair] {
|
||||
guard !mergeSources.isEmpty else {
|
||||
return localPairs
|
||||
}
|
||||
|
||||
let localKeys = Set(localPairs.map(\.keyNode))
|
||||
var inheritedKeys = Set<PureYAML.Model.Key>()
|
||||
var inheritedPairs: [PureYAML.Model.Pair] = []
|
||||
for source in mergeSources {
|
||||
var sourceKeys = Set<PureYAML.Model.Key>()
|
||||
for pair in source {
|
||||
guard !localKeys.contains(pair.keyNode),
|
||||
!inheritedKeys.contains(pair.keyNode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
inheritedPairs.append(pair)
|
||||
sourceKeys.insert(pair.keyNode)
|
||||
}
|
||||
inheritedKeys.formUnion(sourceKeys)
|
||||
}
|
||||
return inheritedPairs + localPairs
|
||||
}
|
||||
|
||||
/// The parse error raised when a merge value is neither a mapping nor a
|
||||
/// sequence of mappings.
|
||||
static func invalidMergeValue(mark: PureYAML.Parsing.Mark) -> PureYAML.Parsing.ParseError {
|
||||
PureYAML.Parsing.ParseError.invalidMergeValue(
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct NodeProperties: Equatable {
|
||||
var anchor: String?
|
||||
var tag: String?
|
||||
var mark: Mark?
|
||||
|
||||
static var none: Self {
|
||||
Self(anchor: nil, tag: nil, mark: nil)
|
||||
}
|
||||
|
||||
mutating func mergeUnset(from other: Self) {
|
||||
anchor = anchor ?? other.anchor
|
||||
tag = tag ?? other.tag
|
||||
mark = mark ?? other.mark
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct NodeResult: Equatable {
|
||||
var kind: NodeKind
|
||||
var endMark: Mark
|
||||
}
|
||||
}
|
||||
41
Sources/Parsing/ParseError.LibYAML.swift
Normal file
41
Sources/Parsing/ParseError.LibYAML.swift
Normal file
@ -0,0 +1,41 @@
|
||||
extension PureYAML.Parsing.ParseError {
|
||||
/// Translate a ported-scanner error into the package's diagnostic-aware error,
|
||||
/// converting libyaml's 0-based mark to the 1-based convention and mapping the
|
||||
/// well-known scanner failures to precise diagnostic codes.
|
||||
init(translating error: PureYAML.Parsing.LibYAML.ScannerError) {
|
||||
let line = error.mark.line + 1
|
||||
let column = error.mark.column + 1
|
||||
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") {
|
||||
self = .unterminatedTag(line: line)
|
||||
} else {
|
||||
self = .malformed(reason: Self.reason(context: error.context, problem: error.problem), line: line, column: column)
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a ported-parser error into the package's diagnostic-aware error.
|
||||
init(translating error: PureYAML.Parsing.LibYAML.ParserError) {
|
||||
let line = error.mark.line + 1
|
||||
let column = error.mark.column + 1
|
||||
if error.problem == "found incompatible YAML document" {
|
||||
self = .incompatibleYAMLDirective(line: line)
|
||||
} else {
|
||||
self = .malformed(reason: Self.reason(context: error.context, problem: error.problem), line: line, column: column)
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a libyaml context and problem into one readable reason, e.g.
|
||||
/// "while parsing a flow sequence, did not find expected ',' or ']'".
|
||||
private static func reason(context: String?, problem: String) -> String {
|
||||
guard let context else {
|
||||
return problem
|
||||
}
|
||||
return "\(context), \(problem)"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -11,6 +12,7 @@ public extension PureYAML.Parsing {
|
||||
case expectedNode(line: Int, column: Int)
|
||||
case expectedScalarKey(line: Int, column: Int)
|
||||
case incompatibleYAMLDirective(line: Int)
|
||||
case directiveWithoutDocument(line: Int)
|
||||
case invalidTaggedScalar(tag: String, value: String, line: Int, column: Int)
|
||||
case invalidMergeValue(line: Int, column: Int)
|
||||
case unterminatedTag(line: Int)
|
||||
@ -20,6 +22,15 @@ public extension PureYAML.Parsing {
|
||||
case undefinedAlias(anchor: String, line: Int, column: Int)
|
||||
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 {
|
||||
switch self {
|
||||
@ -27,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):
|
||||
@ -43,6 +56,8 @@ public extension PureYAML.Parsing {
|
||||
"expected a scalar mapping key at line \(line), column \(column)"
|
||||
case let .incompatibleYAMLDirective(line):
|
||||
"incompatible YAML directive at line \(line)"
|
||||
case let .directiveWithoutDocument(line):
|
||||
"directive is not followed by a document start at line \(line)"
|
||||
case let .invalidTaggedScalar(tag, value, line, column):
|
||||
"invalid scalar '\(value)' for tag '\(tag)' at line \(line), column \(column)"
|
||||
case let .invalidMergeValue(line, column):
|
||||
@ -61,6 +76,128 @@ public extension PureYAML.Parsing {
|
||||
"expected \(expected) at line \(line), column \(column), found \(actual)"
|
||||
case let .unexpectedToken(expected, actual, line, column):
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
|
||||
public var diagnosticCode: String {
|
||||
switch self {
|
||||
case .emptyDocument:
|
||||
"emptyDocument"
|
||||
case .tabIndentation:
|
||||
"tabIndentation"
|
||||
case .invalidControlCharacter:
|
||||
"invalidControlCharacter"
|
||||
case .unexpectedIndentation:
|
||||
"unexpectedIndentation"
|
||||
case .mixedCollectionStyles:
|
||||
"mixedCollectionStyles"
|
||||
case .expectedMappingKey:
|
||||
"expectedMappingKey"
|
||||
case .expectedAnchorName:
|
||||
"expectedAnchorName"
|
||||
case .expectedAliasName:
|
||||
"expectedAliasName"
|
||||
case .expectedNode:
|
||||
"expectedNode"
|
||||
case .expectedScalarKey:
|
||||
"expectedScalarKey"
|
||||
case .incompatibleYAMLDirective:
|
||||
"incompatibleYAMLDirective"
|
||||
case .directiveWithoutDocument:
|
||||
"directiveWithoutDocument"
|
||||
case .invalidTaggedScalar:
|
||||
"invalidTaggedScalar"
|
||||
case .invalidMergeValue:
|
||||
"invalidMergeValue"
|
||||
case .unterminatedTag:
|
||||
"unterminatedTag"
|
||||
case .unterminatedQuotedString:
|
||||
"unterminatedQuotedString"
|
||||
case .unsupportedDirective:
|
||||
"unsupportedDirective"
|
||||
case .unsupportedMultiDocumentStream:
|
||||
"unsupportedMultiDocumentStream"
|
||||
case .undefinedAlias:
|
||||
"undefinedAlias"
|
||||
case .unexpectedEvent:
|
||||
"unexpectedEvent"
|
||||
case .unexpectedToken:
|
||||
"unexpectedToken"
|
||||
case .nestingTooDeep:
|
||||
"nestingTooDeep"
|
||||
case .expansionTooLarge:
|
||||
"expansionTooLarge"
|
||||
case .malformed:
|
||||
"malformed"
|
||||
}
|
||||
}
|
||||
|
||||
public var sourceLine: Int? {
|
||||
switch self {
|
||||
case .emptyDocument:
|
||||
nil
|
||||
case let .tabIndentation(line),
|
||||
let .unexpectedIndentation(line),
|
||||
let .mixedCollectionStyles(line),
|
||||
let .expectedMappingKey(line),
|
||||
let .expectedAnchorName(line),
|
||||
let .expectedAliasName(line),
|
||||
let .incompatibleYAMLDirective(line),
|
||||
let .directiveWithoutDocument(line),
|
||||
let .unterminatedTag(line),
|
||||
let .unterminatedQuotedString(line),
|
||||
let .unsupportedDirective(_, line),
|
||||
let .unsupportedMultiDocumentStream(line):
|
||||
line
|
||||
case let .expectedNode(line, _),
|
||||
let .expectedScalarKey(line, _),
|
||||
let .invalidTaggedScalar(_, _, line, _),
|
||||
let .invalidMergeValue(line, _),
|
||||
let .undefinedAlias(_, line, _),
|
||||
let .unexpectedEvent(_, _, line, _),
|
||||
let .unexpectedToken(_, _, line, _),
|
||||
let .nestingTooDeep(line, _),
|
||||
let .expansionTooLarge(line, _),
|
||||
let .invalidControlCharacter(line, _),
|
||||
let .malformed(_, line, _):
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
public var sourceColumn: Int? {
|
||||
switch self {
|
||||
case .emptyDocument,
|
||||
.tabIndentation,
|
||||
.unexpectedIndentation,
|
||||
.mixedCollectionStyles,
|
||||
.expectedMappingKey,
|
||||
.expectedAnchorName,
|
||||
.expectedAliasName,
|
||||
.incompatibleYAMLDirective,
|
||||
.directiveWithoutDocument,
|
||||
.unterminatedTag,
|
||||
.unterminatedQuotedString,
|
||||
.unsupportedDirective,
|
||||
.unsupportedMultiDocumentStream:
|
||||
nil
|
||||
case let .expectedNode(_, column),
|
||||
let .expectedScalarKey(_, column),
|
||||
let .invalidTaggedScalar(_, _, _, column),
|
||||
let .invalidMergeValue(_, column),
|
||||
let .undefinedAlias(_, _, column),
|
||||
let .unexpectedEvent(_, _, _, column),
|
||||
let .unexpectedToken(_, _, _, column),
|
||||
let .nestingTooDeep(_, column),
|
||||
let .expansionTooLarge(_, column),
|
||||
let .invalidControlCharacter(_, column),
|
||||
let .malformed(_, _, column):
|
||||
column
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,91 @@
|
||||
extension PureYAML.Parsing.Parser {
|
||||
func parseEvents(_ yaml: String) throws -> [PureYAML.Parsing.Event] {
|
||||
let tokens = try PureYAML.Parsing.Scanner().scan(yaml)
|
||||
var parser = PureYAML.Parsing.TokenEventParser(tokens: tokens, scalarParser: self)
|
||||
return try parser.parse()
|
||||
let events: [PureYAML.Parsing.Event]
|
||||
do {
|
||||
var parser = try PureYAML.Parsing.LibYAML.Parser(yaml)
|
||||
events = try parser.parse().map { $0.bumpingMark() }
|
||||
} catch let error as PureYAML.Parsing.LibYAML.ScannerError {
|
||||
throw PureYAML.Parsing.ParseError(translating: error)
|
||||
} catch let error as PureYAML.Parsing.LibYAML.ParserError {
|
||||
throw PureYAML.Parsing.ParseError(translating: error)
|
||||
}
|
||||
try enforceNestingLimit(events)
|
||||
return events
|
||||
}
|
||||
|
||||
/// The deepest collection nesting the value/event composers will build. The
|
||||
/// ported parser produces events iteratively without recursion, but the
|
||||
/// composers, equality, hashing, and `Model/Value` teardown all recurse with
|
||||
/// the nesting depth, so the event stream is bounded here before any recursive
|
||||
/// consumer runs. 64 accepts every realistic document yet rejects pathological
|
||||
/// input well before it can overflow the stack.
|
||||
static var maxNestingDepth: Int {
|
||||
64
|
||||
}
|
||||
|
||||
/// Reject an event stream that nests collections past ``maxNestingDepth`` with
|
||||
/// a ``ParseError/nestingTooDeep(line:column:)`` so deeply nested input fails
|
||||
/// cleanly instead of overflowing a recursive consumer's stack.
|
||||
private func enforceNestingLimit(_ events: [PureYAML.Parsing.Event]) throws {
|
||||
var depth = 0
|
||||
for event in events {
|
||||
switch event {
|
||||
case .sequenceStart, .mappingStart:
|
||||
depth += 1
|
||||
if depth > Self.maxNestingDepth {
|
||||
throw PureYAML.Parsing.ParseError.nestingTooDeep(line: event.mark.line, column: event.mark.column)
|
||||
}
|
||||
case .sequenceEnd, .mappingEnd:
|
||||
depth -= 1
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseStreaming(_ yaml: String) throws -> PureYAML.Model.Value {
|
||||
var composer = PureYAML.Parsing.EventStreamComposer(
|
||||
scalarParser: self,
|
||||
mode: .singleDocument,
|
||||
)
|
||||
for event in try parseEvents(yaml) {
|
||||
try composer.consume(event)
|
||||
}
|
||||
return try composer.composedValue()
|
||||
}
|
||||
|
||||
func parseStreamStreaming(_ yaml: String) throws -> [PureYAML.Stream.Document] {
|
||||
var composer = PureYAML.Parsing.EventStreamComposer(
|
||||
scalarParser: self,
|
||||
mode: .stream,
|
||||
)
|
||||
for event in try parseEvents(yaml) {
|
||||
try composer.consume(event)
|
||||
}
|
||||
return try composer.composedStream()
|
||||
}
|
||||
|
||||
func parseLocatedStreaming(_ yaml: String) throws -> PureYAML.Model.LocatedValue {
|
||||
var composer = PureYAML.Parsing.EventStreamComposer(
|
||||
scalarParser: self,
|
||||
mode: .singleDocument,
|
||||
tracksLocations: true,
|
||||
)
|
||||
for event in try parseEvents(yaml) {
|
||||
try composer.consume(event)
|
||||
}
|
||||
return try composer.composedLocated()
|
||||
}
|
||||
|
||||
func parseStreamLocatedStreaming(_ yaml: String) throws -> [PureYAML.Model.LocatedValue] {
|
||||
var composer = PureYAML.Parsing.EventStreamComposer(
|
||||
scalarParser: self,
|
||||
mode: .stream,
|
||||
tracksLocations: true,
|
||||
)
|
||||
for event in try parseEvents(yaml) {
|
||||
try composer.consume(event)
|
||||
}
|
||||
return try composer.composedLocatedStream()
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,13 @@ extension PureYAML.Parsing.Parser {
|
||||
return tagged
|
||||
}
|
||||
|
||||
// An explicit tag that is not a recognized core type, a local tag like `!foo` or the
|
||||
// non-specific `!`, still fixes the node as a string: a tagged scalar is not subject to the
|
||||
// implicit plain-scalar resolution that would otherwise read `! 12` as the integer 12.
|
||||
if normalizedTag(tag) != nil {
|
||||
return .string(value)
|
||||
}
|
||||
|
||||
switch style {
|
||||
case .folded, .literal:
|
||||
return .string(value)
|
||||
@ -28,6 +35,10 @@ extension PureYAML.Parsing.Parser {
|
||||
return nil
|
||||
}
|
||||
switch tag {
|
||||
case "!":
|
||||
// The non-specific `!` tag resolves a scalar by kind alone: a scalar is
|
||||
// a string, with no implicit typing, so `! 12` is "12" not the int 12.
|
||||
return .string(value)
|
||||
case "tag:yaml.org,2002:str":
|
||||
return .string(value)
|
||||
case "tag:yaml.org,2002:int":
|
||||
|
||||
@ -4,15 +4,19 @@ public extension PureYAML.Parsing {
|
||||
public init() {}
|
||||
|
||||
public func parse(_ yaml: String) throws -> PureYAML.Model.Value {
|
||||
let events = try parseEvents(yaml)
|
||||
var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: self)
|
||||
return try composer.compose()
|
||||
try parseStreaming(yaml)
|
||||
}
|
||||
|
||||
public func parseStream(_ yaml: String) throws -> [PureYAML.Stream.Document] {
|
||||
let events = try parseEvents(yaml)
|
||||
var composer = PureYAML.Parsing.EventComposer(events: events, scalarParser: self)
|
||||
return try composer.composeStream()
|
||||
try parseStreamStreaming(yaml)
|
||||
}
|
||||
|
||||
public func parseLocated(_ yaml: String) throws -> PureYAML.Model.LocatedValue {
|
||||
try parseLocatedStreaming(yaml)
|
||||
}
|
||||
|
||||
public func parseStreamLocated(_ yaml: String) throws -> [PureYAML.Model.LocatedValue] {
|
||||
try parseStreamLocatedStreaming(yaml)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 != "_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,18 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct Reader {
|
||||
private let source: String
|
||||
private let utf8: String.UTF8View
|
||||
private var position: String.Index
|
||||
private var utf8Position: String.UTF8View.Index
|
||||
private var line: Int
|
||||
private var column: Int
|
||||
private var utf8Index: Int
|
||||
|
||||
init(_ source: String) {
|
||||
self.source = source
|
||||
utf8 = source.utf8
|
||||
position = source.startIndex
|
||||
utf8Position = source.utf8.startIndex
|
||||
line = 1
|
||||
column = 1
|
||||
utf8Index = 0
|
||||
@ -42,22 +46,20 @@ extension PureYAML.Parsing {
|
||||
return nil
|
||||
}
|
||||
let character = source[position]
|
||||
position = source.index(after: position)
|
||||
utf8Index += String(character).utf8.count
|
||||
if character == "\r\n" {
|
||||
switch utf8[utf8Position] {
|
||||
case 13:
|
||||
advanceCarriageReturn()
|
||||
line += 1
|
||||
column = 1
|
||||
} else if character == "\r" {
|
||||
if position < source.endIndex, source[position] == "\n" {
|
||||
position = source.index(after: position)
|
||||
utf8Index += 1
|
||||
}
|
||||
case 10:
|
||||
advanceASCIICharacter()
|
||||
line += 1
|
||||
column = 1
|
||||
} else if character == "\n" {
|
||||
line += 1
|
||||
column = 1
|
||||
} else {
|
||||
case 0 ..< 128:
|
||||
advanceASCIICharacter()
|
||||
column += 1
|
||||
default:
|
||||
advanceExtendedCharacter()
|
||||
column += 1
|
||||
}
|
||||
return character
|
||||
@ -71,5 +73,35 @@ extension PureYAML.Parsing {
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private mutating func advanceASCIICharacter() {
|
||||
position = source.index(after: position)
|
||||
utf8Position = utf8.index(after: utf8Position)
|
||||
utf8Index += 1
|
||||
}
|
||||
|
||||
private mutating func advanceCarriageReturn() {
|
||||
position = source.index(after: position)
|
||||
utf8Position = utf8.index(after: utf8Position)
|
||||
utf8Index += 1
|
||||
guard utf8Position < utf8.endIndex, utf8[utf8Position] == 10 else {
|
||||
return
|
||||
}
|
||||
utf8Position = utf8.index(after: utf8Position)
|
||||
utf8Index += 1
|
||||
if position < source.endIndex, source[position] == "\n" {
|
||||
position = source.index(after: position)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func advanceExtendedCharacter() {
|
||||
position = source.index(after: position)
|
||||
guard let nextUTF8Position = position.samePosition(in: utf8) else {
|
||||
utf8Index += String(source[source.index(before: position)]).utf8.count
|
||||
return
|
||||
}
|
||||
utf8Index += utf8.distance(from: utf8Position, to: nextUTF8Position)
|
||||
utf8Position = nextUTF8Position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
@ -110,22 +42,8 @@ extension PureYAML.Parsing.Parser {
|
||||
guard text.count >= 2, text.last == "'" else {
|
||||
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: line)
|
||||
}
|
||||
let inner = text.dropFirst().dropLast()
|
||||
var output = ""
|
||||
var iterator = inner.makeIterator()
|
||||
while let character = iterator.next() {
|
||||
if character == "'", let next = iterator.next() {
|
||||
if next == "'" {
|
||||
output.append("'")
|
||||
} else {
|
||||
output.append(character)
|
||||
output.append(next)
|
||||
}
|
||||
} else {
|
||||
output.append(character)
|
||||
}
|
||||
}
|
||||
return output
|
||||
let inner = Array(text.dropFirst().dropLast())
|
||||
return foldFlowScalar(inner, single: true)
|
||||
}
|
||||
|
||||
func parseDoubleQuoted(
|
||||
@ -135,37 +53,271 @@ extension PureYAML.Parsing.Parser {
|
||||
guard text.count >= 2, text.last == "\"" else {
|
||||
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: line)
|
||||
}
|
||||
let inner = text.dropFirst().dropLast()
|
||||
var output = ""
|
||||
var escaping = false
|
||||
for character in inner {
|
||||
if escaping {
|
||||
appendDoubleQuotedEscape(character, to: &output)
|
||||
escaping = false
|
||||
} else if character == "\\" {
|
||||
escaping = true
|
||||
} else {
|
||||
output.append(character)
|
||||
}
|
||||
}
|
||||
if escaping {
|
||||
output.append("\\")
|
||||
}
|
||||
return output
|
||||
let inner = Array(text.dropFirst().dropLast())
|
||||
return foldFlowScalar(inner, single: false)
|
||||
}
|
||||
|
||||
func appendDoubleQuotedEscape(
|
||||
_ character: Character,
|
||||
to output: inout String,
|
||||
/// Flow (quoted) scalar line folding, mirroring libyaml's
|
||||
/// yaml_parser_scan_flow_scalar (single and double quoted). Trailing
|
||||
/// whitespace before a break is dropped, leading whitespace on a
|
||||
/// continuation line is dropped, a single break between non-empty lines
|
||||
/// folds to one space, and N consecutive breaks yield N-1 line feeds.
|
||||
/// For double quoted scalars a backslash at end of line suppresses the
|
||||
/// break entirely.
|
||||
func foldFlowScalar(
|
||||
_ inner: [Character],
|
||||
single: Bool,
|
||||
) -> String {
|
||||
var state = FlowFoldState()
|
||||
let count = inner.count
|
||||
while state.index < count {
|
||||
state.leadingBlanks = false
|
||||
consumeFlowContent(inner, single: single, state: &state)
|
||||
consumeFlowBlanks(inner, state: &state)
|
||||
joinFlowFold(state: &state)
|
||||
}
|
||||
return state.output
|
||||
}
|
||||
|
||||
/// Mutable accumulators for flow scalar folding, matching the C reference's
|
||||
/// string / leading_break / trailing_breaks / whitespaces buffers.
|
||||
struct FlowFoldState {
|
||||
var output = ""
|
||||
var whitespaces = ""
|
||||
var leadingBreak = ""
|
||||
var trailingBreaks = ""
|
||||
var leadingBlanks = false
|
||||
var index = 0
|
||||
}
|
||||
|
||||
/// Consume the run of non-blank characters at `state.index`, decoding
|
||||
/// escapes. A double quoted backslash at end of line sets `leadingBlanks`
|
||||
/// and stops, so the following break is suppressed during folding.
|
||||
func consumeFlowContent(
|
||||
_ inner: [Character],
|
||||
single: Bool,
|
||||
state: inout FlowFoldState,
|
||||
) {
|
||||
switch character {
|
||||
case "\"": output.append("\"")
|
||||
case "\\": output.append("\\")
|
||||
case "/": output.append("/")
|
||||
case "n": output.append("\n")
|
||||
case "r": output.append("\r")
|
||||
case "t": output.append("\t")
|
||||
default: output.append(character)
|
||||
let count = inner.count
|
||||
while state.index < count, !isFlowBlank(inner[state.index]), !isFlowBreak(inner[state.index]) {
|
||||
if single, inner[state.index] == "'" {
|
||||
state.output.append("'")
|
||||
let isEscapedQuote = state.index + 1 < count && inner[state.index + 1] == "'"
|
||||
state.index += isEscapedQuote ? 2 : 1
|
||||
continue
|
||||
}
|
||||
if !single, inner[state.index] == "\\" {
|
||||
if state.index + 1 < count, isFlowBreak(inner[state.index + 1]) {
|
||||
state.index += 2
|
||||
state.leadingBlanks = true
|
||||
break
|
||||
}
|
||||
state.index += 1
|
||||
appendDoubleQuotedEscape(inner, index: &state.index, to: &state.output)
|
||||
continue
|
||||
}
|
||||
state.output.append(inner[state.index])
|
||||
state.index += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the run of blanks and line breaks at `state.index`, sorting them
|
||||
/// into the whitespace / leading-break / trailing-break buffers.
|
||||
func consumeFlowBlanks(
|
||||
_ inner: [Character],
|
||||
state: inout FlowFoldState,
|
||||
) {
|
||||
let count = inner.count
|
||||
while state.index < count, isFlowBlank(inner[state.index]) || isFlowBreak(inner[state.index]) {
|
||||
if isFlowBlank(inner[state.index]) {
|
||||
if !state.leadingBlanks {
|
||||
state.whitespaces.append(inner[state.index])
|
||||
}
|
||||
} else if !state.leadingBlanks {
|
||||
state.whitespaces = ""
|
||||
state.leadingBreak = "\n"
|
||||
state.leadingBlanks = true
|
||||
} else {
|
||||
state.trailingBreaks.append("\n")
|
||||
}
|
||||
state.index += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Join the captured whitespace or fold the captured line breaks into the
|
||||
/// output, per YAML 1.2 flow folding.
|
||||
func joinFlowFold(state: inout FlowFoldState) {
|
||||
guard state.leadingBlanks else {
|
||||
state.output.append(state.whitespaces)
|
||||
state.whitespaces = ""
|
||||
return
|
||||
}
|
||||
if state.leadingBreak.first == "\n" {
|
||||
if state.trailingBreaks.isEmpty {
|
||||
state.output.append(" ")
|
||||
} else {
|
||||
state.output.append(state.trailingBreaks)
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
state.leadingBreak = ""
|
||||
} else {
|
||||
state.output.append(state.leadingBreak)
|
||||
state.output.append(state.trailingBreaks)
|
||||
state.leadingBreak = ""
|
||||
state.trailingBreaks = ""
|
||||
}
|
||||
}
|
||||
|
||||
func isFlowBlank(_ character: Character) -> Bool {
|
||||
character == " " || character == "\t"
|
||||
}
|
||||
|
||||
func isFlowBreak(_ character: Character) -> Bool {
|
||||
character == "\n" || character == "\r" || character == "\r\n"
|
||||
}
|
||||
|
||||
/// Decode a double quoted escape. On entry index points at the escape
|
||||
/// character (the position just after the backslash). On return index has
|
||||
/// advanced past every character the escape consumed. The escape set
|
||||
/// mirrors libyaml's yaml_parser_scan_flow_scalar.
|
||||
func appendDoubleQuotedEscape(
|
||||
_ text: [Character],
|
||||
index: inout Int,
|
||||
to output: inout String,
|
||||
) {
|
||||
guard index < text.count else {
|
||||
output.append("\\")
|
||||
return
|
||||
}
|
||||
let character = text[index]
|
||||
if let simple = Self.simpleDoubleQuotedEscapes[character] {
|
||||
output.append(simple)
|
||||
index += 1
|
||||
return
|
||||
}
|
||||
switch character {
|
||||
case "x":
|
||||
index += 1
|
||||
appendHexEscape(text, index: &index, digits: 2, marker: "x", to: &output)
|
||||
case "u":
|
||||
index += 1
|
||||
appendUnicodeEscape(text, index: &index, to: &output)
|
||||
case "U":
|
||||
index += 1
|
||||
appendHexEscape(text, index: &index, digits: 8, marker: "U", to: &output)
|
||||
default:
|
||||
output.append(character)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Single character double quoted escapes, matching libyaml's escape table.
|
||||
static let simpleDoubleQuotedEscapes: [Character: Character] = [
|
||||
"0": "\u{00}",
|
||||
"a": "\u{07}",
|
||||
"b": "\u{08}",
|
||||
"t": "\t",
|
||||
"\t": "\t",
|
||||
"n": "\n",
|
||||
"v": "\u{0B}",
|
||||
"f": "\u{0C}",
|
||||
"r": "\r",
|
||||
"e": "\u{1B}",
|
||||
" ": " ",
|
||||
"\"": "\"",
|
||||
"/": "/",
|
||||
"\\": "\\",
|
||||
"N": "\u{85}",
|
||||
"_": "\u{A0}",
|
||||
"L": "\u{2028}",
|
||||
"P": "\u{2029}",
|
||||
]
|
||||
|
||||
/// Decode \xXX or \UXXXXXXXX. On entry index points at the first hex digit.
|
||||
func appendHexEscape(
|
||||
_ text: [Character],
|
||||
index: inout Int,
|
||||
digits: Int,
|
||||
marker: Character,
|
||||
to output: inout String,
|
||||
) {
|
||||
guard let value = readHex(text, index: &index, digits: digits) else {
|
||||
output.append(marker)
|
||||
return
|
||||
}
|
||||
if let scalar = UnicodeScalar(value) {
|
||||
output.unicodeScalars.append(scalar)
|
||||
} else {
|
||||
output.append(marker)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode \uXXXX, including surrogate pairs. On entry index points at the
|
||||
/// first hex digit.
|
||||
func appendUnicodeEscape(
|
||||
_ text: [Character],
|
||||
index: inout Int,
|
||||
to output: inout String,
|
||||
) {
|
||||
let start = index
|
||||
guard let codeUnit = readHex(text, index: &index, digits: 4) else {
|
||||
output.append("u")
|
||||
index = start
|
||||
return
|
||||
}
|
||||
if isHighSurrogate(codeUnit), let scalar = readSurrogatePair(high: codeUnit, text: text, index: &index) {
|
||||
output.unicodeScalars.append(scalar)
|
||||
} else if let scalar = UnicodeScalar(codeUnit) {
|
||||
output.unicodeScalars.append(scalar)
|
||||
} else {
|
||||
output.append("u")
|
||||
}
|
||||
}
|
||||
|
||||
func readSurrogatePair(
|
||||
high: UInt32,
|
||||
text: [Character],
|
||||
index: inout Int,
|
||||
) -> UnicodeScalar? {
|
||||
var probe = index
|
||||
guard probe + 1 < text.count, text[probe] == "\\", text[probe + 1] == "u" else {
|
||||
return nil
|
||||
}
|
||||
probe += 2
|
||||
guard let low = readHex(text, index: &probe, digits: 4), isLowSurrogate(low) else {
|
||||
return nil
|
||||
}
|
||||
index = probe
|
||||
let value = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)
|
||||
return UnicodeScalar(value)
|
||||
}
|
||||
|
||||
/// Read exactly `digits` hex characters starting at index. On success index
|
||||
/// advances past them, otherwise index is left unchanged.
|
||||
func readHex(
|
||||
_ text: [Character],
|
||||
index: inout Int,
|
||||
digits: Int,
|
||||
) -> UInt32? {
|
||||
guard index + digits <= text.count else {
|
||||
return nil
|
||||
}
|
||||
var value: UInt32 = 0
|
||||
for offset in 0 ..< digits {
|
||||
guard let digit = text[index + offset].hexDigitValue else {
|
||||
return nil
|
||||
}
|
||||
value = (value << 4) + UInt32(digit)
|
||||
}
|
||||
index += digits
|
||||
return value
|
||||
}
|
||||
|
||||
func isHighSurrogate(_ value: UInt32) -> Bool {
|
||||
(0xD800 ... 0xDBFF).contains(value)
|
||||
}
|
||||
|
||||
func isLowSurrogate(_ value: UInt32) -> Bool {
|
||||
(0xDC00 ... 0xDFFF).contains(value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
extension PureYAML.Parsing.Scanner {
|
||||
func scanDirectiveOrDocumentMarker(_ state: inout State) throws -> Bool {
|
||||
guard state.indentation.last == 0 else {
|
||||
return false
|
||||
}
|
||||
if state.reader.peek() == "%" {
|
||||
try scanDirective(&state)
|
||||
return true
|
||||
}
|
||||
if state.reader.peek() == "#" {
|
||||
return false
|
||||
}
|
||||
if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
|
||||
scanDocumentMarker(.documentStart, state: &state)
|
||||
resetForDocumentStart(&state)
|
||||
return true
|
||||
}
|
||||
if startsWith("...", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
|
||||
guard !state.isDocumentClosed else {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
|
||||
}
|
||||
scanDocumentMarker(.documentEnd, state: &state)
|
||||
resetForDocumentEnd(&state)
|
||||
return true
|
||||
}
|
||||
guard !state.isDocumentClosed else {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scanDocumentMarker(
|
||||
_ kind: PureYAML.Parsing.TokenKind,
|
||||
state: inout State,
|
||||
) {
|
||||
let start = state.reader.mark
|
||||
state.reader.advance()
|
||||
state.reader.advance()
|
||||
state.reader.advance()
|
||||
state.append(kind, mark: start, endMark: state.reader.mark)
|
||||
skipLine(&state)
|
||||
}
|
||||
|
||||
func resetForDocumentStart(_ state: inout State) {
|
||||
state.hasDocumentContent = false
|
||||
state.hasDocumentStartMarker = true
|
||||
state.isDocumentClosed = false
|
||||
}
|
||||
|
||||
func resetForDocumentEnd(_ state: inout State) {
|
||||
state.hasDocumentContent = false
|
||||
state.hasDocumentStartMarker = false
|
||||
state.isDocumentClosed = true
|
||||
}
|
||||
|
||||
func scanDirective(_ state: inout State) throws {
|
||||
let start = state.reader.mark
|
||||
let line = state.reader.consume { character in
|
||||
!isLineBreak(character)
|
||||
}
|
||||
let parts = line.split(separator: " ").map(String.init)
|
||||
guard let name = parts.first else {
|
||||
return
|
||||
}
|
||||
guard !state.hasDocumentContent, !state.hasDocumentStartMarker, !state.isDocumentClosed else {
|
||||
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
|
||||
}
|
||||
switch name {
|
||||
case "%YAML":
|
||||
guard parts.indices.contains(1), parts[1] == "1.1" || parts[1] == "1.2" else {
|
||||
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
|
||||
}
|
||||
case "%TAG":
|
||||
guard parts.count >= 3 else {
|
||||
return
|
||||
}
|
||||
state.tagHandles[parts[1]] = parts[2]
|
||||
default:
|
||||
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
extension PureYAML.Parsing.Scanner {
|
||||
func startsWith(
|
||||
_ prefix: String,
|
||||
_ reader: PureYAML.Parsing.Reader,
|
||||
) -> Bool {
|
||||
for (offset, character) in prefix.enumerated() {
|
||||
guard reader.peek(offset: offset) == character else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLineBreak(_ character: Character?) -> Bool {
|
||||
character == "\n" || character == "\r" || character == "\r\n"
|
||||
}
|
||||
|
||||
func isBlockEntry(_ reader: PureYAML.Parsing.Reader) -> Bool {
|
||||
reader.peek() == "-" && isIndicatorBoundary(reader.peek(offset: 1))
|
||||
}
|
||||
|
||||
func isIndicatorBoundary(_ character: Character?) -> Bool {
|
||||
guard let character else {
|
||||
return true
|
||||
}
|
||||
return character == " " || isLineBreak(character)
|
||||
}
|
||||
|
||||
func isMappingValueBoundary(_ character: Character?) -> Bool {
|
||||
guard let character else {
|
||||
return true
|
||||
}
|
||||
return character == " "
|
||||
|| isLineBreak(character)
|
||||
|| character == ","
|
||||
|| character == "]"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func isBlockMappingValueBoundary(_ character: Character?) -> Bool {
|
||||
guard let character else {
|
||||
return true
|
||||
}
|
||||
return character == " " || isLineBreak(character)
|
||||
}
|
||||
|
||||
func isTokenTerminator(_ character: Character) -> Bool {
|
||||
character == " "
|
||||
|| isLineBreak(character)
|
||||
|| character == ","
|
||||
|| character == "["
|
||||
|| character == "]"
|
||||
|| character == "{"
|
||||
|| character == "}"
|
||||
|| character == "#"
|
||||
}
|
||||
|
||||
func canStartAnchorName(_ character: Character?) -> Bool {
|
||||
guard let character else {
|
||||
return false
|
||||
}
|
||||
return !isTokenTerminator(character)
|
||||
&& character != "*"
|
||||
&& character != "&"
|
||||
&& character != "!"
|
||||
&& character != "\""
|
||||
&& character != "'"
|
||||
}
|
||||
|
||||
func hasNodeOnSameLine(afterCurrentPositionIn reader: PureYAML.Parsing.Reader) -> Bool {
|
||||
var offset = 0
|
||||
while reader.peek(offset: offset) == " " {
|
||||
offset += 1
|
||||
}
|
||||
guard let character = reader.peek(offset: offset) else {
|
||||
return false
|
||||
}
|
||||
return !isLineBreak(character) && character != "#"
|
||||
}
|
||||
|
||||
func isFlowDelimiter(_ character: Character) -> Bool {
|
||||
character == ","
|
||||
|| character == "["
|
||||
|| character == "]"
|
||||
|| character == "{"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func isBlockScalarHeaderCharacter(_ character: Character) -> Bool {
|
||||
character == "+"
|
||||
|| character == "-"
|
||||
|| character.isNumber
|
||||
}
|
||||
|
||||
func trimTrailingSpaces(_ value: String) -> String {
|
||||
var end = value.endIndex
|
||||
while end > value.startIndex {
|
||||
let previous = value.index(before: end)
|
||||
guard value[previous] == " " else {
|
||||
break
|
||||
}
|
||||
end = previous
|
||||
}
|
||||
return String(value[..<end])
|
||||
}
|
||||
|
||||
func scanPlainScalarContinuations(
|
||||
after firstLine: String,
|
||||
state: inout State,
|
||||
) -> String {
|
||||
var value = firstLine
|
||||
while true {
|
||||
var probe = state.reader
|
||||
guard isLineBreak(probe.peek()) else {
|
||||
return value
|
||||
}
|
||||
|
||||
probe.advance()
|
||||
var blankLineCount = 0
|
||||
while true {
|
||||
var blankProbe = probe
|
||||
_ = blankProbe.consume { character in
|
||||
character == " "
|
||||
}
|
||||
guard isLineBreak(blankProbe.peek()) else {
|
||||
break
|
||||
}
|
||||
blankProbe.advance()
|
||||
probe = blankProbe
|
||||
blankLineCount += 1
|
||||
}
|
||||
|
||||
_ = probe.consume { character in
|
||||
character == " "
|
||||
}
|
||||
let indentationWidth = probe.mark.column - 1
|
||||
|
||||
guard !probe.isAtEnd, !isLineBreak(probe.peek()), probe.peek() != "#" else {
|
||||
return value
|
||||
}
|
||||
guard !isBlockEntry(probe) else {
|
||||
return value
|
||||
}
|
||||
|
||||
let baseIndentation = plainScalarContinuationBaseIndentation(state: state)
|
||||
guard indentationWidth > baseIndentation else {
|
||||
return value
|
||||
}
|
||||
|
||||
var continuationState = state
|
||||
continuationState.reader = probe
|
||||
let continuation = scanPlainScalarContinuationLine(&continuationState)
|
||||
guard !isAtMappingValueIndicator(state: continuationState) else {
|
||||
return value
|
||||
}
|
||||
|
||||
state = continuationState
|
||||
if blankLineCount == 0 {
|
||||
value += " "
|
||||
} else {
|
||||
value += String(repeating: "\n", count: blankLineCount)
|
||||
}
|
||||
value += continuation
|
||||
}
|
||||
}
|
||||
|
||||
func plainScalarContinuationBaseIndentation(state: State) -> Int {
|
||||
state.indentation.last ?? 0
|
||||
}
|
||||
|
||||
func scanPlainScalarContinuationLine(_ state: inout State) -> String {
|
||||
var value = ""
|
||||
while let character = state.reader.peek(), !isLineBreak(character) {
|
||||
if character == " ", state.reader.peek(offset: 1) == "#" {
|
||||
break
|
||||
}
|
||||
if character == "#", value.isEmpty || value.last == " " {
|
||||
break
|
||||
}
|
||||
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
|
||||
break
|
||||
}
|
||||
if state.flowDepth > 0, isFlowDelimiter(character) {
|
||||
break
|
||||
}
|
||||
value.append(character)
|
||||
state.reader.advance()
|
||||
}
|
||||
return trimTrailingSpaces(value)
|
||||
}
|
||||
|
||||
func skipLine(_ state: inout State) {
|
||||
_ = state.reader.consume { character in
|
||||
!isLineBreak(character)
|
||||
}
|
||||
}
|
||||
|
||||
func expandTag(
|
||||
_ tag: String,
|
||||
state: State,
|
||||
) -> String {
|
||||
let handles = state.tagHandles.keys.sorted { first, second in
|
||||
first.count > second.count
|
||||
}
|
||||
for handle in handles where tag.hasPrefix(handle) {
|
||||
let suffix = tag.dropFirst(handle.count)
|
||||
return (state.tagHandles[handle] ?? handle) + suffix
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func shouldEndPlainScalar(
|
||||
_ character: Character,
|
||||
state: State,
|
||||
accumulated: String,
|
||||
) -> Bool {
|
||||
if isLineBreak(character) {
|
||||
return true
|
||||
}
|
||||
if character == " ", state.reader.peek(offset: 1) == "#" {
|
||||
return true
|
||||
}
|
||||
if character == "#", accumulated.isEmpty || accumulated.last == " " {
|
||||
return true
|
||||
}
|
||||
if character == ":", shouldEndPlainScalarAtMappingValue(state: state) {
|
||||
return true
|
||||
}
|
||||
if state.flowDepth > 0, isFlowDelimiter(character) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldEndPlainScalarAtMappingValue(state: State) -> Bool {
|
||||
if state.flowDepth > 0 {
|
||||
return isMappingValueBoundary(state.reader.peek(offset: 1))
|
||||
}
|
||||
return isBlockMappingValueBoundary(state.reader.peek(offset: 1))
|
||||
}
|
||||
|
||||
func isAtMappingValueIndicator(state: State) -> Bool {
|
||||
state.reader.peek() == ":" && shouldEndPlainScalarAtMappingValue(state: state)
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
extension PureYAML.Parsing.Scanner {
|
||||
func scanIndentation(_ state: inout State) throws {
|
||||
let start = state.reader.mark
|
||||
let width = try scanIndentationWidth(&state)
|
||||
guard shouldProcessIndentation(state) else {
|
||||
return
|
||||
}
|
||||
|
||||
let end = state.reader.mark
|
||||
let current = state.indentation.last ?? 0
|
||||
if consumeBlockScalarContentIndentation(width: width, state: &state) {
|
||||
return
|
||||
}
|
||||
|
||||
if let indicator = state.pendingBlockScalarIndentationIndicator {
|
||||
try applyExplicitBlockScalarIndentation(
|
||||
indicator: indicator,
|
||||
width: width,
|
||||
current: current,
|
||||
marks: (start: start, end: end),
|
||||
state: &state,
|
||||
)
|
||||
} else if width > current {
|
||||
applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
|
||||
} else if width < current {
|
||||
try applyDedentation(width: width, start: start, end: end, state: &state)
|
||||
}
|
||||
|
||||
pruneImplicitIndentation(&state)
|
||||
clearPendingBlockScalarIndentation(&state)
|
||||
}
|
||||
|
||||
func scanIndentationWidth(_ state: inout State) throws -> Int {
|
||||
var width = 0
|
||||
while let character = state.reader.peek() {
|
||||
if character == " " {
|
||||
width += 1
|
||||
state.reader.advance()
|
||||
} else if character == "\t" {
|
||||
width = nextTabStop(after: width)
|
||||
state.reader.advance()
|
||||
} else {
|
||||
return width
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func nextTabStop(after width: Int) -> Int {
|
||||
((width / 4) + 1) * 4
|
||||
}
|
||||
|
||||
func shouldProcessIndentation(_ state: State) -> Bool {
|
||||
guard !state.reader.isAtEnd, !isLineBreak(state.reader.peek()) else {
|
||||
return false
|
||||
}
|
||||
guard state.reader.peek() != "#" else {
|
||||
return false
|
||||
}
|
||||
return state.flowDepth == 0
|
||||
}
|
||||
|
||||
func consumeBlockScalarContentIndentation(
|
||||
width: Int,
|
||||
state: inout State,
|
||||
) -> Bool {
|
||||
guard let blockScalarIndentation = state.blockScalarIndentation else {
|
||||
return false
|
||||
}
|
||||
guard width < blockScalarIndentation else {
|
||||
state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation)
|
||||
return true
|
||||
}
|
||||
state.blockScalarIndentation = nil
|
||||
state.blockScalarContentPrefix = ""
|
||||
return false
|
||||
}
|
||||
|
||||
func applyExplicitBlockScalarIndentation(
|
||||
indicator: Int,
|
||||
width: Int,
|
||||
current: Int,
|
||||
marks: (start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark),
|
||||
state: inout State,
|
||||
) throws {
|
||||
let blockScalarIndentation = current + indicator
|
||||
guard width >= blockScalarIndentation else {
|
||||
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: marks.end.line)
|
||||
}
|
||||
state.indentation.append(blockScalarIndentation)
|
||||
state.append(.indent(width: blockScalarIndentation), mark: marks.start, endMark: marks.end)
|
||||
state.blockScalarIndentation = blockScalarIndentation
|
||||
state.blockScalarContentPrefix = String(repeating: " ", count: width - blockScalarIndentation)
|
||||
clearPendingBlockScalarIndentation(&state)
|
||||
}
|
||||
|
||||
func applyIncreasedIndentation(
|
||||
width: Int,
|
||||
start: PureYAML.Parsing.Mark,
|
||||
end: PureYAML.Parsing.Mark,
|
||||
state: inout State,
|
||||
) {
|
||||
state.indentation.append(width)
|
||||
state.append(.indent(width: width), mark: start, endMark: end)
|
||||
guard state.pendingBlockScalarIndentation else {
|
||||
return
|
||||
}
|
||||
state.blockScalarIndentation = width
|
||||
state.blockScalarContentPrefix = ""
|
||||
clearPendingBlockScalarIndentation(&state)
|
||||
}
|
||||
|
||||
func applyDedentation(
|
||||
width: Int,
|
||||
start: PureYAML.Parsing.Mark,
|
||||
end: PureYAML.Parsing.Mark,
|
||||
state: inout State,
|
||||
) throws {
|
||||
while state.indentation.count > 1, width < (state.indentation.last ?? 0) {
|
||||
state.indentation.removeLast()
|
||||
state.append(.dedent(width: state.indentation.last ?? 0), mark: end, endMark: end)
|
||||
}
|
||||
if canUseImplicitIndentation(width: width, state: state) {
|
||||
state.indentation.append(width)
|
||||
state.append(.indent(width: width), mark: start, endMark: end)
|
||||
}
|
||||
guard width == state.indentation.last else {
|
||||
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: end.line)
|
||||
}
|
||||
}
|
||||
|
||||
func canUseImplicitIndentation(
|
||||
width: Int,
|
||||
state: State,
|
||||
) -> Bool {
|
||||
width != state.indentation.last
|
||||
&& width > (state.indentation.last ?? 0)
|
||||
&& state.validImplicitIndentation.contains(width)
|
||||
}
|
||||
|
||||
func pruneImplicitIndentation(_ state: inout State) {
|
||||
let current = state.indentation.last ?? 0
|
||||
state.validImplicitIndentation = state.validImplicitIndentation.filter { $0 <= current }
|
||||
}
|
||||
|
||||
func clearPendingBlockScalarIndentation(_ state: inout State) {
|
||||
state.pendingBlockScalarIndentation = false
|
||||
state.pendingBlockScalarIndentationIndicator = nil
|
||||
}
|
||||
|
||||
func closeIndentation(_ state: inout State) {
|
||||
let mark = state.reader.mark
|
||||
while state.indentation.count > 1 {
|
||||
state.indentation.removeLast()
|
||||
state.append(.dedent(width: state.indentation.last ?? 0), mark: mark, endMark: mark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,386 +0,0 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct Scanner {
|
||||
init() {}
|
||||
|
||||
func scan(_ yaml: String) throws -> [Token] {
|
||||
var state = State(reader: Reader(yaml))
|
||||
state.append(.streamStart)
|
||||
|
||||
while !state.reader.isAtEnd {
|
||||
if isLineBreak(state.reader.peek()) {
|
||||
state.reader.advance()
|
||||
state.isAtLineStart = true
|
||||
continue
|
||||
}
|
||||
|
||||
if state.isAtLineStart {
|
||||
try scanIndentation(&state)
|
||||
if state.reader.isAtEnd {
|
||||
break
|
||||
}
|
||||
if isLineBreak(state.reader.peek()) {
|
||||
continue
|
||||
}
|
||||
if try scanDirectiveOrDocumentMarker(&state) {
|
||||
continue
|
||||
}
|
||||
state.isAtLineStart = false
|
||||
}
|
||||
|
||||
try scanToken(&state)
|
||||
}
|
||||
|
||||
closeIndentation(&state)
|
||||
state.append(.streamEnd)
|
||||
return state.tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing.Scanner {
|
||||
struct State {
|
||||
var reader: PureYAML.Parsing.Reader
|
||||
var tokens: [PureYAML.Parsing.Token] = []
|
||||
var indentation: [Int] = [0]
|
||||
var isAtLineStart = true
|
||||
var hasDocumentContent = false
|
||||
var hasDocumentStartMarker = false
|
||||
var isDocumentClosed = false
|
||||
var flowDepth = 0
|
||||
var validImplicitIndentation: Set<Int> = []
|
||||
var pendingBlockScalarIndentation = false
|
||||
var pendingBlockScalarIndentationIndicator: Int?
|
||||
var blockScalarIndentation: Int?
|
||||
var blockScalarContentPrefix = ""
|
||||
var tagHandles = [
|
||||
"!": "!",
|
||||
"!!": "tag:yaml.org,2002:",
|
||||
]
|
||||
|
||||
mutating func append(_ kind: PureYAML.Parsing.TokenKind) {
|
||||
let mark = reader.mark
|
||||
tokens.append(PureYAML.Parsing.Token(kind: kind, mark: mark, endMark: mark))
|
||||
markDocumentContentIfNeeded(kind)
|
||||
}
|
||||
|
||||
mutating func append(
|
||||
_ kind: PureYAML.Parsing.TokenKind,
|
||||
mark: PureYAML.Parsing.Mark,
|
||||
endMark: PureYAML.Parsing.Mark,
|
||||
) {
|
||||
tokens.append(PureYAML.Parsing.Token(kind: kind, mark: mark, endMark: endMark))
|
||||
markDocumentContentIfNeeded(kind)
|
||||
}
|
||||
|
||||
mutating func markDocumentContentIfNeeded(_ kind: PureYAML.Parsing.TokenKind) {
|
||||
switch kind {
|
||||
case .comment, .dedent, .documentEnd, .documentStart, .indent, .streamEnd, .streamStart:
|
||||
return
|
||||
default:
|
||||
hasDocumentContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing.Scanner {
|
||||
func scanToken(_ state: inout State) throws {
|
||||
skipSeparation(&state)
|
||||
guard let character = state.reader.peek(), !isLineBreak(character) else {
|
||||
return
|
||||
}
|
||||
|
||||
if state.blockScalarIndentation != nil {
|
||||
scanBlockScalarContent(&state)
|
||||
return
|
||||
}
|
||||
if try scanLineIndicatorToken(character, state: &state) {
|
||||
return
|
||||
}
|
||||
if scanFlowToken(character, state: &state) {
|
||||
return
|
||||
}
|
||||
if try scanQuotedOrBlockScalarToken(character, state: &state) {
|
||||
return
|
||||
}
|
||||
if try scanNodePropertyToken(character, state: &state) {
|
||||
return
|
||||
}
|
||||
scanPlainScalar(&state)
|
||||
}
|
||||
|
||||
func scanLineIndicatorToken(
|
||||
_ character: Character,
|
||||
state: inout State,
|
||||
) throws -> Bool {
|
||||
switch character {
|
||||
case "#":
|
||||
scanComment(&state)
|
||||
return true
|
||||
case "-" where isBlockEntry(state.reader):
|
||||
scanFixedToken(.blockEntry, state: &state)
|
||||
return true
|
||||
case "?" where isIndicatorBoundary(state.reader.peek(offset: 1)):
|
||||
scanFixedToken(.mappingKey, state: &state)
|
||||
return true
|
||||
case ":" where isMappingValueBoundary(state.reader.peek(offset: 1)):
|
||||
scanFixedToken(.mappingValue, state: &state)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanFlowToken(
|
||||
_ character: Character,
|
||||
state: inout State,
|
||||
) -> Bool {
|
||||
switch character {
|
||||
case "[":
|
||||
state.flowDepth += 1
|
||||
scanFixedToken(.flowSequenceStart, state: &state)
|
||||
return true
|
||||
case "]":
|
||||
state.flowDepth = max(0, state.flowDepth - 1)
|
||||
scanFixedToken(.flowSequenceEnd, state: &state)
|
||||
return true
|
||||
case "{":
|
||||
state.flowDepth += 1
|
||||
scanFixedToken(.flowMappingStart, state: &state)
|
||||
return true
|
||||
case "}":
|
||||
state.flowDepth = max(0, state.flowDepth - 1)
|
||||
scanFixedToken(.flowMappingEnd, state: &state)
|
||||
return true
|
||||
case ",":
|
||||
scanFixedToken(.flowEntry, state: &state)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanNodePropertyToken(
|
||||
_ character: Character,
|
||||
state: inout State,
|
||||
) throws -> Bool {
|
||||
switch character {
|
||||
case "&":
|
||||
try scanNamedToken(
|
||||
kind: PureYAML.Parsing.TokenKind.anchor,
|
||||
missingNameError: PureYAML.Parsing.ParseError.expectedAnchorName,
|
||||
state: &state,
|
||||
)
|
||||
return true
|
||||
case "*":
|
||||
try scanNamedToken(
|
||||
kind: PureYAML.Parsing.TokenKind.alias,
|
||||
missingNameError: PureYAML.Parsing.ParseError.expectedAliasName,
|
||||
state: &state,
|
||||
)
|
||||
return true
|
||||
case "!":
|
||||
try scanTag(&state)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanQuotedOrBlockScalarToken(
|
||||
_ character: Character,
|
||||
state: inout State,
|
||||
) throws -> Bool {
|
||||
switch character {
|
||||
case "\"":
|
||||
try scanQuotedScalar(.doubleQuoted, state: &state)
|
||||
return true
|
||||
case "'":
|
||||
try scanQuotedScalar(.singleQuoted, state: &state)
|
||||
return true
|
||||
case "|":
|
||||
scanBlockScalarHeader(.literal, state: &state)
|
||||
return true
|
||||
case ">":
|
||||
scanBlockScalarHeader(.folded, state: &state)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func skipSeparation(_ state: inout State) {
|
||||
while state.reader.peek() == " " {
|
||||
state.reader.advance()
|
||||
}
|
||||
}
|
||||
|
||||
func scanFixedToken(
|
||||
_ kind: PureYAML.Parsing.TokenKind,
|
||||
state: inout State,
|
||||
) {
|
||||
let start = state.reader.mark
|
||||
state.reader.advance()
|
||||
state.append(kind, mark: start, endMark: state.reader.mark)
|
||||
if case .blockEntry = kind, hasNodeOnSameLine(afterCurrentPositionIn: state.reader) {
|
||||
state.validImplicitIndentation.insert(start.column + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func scanComment(_ state: inout State) {
|
||||
let start = state.reader.mark
|
||||
state.reader.advance()
|
||||
if state.reader.peek() == " " {
|
||||
state.reader.advance()
|
||||
}
|
||||
let value = state.reader.consume { character in
|
||||
!isLineBreak(character)
|
||||
}
|
||||
state.append(.comment(value), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
func scanQuotedScalar(
|
||||
_ style: PureYAML.Parsing.ScalarStyle,
|
||||
state: inout State,
|
||||
) throws {
|
||||
let start = state.reader.mark
|
||||
let quote: Character = style == .doubleQuoted ? "\"" : "'"
|
||||
state.reader.advance()
|
||||
var value = ""
|
||||
var previousWasEscape = false
|
||||
while let character = state.reader.peek() {
|
||||
if style == .singleQuoted, character == quote, state.reader.peek(offset: 1) == quote {
|
||||
value.append(character)
|
||||
state.reader.advance()
|
||||
value.append(character)
|
||||
state.reader.advance()
|
||||
continue
|
||||
}
|
||||
if character == quote, !previousWasEscape {
|
||||
break
|
||||
}
|
||||
value.append(character)
|
||||
previousWasEscape = character == "\\" && style == .doubleQuoted && !previousWasEscape
|
||||
state.reader.advance()
|
||||
if character != "\\" {
|
||||
previousWasEscape = false
|
||||
}
|
||||
}
|
||||
guard state.reader.peek() == quote else {
|
||||
throw PureYAML.Parsing.ParseError.unterminatedQuotedString(line: start.line)
|
||||
}
|
||||
state.reader.advance()
|
||||
appendMappingKeyIfNeeded(start: start, state: &state)
|
||||
state.append(.scalar(value: value, style: style), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
func scanBlockScalarHeader(
|
||||
_ style: PureYAML.Parsing.BlockScalarStyle,
|
||||
state: inout State,
|
||||
) {
|
||||
let start = state.reader.mark
|
||||
var chomping = PureYAML.Parsing.BlockScalarChomping.clip
|
||||
var indentationIndicator: Int?
|
||||
state.reader.advance()
|
||||
while let character = state.reader.peek(), isBlockScalarHeaderCharacter(character) {
|
||||
if character == "-" {
|
||||
chomping = .strip
|
||||
} else if character.isNumber {
|
||||
indentationIndicator = Int(String(character))
|
||||
}
|
||||
state.reader.advance()
|
||||
}
|
||||
state.append(
|
||||
.blockScalarHeader(style: style, chomping: chomping),
|
||||
mark: start,
|
||||
endMark: state.reader.mark,
|
||||
)
|
||||
state.pendingBlockScalarIndentation = true
|
||||
state.pendingBlockScalarIndentationIndicator = indentationIndicator
|
||||
}
|
||||
|
||||
func scanBlockScalarContent(_ state: inout State) {
|
||||
let start = state.reader.mark
|
||||
let value = state.blockScalarContentPrefix + state.reader.consume { character in
|
||||
!isLineBreak(character)
|
||||
}
|
||||
state.blockScalarContentPrefix = ""
|
||||
state.append(.scalar(value: value, style: .plain), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
func scanNamedToken(
|
||||
kind: (String) -> PureYAML.Parsing.TokenKind,
|
||||
missingNameError: (Int) -> PureYAML.Parsing.ParseError,
|
||||
state: inout State,
|
||||
) throws {
|
||||
let start = state.reader.mark
|
||||
state.reader.advance()
|
||||
let name = state.reader.consume { character in
|
||||
!isTokenTerminator(character)
|
||||
}
|
||||
guard !name.isEmpty else {
|
||||
throw missingNameError(start.line)
|
||||
}
|
||||
state.append(kind(name), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
func scanTag(_ state: inout State) throws {
|
||||
let start = state.reader.mark
|
||||
state.reader.advance()
|
||||
var value = "!"
|
||||
if state.reader.peek() == "<" {
|
||||
value.append(state.reader.advance() ?? "<")
|
||||
var didClose = false
|
||||
while let character = state.reader.peek() {
|
||||
value.append(character)
|
||||
state.reader.advance()
|
||||
if character == ">" {
|
||||
didClose = true
|
||||
break
|
||||
}
|
||||
}
|
||||
guard didClose else {
|
||||
throw PureYAML.Parsing.ParseError.unterminatedTag(line: start.line)
|
||||
}
|
||||
} else {
|
||||
value += state.reader.consume { character in
|
||||
!isTokenTerminator(character)
|
||||
}
|
||||
value = expandTag(value, state: state)
|
||||
}
|
||||
state.append(.tag(value), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
func scanPlainScalar(_ state: inout State) {
|
||||
let start = state.reader.mark
|
||||
var value = ""
|
||||
while let character = state.reader.peek() {
|
||||
if shouldEndPlainScalar(character, state: state, accumulated: value) {
|
||||
break
|
||||
}
|
||||
value.append(character)
|
||||
state.reader.advance()
|
||||
}
|
||||
var trimmed = trimTrailingSpaces(value)
|
||||
let isMappingKey = appendMappingKeyIfNeeded(start: start, state: &state)
|
||||
if !isMappingKey {
|
||||
trimmed = scanPlainScalarContinuations(
|
||||
after: trimmed,
|
||||
state: &state,
|
||||
)
|
||||
}
|
||||
state.append(.scalar(value: trimmed, style: .plain), mark: start, endMark: state.reader.mark)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func appendMappingKeyIfNeeded(
|
||||
start: PureYAML.Parsing.Mark,
|
||||
state: inout State,
|
||||
) -> Bool {
|
||||
guard state.reader.peek() == ":", isMappingValueBoundary(state.reader.peek(offset: 1)) else {
|
||||
return false
|
||||
}
|
||||
state.append(.mappingKey, mark: start, endMark: start)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
extension PureYAML.Parsing {
|
||||
struct Token: Equatable, CustomStringConvertible {
|
||||
var kind: TokenKind
|
||||
var mark: Mark
|
||||
var endMark: Mark
|
||||
|
||||
var description: String {
|
||||
"\(kind) @\(mark)..\(endMark)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing {
|
||||
enum TokenKind: Equatable {
|
||||
case streamStart
|
||||
case streamEnd
|
||||
case documentStart
|
||||
case documentEnd
|
||||
case indent(width: Int)
|
||||
case dedent(width: Int)
|
||||
case blockEntry
|
||||
case mappingKey
|
||||
case mappingValue
|
||||
case flowSequenceStart
|
||||
case flowSequenceEnd
|
||||
case flowMappingStart
|
||||
case flowMappingEnd
|
||||
case flowEntry
|
||||
case scalar(value: String, style: ScalarStyle)
|
||||
case blockScalarHeader(style: BlockScalarStyle, chomping: BlockScalarChomping)
|
||||
case anchor(String)
|
||||
case alias(String)
|
||||
case tag(String)
|
||||
case comment(String)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Parsing.TokenKind: CustomStringConvertible {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .streamStart:
|
||||
"streamStart"
|
||||
case .streamEnd:
|
||||
"streamEnd"
|
||||
case .documentStart:
|
||||
"documentStart"
|
||||
case .documentEnd:
|
||||
"documentEnd"
|
||||
case let .indent(width):
|
||||
"indent width=\(width)"
|
||||
case let .dedent(width):
|
||||
"dedent width=\(width)"
|
||||
case .blockEntry:
|
||||
"blockEntry"
|
||||
case .mappingKey:
|
||||
"mappingKey"
|
||||
case .mappingValue:
|
||||
"mappingValue"
|
||||
case .flowSequenceStart:
|
||||
"flowSequenceStart"
|
||||
case .flowSequenceEnd:
|
||||
"flowSequenceEnd"
|
||||
case .flowMappingStart:
|
||||
"flowMappingStart"
|
||||
case .flowMappingEnd:
|
||||
"flowMappingEnd"
|
||||
case .flowEntry:
|
||||
"flowEntry"
|
||||
case let .scalar(value, style):
|
||||
"scalar value=\"\(escape(value))\" style=\(style)"
|
||||
case let .blockScalarHeader(style, chomping):
|
||||
if chomping == .clip {
|
||||
"blockScalarHeader style=\(style)"
|
||||
} else {
|
||||
"blockScalarHeader style=\(style) chomping=\(chomping)"
|
||||
}
|
||||
case let .anchor(value):
|
||||
"anchor name=\(value)"
|
||||
case let .alias(value):
|
||||
"alias name=\(value)"
|
||||
case let .tag(value):
|
||||
"tag value=\(value)"
|
||||
case let .comment(value):
|
||||
"comment value=\"\(escape(value))\""
|
||||
}
|
||||
}
|
||||
|
||||
private func escape(_ value: String) -> String {
|
||||
var output = ""
|
||||
for character in value {
|
||||
switch character {
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.append(character)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user