yaml-edit 0.3.4

A lossless parser and editor for YAML files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# Design

This document covers the architecture and key patterns in `yaml-edit`.

## Principles

The primary goal is lossless editing: modifying YAML files while preserving
formatting, comments, whitespace, quote styles, key ordering, tags and
anchors/aliases. This distinguishes `yaml-edit` from libraries that parse into
a data model and re-serialize, losing formatting.

A few rules follow from that, and take precedence over convenience:

- **Byte-identical round trips.** Loading a file and writing it back without
  edits must reproduce it byte for byte. Nothing is rewritten implicitly; an
  edit only touches the text it was asked to change. Formatting information
  such as tags and quoting styles is carried through value types rather than
  dropped.
- **Follow the YAML 1.2 spec.** Typing follows the core schema, keys compare
  by tag and canonical value, and implicit keys obey the spec's length and
  line limits. The crate is tested against the YAML test suite.
- **The parser is lenient, the validator is strict.** The parser accepts
  anything it can turn into a sensible tree, so that slightly broken files can
  still be loaded and fixed. Spec violations are reported by the validator,
  not by refusing to parse. Handlers such as the JSON deserializer follow the
  same split.
- **Build trees, not text.** Builders and mutations construct CST nodes
  directly, in exactly the shape the parser would produce for the same text.
  They never render a string and re-parse it.
- **Never drop content or errors silently.** If an operation cannot be done
  without losing something (comments, entries, a tag), it reports that rather
  than quietly doing something else. A failed operation leaves the document
  unchanged.

## Rowan foundation

