Skip to content

Type, quote and keep YAML scalars exactly as the front matter readers do - #83

Merged
morisil merged 10 commits into
mainfrom
yaml-plain-colon-hash
Sep 26, 2026
Merged

morisil merged 10 commits into
mainfrom
yaml-plain-colon-hash

Conversation

@morisil

@morisil morisil commented Sep 26, 2026

Copy link
Copy Markdown
Member

Makes markanywhere-yaml's typing and quoting follow what the front matter readers — Psych (Jekyll), PyYAML and go-yaml v2 (Hugo) — actually do, verified by running all three over generated strings rather than from their docs.

Parser typing

  • A plain scalar is typed exactly when some reader types it: YAML 1.1 booleans and nulls in any case plus y / n, numbers with _ / , separators, binary / upper-case / signed base prefixes, base 60 (12:30), and looser timestamps — including Jekyll's documented 2016-01-01 12:00:00 -0500, which used to come back quoted.
  • A shape no reader types stays a string (1,, -0b-1, 2024-2-30, 1e999), so type=int / type=float keeps meaning a number.
  • A timestamp is typed only within the ranges the readers accept (calendar dates, Psych's Time bounds, offsets under a day).
  • A line the readers read differently from one another is kept verbatim: a flow scalar's : before , / [ / ] / { / } ([draft:, x] is a mapping to PyYAML, an error to Psych, "draft:" to go-yaml) and a block scalar header followed by # with no space (PyYAML refuses it).

Writer quoting

  • The writer quotes exactly the strings the parser would type, from the same rules (YamlScalarType.kt).
  • DIVERGENCE: a shape a reader refuses to load (=, <<, 0x_, 2024-13-45) is quoted too, though the parser keeps it a string.
  • Keys: Psych-typed words (yEs, nULL) are quoted; y / n stay plain at the top level (go-yaml decodes a front matter key as a string) and are quoted below it (a nested key is interface{}).
  • Every character YAML does not allow in a document is escaped; a lone surrogate becomes U+FFFD (DIVERGENCE: lossy, Psych and go-yaml refuse its \u escape).

Robustness

  • No number pattern repeats a regex group — the JVM engine recursed once per repetition and a long digit run threw StackOverflowError.
  • Base-prefixed digit runs match one way only, so a long near-miss resolves in linear time; the test guards it with an input large enough that a regression hangs instead of a flaky wall-clock ratio.

Housekeeping

./gradlew check passes on JVM and JS; markanywhere-yaml also on macOS Native.

🤖 Generated with Claude Code

morisil and others added 10 commits September 24, 2026 13:04
…#80)

YamlWriter quoted every value containing `:`, `#`, `"` or `\`, so URLs
like `https://xemantic.com/contact` or `C#` came out needlessly
double-quoted. A `:` is now only a mapping indicator when followed by a
space or at the end of the value, and a `#` only starts a comment after a
space; other occurrences, and inner `"` / `\`, stay plain. YAML 1.1
sexagesimals (`12:30`) remain quoted, since Psych and PyYAML type them as
numbers.

The parser's lenient reading of a mapping indicator inside a plain value
(`k: Note: see`) is documented and pinned as a DIVERGENCE.

Closes #80

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
YamlWriter already quoted YAML 1.1 sexagesimals (`12:30`), but Psych
(Jekyll) and PyYAML also type other shapes YAML 1.2 reads as strings:
booleans / nulls in any letter case (`yEs`, `nULL`), integers and floats
with `_` / `,` separators, binary, signed hex, leading-zero octal,
floats with a leading or trailing dot, looser timestamps (`2024-5-1`,
offsets without a colon), and PyYAML's `=` / `<<` tags. The new
`isYaml11Typed` covers the union, and both values and keys that match it
are now double-quoted.

Also guards the `#` comment check against index 0, and documents that a
sequence item `- Note: see` is a compact mapping, not a lenient scalar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
YamlWriter now escapes every character outside YAML's printable set
(YAML 1.2 §5.1): C1 controls, U+FFFE / U+FFFF and lone surrogates, which
Psych and PyYAML refuse anywhere in a document, even in a block scalar.
Surrogate pairs (emoji) stay plain.

isYaml11Typed also covers the one-letter booleans `y` / `n` (YAML 1.1,
go-yaml v2) and the special floats in any letter case (`.Nan`, `+.InF`),
deriving its word set from the parser's reserved literals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The parser now types the shapes Psych (Jekyll), PyYAML and go-yaml v2
type even though YAML 1.2 reads them as strings: one-letter and
mixed-case booleans / nulls, `_` / `,` separated numbers, binary, signed
hex, sexagesimals and looser timestamps — including Jekyll's documented
`2016-01-01 12:00:00 -0500`, which the writer used to quote and so
rewrote on round-trip. Quoting only in the writer is not enough; the
typing rules now live in one place, `YamlScalarType.kt`, shared by both
sides through `isTypedPlainScalar`.

The writer double-quotes a value holding a line break, carriage return
or tab, and escapes a byte order mark (YAML 1.2 §5.2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
YamlScalarType now transcribes the number rules of PyYAML, Psych and
go-yaml v2 separately instead of one merged pattern, which typed shapes
none of them reads as a number (`1,`, `1_,2`). go-yaml v2 deletes every
`_` first, so `1_e5` / `+_1` / `0_x1` are numbers, and it takes an
upper-case base prefix (`0X1F`). A timestamp is typed only when it is in
range: a date must be in the calendar, a date and time within what
Psych's Time accepts.

Shapes a reader refuses to load (`=`, `<<`, `0x_`, `.e+4`, `2024-13-45`)
stay strings in the parser but are still quoted by the writer, pinned as
DIVERGENCE. The colon / comment indicator checks are shared between
parser and writer via isMappingColonAt / isCommentStartAt, so the writer
now also treats a tab like a space there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Checked against PyYAML, Psych and go-yaml v2:
- quote a date-shaped string only when PyYAML refuses it (2024-2-30
  stays plain)
- quote only the keys Psych types (yEs, nULL), not y / n, which go-yaml
  v2 decodes into a string key
- stop typing -0b-1 / -0b+1 as int, type 08 / 019 / 0_9 as float
- refuse hour 24 beyond 24:00:00 and offset minutes over 59
- share the mapping-colon and comment rules with flow collections
- gate the refused-shape patterns on the first character

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- split a timestamp offset as Psych does (at the colon, else two hour
  digits) and bound the whole offset under a day, so `+05:99` is typed
  and `+530` / `+2400` stay strings
- write base-prefixed digit runs so a long near-miss resolves in linear
  time instead of backtracking quadratically
- end a flow mapping key at a ` #` comment, and start a comment at any
  `#` after a closed quoted scalar or flow collection
- share the mapping-colon check with the key line detector

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- rewrite every YAML number pattern that repeated a group
  (`(?:_?[0-9])*`, `(?::[0-9])+`) as a character-class run plus a hand
  check, since the JVM regex engine recurses once per repetition and a
  long digit run threw StackOverflowError
- reject a string holding a character no numeric or timestamp shape can
  contain before running any pattern
- require the offset minutes after a colon, so `+5:` is not a timestamp
- refuse a date-only shape with a signed zero year (`-0000-01-01`)
- write a lone surrogate as U+FFFD (DIVERGENCE: lossy), as Psych and
  go-yaml refuse its `\u` escape

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… first

- quote a `y` / `n` key below the top level, since go-yaml v2 decodes a
  nested mapping with interface{} keys and reads a plain `y:` as `true`
- type a timestamp-shaped or `:`-holding scalar through its own patterns
  only, so the number patterns never run on it
- merge the Psych and PyYAML base-60 int and float into one pattern each,
  with Psych's leading `0` limited to two segments
- derive PyYAML's timestamp shape from the shared match instead of a
  second pattern
- pin the libyaml comment-after-closed-token rule as a DIVERGENCE test
- make the linear-time test compare two input sizes instead of an
  absolute bound, so slower Native / JS regex engines don't trip it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mbers

- keep a flow scalar whose `:` precedes `,` / `[` / `]` / `{` / `}`
  verbatim: PyYAML reads a mapping, Psych refuses the line and go-yaml v2
  reads the colon as content, so no single reading is right
- keep a block scalar header followed by `#` with no space verbatim, as
  PyYAML refuses it
- type a number go-yaml v2 alone reads only within its int64 / uint64 /
  float64 range (`1e999`, `0X` and 17 hex digits stay strings)
- match the timestamp pattern once in `isUnsafePlainScalar`
- replace the wall-clock linear-time test with an input large enough that
  quadratic backtracking hangs
- name every divergence test `DIVERGENCE - …`
- condense the YAML entries in CLAUDE.md and state the lesson / why rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@morisil
morisil merged commit 1786228 into main Sep 26, 2026
2 checks passed
@morisil
morisil deleted the yaml-plain-colon-hash branch September 26, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant