fig-sys 2.5.3

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
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
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
# fig language design

Two text surfaces sit over one AST (`src/ast/ast.zig`). They optimize for
opposite things, so they are kept as separate surfaces rather than one
compromise format.

| | **canonical form** (`src/canonical/`, formerly "native") | **fig authoring dialect** (`src/languages/fig/`) |
|---|---|---|
| Audience | machines / the test oracle | humans writing config by hand |
| Goal | totality + a single spelling per document | memorability, typeability, ergonomics |
| Spellings per doc | exactly one (diffable by `strcmp`) | many (indentation free; several flatteners) |
| String default | always quoted | bare by default |
| Separator | `key: value` | `key = value` |
| Role | lossless oracle, debug output, comparison | the format users actually author |

The canonical form already exists and is a **total bijection** with the AST — it
can represent every `Node.Kind`, the YAML reference layer (`&`/`!`/`*`), the
int/float sigil (`~i`/`~f`), non-string keys, and line/block comments. Its
rigidity (fixed indent, always-quoted, one spelling) is exactly what makes it a
valid comparison oracle. **We are not changing it.**

The fig authoring dialect is the memorable, hand-writable surface. It parses
into the same AST and is emitted by `fig fmt`. It is allowed to be *lossy at the
edges* because the canonical form and the `$fig-envelope` lossless mode are
always available as the faithful fallback. It gives up canonicality (many
spellings) on purpose — it is not the oracle.

---

## The fig authoring dialect

### Design goals

- **Typeable on a bare keyboard.** No glyph that requires an editor plugin,
  snippet, or copy-paste. (This killed the original box-drawing ``/`` idea:
  config gets edited in emergencies over SSH on machines with no tooling.)
- **Whitespace never changes meaning.** The `>` count is the sole source of truth,
  so any reformatting — re-indent, paste, editor mangling — is semantically safe.
  This is a *semantic* guarantee, **not a keystroke claim**: it does not promise
  less typing than tooled YAML (see "Depth is a correctness risk" for the honest
  ergonomic contract). The indented/box-drawing view is a human aid layered on top,
  never the source of meaning.
- **Strict.** Prefer an error over a silent surprise (see duplicate keys,
  skipped levels, Norway problem).
- **Memorable.** The box-drawing aesthetic that started this survives as
  *rendered* output from `fig fmt`, not as required input.

### Structure — prefix-count depth

Nesting depth = **count of leading `>` markers**. Not indentation.

```
database
> host = localhost        # database.host
> port = 5432             # database.port
> pool                    # container header (no `=`)
>> size = 10              # database.pool.size
logging
> level = info
```

- A bare word with **no `=`** is a container header; with `=` it is an
  assignment.
- **Root keys carry zero markers.** A `>` at the top with no parent above it is a
  skipped-level error, not a root assignment.
- **Root is a map or a sequence — never a bare scalar.**
  - *Map* (the default): bare keys, assignments, and headers at zero markers — the
    shape of nearly all config.
  - *Sequence*: `*` elements at zero markers. `* 1` / `* 2` for scalars; a bare
    `*` then `> field` lines for a list of maps. The root can't be *both* — the
    `key = v` + `*` type error applies at root too.
  - *Scalar* (a whole document that is just `42` or `"hi"`): **no authoring
    spelling.** A bare token at root is a container header, so there is no
    unambiguous scalar-root syntax — and real config never needs one. Falls to the
    canonical form / envelope (tier 3), same as non-string keys.
- **Skipped levels error** (`>` then `>>>`). Almost always a miscount; erroring
  is how the format catches the one thing prefix-counting is bad at.
- **Marker tokenization.** A depth marker is a run of `>`; count = depth. The run
  may carry optional *leading* indentation (cosmetic — indentation is never
  load-bearing) and **requires** whitespace between the run and the key.
  Contiguous (`>>>`) and spaced (`> > >`) runs are equal; **`fig fmt` normalizes
  to a spaced run** (settled — the earlier contiguous-run house style is
  reversed). The spaced run recreates indentation's column geometry (two columns
  per level) out of the markers themselves: a visual ruler with no redundant
  second signal to keep in sync, easier to count than a tally-glued `>>>>`, and
  less intimidating to approach. Contiguous runs stay first-class input (faster
  to type; fmt restores the ruler). The marker/key separator is the one
  load-bearing space in the block layer.
- **The element marker is `*`, written in key position: `> *`.** A sequence
  element line is a key line with an *anonymous* key — `*` sits exactly where a
  named key would (`> host` / `> *`), and its children live one level deeper
  either way. `*` was chosen over the earlier glued `>-` (settled — see
  "Resolved"): it is visually distinct from `>`, so nothing can be mis-tallied
  and no glue rule is needed (`> > *` spaced is the normalized form; glued
  `>*` also parses); it is the universal plaintext bullet; and it ends the
  dash's double duty with the minus sign — a negative element is just `* -5`,
  no disambiguation rule required. At root an element is a bare `*` (zero
  markers, mirroring root keys exactly). `*` is a role marker, **not** a
  counted depth mark — depth is the `>` count alone (making `*` count-bearing
  was considered and rejected: a depth-0 root element would be unspellable, or
  root sequences would collide with nested ones). A `*` glued to its value
  (`>*v`, `*v`) is a `FigBadMarkerSeparator`, exactly like `>v`. Any `-`
  element spelling (`- v`, `>-`, `> -`) is a hard `FigForeignSyntaxDash` error
  naming the `*` form — YAML's list marker deserves a teaching error, not a
  silent parse.
- `>` chosen over `|`/`+`/mixed glyphs: it is the only single repeated char that
  (a) generalizes cleanly (level N+1 = append one `>`) and (b) stays countable.

### Depth is a correctness risk (not just readability)

Prefix-count did not *remove* load-bearing structure; it *relocated* it from
indentation to the `>` count. And the failure modes are asymmetric — the
skipped-level error catches only one of four miscount directions:

- **Deeper by ≥2** (`>` then `>>>`) → skipped-level error. **Caught.**
- **Deeper by exactly 1** → a valid new child. **Silent.**
- **Shallower by any amount** → valid (closing N containers at once is legitimate
  and common). **Silent.**
- **Same depth when a child was meant (or vice versa)** → both valid. **Silent.**

The everyday edit — "add a line one level in, tally one `>` wrong" — lands on a
still-valid depth with no error and, without indentation, no misalignment to catch
it by eye. And this **cannot be caught from the count alone**: shallow-by-many is
a legal, necessary operation, so no rule over a single signal can tell "meant it"
from "miscounted." Detection *requires a second, redundant signal.*

**The honest contract.** In the safe mode, indentation does the *visual* work and
the `>` count is the machine-checked *truth* — the lint forces them to agree. This
is the real design and a good one (double-entry bookkeeping for structure), but it
is the inverse of a naive "whitespace is free saves keystrokes" reading: you author
*one* signal and `fig fmt` maintains the redundancy; you carry both by hand in only
one loop — editing an already-indented file in a dumb editor without running fmt.
And YAML's "one gesture to deepen a block" is a *tooling* advantage (select+tab)
that the stated target user (nano over SSH) doesn't have either — so the ergonomic
gap is mainly against *tooled* YAML, which needs the safety net least. Two modes,
your choice, meaning always the count:

- **Bare `>`** — minimal to type, no visual net; miscounts are silent. Fine for
  shallow files and quick edits.
- **Indented `>`** — the cross-checked mode below; a write-time miscount net.

Mitigations, strongest first:

1. **Checked indentation (the second signal), opt-in.** Meaning stays
   entirely in the `>` count, so the semantic invariant holds exactly. Authors
   *may* indent; the linter warns only when a line's indent is **present and
   disagrees** with its marker count — so bare-`>` stays lint-clean and
   first-class, while indented files get cross-checked. The two encodings fail
   *independently* — indent you get right by visual alignment, count by
   tallying — so a silent wrong tree needs the *same* mistake in both at once.
   This restores YAML's visual-ruler catchability *as a check* rather than *as
   load-bearing structure*, and it is a **write-time** net. **Settled
   (revised):** emitted indentation is *not* the fmt default — the default house
   style is spaced marker runs (`> > `), which rebuild the visual ruler out of
   the markers themselves at half the leader width and with nothing to drift
   out of sync (see "What `fig fmt` normalizes"). Indentation *on top* of the
   count spends 3×depth columns doing with whitespace what the `>` run already
   does inherently, and an fmt'd file that intimidates readers into "don't
   touch without tooling" defeats the nano-over-SSH goal. A future
   `fig fmt --indent` re-adds derived indentation for repos that want the
   machine-checkable double-entry; the lint honors it either way. What the
   spaced-run default gives up is only the *machine* cross-check — the by-eye
   misalignment catch survives, since a miscounted line still sits at a
   visibly wrong column.
2. **`fig fmt` guide rendering.** Re-emit with the shelved box-drawing/guide
   aesthetic so misplacement is glaring on review. A **read-time** aid; make
   `fig fmt` the expected default (gofmt-style, in pre-commit/CI) so it is routine,
   not "if the user runs it."
3. The two **flatteners** below genuinely shrink the surface: if dotted keys and
   section headers keep typical depth at 2–3, deep `>>>>` runs are rare in
   well-written *map-heavy* config (but see the list caveat under "Sequences").

What does **not** work: round-tripping through `fig fmt` does *not* detect a
miscount. fmt re-emits the tree you *wrote*, not the one you *meant*; a miscounted
line is an internally-consistent wrong tree, so normalize-and-re-emit yields no
semantic diff. Round-trip surfaces formatting drift, never intent mismatch.

**Design fork (settled: count-authoritative).** Which signal is authoritative for
`fig fmt`? *Count-authoritative* (chosen): fmt derives indentation from the count,
so meaning is paste-safe *always* — fig's core differentiator. *Indent-
authoritative-until-fmt* (rejected): author by indentation and let fmt lower it to
counts — ergonomically closer to YAML, but a pre-fmt file's meaning would depend on
indentation (fragile until fmt runs), forfeiting paste-safety. Recorded so the
choice is deliberate; revisit only if nano-authoring ergonomics ever outweigh
pre-fmt paste-safety.

### Flatteners (escape hatches from "nesting hell")

- **Dotted keys** flatten within a line: `> pool.size = 10`.
- **Dotted section headers** re-anchor the baseline, exactly like TOML
  `[a.b.c]`:

  ```
  list.of.servers
  > host = a.com            # list.of.servers.host
  ```

  A header only *selects/creates* a map path. **Depth markers count relative to
  the active header's baseline**, not absolute document depth — so `> host` above
  is `list.of.servers.host`, and the skipped-level check is likewise relative.
  (Relative depth is deliberate: mandatory absolute depth would renumber the whole
  file on any near-root insert — the same edit-fragility rejected for sequence
  indices. The skipped-level error is the safety net for its one downside.)
  **A baseline stays active until the next zero-marker line**, which establishes a
  new one — another header, or root for a plain key/assignment/`*` element. Blank
  and comment-only lines don't reset it, and baselines don't stack: each
  zero-marker header *replaces* the active one (there is no "pop to parent").
  Re-entering a path to add new keys is fine; redefining a leaf key that already
  exists is a duplicate-key error. This is the **map-side** flattener; its
  sequence-side twin is the append header below.