The crate is built on [rowan](https://github.com/rust-analyzer/rowan), the
lossless syntax tree library from rust-analyzer. Rowan provides a two-layer
tree: immutable, deduplicated "green" nodes for storage, and mutable "red"
nodes (`SyntaxNode`) for navigation and mutation. Red nodes use interior
mutability, so methods take `&self` rather than `&mut self`.

This is documented in the crate-level docs with examples, since it's
surprising for Rust developers.

## Type hierarchy

The syntax tree types (`Document`, `Mapping`, `Sequence`, `Scalar`,
`TaggedNode`) are thin wrappers around `SyntaxNode`. A `YamlFile` contains one
or more `Document`s, each of which holds a tree of these wrappers.
`MappingEntry` is the key-value pair inside a `Mapping`; `Directive` covers
`%YAML` and `%TAG` headers.

`YamlNode` is a type-erased enum (`Scalar | Mapping | Sequence | TaggedNode |
Alias`) returned by navigation methods like `Mapping::get()`.

`MergedMapping` is a read-only view of a mapping with aliases and `<<` merge
keys resolved. It and `Mapping` both implement `MappingView`, so read-only code
can work against either.

## Parsing and validation

The parser (`parser/`) produces a tree for anything it can represent: a
document with inconsistent indentation, a plain scalar starting with an
indicator, or a tab used for indentation still parses, and the tree
round-trips byte-identically. Parse errors are reserved for input that cannot
be turned into a sensible tree.

Edits do what they are asked even when the result is invalid YAML: an edit to
a collection that is a mapping key can make it equal to a sibling key, which
the validator then reports as a duplicate key, as it would for two keys set
to the same name by hand.

`Validator` walks the tree and reports spec violations with positions. A new
spec check goes into the validator, with a test, and not into the parser. The
YAML test suite harness counts an invalid case as handled when either the
parser or the validator rejects it, and requires the validator to accept every
valid case. Valid cases the validator still rejects
are listed in `KNOWN_VALIDATOR_FALSE_POSITIVES`; a listed case that starts
validating cleanly fails the run, so the list can only shrink.

## `splice_children` for mutation

Never rebuild entire nodes. Use rowan's `splice_children` to replace only the
parts that changed. This preserves formatting on everything else.

When using `splice_children`, collect the new children into a `Vec` first -
passing an iterator directly causes borrow conflicts:

```rust
let children: Vec<_> = new_node.children_with_tokens()
    .map(|c| c.into())
    .collect();
self.0.splice_children(range, children);
```

Also note that `splice_children` uses `children_with_tokens()` indices, not
`children()` indices.

## Builders

`MappingBuilder`, `SequenceBuilder` and `YamlBuilder` emit green nodes with
the same structure the parser produces: `MAPPING_ENTRY` with `KEY`, `COLON`
and `VALUE`, `SEQUENCE_ENTRY` with `DASH`, entries owning their trailing
`NEWLINE`, and so on. A built document must be indistinguishable from parsing
its own text: `len`, `get`, `set` and the other accessors behave the same,
and `debug::shape_to_string` gives the same output for both. String keys and
values are quoted where a plain scalar would be read back as something else.

There is one exception. YAML has no block syntax for an empty collection, so
an empty one is written `{}` or `[]` whatever its style. One that is to be
block style once it has entries (`Mapping::new_pending_block`, or a block
collection whose last entry was removed or that was cleared) carries a
zero-width `WHITESPACE ""` token between its brackets, so that adding an
entry writes it as a block collection again. A `{}` or `[]` from the source
has no such marker and stays flow. Re-parsing the text cannot recover the
marker, so `debug::shape_to_string` leaves it out, and is what to compare an
edited tree with a re-parse by; `debug::tree_to_string` shows it.

## `AsYaml` trait

Mutation APIs accept `impl AsYaml` rather than concrete types. This lets
syntax nodes pass through without serializing to a string and re-parsing,
preserving formatting and comments. Primitive types (`i64`, `&str`, `bool`,
etc.) also implement `AsYaml` for ergonomic use.

The trait:

```rust
pub trait AsYaml {
    fn as_node(&self) -> Option<&SyntaxNode>;
    fn kind(&self) -> YamlKind;
    fn build_content(
        &self,
        builder: &mut rowan::GreenNodeBuilder,
        indent: usize,
        flow_context: bool,
    ) -> bool;  // returns true if content ends with NEWLINE
    fn is_inline(&self) -> bool;
}
```

Syntax wrappers return `Some` from `as_node()` and copy their existing tree
structure in `build_content()`. Primitive types return `None` and emit fresh
tokens. `is_inline()` controls whether the value goes on the same line as the
key (scalars, empties) or on a new indented line (block collections).

`build_content` must respect `flow_context`: inside a flow collection, block
content has to be emitted in flow form, since block syntax is not allowed
there.

Strings written into a flow collection follow the quoting of the string
scalars already in it. If its keys and items that are strings are all
double-quoted, as in a JSON file, new strings are double-quoted too, so JSON
stays JSON. Otherwise they are quoted only where a plain scalar would not
read back as the same string, for instance when it holds a flow indicator
such as `,`. A collection with no strings of its own (empty, or only
numbers) goes by the nearest enclosing flow collection that has some, and
failing that quotes only where needed; a block collection ends the search.
This applies to keys and values of new and replaced entries, renamed keys,
and the contents of raw collections (`YamlValue`, `Vec`, ...) written there.
Numbers, booleans and nulls are unaffected, as are strings given an
explicit style and scalar nodes, which keep the style they were written in.
The choice is passed down as `FlowQuoting` through the hidden
`AsYaml::build_flow_content`.

After a quoted key or a flow collection the value may follow the `:`
directly (`{"a":b}`). A key that is renamed to something else, which would
then run on into the value as one plain scalar, gets a space after the
`:` instead of different quoting: `{"a":b}` renamed to `z` gives `{z: b}`.

## `ScalarValue` and scalar typing

Constructors with explicit intent:

```rust
ScalarValue::string("123")  // always a string, no type detection
ScalarValue::parse("123")   // detects type -> integer
```

`ScalarValue::from_scalar` reads a parsed scalar and respects its style and
tag: a quoted or block scalar is a string, whatever its text.

`ScalarValue::classify_plain` (returning `CoreScalarType`) is the single
implementation of YAML 1.2 core schema typing for plain scalars. The lexer and
`ScalarValue` both use it, so the two cannot disagree. `parse()` additionally
recognises timestamps, which are outside the core schema.

`migrate::to_yaml_1_2` rewrites plain scalars whose meaning changed between
YAML 1.1 and 1.2 so that a 1.2 reader sees the value a 1.1 reader saw. It is
explicit and opt-in, the only place where the crate changes the spelling of
existing content.

## Newline ownership

The full set of CST structural invariants lives in the module-level doc
comment of the `nodes` module -- newline placement, INDENT placement, entry
termination, flow separators, and the rule against whole-node rebuilds. That
comment is the authoritative spec for anyone editing mutation code; the
summary here is a quick reference.

YAML newlines are terminators, not separators. Every block-style
`MAPPING_ENTRY` and `SEQUENCE_ENTRY` owns its trailing `NEWLINE` token as a
direct child, explicit-key entries (`? k\n: v\n`) included, and so does the
comment ending its line: a mapping entry has it in its `VALUE`, a sequence
entry after its item. An entry ending in a nested block collection or a
block scalar has its line ended by that instead. The parent
`MAPPING`/`SEQUENCE` node does not own any newlines itself, except for blank
lines and comment lines between entries. Those after a nested collection's
last entry are between entries of the collection holding the next one, and
belong to it: in `a:\n  - x\n\nb: 1\n` the blank line is the outer
mapping's, not the sequence's. At the end of the document, blank and
comment lines go to the root collection, so a nested collection always ends
in an entry.

```
MAPPING_ENTRY
  KEY -> SCALAR "host"
  COLON
  WHITESPACE
  VALUE -> SCALAR "localhost"
  NEWLINE                      <- owned by the entry
```

An empty value holds nothing but its zero-width null, so `key:\n` ends
with the entry's own NEWLINE just as `key: v\n` does; the lines after it,
which the parser looks at for a value, belong to the collection.

When inserting or replacing entries, check whether the entry already ends with
a newline (nested collections do, scalars don't) before adding separators.

Trivia outside the root collection lives at the document level: a leading BOM,
blank lines and comments before the root, and the newline and blank lines
after a scalar or flow root, are children of `DOCUMENT`. Directives, and
comments before a `---`, after a `...` or in a stream without a document,
are children of the file root instead. Mutations
that add documents or directives must keep a BOM first in the stream.

A new entry at the end of a collection (`set` with a new key, `push`)
goes after its last entry and any comment lines after that. Blank and
comment lines after a nested collection belong to its parent, so they stay
after the new entry: `a:\n  x: 1\n\nb: 2\n` with `y` set in `a` gives
`a:\n  x: 1\n  y: 3\n\nb: 2\n`. Those ending the root collection stay
where they are, and the new entry goes after them: `- x\n\n` with `y`
pushed gives `- x\n\n- y\n`. Input that has no final line break
still has none after a new last entry, whichever method adds it:
`a: 1` with `b` set gives `a: 1\nb: 2`.

A kept (`|+`, `>+`) block scalar takes every blank line after it into its
value. A new entry whose value ends in one therefore goes after any blank
lines at the place it is inserted rather than before them:
`insert_after("a", "k", "text\n\n")` on `a: 1\n\nb: 2\n` gives
`a: 1\n\nk: |+\n  text\n\nb: 2\n`. The text around the new entry stays as
it was, and the value keeps its style.

Any block scalar written by an edit, inserted or replacing a value, reads
the lines after it as body until one is indented less than its body. A
whitespace-only line with spaces past the body's column, or a tab, would
add text to its value, so that line loses its white space: `a: 1\n     \n`
with `k: "text\n"` inserted after `a` gives `a: 1\nk: |\n  text\n\n`. A
comment line indented as far as the body, after any blank lines, moves
back to the entry's column. Other lines are left as they are.

## Implicit-null values

Every `MAPPING_ENTRY` and `SEQUENCE_ENTRY` in the CST holds a value node,
even when the source omits it. The parser emits a zero-width
`SCALAR { NULL "" }` in that position for every "missing value" syntax:

- Block mapping value: `key:\n` or `key:` at EOF
- Flow mapping value: `{key}`, `{key:}`, `{key,}`, `{a:, b:}`
- Flow mapping key: `{,}`, `{: v}`, `{:}`
- Explicit-key mapping: `? key\n`, `? \n: v`, `? key\n:\n`
- Block sequence entry: `- \n`, `-\n`
- Flow sequence entry: `[a, , c]`, `[,]`, `[,,]`
- Tag without content: `- !e\n` gives `TAGGED_NODE { TAG, SCALAR { NULL "" } }`
- Block mapping key missing its `:`, a parse error: `[{]:`, whose flow
  key runs to the end of the input, gives the key a zero-width `COLON ""`
  as well

Every KEY and VALUE always contains exactly one scalar or collection
node. Mappings wrap the value in a `VALUE` node; sequences hold the
scalar directly as a `SEQUENCE_ENTRY` child, matching how each shape
already treats non-null values. The zero-width `NULL ""` token renders
as nothing, so round-trips are lossless.

**Parsed nulls vs written nulls.** Programmatically-constructed nulls
(`YamlValue::Scalar(ScalarValue::null())`) build `SCALAR { NULL "null" }`
-- textual, four characters. On read, keep the parser's zero-width form
so unchanged nulls stay implicit; use the textual form only when writing
a new null. When copying a collection into a form where an empty value is
not allowed (such as a block sequence written out as a flow sequence),
write the textual form so the entry is not lost.

**Consequences for accessors and mutators:**

- `len` / `get` / `set` / `remove` / `iter` on sequences and mappings all
  index by entry, not by "entry with a non-null value" -- so indexes agree
  across accessors and mutators.
- `Scalar::is_null()` returns `true` for the zero-width form.
- When replacing a zero-width null with a real value via `set`, insert a
  `WHITESPACE " "` between the preceding structural token (`DASH` in
  block sequences) and the new value -- the original had no separator
  because the value itself was zero-width. See `Sequence::set` for the
  pattern.

### Inline vs block values

After a colon in a mapping:
- **Inline** (WHITESPACE after colon): scalars, flow collections, empties
- **Block** (NEWLINE + INDENT after colon): non-empty block collections

After a dash in a sequence, content is always inline. For block
mappings/sequences as sequence items, the first entry shares the dash line;
subsequent entries go on new indented lines. This is the "inline-start"
pattern, as opposed to the "block-start" pattern used after colons.

### CST example

```yaml
config:
  host: localhost
  port: 8080
```

```
DOCUMENT
  MAPPING
    MAPPING_ENTRY
      KEY -> SCALAR "config"
      COLON
      VALUE
        NEWLINE
        INDENT "  "
        MAPPING
          MAPPING_ENTRY
            KEY -> SCALAR "host"
            COLON
            WHITESPACE
            VALUE -> SCALAR "localhost"
            NEWLINE
          INDENT "  "
          MAPPING_ENTRY
            KEY -> SCALAR "port"
            COLON
            WHITESPACE
            VALUE -> SCALAR "8080"
            NEWLINE
      NEWLINE
```

## Comments around a replaced value

Replacing a value (`Mapping::set` on an existing key, `Sequence::set`,
`set_path`) keeps or removes the comments around the old value by
position, the same for mappings and sequences. A comment on the entry's
first line, the key's line after the colon or a sequence item's dash
line, belongs to the entry and stays, even when the old value started
on that line: `a: [1,  # c\n  2]`, `- x: 1  # c` and `- - 1  # c` set
to `5` give `a: 5  # c` and `- 5  # c`. It moves after the new value, or
onto the line where a new block value starts (`-  # c\n  y: 2`).
Comments on the old value's later lines go with it:
`a:  # c\n  # d\n  x: 1\n` with `a` set to `5` gives `a: 5  # c\n`.
Comments, blank lines and indentation outside the old value are kept as
they are, except that a comment line that would read as part of a new
block scalar's body is moved back to the entry's column. The blank lines
after a new block scalar are part of its body, as the parser reads them;
spaces on them that would be body text are dropped. A kept (`|+`) block
scalar takes every blank line after it into its value, and only a
comment line could end it before them, so those blank lines are dropped.

A block scalar whose header line has a comment of its own (`|  # src`)
leaves no room on that line for the entry's comment, which then goes on
a line of its own directly above the entry, at the entry's column.
An entry that is not in a mapping (one returned by `Mapping::remove`, say)
has no lines around it, so that line goes first in the entry, and the
lines that would follow it go at its end.

Moving an entry (`reorder_fields`, and `move_after`/`move_before` given
the entry's own value) takes along the comments on its lines and the
comment lines before it, back to the previous entry, with any blank
lines among and after them: a comment line between two entries
describes the one below it. `a: 1\n# about b\nb: 2\n` with `a` moved
after `b` gives `# about b\nb: 2\na: 1\n`. The blank lines before those
comment lines belong to the position, so `a: 1\n\nb: 2\n` reordered
gives `b: 2\n\na: 1\n`. Lines before the first entry and after the last
stay where they are. Comment lines that end up before the first entry
go where the parser puts them, ahead of the mapping, so
`top:\n  a: 1\n  # c\n  b: 2\n` reordered gives
`top:\n  # c\n  b: 2\n  a: 1\n` with `# c` outside the mapping. A block
scalar takes the blank lines after it into its value, so those of a
position whose entry now ends in one go after the next entry that does
not, or failing that the one before. `move_after`/`move_before` given
another value remove the old entry as `remove` does, leaving its comment
lines where they were.

A flow mapping is no different: an entry owns its comma and the rest of
that line, and the comment lines after it go with the entry below them.
The commas stay with their positions, and one that has to follow an entry
whose comment ends its line goes before that comment:
`{a: 1, # c\n b: 2 # d\n}` reordered gives `{b: 2, # d\n a: 1 # c\n}`.

Removing an entry or item takes along the comment at the end of its
line, and leaves the comment lines before and after it: `- x\n# c\n- a  # d\n- y\n`
with `a` removed gives `- x\n# c\n- y\n`. A comment line above an entry
usually describes it, and goes with it when it is moved, but removing it
too would drop text the caller did not ask to remove. Blank lines on both sides of the entry would
merge into a double gap, so only those after it are kept:
`a: 1\n\nb: 2\n\nc: 3\n` with `b` removed gives `a: 1\n\nc: 3\n`.

Removing an entry leaves the comment and blank lines after it. If it was
the first entry of a collection that starts on the line of a `- ` or
`? ` indicator, those lines cannot join that line, so the collection
starts on the next one: `- a: 1\n  # c\n  b: 2\n` with `a` removed gives
`-\n  # c\n  b: 2\n`.

## Paths, anchors and aliases

Paths look like `server.hosts[0].name`, with `\` escapes for keys that
contain `.` or `[`. Lookups fall back to non-string keys (`1`, `true`) when no
string key matches. Malformed paths are a `PathParseError`, not a best
guess.

Reads resolve aliases. Writes through an alias are refused by default,
because they would change every other alias of the same anchor;
`AliasPolicy::Follow` writes to the anchored node and `AliasPolicy::Expand`
replaces the alias with an independent copy, built as CST.

Replacing a value (`Mapping::set`, `Sequence::set`, `set_path`,
`insert_at_index_preserving`) keeps the old value's anchor on the new one,
so aliases to it still resolve. A new value that is a node with an anchor
of its own keeps it only if the old value had none: the document's aliases
refer to the old anchor, and the node the value was copied from keeps its
own. A node passed as a new key keeps its anchor too.

Other anchors in a node that is removed or replaced (`remove`, `clear`,
`set`, `set_path`, `remove_path`, `rename_key`, ...) go with it, and so
would what the aliases after it refer to. Instead, the first alias that
referred to such an anchor is replaced with a copy of the anchored node,
anchor included, and the aliases after it refer to that copy:
`b: {c: &y 1}\nd: *y\n` with `b` set to `6` gives `b: 6\nd: &y 1\n`. The
same happens to the old anchor when the new value is an alias, which
cannot carry one. Only aliases that referred to the removed node count, so
`*y` in `a: &y 0\nb: {c: &y 1}\nd: *y\n` gets the copy rather than falling
back to `a`. Anchors within the copy that would take over aliases
referring to another node are left off it. An alias within the copy that
would then refer to the wrong node is in turn replaced with a copy, which
keeps only the anchors that aliases in it use. The shared helper for all
of this is `nodes::kept_aliases::keeping_aliases`.

An alias with a tag or anchor of its own, which the parser tolerates though
YAML does not, passes them on to its copy, which then goes without the
anchor it was copied with: a tag would change what later aliases to that
anchor refer to, and a node has room for one anchor only. Later aliases get
copies of their own. `k: !t *a` gives `k: !t 1` and `k: &b *a` gives
`k: &b 1`. The tag of the alias, being what was written at its place, also
takes the place of any tag the node has: with `a: &a !u 1`, `k: !t *a`
gives `k: !t 1`.

Moving entries (`reorder_fields`, and `move_after`/`move_before` given the
entry's own value) keeps every alias resolving to the node it did. An alias
that ends up before that node becomes a copy of it, anchor included, and
the node becomes an alias to the copy: `a: &x 1\nb: *x\n` reordered to
`b, a` gives `b: &x 1\na: *x\n`. Keys are no different: `a: &a 1\n*a : 2\n`
with `*a` moved first gives `&a 1 : 2\na: *a\n`, and `&k a: 1\nb: *k\n`
reordered to `b, a` gives `b: &k a\n*k : 1\n`. The copy keeps the anchors
inside the node too, needed or not, so the node can become an alias
without losing them: `{ &a [&b b]: x, *a : c}` with the first entry moved
last gives `{ &a [&b b] : c, *a : x}`. The node stays as it is, a copy
with its own anchor, if the copy cannot keep an anchor inside it, as it
would capture an alias to another node, or another anchor with one of
their names comes between; the copy then goes without the anchors inside it that no alias
needs. With `&b y: z` added last and the first entry moved after it,
`{ &a [b] : c, &b y: z, &a [&b b]: x}` has two keys the validator sees as
equal, as `*a` and the key it referred to were all along. An alias that
ends up after another anchor with its name becomes a copy, with the anchor
only if later aliases need it.
`move_after`/`move_before` given another value rebuild the entry with it
in place, the old value going as with `remove`, and then move it the same
way, comment lines after it included, so an alias in its key keeps
resolving too. Sequences have no reordering methods.

Looking up a key that is an alias (`get`, `set`, `rename_key`, ...)
matches an alias key of the same name, not the node it refers to, and
the string `"*a"` matches neither. The same goes for an alias within a
key: `[*a]` matches `? [*a]`.

An alias resolves to the closest preceding definition of its anchor in the
same document, as the spec requires. In `<<` merge keys only alias nodes are
merge operands; a quoted `'*a'` is a string.

Resolving aliases (`get_resolved`, `YamlValue::from_document`) reads an
alias with a tag of its own as its copy would read: `k: !t *a` as
`k: !t 1`. The tag of the alias also takes the place of any tag the node
has: with `a: &a !u 1`, `k: !t *a` reads as `k: !t 1`. An anchor on an
alias names the node the alias refers to, so `*b` in `b: &b *a\nc: *b\n`
resolves, on reads and through paths, to what `*a` does.

Mapping keys are compared everywhere by `as_yaml::NodeIdentity`: the
resolved tag and canonical content of the key, with aliases followed where
there is a registry to follow them. A `YamlValue` mapping holds each key by
the string form of its identity, so `? [a,b]` and `? - a\n  - b` give the
same key, `[a, b]`. The validator compares a key that is or holds an alias
with nothing, as the YAML test suite has `{&a [a]: 1, *a : 2}` as valid.

## Error handling

The primary error type is `YamlError`, with variants for I/O, parse errors
(with line and column), key-not-found (with available keys), type mismatch,
invalid index, and invalid operation.

Domain-specific error types are kept separate intentionally: `PathError` and
`PathParseError` for path access, `ValidationError` for schema validation,
`CustomTagError` for custom tags.

Some `YamlFile` convenience methods (`set`, `insert_at_index`,
`reorder_fields`) return `bool`, `false` meaning there was no suitable
document to act on and the file is unchanged.

Accessors that can hit a broken reference (an undefined alias, a merge
operand that is not a mapping) report it as an error rather than returning
the alias text or skipping it.

## Testing

Every test that mutates a tree checks the result after each mutation:

- `debug::validate_tree` checks the structural invariants from the `nodes`
  module docs: entries terminated by a NEWLINE of their own, no comment
  ending an entry's line outside it, no stacked INDENTs, no doubled
  trailing NEWLINEs, one value node per KEY/VALUE/entry.
- `debug::roundtrip_ok` re-parses the text and checks that it parses without
  errors and renders back to the same text.
- In integration tests, the shared `assert_cst_ok(&doc)` and
  `assert_file_cst_ok(&file)` helpers run both. Unit tests use the
  test-only `debug::assert_cst_ok`, which takes any AST node. The fuzz and
  property tests have their own shared mutation-check helpers.
- Tests of operations that fail on purpose check the CST too, to show the
  failed operation left the tree intact.

These checks don't catch everything. A mutation can put a token in a
different node than the parser would (a NEWLINE under `DOCUMENT` rather than
in the last entry, a comment inside a `TAGGED_NODE`) and still pass both. For
mutations that touch document structure or move trivia around, also compare
`debug::shape_to_string` of the edited tree with that of a re-parse of its
text (see Builders for the one token it leaves out), and assert the exact
output text with `assert_eq!`.

Tests compare with `assert_eq!` against exact expected values, not
`contains` or `matches!`.

## Code organization

- `lib.rs` - public API re-exports
- `yaml.rs` - `YamlFile` and core types
- `lex.rs` - lexer, token types (`SyntaxKind`)
- `parser/` - parser (block and flow collections, scalars)
- `parse.rs` - parse result types
- `nodes/` - AST node wrappers (`Document`, `Mapping`, `Sequence`, ...) and
  the CST invariants
- `as_yaml.rs` - `AsYaml` trait, `YamlNode` enum, `yaml_eq`, tagged
  collection types
- `value.rs` - `YamlValue`, an owned value tree not backed by the CST
- `scalar.rs` - `ScalarValue` and core schema typing
- `builder.rs` - fluent builder API
- `path.rs` - path access and alias policies
- `anchor_resolution.rs` - anchor/alias resolution, merge keys,
  `MergedMapping`
- `mapping_view.rs` - `MappingView` trait shared by `Mapping` and
  `MergedMapping`
- `migrate.rs` - YAML 1.1 to 1.2 migration
- `error.rs` - error types
- `schema.rs` - schema validation (Failsafe, JSON, Core)
- `custom_tags.rs` - custom tag registry
- `visitor.rs` - visitor pattern traversal
- `error_recovery.rs` - parse error recovery
- `validator.rs` - YAML spec validation
- `debug.rs` - tree visualization and CST checks

## Checklist

- Read the CST-invariants doc of the `nodes` module before editing mutation
  code
- Use `splice_children` for mutations - don't rebuild nodes
- Build CST nodes directly - never render text and re-parse it
- Put new spec checks in the validator, not the parser
- Methods take `&self`, not `&mut self` (interior mutability)
- Test that formatting is preserved (lossless round-trip)
- Call `debug::validate_tree` + `debug::roundtrip_ok` (or the
  `assert_cst_ok` / `assert_file_cst_ok` test helpers) after every
  mutation in new tests, and compare the tree with a re-parse when the
  mutation moves trivia or touches document structure
- Use `debug::print_tree()` to understand the CST structure
- Use `assert_eq!` with exact expected values in tests

## References

- [rowan]https://github.com/rust-analyzer/rowan
- [YAML 1.2 Spec]https://yaml.org/spec/1.2.2/