- **Appending sequence headers** `a.b[]` are the sequence-side twin of dotted
  section headers — the primary surface for nested lists of maps (k8s / CI /
  Compose). A `[]` suffix on a header path **appends a new element** to that
  sequence and re-anchors the baseline to it, so its fields sit one `>` deep no
  matter how deeply the *list* nests:

  ```
  jobs.test.steps[]           # append an element to steps, re-anchor here
  > uses = actions/setup-node@v4
  > with.node-version = 20
  jobs.test.steps[]           # append another element
  > run = npm test
  ```

  The element stays anonymous (positional) — `[]` *appends*, it does not *name* —
  so the old "headers can't name an element" objection does not apply. And because
  it appends rather than indexes, it is **edit-stable**: drop a new `steps[]`
  block anywhere and nothing renumbers. Same AST as `> *` (a `sequence` of element
  children). Borrowed straight from TOML's array-of-tables `[[steps]]`.

  **An append header is a log, not a declaration.** Because `[]` appends and a
  non-final `[]` binds to *the last element*, the file is deliberately
  **order-dependent**: reordering two `spec.containers[]` blocks changes which
  container a following `spec.containers[].ports[]` (or `+`) attaches to. This is
  the price of edit-stability (no indices to renumber) and is intended — but it
  means append blocks read top-to-bottom as a sequence of operations, not as a
  set of independent declarations. Index addressing (`[i]`) is the order-*in*dependent
  counterpart, trading edit-stability for it.

  **Nested lists of lists** use TOML's rule: a **non-final `[]` means "the last
  element,"** only the final `[]` appends. So `containers[].ports[]` appends a port
  to the *last* container — the real k8s shape, still flat:

  ```
  spec.containers[]           # append a container
  > name = app
  spec.containers[].ports[]   # last container → append a port
  > containerPort = 80
  spec.containers[].ports[]   # last container → append another port
  > containerPort = 443
  ```
- **`+` continuation lines** de-duplicate a run of identical append headers. A
  zero-marker line consisting of just `+` (optionally a trailing comment)
  re-runs the most recent zero-marker `[]` append header: append another
  element, re-anchor to it.

  ```
  workspace.metadata.pre-release-replacements[]
  > file = README.md
  > exactly = 0
  +                           # same as repeating the [] header line
  > file = CHANGELOG.md
  > exactly = 0
  ```

  Rules: `+` is valid **iff the most recent zero-marker *structural* line was a
  `[]` append header or another `+`** — blank and comment lines don't break the
  chain (body lines are deeper, so they can't). A dangling `+` is a hard error
  (`FigDanglingContinuation`) naming the fix, which makes reorder/delete
  accidents loud — the deliberate price of a block that isn't self-contained
  (or greppable) the way a repeated header is. For a nested header
  (`containers[].ports[]`), `+` re-runs the *whole path* — non-final `[]` still
  means "the last element" — so it appends another port to the last container.
  A bare key may not begin with `+` (quote such a key), mirroring `>` (and `*`/`-`, which are never bare-key characters at all).
  `fig fmt` emits `[]` for a sequence's first element and `+` for the rest.
- **Index addressing** `servers[0].pool.size = x` addresses an *existing*
  element. Unlike `[]`, it is **edit-fragile** (inserting an element renumbers the
  rest), so it is for editing/addressing, **not** authoring fresh lists. The AST
  sequence has no index concept, only order; indices canonicalize to position.
  **Skipping an index errors** — write the earlier element first (even
  `servers[0] = null`), so gaps are explicit. **Mixing `[]`/`[i]` addressing and
  `*` element syntax for the same sequence is a type error** (same spirit as the
  `key = v` + `*` rule).

**When a path doesn't resolve: auto-create absent structure, error on conflicts.**
The rule for headers, dotted keys, `[]`, and `+` is asymmetric on purpose:

- **Absent parent structure is auto-created.** A header or dotted key names a map
  path; missing intermediate maps are materialized silently (`a.b.c = 1` creates
  `a` and `a.b`). Deep config shouldn't require declaring every ancestor first.
- **Conflicting structure is a hard error.** Stepping a path into an existing
  *non-container* value is `FigKeyNotContainer`; a non-final `[]` on an *empty*
  sequence (there is no "last element") is `FigEmptyAppendTarget`; a `+` with no
  preceding `[]` header is `FigDanglingContinuation`; re-defining an existing leaf
  is `FigDuplicateKey`. Auto-vivification never *overwrites* — it only fills gaps.
  **Absent and empty converge on one error, by design:** a non-final
  `spec.containers[].ports[]` on a file with *no* `spec.containers` auto-creates
  `spec.containers` as an empty sequence, whose "last element" then doesn't
  exist — landing on the same `FigEmptyAppendTarget`. Both paths name the remedy
  ("append one first with a header-final `[]`"), so a CI log tells the author the
  fix regardless of which path they took.

**The auto-create footgun tooling can catch (the format can't).** Auto-vivify has
one inherent hazard no *syntax* can fix: a typo'd path (`servces.web` for
`services.web`) silently creates a parallel tree rather than erroring — the classic
INI/TOML footgun. This is a property of auto-create itself, not a fig flaw, and the
right home for it is a **lint**, not a parse rule. fig is unusually well-positioned
to ship it: the CST already holds every sibling name and every read/write site, so
a `fig fmt`/`check --strict` lint can flag *sibling containers with edit-distance-1
names* and *a container created but never read*. Recorded as a tooling direction,
not a format change.

The four ways to descend, as a 2×2 — the append header completes it:

| | nested (costs depth) | flat header (re-anchors) |
|---|---|---|
| **map** | `> key = v` | `a.b.c` section header |
| **sequence** | `> *` (A2) | `a.b[]` append header |

Which list surface to reach for:

- **`a.b[]` append header** — nested / vertical lists of maps (the common
  real-world shape). Fig-native, flat, edit-stable, clean diffs. A `+` line
  continues a run of appends without re-typing the path.
- **`> *` (A2)** — short block lists where nesting is shallow, or lists whose
  elements carry comments / multi-line strings (which multi-line flow can't hold).
- **flow `[…]`** — genuinely inline small lists (`ports = [80, 443]`),
  heterogeneous data, or pasted JSON. The inline convenience, **not** the primary
  list surface. `fig fmt` **stacks** a long or many-element scalar/nested-list
  flow value onto multiple lines (see "Multi-line flow for long lists") — the
  answer for frontmatter link/tag lists.
- **`[i]` index** — editing / addressing an *existing* element (accepts the
  fragility).

### Append headers are a log, not a declaration

The conceptual key to the whole sequence surface — and the thing that makes its
order-dependence feel *designed* rather than accidental. A reader arriving from
TOML expects headers to be **declarative and commutative**: `[a.b]` names a table,
and reordering two `[a.b]` blocks is a no-op. fig's append surface is the
opposite by construction: **`a.b[]`, `+`, and the non-final `[]` are events
applied in document order.** The file is a *script that builds the tree*, and the
canonical form is the **replayed log** — one canonical event sequence. This single
reframe explains behaviors that are otherwise surprising:

- **Why reordering `[]` blocks changes meaning.** Two `spec.containers[]` blocks
  are two append events; swap them and a following `spec.containers[].ports[]`
  (whose non-final `[]` means "the last element") attaches to a different
  container. That is a log being replayed in a new order, not a declaration being
  mis-parsed.
- **Why `+` is positional.** `+` re-runs *the most recent `[]` append event*
  (verified: an intervening plain assignment or bare header breaks the run into a
  hard `FigDanglingContinuation`; only another `[]` header rebinds it — because a
  new append event legitimately becomes the thing `+` continues). Under the log
  model this is not "action at a distance" — `+` means "another element of the
  append that just ran," and reading top-to-bottom there is exactly one such
  append. The retarget some reviewers worry about (interpose or reorder a `[]`
  header above a `+` and its target shifts) is **the log behaving as a log**: the
  event that most recently ran *is* the antecedent. No purely-local rule can make
  that an error instead of a rebind, because after the edit the `+` is genuinely
  positioned to continue the new event — only an accessor that re-reads intent
  (a full-path echo, or a lint over the CST) could, and the log framing is what
  makes the plain positional rule the *correct* one rather than a compromise.
- **Why `[i]` index addressing exists.** It is the escape hatch to *declaration*
  semantics — address element `[0]` by identity, order-independently — traded for
  edit-fragility (inserting renumbers). Append when authoring a growing list;
  index when you need to name an existing element.

The fmt contract falls out cleanly: the printer **replays the log and emits one
canonical event sequence** — `[]` for a sequence's first element, `+` for the
rest — so any two documents that build the same tree normalize to the same script.

### What `fig fmt` normalizes (house style)

Canonical is the diff oracle, but the authoring dialect needs a *house style* so a
fmt'd repo reads consistently (many spellings → one predictable form; the reader
learns *this rule*, not each author's taste). The decision procedure below is
**implemented** (`src/languages/fig/printer.zig`); the numbers are defaults, not law.

- **Markers:** spaced runs (`> > key`), zero leading indentation. The spaced run
  recreates indentation's column geometry (two columns per level) out of the
  markers themselves — the visual ruler with no redundant second signal to keep
  in sync. Element stars occupy their own cell (`> > *`). A future
  `fig fmt --indent` can re-add derived indentation for repos that want the
  machine-checkable double-entry; the indent/count lint applies either way.
- **Fits-or-breaks (the split rule).** A container value renders as inline flow
  **iff** every descendant is flow-representable, no comment would be dropped
  or re-anchored, no object directly contains another object, and the whole
  line fits the width budget (`width` option, default 80 cols). Otherwise it
  renders block, and each child re-decides independently. All-or-nothing per
  node — deliberately *not* "measure which member contributes most and hoist
  it": partial inlining creates mixed spellings (see the closed-flow rule) and
  makes layout flip under small edits. The recursive fits rule needs no
  complexity metric, is stable and idempotent, and reproduces hand taste: a
  `serde = { version = "1.0", features = [derive] }` that fits stays inline; a
  `chrono` that doesn't gets block lines with its `features = […]` leaf inline.
- **Multi-line flow for long lists (the frontmatter surface).** A **sequence**
  that is fully flow-representable (every element has an inline spelling, no
  comment would drop) and holds **no map element** renders as *multi-line flow*
  — `key = [`, one element per line (two-space cosmetic indent, trailing comma),
  a `]` back at the opener's indent — whenever the inline form is rejected, for
  **either** reason: the line overflows the width budget, **or** the list
  exceeds an item-count threshold (`max_inline_seq_items`, default 6) even if
  it would fit. Short lists stay inline (`ports = [80, 443]`); long index-style
  lists stack. This is the terse answer for markdown frontmatter — a list of
  links or tags reads and diffs one-per-line without the depth-marker overhead
  of `> *` block lines. The interior is a suspended flow region (block markers
  don't apply inside), so the indent is purely visual and paste-safe.
  - **Nested lists re-decide fits-or-breaks independently** — the same
    recursive rule as everywhere else: a nested element rejected for the
    inline form (width or item count) stacks recursively, two more columns per
    level. No "nested stays on one line" exception; a long inner list can't
    smuggle a 200-column line into an otherwise-stacked value.
  - **The value's trailing comment rides the opener line** — `key = [  # note`
    — the multiline-string opener rule's flow twin: the opener line is still
    block-layer, so a `# …` after the `[` (with nothing else on the line)
    parses as the sequence's trailing comment, and fmt emits it there — next
    to the key it describes, not after the far-away `]`. A comment written
    after the closing `]` still parses as trailing and migrates to the opener
    on the next fmt; when both are written, the opener wins — same as `'''`.
  - **Not blank-line separated at the root.** A stacked list is still one
    assignment, so it packs tight with its scalar neighbors — the frontmatter
    look — rather than reading as a section the way a header + block body does.
  - **Quote-avoidance: bare beats quoted.** When a list breaks, it takes the
    `> *` block form instead of stacked flow if flow would force quotes onto
    an element that block position renders bare. The common case is prose with
    a top-level comma — `,` terminates a bare flow value but is ordinary text
    in a block bare string:

    ```
    random
    > * test of very long things in a list to see if it works
    > * hello there, this is also a long test; hopefully it works
    ```

    Markdown links, tags, and paths have no top-level commas, so the
    frontmatter lists stay on stacked flow. Elements needing quotes in *both*
    positions (`"true"`, a leading space, a flow-shaped string) don't count
    against flow — quoting is the same price either way. The rule recurses
    into nested lists (their interiors are flow-quoted too while the outer
    value stays flow), and it only arbitrates between the two *broken* forms:
    a short comma-carrying list that fits stays inline (`[a, "b, c"]` — one
    quoted element beats three lines). The trade, accepted: editing a comma
    into an element flips the list's layout on the next fmt — the same class
    of flip as crossing the width or item-count threshold.

  Falls back to `> *` block lines also when an element carries a comment
  (multi-line flow discards *interior* comments — the opener/closer trailing
  comment above is block-layer, not interior), is a multi-line string, or is an
  enum/char/float atom with no flow spelling. **Lists of maps stay on the
  append-header / block path** — flow of maps is brace-heavy (see the A2 vs
  `[{…}]` stress test).
- **Dotted keys:** a chain of single-child maps collapses into one dotted key
  (`a.b.c = v`) — blocked only where a comment's anchor wouldn't survive the
  collapse.
- **Sections (document root).** Each root entry becomes one or more sections:
  - scalar or inline-fitting value → dotted assignment (`workspace.resolver = "2"`);
  - a map whose block body stays within **depth 2** → dotted section header
    with a nested body;
  - a deeper map **hoists** into sections, recursively — the depth budget is
    what pushes k8s/CI-shaped config out to the flat `a.b.c` / `a.b[]`
    surfaces instead of `>>>>` runs. Hoisting is **grouped**: a run of ≥2
    consecutive flat siblings (scalar / inline-fitting) shares ONE header
    re-entry — the path is named once, not repeated per line — while each
    deep child gets its own section, and a lone flat child stays a dotted
    assignment (one line beats a two-line header group). Headers are
    re-enterable, so interleaved runs stay legal and key order is preserved
    exactly (this is TOML's own shape: scalars under `[a.b]`, sub-tables
    after);
  - a sequence of maps → `path[]` append header, then `+` continuations;
  - any other non-inline sequence → header + `* v` element lines.
- **Index `[i]`:** never emitted — rewritten to append/block form.
- **Multi-line strings:** a string containing newlines prints as a `'''` raw
  block (verbatim, flush-left), or `"""` with `\`/`"` escaped when the content
  contains `'''` or a carriage return. The closer sits at column 0, pinning the
  escaped flavor's smart-dedent at zero so leading whitespace in the value
  survives. A multi-line string also forces its container out of inline flow —
  blocks only exist in block position.
- **Quotes:** bare where literal-else-string round-trips the value unquoted —
  tightened in flow position, where a bare value must also survive the
  flow scanner (`,`/`]`/`}` anywhere, or a leading `[`/`{`, force quotes); else
  a double-quoted form. (Choosing `'…'` when no escapes are needed is a
  remaining nicety.)
- **Comments are conservative blockers.** Inline flow, dotted collapse,
  hoisting, and `[]`/`+` groups are all skipped when they would drop a comment
  or re-anchor it onto a different node; the printer falls back to the nested
  form that round-trips it exactly. fmt never loses a comment to win a
  prettier shape.

### Sequences — form A2 (`*` = anonymous positional key)

A list is a map whose keys are positions; `*` is how you write a positional key.
So `*` behaves *exactly* like a named key — it is written in key position, and
its fields live one level deeper — which means **no new parse rules** and every
line's *role* is fully determined by its own prefix. (Precise wording: a line
self-describes its **role** — assignment / header / element — not its **path**.
The path is baseline-relative, like TOML: it depends on the nearest zero-marker
line above.)

```
servers
> *                       # anonymous key at depth 1 = a new element
>> host = a.com           # its fields at depth 2, like any map
>> port = 25565
>> backends               # nested list field
>>> *                     # a backend element
>>>> url = x
>>>> weight = 1
>>> *
>>>> url = y
> *                       # second server element
>> host = b.com
```

(Spaced `> *` is the normalized form; glued `>*` parses identically — see
"Marker tokenization".)

- Chosen over **A1** (first field rides the element line, siblings continue at
  the same depth). A1 is one marker shallower per list, but it introduces the one
  place in fig where two same-depth lines play different roles (element-opener vs
  continuation). A2 keeps every line self-describing, matching fig's "role is in
  the prefix, never in whitespace" thesis.
- **A2's depth cost is real, and it lands worst where config is most common
  (nested lists of maps).** A real GitHub Actions job in A2 reaches depth 5
  (`steps` → element → `with` → key). Stress-tested against the alternatives:
  - *A2* — depth 5; self-describing, but marker-dense.
  - *A1* — depth 4 (saves one level) at the cost of the self-describing thesis.
  - *flow* `[{…}]` — flat but brace-heavy, repeats keys, noisy diffs.
  - *append header* `steps[]` — **depth 1, bare keys, edit-stable.** The winner.

  So A2 stays the block-layer default for shallow lists, but the answer for
  list-heavy config is the **append header** (see Flatteners), not deep `>`. Deep
  block list-in-list is friction *by design* — a nudge toward the header.
- Rejected: counter-shelving sugar (a marker that suspends depth counting). A whitespace-free, count-based
  language cannot contain a counter-free zone — a zone needs boundaries, and the
  only boundary marker available is the count itself. The moment such objects
  nest, the counter returns (or indentation does). Structural, not cosmetic.
- Scalar element: `> * 25565` (at root, `* 25565`). Map element: `> *` then
  `>>` fields.
- **No inline field on an element.** `* host = a.com` does *not* make a
  one-field map element (that is the rejected A1 shape). An element line whose
  RHS contains ` = ` is a **hard error** pointing at the `> *` / `>> host` form
  — never the silent string `"host = a.com"`. Muscle memory makes this worth an
  error, not a coercion.
- **Empty** list vs map is ambiguous as a bare childless container, so require
  inline `= []` / `= {}` (native via flow mode).
- A container with both `key = v` children **and** `*` children is a type error.

### Values

- **Literal-else-string rule (bare tokens only):** a bare, unsigiled, undelimited
  RHS is typed by *sniffing* — if the whole trimmed token parses as a
  number / bool / null / datetime it is that type; otherwise a bare string.
  Resolves `12 monkeys` (string), `true` (bool). Enum atoms and non-finite floats
  (`inf`/`nan`) are **not** sniffed — they are explicit-typing-only (below), so
  bare `@x` and bare `inf` are plain strings.
- **Committed values error, never fall back — but commitment is a property of the
  whole RHS, not just the first char.** The first-char test is a *proxy* for "the
  author intended structure"; for brackets it is too coarse, so the precise rule
  splits by delimiter:
  - **Quotes/triple** (`"` `'` / `"""` `'''`) commit on the first char: a parse
    failure is a **hard error**, never a string (`f = "true`, unclosed → error).
    Intent to write a string was declared by the opening quote. This is the
    principled *asymmetry* with the bracket rule above, and it is why the
    markdown-link lookahead does **not** generalize to quotes: the balanced-then-
    trailing fallback keeps the delimiters as *content* (`[Blog](/x)` verbatim is
    the intended value), but a quote's delimiters are *syntax meant to be
    stripped* — so a verbatim fallback (`"She said, "Hi""` including the outer
    quotes) is never what the author meant. A leading `"` also has no competing
    bare idiom (unlike `[`, overloaded with links/globs/regexes), so it is a
    *reliable* intent signal. A single-line quote that **closes early with more
    content on the line** — the wrapped-a-bare-string-in-unescaped-quotes slip
    (`she = "She said, "Hey there!""`) — is therefore a hard error
    (`FigQuotedTrailingContent`), and its message names the two fig fixes: drop
    the outer quotes (a bare string carries interior quotes fine — that is the
    common case) or escape them (`"She said, \"Hey there!\""`).
  - **Flow** (`[` / `{`) commits **only when the matching close is the final
    non-comment token** of the RHS. `ports = [80, 443` (never closes) is
    committed → **hard error**. (Note a *missing comma* inside a flow object is
    no longer an error: since bare flow values run to the comma, `{x = 1 y = 2}`
    reads `x = "1 y = 2"` — a spaced bare string, not a malformed pair. The price
    of bare values with spaces; quote or comma-separate to be explicit.) A leading
    `[`/`{` whose *balanced* close is
    followed by more content was **never** flow — it is a **bare string**, left
    unquoted: a markdown link `[Archived Docs](</a.md>)`, a glob `[a-z]*.md`, a
    regex `[0-9]+`, BBCode `[b]x[/b]`. This is what lets markdown links (and their
    kin) go unquoted: the author never intended structure, so string is the
    correct read — no surprise.
  Only *bare* tokens use the literal-else-string fallback; the balanced-then-
  trailing forms above join them. A genuine typo'd array (`[80, 443] x` — balanced
  but with trailing junk) also falls to string, but earns a coercion **warn**
  (below) since it *looks* like it wanted to be flow.
- **Leading-zero rule (TOML-style):** `007` is *not* a valid number, so it falls
  to the string `"007"` — zero-padded IDs, zip codes, and phone numbers keep
  their padding. Same family as the Norway fix.
- **Coercion diagnostic.** Because sniffing reinterprets by shape, the diagnostics
  layer warns on the surprising cases (see "Coercion diagnostics"): a
  number/bool/null-looking token that fell to string, or an inline `#` that
  truncated a bare value. A bare clock time warns **quietly** (`10:30` — the same
  shape as a duration/ratio/score, with no unambiguous subset even at full
  seconds precision); a bare *date* (`2026-07-01`) does not, since that shape is
  the overwhelmingly common, deliberate hand-authored case (frontmatter dates,
  deadlines, changelog entries) and warning on it was warn-fatigue rather than a
  catch. A full RFC-3339 timestamp (`T`/zone present) is unambiguous either way
  and always stays silent. Strictness via warning, not by removing the
  ergonomic default.
- **Bare strings by default**, including spaces: `title = My server`.
- **Bare-string whitespace: interior kept, edges trimmed; quote to preserve
  edges.** A bare value is trimmed of leading and trailing whitespace but keeps
  its interior spaces verbatim — `title = My server` is `"My server"`, and
  `movie = 12 monkeys` (no comment) is `"12 monkeys"` with the trailing run
  dropped. This composes with the `#` rule below: the value ends at the first
  whitespace-preceded `#`, then edge-trimming removes the space *before* that
  `#`. To keep a leading/trailing space (or a value that is only whitespace),
  quote it — `'  padded  '` preserves every byte, since quotes turn off both the
  trim and the bare-`#` truncation.
- **Comment marker (`#`) only after whitespace or line-start.** This saves URLs
  and fragments: `url = https://example.com  # real comment` → value
  `https://example.com`, comment `real comment`; `url = https://x#frag` keeps
  `#frag` (the `#` has no preceding space). See "Comments" for why `#`.
- **Quotes:** single `'…'` = raw/literal (no escapes); double `"…"` = escapes
  (`\n`, `\"`, `\uXXXX`). Mirrors TOML. Quote to override a literal:
  `team = "99"`.
- **Lowercase only** for `true` / `false` / `null`. `Yes`, `on`, `TRUE`, `NO`
  are strings. (Avoids YAML's Norway problem.)
- **Enum literal** — a tagged atom (canonical `@enum_literal "…"`), distinct from a
  string, **authored explicit-typing-only:** `mode: enum = creative`. There is no
  bare value sigil, so `reviewer = @alice` is the *string* `"@alice"`, not a silent
  atom (see "Enum: explicit-only" for why the `@`-value-sigil idea was dropped).
- **Char literal** — a single Unicode scalar (canonical `@char_literal "<codepoint>"`,
  the value ZON authors as `'a'`), **authored explicit-typing-only:** `sep: char = ','`.
  The `: char =` annotation is the disambiguator — it reads the RHS as a ZON-style
  char literal (`'A'`, `'\t'`, `'\''`, `'\u{1F600}'`) rather than as a length-1
  string, exactly the trick `: enum =`/`: float =` use. Stored as the decimal
  codepoint (the cross-format `char_literal` invariant), so `fig fmt` re-emits the
  `'…'` form and the value round-trips fig↔ZON↔canonical without loss. A **bare**
  (unquoted) RHS or a multi-codepoint literal is a `FigTypeMismatch`. Reusing the
  Zig char codec means escapes work for free.
- **Numbers** as bare lexemes; datetimes (RFC-3339, self-identifying) sniff
  natively. **Non-finite floats (`inf`/`nan`) are explicit-typing-only** —
  `timeout: float = inf` — so bare `inf`/`nan` stay strings; `fig fmt` emits the
  explicit form when the AST holds a `number_special`.
- **A number's value *is* its lexeme (normative, cross-implementation).** fig
  stores the source bytes of a number, not a decoded `f64`/`i64`: equality,
  round-trip, and canonical emission are all defined **over the source lexeme**,
  so `1.10` stays `1.10` (never `1.1`), `1e2` stays distinct from `100.0`, and
  `0xFF` is preserved rather than normalized to `255`. A conforming parser **MUST
  NOT** define value identity by numeric conversion; it **MAY** offer numeric
  coercion (`as_f64()`, `as_i64()`) as an explicitly *lossy accessor*, never as
  the stored value. This is the guarantee that stops a `float()`-based
  reimplementation from silently mangling versions and significant figures — it
  is a property of *the format*, not merely of the reference Zig library (whose
  `Number { raw, kind }` is where it happens to live today). Two numbers are
  equal iff their lexemes match byte-for-byte and their kinds agree.
- **Keys** are strings (bare or quoted). A **bare key may not contain a structural
  character** — `.` (path), `:` (type), `=` (assign), `[` (index) — nor whitespace,
  and may not begin with `>` or `-` (and `*`/`+` are not bare-key characters); any key that needs such a character must be quoted:
  `"my.key" = x`, `"foo:bar" = x` (bare `foo:bar = x` would read `bar` as a type).
  The no-whitespace rule closes the **dropped-`=` trap**: a header is a bare word,
  so `port 5432` (a `port = 5432` with the `=` fumbled) does **not** silently
  create a container named `port 5432` — the interior space makes it a hard
  `FigBadKey` ("a bare key cannot contain … whitespace"). A multi-word name that
  is genuinely intended must be quoted (`"port 5432"`). Erroring on the typo is
  the whole point: a format earns trust by demonstrating it has seen the traps.
  Non-string keys are deliberately **not** natively representable — the AST allows
  them (`keyvalue.key` is any node) but almost no config needs them; they fall to
  the canonical form / envelope.
- **Duplicate keys error** (within a map, and within one sequence element).

### Comments

- `#` line comments (chosen over `//`: it is the universal config comment char —
  shell, YAML, TOML, Python — which matters for a format meant to be edited over
  SSH in an emergency, and it collides with fewer bare values; only URL fragments
  carry `#`, and the after-whitespace rule saves those). Block comments degrade to
  a run of `#` lines (the AST records line-vs-block as a hint; printers
  downgrade).
- **Dangling / attached comments.** A `#`-only line carrying a depth prefix
  attaches at that depth to the *next* sibling; a trailing comment inside an
  otherwise-empty container attaches to the container. The depth prefix is the
  placement anchor YAML/TOML lack. When containers close over a buffered
  comment, its own depth decides its home: at or below the closing container's
  child depth → that container's **dangling** run (it was written inside);
  shallower → **leading** on the next sibling line (it was written between
  sections). This distinction is load-bearing for `fig fmt` output, where a
  section's leading comment sits at depth 0 right after a deeply nested body.

### Enum: explicit-only (why no value sigil)

Enum atoms are authored `x: enum = atom` and have **no bare value sigil**. We
first searched for one — a terse `@atom` spelling — then decided against a value
sigil at all; both the search and its abandonment are worth recording.

A value sigil would be a leading char meaning "the following bare word is a tagged
atom, not a string." Every candidate collided or overloaded:

| Sigil | Verdict |
|---|---|
| `.foo` | **out** — collides with dotfiles (`.env`, `.gitignore`) and overloads the path separator. This is what started the search. |
| `:foo` | Culturally *most correct* (the atom/symbol sigil), but re-muddies `:` the moment `key: type = value` gave it one clean meaning. Self-inflicted cognitive double-duty. |
| `!foo` | YAML-tag connotation; burns `!`, reserved for the reference layer. |
| `#foo` | taken by comments. |
| `$foo` | reads as variable/interpolation; also collides with `$fig-envelope`. |
| `%foo` / `^foo` / `/foo` | connotation-light but arbitrary; `/foo` reads as a path, `%` as URL-encoding. No cultural fit. |
| `@foo` | the least-bad — atom/handle-shaped, one char, no structural clash — and briefly chosen. But it still collides with bare handles (`reviewer = @alice`). |

The `@foo` collision is the general problem: *any* value sigil re-creates a
Norway-class edge where a plausible string silently becomes an atom. Since enum
atoms are rare in hand-authored config (most "enums" are perfectly good bare
strings — `level = info`), a sigil buys terseness for the exotic case at the cost
of a footgun on the common one. **Explicit-typing-only** removes the collision
entirely (bare `@x` is just a string) and shrinks the value grammar; `@` is
retired from the authoring value surface.

### Explicit typing — `key: type = value`

Typing is optional and reuses the separator split: `:` introduces a type, `=`
assigns. This is *why* the two separators stay distinct.

```
port: int = 5432
name: string = My server
mode: enum = creative      # the only way to author an enum atom
```

- Untyped `key = value` and typed `key: type = value` form a gradient, exactly
  like inferred vs annotated numbers.
- **The annotation is checked, coercing, AND stored.** It does two jobs at
  parse time: (a) *coerce/disambiguate* — `x: string = 42` yields the string
  `"42"`, not the int, turning off literal-else-string for that line; (b)
  *validate* — the RHS must be compatible with the named type or it is a **parse
  error** (`port: int = hello` → error). This is the annotation's whole point: it
  converts literal-else-string's silent coercion into a checked assertion — and it
  is what lets the two separators earn their keep. It is **also persisted** as a
  cross-format type tag (`ast.node_tags`; see AST fit #2), so it **round-trips**:
  `fig fmt` re-emits `: int/float/string/bool` and keeps the value's **verbatim
  lexeme** (`1_sig_fig: float = 1.` stays `1.`, `id: int = 09` stays `09`,
  `x: string = [ 1 + 2 ]` re-emits bare, not quoted). A redundant annotation
  (`n: int = 3`) is preserved too — the tag is recorded whenever the annotation
  is written. (`enum`/`datetime` map to distinct `extended` node kinds that
  self-annotate on print, so they carry no tag.)
- **Composes with `*`.** Because `*` is a positional key, an element types the
  same way: `*: int = 5`. One typing syntax everywhere — no special element case.
- Rejected `!type` (YAML-tag prefix): positionally ambiguous (`!int 5` — where
  does the tag end?) and it burns `!`, reserved for the reference layer.

### Flow mode & suspended regions

fig has two layers:

- **Block layer** — prefix-count depth; every line self-describes (the bulk of
  the format).
- **Delimited regions** — a value that begins `[` or `{` enters an inline **flow
  sublanguage** and parses until the matching close, *suspending* the block /
  prefix rules inside. Multiline strings (below) suspend the block layer the same
  way — one concept, two uses.

Flow objects come in **two spellings, and the pair separator selects between
them** — fig-inline (`=`) or JSON (`:`). This is *not* a JSON5 superset (that was
tried and dropped): the point is only that **real pasted JSON parses verbatim**,
not that fig embeds a second messy format.

```
tags   = [a, b, c]                 # fig-inline: bare strings
names  = [Adam Harris, Makena Harris]   # bare values run to the comma — spaces OK
point  = { x = 1, y = 2 }          # fig-inline object: `=` pairs, bare keys
pasted = { "x": 1, "y": [2, 3] }   # JSON object: `:` pairs, quoted keys
empty  = []                        # required spelling for an empty sequence
none   = {}                        # required spelling for an empty map
```

| Object spelling | Separator | Keys | Source |
|---|---|---|---|
| **fig-inline** | `=` | bare or quoted | terse hand-authored |
| **JSON** | `:` | **must** be quoted | pasted JSON verbatim |

- **The separator is the discriminator, chosen per object, and it may not mix.**
  `{x = 1, "y": 2}` is a **hard error** (`FigMixedFlowSeparators`) — an object is
  fig or JSON, never both.
- **A bare key before `:` is an error** (`{x: 1}` → "`x = 1` (fig) or `"x": 1`
  (JSON)") — the same YAML/JSON-habit guardrail as the block layer's `key: value`.
  So `:` never acts as a bare separator anywhere in fig.
- **Arrays have no separator, so no mode** — they are neutral containers; each
  element self-selects (`[a, b]` is fig by its bare strings, `["a","b"]` is
  neutral, `[{"x":1}]` holds a JSON object). Only *objects* fork. Selection is
  **per brace**, so a pasted JSON subtree drops straight into a fig object:
  `{ x = {"deep": [1, 2]} }`.
- **Bare values run to the next `,`/`]`/`}`** (spaces included, then trimmed), so
  `[Adam Harris, Makena Harris]` is two two-word strings — the exact ergonomic
  win that makes flow the answer for hand-written scalar lists.
- **Balanced-then-trailing is a bare string in flow too.** A flow element whose
  first char is `[`/`{` normally opens a nested collection — but the same
  whole-shape test the block layer uses (see "Committed values") applies: if the
  bracket's matching close is followed by a *flow terminator* (`,`/`]`/`}`) the
  element is genuine nested flow (`[[1, 2], [3, 4]]`); if it is followed by *more
  content* it was never a collection but a **bare string** — a markdown link
  `[Blog](/Blog.md)`, a glob, a regex. The bare scan is then **bracket-aware**:
  `[`/`{` raise a nesting depth and `]`/`}` lower it, so the interior `]` of
  `[text]` does not terminate the value — only a depth-0 `,`/`]`/`}` does. This
  is what lets a frontmatter list of markdown links, `[[Blog](/Blog.md),
  [Resume](/Resume.md)]`, stay entirely unquoted. (A markdown link whose text
  contains a top-level `,`/`]`/`}` still needs quoting — those are the flow
  terminators — but that is rare.)
- **JSONC affordances:** trailing commas and `#` **comments** (fig's comment char,
  not `//`/`/* */`) are accepted in both spellings. Interior comments are
  *discarded*, not attached to the AST (a documented edge cut).
- **Dropped from JSON5:** unquoted-key-`:`, `Infinity`/`NaN` (a bare `Infinity`
  now sniffs to the *string* `"Infinity"`, exactly as bare `inf` does in the block
  layer — closing a Norway-class hole the union had re-opened), and single-quoted
  *JSON* keys (single-quote stays fig's raw-string delimiter).
- **Not** a claim the whole language is a JSON superset — bare block strings,
  block `#` comments, and root-must-be-map still differ. Think "JSON embeddable as
  any value."
- **Flow values are closed** (TOML's inline-table rule): a container written as
  a flow value cannot be extended later by a dotted path, header, or index —
  `fig = { version = "1.0" }` followed by `fig.features = […]` is a hard error
  (`FigClosedFlowValue`). An inline value reads as *complete*; extending it
  after the fact is mutation at a distance, and the mixed spelling (half flow,
  half dotted siblings) was considered and rejected as a `fig fmt` output for
  the same reason. Write the block/header form when a table needs to grow.
- Makes inline `[]`/`{}` **native** (tier 1) and is the single rule behind the
  literal-else-string "flow collection" case; empty `[]` vs `{}` stay distinct
  (empty `sequence` node vs empty `mapping` node).
- Also why fig does **not** need an everything-is-a-map hack to get sequences:
  real sequences exist, and flow mode covers the inline case.

### Multiline strings — two flavors

Both are delimited regions that suspend the block layer until the close, mirroring
the single-line quote rule:

- `'''…'''` — **raw / verbatim**: no escapes, content whitespace preserved
  exactly. For blobs (PEM keys, regexes, pasted SQL). Caveat: because it is
  verbatim, leading indentation *becomes content* — do not indent raw blocks.
- `"""…"""` — **escaped + smart-dedent**: `\n`/`\uXXXX` honored, and the common
  leading indentation is stripped so the block can be indented to line up under
  its key without that indent leaking into the value.
- The opener line is still **block-layer**: a trailing `# …` after the opening
  `'''`/`"""` is an ordinary same-line comment; content capture begins at the next
  newline and runs until the close. (Stored as a `trailing` comment on the value;
  purely a lexer sequencing rule — see "AST fit".)

Multiline *comments* are intentionally **not** added (settled — see "Deferred /
declined losslessness"). A `/* … */` block would fight the line-oriented model
(where do depth `>` markers land on continuation lines?), and the loss it would
avoid is small: a block comment's *content* already survives as a run of `#`
lines; only the line-vs-block *style* flag degrades, and the AST documents that
flag as a downgradeable hint. `#`-per-line stays the one comment form.

### Authoring-time diagnostics

fig's strictness is delivered through diagnostics, not silent behavior. Two
severities, both routed through the existing diagnostics layer (`--quiet` /
`--strict`):

- **error** — refuse to parse; the document has no well-defined tree (or a
  structurally forbidden one).
- **warn** — the tree is well-defined but the line is a likely mistake. `--strict`
  promotes warns to errors; `--quiet` silences them.

Every diagnostic **names the fix.** The format teaches its own conventions at the
point of failure, because the target user is editing over SSH with no docs open.

#### Foreign-syntax guardrails (errors)

These catch muscle memory from YAML / JSON / TOML. Each has a valid fig spelling
that looks *almost* identical, so a silent misparse would be the worst outcome —
they are hard errors that point at the fig form rather than coercions.

| You wrote | Habit from | Diagnostic |
|---|---|---|
| `key: value` (no `=`) | YAML / JSON | error → "`:` introduces a *type*, not a value; did you mean `key = value`, or `key: type = value`?" |
| `- item` (any `-` element line) | YAML | error → "fig elements are `*`; write `> *` then `>> host = a.com`" |
| `* host = a.com` | A1 habit | error → "an element's fields go on following lines; write `> *` then `>> host = a.com`" |
| `[section]` / `[[x]]` at line-start | TOML | error → "fig section headers are bare dotted paths; write `section` / `x[]`, not `[section]` / `[[x]]`" |

`key: value` is the single most likely mistake in the whole format — every
YAML/JSON user has it in muscle memory — so it earns the most explicit message,
in the same spirit as the element-inline-field error.

#### Depth diagnostics

- **Skipped level** (`>` then `>>>`) — error. The one miscount that prefix-counting
  can catch from the count *alone* (see "Depth is a correctness risk").
- **Root marker** — a `>` at top level with no parent above it — error → "root keys
  carry zero markers; remove the `>`". (Distinct from skipped-level: here there is
  no parent at all.)
- **Indent/count disagreement** — warn, **on by default.** When a line's visual
  indentation does not match its marker count (the `--indent` convention is
  `2 × depth` spaces before the `>` run), the two redundant signals disagree and one
  is wrong. This is the write-time net from mitigation #1 — the *only* diagnostic
  that catches a deeper-by-exactly-1 or same-depth-when-a-child-was-meant miscount,
  which no rule over the count alone can detect. (Fires only when indentation is
  *present*; the bare/spaced-marker house style is lint-clean.)
- **Comment depth mismatch** — warn (planned). A `#`-only line whose marker depth
  matches neither the previous nor the next structural line probably anchors
  somewhere the author didn't intend (comment depth is load-bearing for
  attachment — see "Comments"). Catches the "wrote a comment for a depth-1 key
  at depth 0" slip.

#### Coercion diagnostics (warns)

The sniffing cost, surfaced rather than removed:

- a bare token that *looks* typed but fell to string, or the reverse (`Yes`, `007`,
  `12 monkeys`, `"true"`);
- a bare **clock time** where the author may have meant a duration/ratio/score
  (`meet = 10:30`) — but **only the time shape**, and quietly. A bare *date*
  (`day = 2026-07-01`) does **not** warn: in hand-authored config that shape is
  almost always a deliberate date (frontmatter, deadlines, changelogs), so
  flagging it was warn-fatigue on the common case rather than a catch. A bare
  time has no comparable safe majority — `HH:MM[:SS[.frac]]` is exactly a
  duration's/ratio's shape too, and neither seconds nor fractional precision
  tell them apart, so there is no narrower unambiguous subset to carve out the
  way the date/timestamp split does. A full RFC-3339 timestamp (`T` or zone
  present) is unambiguous either way and always stays silent;
- an inline `#` that silently truncated a bare value;
- a **balanced `[…]`/`{…}` with trailing content** that fell to a bare string
  (`[80, 443] oops`) — the shape opens like a flow collection but its close is not
  terminal, so it was read as a string. The fix the message names: drop the
  trailing content (to make it flow) or quote the whole value (to affirm the
  string). Markdown links, globs, and regexes are the *common, intended* form of
  this shape, so — like the datetime warn — it fires **only when the leading
  bracket-pair is itself well-formed flow** (`[80, 443]`), never for a link whose
  first `]` sits mid-value (`[text](url)`), to avoid warn-fatigue on the very case
  the rule exists to un-quote.

(Enum atoms and `inf`/`nan` are explicit-typing-only, so they never sniff and never
warn — bare `@x`/`inf` are plainly strings.) The fix is always a quote (`"…"`) or
an explicit `: type`, which the message names.

#### Structural semantic errors (post-parse)

Consolidated from the context-sensitive layer; all errors:

- a **committed value** that fails to parse — a leading quote/triple, or a leading
  `[`/`{` whose matching close is the final token (or never closes) — never string
  fallback; "close the `]`/quote, or quote the whole value". (A leading `[`/`{`
  whose balanced close has trailing content is *not* committed — it is a bare
  string; see Values.)
- a **single-line quote that closes early**, leaving stray non-comment content on
  the line (`she = "She said, "Hey there!""`) — a quote-specific trailing-content
  error (`FigQuotedTrailingContent`) that names the bare-string fix ("drop the
  outer quotes, or escape the inner ones"), rather than the generic
  `FigTrailingContent`. Triple-quoted openers keep the generic message.
- duplicate key (within a map, or within one sequence element);
- bare key containing a structural char (`.` `:` `=` `[`) or a leading `>`/`-` —
  "quote it: `\"my.key\"`";
- `[]`-as-last-element (a non-final or assignment `[]`) on an empty sequence —
  nothing to refer to (only a header-final `[]` *creates* an element);
- a **dangling `+`** — a continuation with no `[]` append header (or `+`) as the
  most recent zero-marker structural line, or a `+` carrying depth markers —
  "repeat the `a.b[]` header, or move the `+` directly after its group";
- **extending a closed flow value** — a dotted path / header / index stepping
  into a container written as `[…]`/`{…}` (`FigClosedFlowValue`, the TOML
  inline-table rule) — "write the block or header form if this table grows";
- mixing `[]`/`[i]` addressing with `*` elements for the same sequence;
- a flow object that **mixes `=` and `:` separators** (`{x = 1, "y": 2}`), or uses
  `:` after a **bare** key (`{x: 1}`) — "`x = 1` (fig) or `"x": 1` (JSON)";
- skipping a sequence index (`[0]` then `[2]`) — "write `[1]` first (even `= null`)";
- a container with both `key = v` and `*` children;
- a bare childless container — "give it `= []` or `= {}`";
- a scalar-root document — no authoring spelling; use canonical / `$fig-envelope`;
- **unknown type name** in `key: type = value`. The annotation is checked, and an
  unrecognized name cannot be checked — so it is an error, never a silently dropped
  annotation. The `type` set is *open in the grammar* but *closed at resolution* to
  the known types (plus any a host reader registers).

### Totality strategy — three tiers

The dialect stays *total* (nothing unrepresentable) without being *canonical* by
inheriting the canonical form's escape sigils for the long tail:

1. **Native** (the ergonomic 95%): null, bool, string, number, datetime, enum
   literal (via `: enum =`), char literal (via `: char =`), non-finite float (via
   `: float =`), sequence, mapping, keyvalue, `#` line comments, inline flow
   collections (`[…]`/`{…}`, see "Flow mode"), single/triple-quoted strings, and
   **explicit type annotations** (`: int/float/string/bool =`) — persisted as
   `node_tags` type tags, lexeme kept verbatim, so they round-trip through `fig fmt`
   (see "Explicit typing" and AST fit #2).
2. **Degrade + warn** (the existing diagnostics layer already reports this
   class): block comment → `#` runs, YAML **custom** tags dropped (core type
   tags map to `: type =` — see AST fit #2), aliases materialized. These are
   *deliberate* degrades, not gaps to close — see "Deferred / declined
   losslessness" for the reasoning on each.
3. **Inherited sigils / envelope** for the rest: `@extkind "text"`, `&`, `!`,
   `*`, `~i`/`~f`, non-string keys, scalar-root documents. `$fig-envelope`
   guarantees round-trip by construction.

`fig fmt` bridges the surfaces: parse authoring text → AST → emit canonical for
`strcmp`.

### Deferred / declined losslessness

The tier-2 degrades are choices, not oversights. As the dialect matures, each
`extended` scalar that lacks a bare spelling *can* be promoted to tier 1 by the
"explicit typing is the disambiguator" pattern — but only where the ergonomic
win is worth a new type name. The current dispositions:

- **Char literal — DONE (tier 1).** `: char = 'a'` promotes it exactly like
  `: enum =` did for atoms: the annotation reclassifies a `'…'` RHS (a length-1
  string bare) as a `char_literal`, stored as the decimal codepoint, re-emitted
  as `'a'`. Reuses the Zig char codec, so escapes (`'\t'`, `'\u{1F600}'`) come
  for free. Round-trips fig↔ZON↔canonical without loss. (See "Char literal".)
- **Block comment style — DECLINED.** Kept as a tier-2 degrade on purpose. The
  loss is only the line-vs-block *hint* (content is preserved as `#` runs), and a
  `/* … */` construct is incompatible with prefix-count depth. Byte-faithful
  `/* */` round-trips, if ever needed, belong in `$fig-envelope`, not the
  authoring surface. (See "Comments".)
- **YAML custom tags — DECLINED.** A `!foo` on a value is YAML-local metadata
  with no cross-format meaning. The honest degrade is to **drop the tag and keep
  the value at its real kind** (`!foo 42` stays the number `42`, not a string),
  with a warning — never to stringify the value. A native fig spelling would be
  complexity for a feature that doesn't cross formats; the `!` sigil in
  tier-3/`$fig-envelope` already carries it verbatim for the rare lossless need.
- **Anchors / aliases (`&`/`*`) — DEFERRED to a future point release.** The AST
  already models this fully (the `alias` node kind, `node_anchors`/`anchors`
  side-tables; canonical prints `&name`/`*name`), so the work is purely *surface
  syntax* — `&name`/`*name` glued to names, which sit in usable space. Worth
  doing eventually for large configs (Terraform/CI/infra) that share blocks.
  Three things to settle first, written down so the deferral is cheap to pick up:
  1. **Fundamentally format-limited.** Aliases survive losslessly only through
     fig↔canonical↔YAML; every other target calls `materialize` (expand aliases
     to copied subtrees), so JSON/TOML/ZON cannot carry sharing at all.
  2. **Cycles.** A true cycle (`&a { self = *a }`) cannot be materialized —
     infinite expansion. Decide the policy up front: detect and error cleanly
     (recommended) rather than blow the stack.
  3. **The `*` collision.** `*` is already the element marker in *key* position
     (`> *`). An alias `*name` in *value* position looks unambiguous, but that
     must be proven across flow mode and sequence tails before committing.
- **JSON-spelling preservation (`@json`) — DEFERRED to a point release.** Implicit
  JSON-in-flow (`pasted = {"x": 1}`) is normalized to fig-inline on `fig fmt`
  (`{ x = 1 }`) — the right default for hand-authored config, but it means a
  pasted JSON blob can't be kept verbatim-as-JSON. The wanted feature is to
  *preserve the JSON spelling*, and the key design realization is that this is a
  **rendering directive, not a data type.** A `json`-marked node is already a
  plain `mapping`/`sequence`; a type tag answers "*what* is this value?", a
  directive answers "*how* is it spelled?" — orthogonal axes. So it does **not**
  belong in `node_tags` (type assertions, e.g. `: int =`). Its home is a **new
  directive axis**:
  - An `@name` marker in the annotation slot, *before* an optional type and
    combinable with it: `val: @json = {"k": "v"}` (a container) and
    `val: @json string = "…"` (a scalar's spelling). `@` keeps directives visibly
    distinct from type names, and opens a namespace (`@json` first; future
    `@block`/`@flow`/… style hints could follow).
  - A `node_directives` side-table parallel to `node_comments`/`node_tags`,
    excluded from `eql` (it is trivia, like comments), plus parser recognition of
    `@name` and per-printer honor-or-drop.
  - fig-local by nature: every non-fig format drops it (a render hint has no
    cross-format meaning), so it round-trips through fig + canonical only — the
    same disposition as a dropped tag, NOT carried by `$fig-envelope` until the
    reference-layer envelope work lands.

  Open questions to settle first: what `@json` means on a **scalar** (JSON string
  quoting/escapes vs. a no-op), and whether it **validates** JSON-representability
  (string keys, JSON-legal number lexemes — no `0xFF`/datetime) or is a pure
  spelling directive that best-effort-renders. A whole annotation axis is more
  than this round warrants; the reasoning is captured so it is cheap to pick up.

---

## Grammar

An EBNF for fig is **illustrative, not complete**: like Python or YAML, fig is not
context-free. Depth is a *count* that a stack-based tree-builder resolves, and
several rules (`literal-else-string`, `#`-after-whitespace, suspended regions) are
lexer/strategy rules a CFG cannot state. So the grammar below gives the *shape*;
the "context-sensitive layer" list after it carries the rest.

```ebnf
(* ─── Notation ─────────────────────────────────────────────
   ::=  define     |  alternate     { } zero-or-more
   [ ]  optional   ( ) group        "…" terminal      ε empty
   NL = newline · EOF = end · WS = run of spaces/tabs
   UPPER = lexical token (see Lexical);  lower = grammar rule.   *)

(* ─── Block layer (line-oriented) ─── *)
document    ::= { line NL } [ line ] EOF
line        ::= WS* ( [ prefix WS+ ] body | continuation )
continuation::= "+" [ WS+ comment ]              (* re-runs the last zero-marker `[]` header *)
prefix      ::= markers [ WS* "*" ] | "*"       (* "*" = element role, in key position *)
markers     ::= ">" { WS* ">" }                  (* depth = count of ">"     *)
body        ::= header | assignment | eltail | comment | ε

header      ::= keypath [ WS+ comment ]          (* no "=" → container / section / append header *)
assignment  ::= keypath [ ":" type ] "=" value [ WS+ comment ]
eltail      ::= [ ( [ ":" type ] "=" value ) | value ] [ WS+ comment ]  (* body of a "*" element line *)

keypath     ::= keyseg { "." keyseg }
keyseg      ::= key { index }
index       ::= "[" [ INT ] "]"                  (* [i] address existing · [] append (header-final only) *)
key         ::= BAREKEY | STRING_SQ | STRING_DQ
type        ::= "int" | "float" | "bool" | "string" | "enum" | "char"
             | "datetime" | "date" | "time" | BAREKEY      (* open set *)

(* ─── Values (block RHS) — sniff bare tokens; committed forms error on failure ─── *)
value       ::= NULL | BOOL | NUMBER | DATETIME     (* sniffed bare tokens           *)
             | STRING_SQ | STRING_DQ | mlstring     (* committed: parse-fail = error  *)
             | flow          (* committed IFF matching close is terminal; balanced-then-trailing → BARESTRING *)
             | BARESTRING                            (* fallback — bare tokens & balanced-then-trailing *)
(* enum atoms, char literals & inf/nan are NOT here — explicit-typing-only, recognized only
   when the assignment carries ": enum =" / ": char =" / ": float =". Bare @x / 'a' / inf →
   BARESTRING (a string); a lone "'a'" is a length-1 string until ": char =" reclassifies it. *)

(* ─── Flow mode (recursive; fig-inline ∪ pasted-JSON, NOT JSON5) ─── *)
(* WS here also spans NL and "#" comments (JSONC); trailing "," allowed above.  *)
flow        ::= array | object
array       ::= "[" [ fvalue { "," fvalue } [ "," ] ] "]"
object      ::= figobject | jsonobject          (* separator selects; may not mix *)
figobject   ::= "{" [ figpair  { "," figpair  } [ "," ] ] "}"
jsonobject  ::= "{" [ jsonpair { "," jsonpair } [ "," ] ] "}"
figpair     ::= fkey                 "=" fvalue  (* bare or quoted key *)
jsonpair    ::= ( STRING_SQ | STRING_DQ ) ":" fvalue   (* quoted key required *)
fkey        ::= BAREKEY | STRING_SQ | STRING_DQ
fvalue      ::= NULL | BOOL | NUMBER | DATETIME
             | STRING_SQ | STRING_DQ | flow | BAREVAL
(* NUMSPECIAL is NOT an fvalue — Infinity/NaN sniff to BAREVAL strings, like bare inf/nan. *)

(* ─── Multiline strings (suspend the block layer) ─── *)
mlstring    ::= "'''" NL rawbody "'''"            (* verbatim                 *)
             | '"""' NL escbody '"""'             (* escaped + smart-dedent   *)

(* ─── Lexical tokens ─── *)
comment     ::= "#" { any-but-NL }
NULL        ::= "null"
BOOL        ::= "true" | "false"
NUMBER      ::= INT | FLOAT | HEX | OCT | BIN     (* leading-zero INT is NOT a number → BARESTRING *)
NUMSPECIAL  ::= "inf" | "-inf" | "nan"
            (* ": float =" context ONLY — never bare, in block or flow. JSON5's
               Infinity/NaN spellings are dropped: bare in flow they are strings. *)
DATETIME    ::= (* RFC-3339 offset/local datetime · full-date · time *)
STRING_SQ   ::= "'" { any-but-"'" } "'"           (* raw, no escapes          *)
STRING_DQ   ::= '"' { CHAR | ESC } '"'
ESC         ::= "\" ( "n" | "t" | "r" | "\" | '"' | "u" HEX HEX HEX HEX )
IDENT       ::= letter { letter | digit | "_" | "-" }
BAREKEY     ::= IDENT   (* excludes . : = [ * + and WS, and leading > - @ → quote such keys *)
BARESTRING  ::= (* trimmed RHS that matched no typed value; may contain WS *)
BAREVAL     ::= (* bare flow value: runs to the next "," "]" "}" NL (spaces
                   included, trimmed), or a " #" that opens a comment — so
                   [Adam Harris, ...] is two-word strings; sniffed like a block
                   bare token (Infinity/NaN → string) *)
```

### The context-sensitive layer (what EBNF can't carry)

These rules live in the scanner and tree-builder, not the CFG:

1. **Depth is a count; structure is built with a stack.** `markers` yields a
   number; parent/child nesting comes from comparing that number line-to-line
   (off-side style), which is where the **skipped-level error** fires — like
   Python emitting `INDENT`/`DEDENT` before the CFG runs.
2. **Section-header baseline.** After `a.b.c` (or an append header `a.b[]`), `>`
   counts are *relative* to that header's depth; the builder carries a baseline
   offset. A baseline is active until the next zero-marker line (which sets a new
   one, or root); baselines don't stack. A **header-final `[]`** appends a fresh
   element and re-anchors; a **non-final `[]`** (or `[]` in an assignment path)
   means the *last existing* element (TOML array-of-tables) — so
   `containers[].ports[]` appends a port to the *last* container. `[i]` addresses
   an existing element by position. The builder also remembers the most recent
   zero-marker append-header path (cleared by any other zero-marker structural
   line): a `+` continuation line re-runs it verbatim; with nothing to re-run,
   `+` is a `FigDanglingContinuation` error.
3. **Sniff bare tokens; commit on a leading delimiter — quotes by first char,
   brackets by whole shape.** A bare token is typed by whole-RHS sniffing
   (number/bool/null/datetime), else `BARESTRING` — this makes `12 monkeys`, `007`,
   `Yes` strings. A leading quote (`"` `'` / triple) **commits** to the string form
   on the first char (parse failure = hard error, never string fallback). A leading
   `[`/`{` commits to **flow only if its matching close is the final non-comment
   token** of the RHS: a non-closing bracket (`[80, 443`) stays committed and
   errors, but a *balanced* close with trailing content (`[text](url)`, a glob, a
   regex) was never flow and is a **bare string**. (Scan detail: skip over quoted
   spans while matching brackets, stop at the newline; a bracket that doesn't close
   on the line is either multi-line flow or truncation — both handed to the flow
   parser, which errors at EOF.) Enum atoms and `inf`/`nan` are recognized only in
   typed context (`: enum =` / `: float =`), where the annotation reinterprets the
   bare RHS.
4. **`#`-after-whitespace.** `#` opens a comment only at line-start or after WS; a
   `#` glued to non-WS (a URL fragment) is literal. Character-context → lexer.
5. **Suspended regions.** `'''`, `"""`, `[`, `{` switch the lexer *out of*
   line/prefix mode until the matching close; interior newlines carry no markers.
   `mlstring`/`flow` are the re-entry points.
6. **Semantic errors, not syntax errors** (post-parse checks): a committed value
   (leading `[ { " '`/triple) that fails to parse · duplicate keys · `* host =
   a.com` (inline field on an element) · an unquoted bare-key with a structural char
   (`.`/`:`/`=`/`[`) · a flow object mixing `=` and `:` separators, or a bare key
   before `:` · `[]`-as-last-element on an empty sequence · mixing
   `[]`/`[i]` and `*` for one sequence · skipping an index · a container with both
   `key = v` and `*` children · a bare childless container · a scalar-root document
   · an unknown `: type` name. (Full list: "Structural semantic errors".)
7. **Whitespace is free** *except* three load-bearing spots: the marker↔key
   separator, the space before an inline `#`, and inside quotes/multiline.

### Implied implementation shape

1. **Line splitter** — per line: strip cosmetic indent, count `markers`, split off
   the inline comment (honoring the `#`-after-WS rule).
2. **Stack-based block builder** — uses depth counts + header baseline to nest,
   emitting AST `mapping`/`sequence`/`keyvalue` and enforcing the count-based
   errors.
3. **Recursive-descent sub-parsers** — one for `flow` (the recursive EBNF is used
   almost verbatim), one for `mlstring` bodies: the only genuinely context-free
   parts.
4. **Value resolver** — the `literal-else-string` try-typed-then-fallback, per RHS.

Two productions most likely to change once the reader lands: the `element` tail
and the `type` set.

---

## AST fit

Checked against `src/ast/ast.zig`: the authoring dialect is a **subset of what
the AST already represents** — no new `Node.Kind` is required to hold a fig
document. (Expected — canonical is a total bijection with the AST, and fig is a
friendlier surface over the same tree.)

| fig surface | AST node |
|---|---|
| null / bool / string / number | `null_` / `boolean` / `string` / `number` (`raw` verbatim) |
| `mode: enum = atom` (explicit-only) | `extended` `enum_literal` (text = bare name) |
| datetime / date / time | `extended` `offset_datetime` / `local_date` / `local_time` |
| `inf` / `nan` | `extended` `number_special` — same spelling as TOML, reuses its handling |
| block seq (`> *`) **and** flow `[…]` | `sequence` |
| block map, flow `{…}`, dotted header/key | `mapping` + `keyvalue` |
| index addressing `xs[0]` | `sequence` position (indices canonicalize to order) |
| `key: int/float/string/bool = v` (explicit type) | `node_tags` type tag (`Tag.kind`) + verbatim value |
| `#` comments (leading / trailing / dangling) | `node_comments` side-table |

**Comment model is a direct fit.** `NodeComments{leading, trailing, dangling}`
already encodes fig's placement rules exactly: a depth-prefixed `#` line = a
`leading` comment on the next sibling; an end-of-container `#` = `dangling`; a
same-line `#` = `trailing`; a block comment carries `style = .block` and fig
downgrades it to a `#` run on print. Nothing new needed — including the
multiline-opener trailing comment (a `trailing` on the value; a lexer rule, not an
AST one).

**What the AST intentionally drops** (the "lossy at the edges" surface choices):
flow-vs-block, `=` vs `:` in flow, bare vs quoted, single vs triple quotes,
dotted vs nested. Spellings that decode to the same value build identical trees,
so `fig fmt` normalizes them — by design. (Explicit *type* annotations are **not**
in this list: they persist as `node_tags` and round-trip — see AST fit #2.)

**Optional accommodations (none blocking):**

1. To *preserve* a user's flow-vs-block / quote choice through `fig fmt`, add a
   style-hint side-table (mirroring `node_comments`/`node_tags`). Recommend
   **not** — normalize instead.
2. **Round-tripping explicit type annotations — DONE (`node_tags`).** The
   annotation is stored as a cross-format **type tag** in the generalized
   `node_tags` side-table (`ast.Tag`, a two-arm union: a normalized
   `kind: KindTag` — the companion to `Node.Kind`, so a fig `: int =`, a YAML
   `!!int`, and a canonical `!!int` all decode to the same value — or a verbatim
   `text` for a format tag with no core meaning, e.g. a YAML custom `!foo`). fig
   emits/reads the `kind` arm (`int/float/string/bool`); YAML keeps emitting its
   verbatim `text` tags but its printer + materializer also understand `kind`, so
   the mapping is **bidirectional and core tags round-trip both ways**:
   - *fig→YAML* — a fig `: int =` (a `.kind` tag) prints as `!!int`.
   - *YAML→fig*`materialize` (the leaving-YAML pass) applies a core `!!int`/
     `!!str`/`!!float`/`!!bool`/`!!null` scalar tag to the node's kind AND keeps
     its identity as a normalized `.kind` tag, which the fig printer re-surfaces
     as `: int =` (and the canonical oracle as `!!int`); JSON/TOML/ZON ignore it
     (the value is already concrete). So
     `fig → yaml → fig` preserves the `: type` surface, not just the value.

   The value's verbatim lexeme is kept alongside (the coercion normalizers now
   only *validate*). This reverses the earlier "accept the loss" recommendation.
   `node_tags` is no longer YAML-only — it is the AST's cross-format type-tag
   layer. (Scope: fig + canonical/YAML; the C ABI / Rust bindings have no tag
   consumer and are untouched.) Edges: a preserved non-canonical lexeme (`09`,
   `1.`) is exact through fig↔fig (and fig↔YAML), but a strict target
   (JSON/canonical re-lex) may reject it; fig can't spell a YAML custom `text` tag,
   so it drops on output to fig; and a YAML `!!seq`/`!!map` collection tag is
   validated but not carried (fig has no `: seq`/`: map` annotation).
3. Cosmetic: the `enum_literal` doc comment in `ast.zig` says "without the leading
   `.`" (ZON's sigil) — note fig's `@` when the reader lands.

---

## Naming & migration — landed for 2.0

All of the following is **done**; build, tests, and `zig build semver-check`
(verdict: major → 2.0.0, already covered by `build.zig.zon`) are green.

- **Module:** `src/native/``src/canonical/`; `native.zig``canonical.zig`;
  `root.Native``root.Canonical` (with a deprecated `Native` alias so the
  Diaryx git dep keeps building).
- **AST enum:** `AST.SerializeFormat.native``.canonical`.
- **CLI:** `main.Format.native``.canonical`; new `main.Format.fig` (authoring);
  `--input/--output` accept `canonical` and `fig` (the old `native` token is
  gone); help text updated.
- **Canonical is explicit-only** — it owns **no file extension**. Select it with
  `--input canonical` / `--output canonical`. (It stays the AST oracle: still the
  round-trippable 1:1 encoding, just no longer auto-detected.)
- **`.figl` now routes to the authoring dialect.** (`.figl` is the canonical
  extension; `.fig` remains accepted for back-compat.) Its reader/printer aren't
  built yet, so a `.figl` file (or `--input/--output fig`) errors with a clear
  message:
  *"the fig authoring dialect is not yet implemented (reserved; see DESIGN.md)."*
  The `get` path exits cleanly (`exit 2`, no stack trace); edit/set/check paths
  return `error.FigAuthoringNotImplemented`.
- **C ABI + Rust bindings: untouched by design.** Canonical was never a member of
  the C ABI's `FigFormat` (only json/jsonc/json5/yaml/toml/zon/xml), and the Rust
  `Format` enum mirrors exactly that. So there was nothing to rename there; the
  semver-check C-ABI diff shows no canonical/native delta. If canonical (or the
  authoring dialect) should ever be reachable through the C ABI, that's a
  *feature addition* (`FIG_FORMAT_CANONICAL`), tracked separately below.

### Still open / future

- Add `FIG_FORMAT_CANONICAL` (and eventually a fig-authoring member) to the C ABI
  `FigFormat` if hosts need canonical output through the bindings. Additive →
  MINOR.
- **Landed:** `src/languages/fig/` (reader + `fig fmt` printer), wired into `Language`,
  `AST.SerializeFormat`, and the CLI's `get` path (`--input/--output fig`,
  `.figl` extension — `.fig` still accepted). **Also landed:** the `fig fmt` house-style heuristics
  (spaced markers, fits-or-breaks flow budget, dotted-key collapse, root
  sections with the depth-2 header/hoist rule, `[]`/`+` append groups — see
  "What `fig fmt` normalizes"), the `+` continuation line, the closed-flow-value
  rule, and depth-aware comment attachment at container close. **Also landed:
  teaching-error rendering** — the parser captures a `Diagnostic{code, offset}`
  at the failure site (`parseWithDiagnostic`; the single-pass cursor is the
  position, with `failAt` pinning the few sites that scan past the offender,
  e.g. the `:` of `key: value`), a `describe()` table carries this document's
  name-the-fix messages, and the CLI renders `file:line:col: error: <message>`
  plus the offending line and a caret — `get` exits 2 cleanly, `check` prints
  the report in place of the generic `file: ErrorName` line. The flow-object
  bare-key-`:` case got its own code (`FigFlowBareKeyColon`) so the message can
  name both fixes (`key = 1` fig / `"key": 1` JSON). **Also landed: the WARN
  layer**`Warning{code, offset}` collected during the pass and returned via
  `Report`/`parseWithReport` (valid alongside a failure too). Codes: the
  coercion set (`string_looks_like_literal` for `Yes`/`ON`/`TRUE`/`Null`,
  `string_leading_zero` for `007`, `ambiguous_datetime` for a bare *time* only
  (`10:30` — a bare date no longer warns, and a `T`/zone-carrying timestamp
  stays silent), `flow_like_string` (balanced
  well-formed `[…]` + whitespace + trailing content only — glued links/globs
  never warn; well-formedness by speculative sub-parse of the prefix),
  `flow_missing_comma` (a bare flow value swallowing ` = `, the
  `{x = 1 y = 2}` case — an addition beyond the DESIGN warn list), and
  `indent_marker_mismatch` (the mitigation-#1 lint: fires only when indent is
  present and ≠ `2 × depth` spaces; comment lines included, since their depth
  drives attachment). CLI: `get` renders warns under the existing
  `--quiet`/`--strict` contract (strict aborts, exit 2); `check` prints them
  after the `ok` line (still `ok` — warns don't fail the run). The LSP
  publishes them as severity-2 diagnostics alongside the severity-1 error.
  Deliberately NOT warned: `12 monkeys`-style prose with a leading number
  (warn-fatigue on titles; single-token lookalikes only) and the inline-`#`
  truncation (indistinguishable from an ordinary trailing comment — needs a
  better heuristic than DESIGN records). Not yet done: the comment-depth
  mismatch warn (needs comment offsets threaded through `PendingComment`),
  `fig fmt --indent` (the opt-in derived-indentation mode), and
  minimal-quote selection (`'…'` vs `"…"`).

**Landed: the in-place editor.** `fig/parser.zig` now stamps a real source
`Span` on every built node (key spans included; a container's span widens to
its full subtree bottom-up as children are built — see its "AST assembly"
section), which is what `Editor(fig.Language.FIG)` needs to splice edits
in place, same as TOML/YAML/ZON. `fig/editor_helper.zig` holds the fig-only
pieces: block insert/append/prepend copy an existing sibling's marker-prefix
text verbatim (depth + spaced-vs-glued style, no separate bookkeeping — fig's
self-describing lines make this safe to splice after any existing child,
scattered re-entered headers included); flow-object insert matches the
object's own fig-inline (`=`) vs JSON (`:`) pair mode. `edit`/`set`/`insert`/
`delete`/`comment` all work on `.figl` now.

**Landed: whole-container structural ops.** `deleteContainer`/`moveContainer`/
`reorderContainers` — fig's twins of TOML's `deleteTable`/`moveTable`/
`reorderTables` (library-level only, same as TOML's; no dedicated
`renameContainer` — the generic `replaceKeyAtPath` already splices a header's
key in place). Built on `gatherContainerRegions`, the fig generalization of
TOML's bracket-header region-gather: fig has no `[header]` token to grep for,
but ANY block (non-flow) mapping/sequence-valued entry was introduced by SOME
header line regardless of depth, so recursing into every such child and
recovering its header line from the child's own (always-accurate) span
handles fig's TOML-equivalent scattering (fields split across separate dotted
paths interleaved with foreign siblings) for free. The one shape spans alone
can't discover — a header **re-opening** an already-existing container (the
exact same header re-entered verbatim, a dotted path whose final segment
re-selects an existing container, an `xs[i]` header re-opening an element),
whose line sits in no child's span since a container's span anchors only its
CREATING line — is solved exactly rather than heuristically: the parser
records every header-final re-open at its single choke point
(`resolveHeaderFinal`) into a `Document.reentry_headers` side-table (node id
→ header-line position), and the gather folds those lines into the region
set. This matters beyond hand-authored files: `fig fmt`'s grouped hoisting
*emits* verbatim re-entered headers (a second flat-sibling run re-enters its
section header), so delete/move/reorder must handle them, not merely fail
safe. `deleteKey` still refuses a block-container-valued key outright
(`error.CannotDeleteContainer`) — use `deleteContainer` for that. Two
documented residual edges, both fail-safe via the reparse-rollback net:
replacing a re-entered/scattered container's entire value in one
`replaceValAtPath` splice (span-based, not region-aware), and a delete that
leaves an *ancestor* header childless (`FigEmptyContainer` rolls it back; the
cascade delete is deliberately not implied).

## Resolved (this round)

- **Separator split kept and now justified:** `:` introduces an optional type, `=`
  assigns (`key: type = value`). The split earns its keep.
- **Char literals are native (tier 1) via `: char = 'a'`** — promoted from the
  earlier degrade-to-string by the same explicit-typing-is-the-disambiguator
  pattern as enums, so they round-trip fig↔ZON↔canonical losslessly. (Superseded
  the "degrade to string" decision; see "Char literal" and "Deferred / declined
  losslessness".)
- **Dangling comments:** depth-prefixed `#` line attaches to the next sibling; a
  trailing comment in an empty container attaches to the container.
- **Enums explicit-only** (no value sigil; `x: enum = atom`), **`inf`/`nan`
  explicit-only** (`x: float = inf`), **committed values error** (a leading
  quote/triple, or a leading `[`/`{` whose matching close is terminal, that fails to
  parse is not string fallback — but a balanced `[…]`/`{…}` with trailing content,
  e.g. a markdown link, is a bare string), **comment char `#`**,
  **flow mode = fig-inline (`=`) + pasted JSON (`:`)** — separator-selected, no
  mixing, JSONC trailing commas + `#` comments, JSON5 otherwise dropped —
  **two multiline-string flavors**, leading-zero TOML rule,
  inline-field-on-dash is a hard error.
- **Authoring-time diagnostic set** (see that section): foreign-syntax guardrails
  (`key: value`, `[section]`), the indent/count disagreement warn, and unknown
  type name is an error (the `type` set is open in grammar, closed at resolution).

## Resolved (final batch)

- **Enum keys: no** — keys stay strings; keeps the LHS grammar small (and moot now
  that enums are explicit-only).
- **`"""` smart-dedent: automatic** — it is why the flavor exists (Swift/Java both
  landed there).
- **Table sugar: deferred past 1.0** — the hesitation was correct; if it ever
  lands, `fig fmt` detecting uniform lists is the right home. (A list of
  identical-shape scalar records is more compact as a table — one header row of
  keys, one row per element — but degrades the moment a cell holds a map or is
  absent, and real config is rarely that uniform, so append headers stay the
  general answer.)

## Resolved (fmt house-style round)

Settled while implementing the printer heuristics, prompted by converting a
real `Cargo.toml` and comparing against a hand-written target:

- **Marker style: spaced runs, no indentation** (Example 3 of the three
  candidates: indented+contiguous, bare contiguous, spaced). The markers ARE
  the ruler; emitted indentation was demoted from house default to the future
  `--indent` opt-in. Reverses the earlier normalize-to-contiguous rule.
- **The split rule is recursive fits-or-breaks, not a complexity metric.**
  All-or-nothing per node; no partial hoisting of the widest member. The
  "which children contribute most to line width" idea was considered and
  dropped — layout stability and idempotence win.
- **Flow values are closed** (`FigClosedFlowValue`). The 2-line
  `fig = {…}` + `fig.features = […]` split was considered as printer output
  and rejected: TOML forbids extending inline tables for good reason.
- **`+` continuation** landed in reader and printer (dangling `+` errors;
  chain survives blanks/comments; nested headers re-run the whole path).
- **Section shape budget: depth 2.** A root map renders as a header while its
  body stays within two marker levels, hoists into sections beyond that. This
  single number is what routes deep config to `a.b.c` headers and `a.b[]`
  groups automatically.
- **Hoisting groups flat-sibling runs** (follow-up: repetition). Naive
  per-child hoisting turned a run of sibling scalars into N copies of a long
  dotted path (`workspace.metadata.release.push = …` × 8). Fixed with header
  re-entry — ≥2 consecutive flat children share one `workspace.metadata.release`
  header line; deep children still section out after it. No new syntax needed;
  a relative-header sugar (e.g. `.child[]` continuing the previous section's
  path) was noted as a possible future step but not adopted.
- **Comment anchors outrank aesthetics** in fmt: every heuristic (inline,
  collapse, hoist, `[]` groups) backs off to the nested spelling rather than
  drop or re-anchor a comment.
- **Element marker: `*` replaces `-`** (settled, this round). The dash carried
  two disambiguation rules (the `>-` glue against mis-tallying, and the
  one-dash-only rule against the minus sign) that `*` makes unnecessary — it is
  visually distinct from `>` and collides with nothing in prefix position. `*`
  sits in key position (`> *`), making the A2 "anonymous positional key" thesis
  literal; every `-` element spelling is now a `FigForeignSyntaxDash` teaching
  error, which also makes the YAML-habit guardrail uniform (previously `- 25565`
  silently worked while `- k = v` errored). Considered and rejected: making `*`
  count toward depth itself (`*` / `> *` as depth 1/2) — a depth-0 root element
  would be unspellable, or root sequences would need a special deeper baseline
  and seq-of-seqs at root would collide; keeping `*` a role-in-key-position
  marker preserves exact root symmetry with named keys. Typing cost (`*` is
  shifted) accepted; `*` remains reserved for the YAML reference layer in VALUE
  position (tier 3), which never meets the prefix position.

## Open questions

*(None blocking. Remaining items are tuning-level: the comment-depth-mismatch
warn (the rest of the diagnostics layer — errors and warns — is landed and
rendered), `fig fmt --indent`, minimal-quote selection, and the additive
`FIG_FORMAT_CANONICAL` C-ABI member tracked above.)*