aontu 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +2 -2
  2. package/dist/agentsmd.js +1 -1
  3. package/dist/alias.d.ts +3 -0
  4. package/dist/alias.js +59 -0
  5. package/dist/alias.js.map +1 -0
  6. package/dist/aontu.d.ts +5 -2
  7. package/dist/aontu.js +11 -3
  8. package/dist/aontu.js.map +1 -1
  9. package/dist/cli.d.ts +8 -2
  10. package/dist/cli.js +564 -21
  11. package/dist/cli.js.map +1 -1
  12. package/dist/ctx.d.ts +2 -0
  13. package/dist/ctx.js +1 -0
  14. package/dist/ctx.js.map +1 -1
  15. package/dist/escape.d.ts +5 -0
  16. package/dist/escape.js +455 -0
  17. package/dist/escape.js.map +1 -0
  18. package/dist/format.d.ts +9 -0
  19. package/dist/format.js +550 -55
  20. package/dist/format.js.map +1 -1
  21. package/dist/hints.js +74 -9
  22. package/dist/hints.js.map +1 -1
  23. package/dist/lang.js +374 -58
  24. package/dist/lang.js.map +1 -1
  25. package/dist/lower.d.ts +20 -0
  26. package/dist/lower.js +575 -0
  27. package/dist/lower.js.map +1 -0
  28. package/dist/lsp.d.ts +1 -1
  29. package/dist/lsp.js +4 -4
  30. package/dist/lsp.js.map +1 -1
  31. package/dist/mcp-server.js +2 -2
  32. package/dist/mcp-server.js.map +1 -1
  33. package/dist/mcp.d.ts +1 -0
  34. package/dist/mcp.js +40 -3
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/mod-tool.js +8 -7
  37. package/dist/mod-tool.js.map +1 -1
  38. package/dist/mod.js +6 -6
  39. package/dist/mod.js.map +1 -1
  40. package/dist/render.d.ts +53 -0
  41. package/dist/render.js +542 -0
  42. package/dist/render.js.map +1 -0
  43. package/dist/sigdecl.js +1 -1
  44. package/dist/sigdecl.js.map +1 -1
  45. package/dist/std.d.ts +2 -0
  46. package/dist/std.js +498 -2
  47. package/dist/std.js.map +1 -1
  48. package/dist/template.d.ts +5 -0
  49. package/dist/template.js +257 -0
  50. package/dist/template.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/unify.js +43 -0
  53. package/dist/unify.js.map +1 -1
  54. package/dist/val/AggFuncVal.d.ts +1 -1
  55. package/dist/val/AggFuncVal.js +10 -21
  56. package/dist/val/AggFuncVal.js.map +1 -1
  57. package/dist/val/BagVal.js +1 -1
  58. package/dist/val/BagVal.js.map +1 -1
  59. package/dist/val/ConstraintVal.js +1 -1
  60. package/dist/val/EachFuncVal.d.ts +1 -1
  61. package/dist/val/EachFuncVal.js +9 -15
  62. package/dist/val/EachFuncVal.js.map +1 -1
  63. package/dist/val/EmitFuncVal.d.ts +42 -0
  64. package/dist/val/EmitFuncVal.js +531 -0
  65. package/dist/val/EmitFuncVal.js.map +1 -0
  66. package/dist/val/FilterFuncVal.js +9 -6
  67. package/dist/val/FilterFuncVal.js.map +1 -1
  68. package/dist/val/FormFuncVal.d.ts +14 -0
  69. package/dist/val/FormFuncVal.js +55 -0
  70. package/dist/val/FormFuncVal.js.map +1 -0
  71. package/dist/val/FuncBaseVal.d.ts +1 -0
  72. package/dist/val/FuncBaseVal.js +16 -0
  73. package/dist/val/FuncBaseVal.js.map +1 -1
  74. package/dist/val/MapVal.d.ts +2 -1
  75. package/dist/val/MapVal.js +2 -1
  76. package/dist/val/MapVal.js.map +1 -1
  77. package/dist/val/PackFuncVal.d.ts +1 -1
  78. package/dist/val/PackFuncVal.js +23 -20
  79. package/dist/val/PackFuncVal.js.map +1 -1
  80. package/dist/val/PlaceVal.d.ts +3 -1
  81. package/dist/val/PlaceVal.js +9 -6
  82. package/dist/val/PlaceVal.js.map +1 -1
  83. package/dist/val/RefVal.d.ts +3 -0
  84. package/dist/val/RefVal.js +156 -17
  85. package/dist/val/RefVal.js.map +1 -1
  86. package/dist/val/StrFuncVal.d.ts +32 -0
  87. package/dist/val/StrFuncVal.js +292 -0
  88. package/dist/val/StrFuncVal.js.map +1 -0
  89. package/dist/val/Val.d.ts +7 -1
  90. package/dist/val/Val.js +12 -1
  91. package/dist/val/Val.js.map +1 -1
  92. package/dist/val/members.d.ts +9 -0
  93. package/dist/val/members.js +52 -0
  94. package/dist/val/members.js.map +1 -0
  95. package/grammar/aontu.abnf +4 -3
  96. package/grammar/aontu.gbnf +4 -3
  97. package/grammar/aontu.lark +4 -3
  98. package/grammar/aontu.tmLanguage.json +1 -1
  99. package/package.json +1 -1
  100. package/skill/SKILL.md +4 -4
  101. package/skill/error-codes.md +1 -1
  102. package/skill/examples.md +1 -1
  103. package/skill/grammar-card.md +1 -1
  104. package/src/agentsmd.ts +1 -1
  105. package/src/alias.ts +112 -0
  106. package/src/aontu.ts +20 -2
  107. package/src/cli.ts +630 -23
  108. package/src/ctx.ts +13 -0
  109. package/src/escape.ts +371 -0
  110. package/src/format.ts +648 -56
  111. package/src/hints.ts +94 -9
  112. package/src/lang.ts +430 -63
  113. package/src/lower.ts +636 -0
  114. package/src/lsp.ts +4 -4
  115. package/src/mcp-server.ts +3 -2
  116. package/src/mcp.ts +43 -4
  117. package/src/mod-tool.ts +9 -8
  118. package/src/mod.ts +6 -6
  119. package/src/render.ts +727 -0
  120. package/src/sigdecl.ts +1 -1
  121. package/src/std.ts +506 -1
  122. package/src/template.ts +291 -0
  123. package/src/unify.ts +47 -0
  124. package/src/val/AggFuncVal.ts +10 -21
  125. package/src/val/BagVal.ts +1 -1
  126. package/src/val/ConstraintVal.ts +1 -1
  127. package/src/val/EachFuncVal.ts +9 -19
  128. package/src/val/EmitFuncVal.ts +738 -0
  129. package/src/val/FilterFuncVal.ts +12 -7
  130. package/src/val/FormFuncVal.ts +119 -0
  131. package/src/val/FuncBaseVal.ts +18 -0
  132. package/src/val/MapVal.ts +3 -2
  133. package/src/val/PackFuncVal.ts +24 -22
  134. package/src/val/PlaceVal.ts +9 -6
  135. package/src/val/RefVal.ts +167 -18
  136. package/src/val/StrFuncVal.ts +334 -0
  137. package/src/val/Val.ts +42 -1
  138. package/src/val/members.ts +86 -0
package/src/format.ts CHANGED
@@ -13,10 +13,11 @@
13
13
  // trees: a formatter that cannot prove its output is the same document
14
14
  // refuses rather than return it.
15
15
  //
16
- // This is the syntactic tier only (P1): whitespace, commas, quotes,
17
- // bare keys, chains and pair elements, none of which changes the parse
18
- // tree. The lawful tier -- the repeat-the-prefix rewrite that rests on
19
- // the meet -- is P2, and lands behind its own local check.
16
+ // Two tiers. The syntactic (P1): whitespace, commas, quotes, bare
17
+ // keys, chains and pair elements, none of which changes the parse
18
+ // tree. The lawful (P2), over it: repeat the prefix, and merge what
19
+ // repeats -- rewrites that rest on the meet, each checked by the meet
20
+ // in isolation and kept only where the engine agrees.
20
21
  //
21
22
  // The Go twin is go/format.go, function for function; the shared
22
23
  // behaviour is test/spec/fmt.tsv, executed by both spec runners.
@@ -42,6 +43,18 @@ const MAX_DEPTH = 1000
42
43
  export type FormatOptions = {
43
44
  // The file's name, for the site of a parse failure.
44
45
  path?: string
46
+ // Report the style findings of §4 -- key case, repeated shapes --
47
+ // beside the text. The formatter never acts on them.
48
+ lint?: boolean
49
+ }
50
+
51
+ // A style finding (§4): what the formatter points at and never
52
+ // touches. `line` and `col` are 1-based, of the key or the container.
53
+ export type LintFinding = {
54
+ rule: 'style/key-case' | 'style/repeat'
55
+ line: number
56
+ col: number
57
+ message: string
45
58
  }
46
59
 
47
60
  // The self-check, injectable so the refusal it guards can be exercised
@@ -49,10 +62,11 @@ export type FormatOptions = {
49
62
  // takes that arm on its own.
50
63
  export type FormatHooks = {
51
64
  same?: (root: any, after: string) => boolean
65
+ meet?: (before: string, after: string) => boolean
52
66
  }
53
67
 
54
68
  export type FormatReport =
55
- | { verdict: 'formatted', text: string, changed: boolean }
69
+ | { verdict: 'formatted', text: string, changed: boolean, findings: LintFinding[] }
56
70
  | { verdict: 'error', errors: VetFinding[] }
57
71
 
58
72
 
@@ -134,6 +148,10 @@ type Node = {
134
148
  // value; spread: the value.
135
149
  key?: string
136
150
  opt?: boolean
151
+ // pair: written with `=`, the alias declaration operator, rather than
152
+ // a colon. The spelling is the parse's -- a colon after an alias name
153
+ // is a refused document, and the formatter keeps it one.
154
+ alias?: boolean
137
155
  value?: Node
138
156
 
139
157
  // map, list: the entries, and the comment on the opener's line.
@@ -155,6 +173,16 @@ type Node = {
155
173
 
156
174
  // A comment on the last line of this entry.
157
175
  trail?: string
176
+
177
+ // An argument: a comma stood before it (§3.6), rather than a space.
178
+ sep?: boolean
179
+
180
+ // pair: the statements this one replaces, where the lawful tier
181
+ // merged them, or rewrote something below them.
182
+ orig?: Node[]
183
+
184
+ // The source index of the node's first token: the lint's positions.
185
+ at?: number
158
186
  }
159
187
 
160
188
  const BINARY: Record<string, boolean> = { '#E&': true, '#E|': true, '#E+': true }
@@ -300,6 +328,11 @@ class Reader {
300
328
  opener = false
301
329
  gap = false
302
330
  }
331
+ // A blank line before the closer is no paragraph break: nothing
332
+ // follows it, and the layout would drop it anyway.
333
+ while (0 < body.length && 'blank' === body[body.length - 1].t) {
334
+ body.pop()
335
+ }
303
336
  return { body, open }
304
337
  }
305
338
 
@@ -307,20 +340,22 @@ class Reader {
307
340
  // at the root -- a value.
308
341
  entry(): Node {
309
342
  const n = this.name(0)
343
+ const at = this.T[this.i].sI
310
344
  if ('#OD_multisource' === n) {
311
345
  const text = '@' + normStr(this.T[this.i + 1].src)
312
346
  this.i += 2
313
- return { t: 'include', text }
347
+ return { t: 'include', text, at }
314
348
  }
315
349
  if ('#E&' === n && '#CL' === this.name(1)) {
316
350
  this.i += 2
317
- return { t: 'spread', value: this.value() }
351
+ return { t: 'spread', value: this.value(), at }
318
352
  }
319
353
  if (this.atKey()) {
320
354
  const tok = this.T[this.i]
321
355
  const opt = '#QM' === this.name(1)
356
+ const alias = '=' === this.T[this.i + (opt ? 2 : 1)].src
322
357
  this.i += opt ? 3 : 2
323
- return { t: 'pair', key: keyText(tok), opt, value: this.value() }
358
+ return { t: 'pair', key: keyText(tok), opt, alias, value: this.value(), at }
324
359
  }
325
360
  return this.value()
326
361
  }
@@ -349,12 +384,13 @@ class Reader {
349
384
  if (!this.open(items) && !BINARY[n] && '#LN' !== n && '#CM' !== n) {
350
385
  break
351
386
  }
387
+ const at = this.T[this.i].sI
352
388
  if ('#E&' === n && '#CL' === this.name(1)) {
353
389
  if (0 === items.length) {
354
390
  // A chain through a spread, `a: &: integer`. The braces are
355
391
  // the agreed spelling (X-7), so it is read as the map it is.
356
392
  this.i += 2
357
- return { t: 'map', body: [{ t: 'spread', value: this.value() }] }
393
+ return { t: 'map', body: [{ t: 'spread', value: this.value(), at }], at }
358
394
  }
359
395
  // A sibling spread in a list, `[1 &: 2]`: this value is complete.
360
396
  break
@@ -374,7 +410,7 @@ class Reader {
374
410
  // operator, or on a line the value continues past. Otherwise
375
411
  // it trails the statement and the caller attaches it.
376
412
  if (this.open(items) || BINARY[this.name(this.significant())]) {
377
- items.push({ t: 'note', text: this.T[this.i].src })
413
+ items.push({ t: 'note', text: this.T[this.i].src, at })
378
414
  this.i++
379
415
  continue
380
416
  }
@@ -383,13 +419,13 @@ class Reader {
383
419
  if (BINARY[n]) {
384
420
  items.push({
385
421
  t: 'op', text: this.T[this.i].src,
386
- brk: '#LN' === this.name(-1) || '#LN' === this.name(1),
422
+ brk: '#LN' === this.name(-1) || '#LN' === this.name(1), at,
387
423
  })
388
424
  this.i++
389
425
  continue
390
426
  }
391
427
  if (PREFIX[n]) {
392
- items.push({ t: 'prefix', text: this.T[this.i].src })
428
+ items.push({ t: 'prefix', text: this.T[this.i].src, at })
393
429
  this.i++
394
430
  continue
395
431
  }
@@ -397,7 +433,7 @@ class Reader {
397
433
  this.i++
398
434
  const inner = this.seq()
399
435
  this.i++
400
- items.push({ t: 'paren', inner })
436
+ items.push({ t: 'paren', inner, at })
401
437
  continue
402
438
  }
403
439
  if ('#TX' === n && '#E(' === this.name(1)) {
@@ -405,25 +441,25 @@ class Reader {
405
441
  this.i += 2
406
442
  const args = this.seq()
407
443
  this.i++
408
- items.push({ t: 'call', name, args })
444
+ items.push({ t: 'call', name, args, at })
409
445
  continue
410
446
  }
411
447
  if ('#OB' === n) {
412
448
  this.i++
413
449
  const m = this.body('#CB', true)
414
450
  this.i++
415
- items.push({ t: 'map', body: m.body, open: m.open })
451
+ items.push({ t: 'map', body: m.body, open: m.open, at })
416
452
  continue
417
453
  }
418
454
  if ('#OS' === n) {
419
455
  this.i++
420
456
  const l = this.body('#CS', true)
421
457
  this.i++
422
- items.push({ t: 'list', body: l.body, open: l.open })
458
+ items.push({ t: 'list', body: l.body, open: l.open, at })
423
459
  continue
424
460
  }
425
461
  if ('#OD_multisource' === n) {
426
- items.push({ t: 'include', text: '@' + normStr(this.T[this.i + 1].src) })
462
+ items.push({ t: 'include', text: '@' + normStr(this.T[this.i + 1].src), at })
427
463
  this.i += 2
428
464
  continue
429
465
  }
@@ -439,7 +475,8 @@ class Reader {
439
475
  'note' !== items[0].t) {
440
476
  return items[0]
441
477
  }
442
- return { t: 'expr', items }
478
+ // An empty value, `a:`, is an expression with nothing in it.
479
+ return { t: 'expr', items, at: items[0]?.at }
443
480
  }
444
481
 
445
482
  // Whether the expression so far wants an operand: nothing yet, or an
@@ -454,6 +491,7 @@ class Reader {
454
491
 
455
492
  // The token under the cursor, and the parts glued to it.
456
493
  atom(): Node {
494
+ const at = this.T[this.i].sI
457
495
  let text = atomText(this.T[this.i])
458
496
  this.i++
459
497
  while (GLUE[this.name(0)] &&
@@ -461,7 +499,7 @@ class Reader {
461
499
  text += atomText(this.T[this.i])
462
500
  this.i++
463
501
  }
464
- return { t: 'atom', text }
502
+ return { t: 'atom', text, at }
465
503
  }
466
504
 
467
505
  // A call's arguments, or a parenthesis's contents, up to the closing
@@ -470,6 +508,7 @@ class Reader {
470
508
  seq(): Node[] {
471
509
  const out: Node[] = []
472
510
  let gap = true
511
+ let comma = false
473
512
  for (;;) {
474
513
  const n = this.name(0)
475
514
  if ('' === n || CLOSER[n]) {
@@ -481,9 +520,10 @@ class Reader {
481
520
  }
482
521
  if ('#CA' === n) {
483
522
  if (gap) {
484
- out.push({ t: 'atom', text: 'nil' })
523
+ out.push({ t: 'atom', text: 'nil', sep: comma })
485
524
  }
486
525
  gap = true
526
+ comma = true
487
527
  this.i++
488
528
  continue
489
529
  }
@@ -492,8 +532,11 @@ class Reader {
492
532
  this.i++
493
533
  continue
494
534
  }
495
- out.push(this.value())
535
+ const v = this.value()
536
+ v.sep = comma
537
+ out.push(v)
496
538
  gap = false
539
+ comma = false
497
540
  }
498
541
  return out
499
542
  }
@@ -552,6 +595,11 @@ function width(s: string): number {
552
595
  }
553
596
 
554
597
  function pairHead(node: Node, tight: boolean): string {
598
+ // An alias declaration is `%name = value` at every width: the `=` is
599
+ // an operator, and operators are spaced (§3.2).
600
+ if (node.alias) {
601
+ return node.key! + ' = '
602
+ }
555
603
  return node.key! + (node.opt ? '?' : '') + (tight ? ':' : ': ')
556
604
  }
557
605
 
@@ -611,16 +659,23 @@ function inline(node: Node, tight: boolean): string | undefined {
611
659
  }
612
660
  }
613
661
 
662
+ // Arguments on one line, each after the separator the author wrote
663
+ // (§3.6): a comma stays a comma, and a space a space, because the
664
+ // parser reads `must((v) => 0 <= v, "…")` as a run of arguments too.
614
665
  function inlineSeq(items: Node[]): string | undefined {
615
- const parts: string[] = []
616
- for (const it of items) {
617
- const s = inline(it, true)
666
+ let out = ''
667
+ for (let k = 0; k < items.length; k++) {
668
+ const s = inline(items[k], true)
618
669
  if (undefined === s) {
619
670
  return undefined
620
671
  }
621
- parts.push(s)
672
+ out += (0 === k ? '' : sepOf(items[k])) + s
622
673
  }
623
- return parts.join(', ')
674
+ return out
675
+ }
676
+
677
+ function sepOf(node: Node): string {
678
+ return node.sep ? ', ' : ' '
624
679
  }
625
680
 
626
681
  // Binary operators spaced, prefixes tight (§3.11). An operand is
@@ -679,6 +734,26 @@ class Writer {
679
734
  return width(this.line)
680
735
  }
681
736
 
737
+ // Where the page is, and the lines written since, the current line
738
+ // included: the spelling of one statement, as it stands on the page.
739
+ mark(): number {
740
+ return this.lines.length
741
+ }
742
+
743
+ since(mark: number): string {
744
+ return this.lines.slice(mark).concat([this.line]).map(rtrim).join('\n') + '\n'
745
+ }
746
+
747
+ // The lines since a mark replaced by a text: the spelling before,
748
+ // where a rewrite did not pass its check.
749
+ replace(mark: number, text: string): void {
750
+ const lines = text.split('\n')
751
+ lines.pop()
752
+ this.line = lines.pop()!
753
+ this.lines.length = mark
754
+ this.lines.push(...lines)
755
+ }
756
+
682
757
  finish(): string {
683
758
  if (!this.started) {
684
759
  return ''
@@ -697,8 +772,11 @@ function rtrim(s: string): string {
697
772
 
698
773
  // The entries of a body, one per line at the indentation, with the
699
774
  // blank lines the author kept between them (§3.8) -- never at the
700
- // start or the end.
701
- function emitBody(w: Writer, body: Node[], indent: number): void {
775
+ // start or the end. In STATEMENT position (`stmt`: the root, and the
776
+ // body of a plain map that is itself the value of a statement) a pair
777
+ // is laid out by §3.4, which may repeat its key; anywhere else -- a
778
+ // list, an operand, an argument -- by §3.5 alone.
779
+ function emitBody(w: Writer, body: Node[], indent: number, stmt: Stmt | undefined): void {
702
780
  let pending = false
703
781
  let count = 0
704
782
  for (const node of body) {
@@ -713,6 +791,10 @@ function emitBody(w: Writer, body: Node[], indent: number): void {
713
791
  w.text(node.text!)
714
792
  continue
715
793
  }
794
+ if (undefined !== stmt && 'pair' === node.t) {
795
+ emitStatement(w, node, indent, stmt, '')
796
+ continue
797
+ }
716
798
  const e = chain(node)
717
799
  emitValue(w, e, indent)
718
800
  if (undefined !== e.trail) {
@@ -745,10 +827,10 @@ function emitValue(w: Writer, node: Node, indent: number): void {
745
827
  emitValue(w, node.value!, indent)
746
828
  return
747
829
  case 'map':
748
- emitBlock(w, '{', '}', node, indent)
830
+ emitBlock(w, '{', '}', node, indent, undefined)
749
831
  return
750
832
  case 'list':
751
- emitBlock(w, '[', ']', node, indent)
833
+ emitBlock(w, '[', ']', node, indent, undefined)
752
834
  return
753
835
  case 'expr':
754
836
  emitExpr(w, node.items!, indent)
@@ -763,27 +845,36 @@ function emitValue(w: Writer, node: Node, indent: number): void {
763
845
  }
764
846
 
765
847
  // A call, or a parenthesis, that has no one-line form or is too wide
766
- // for the budget. Three shapes. A single container argument hugs the
767
- // parentheses, `close({` ... `})`, and decides its own lines. Arguments
768
- // that each have a one-line form stay on the one line however wide it
769
- // is: the formatter never breaks a line. Otherwise -- an argument that
770
- // is itself several lines, a comment among the arguments -- the
771
- // parenthesis opens a block: one argument per line one level in, the
772
- // closer alone at the opener's level.
848
+ // for the budget. Three shapes. Arguments that are all FLAT -- none
849
+ // holds a container -- stay on the one line however wide it is: a
850
+ // scalar is no narrower on a line of its own, and the formatter never
851
+ // breaks a line. The last argument HUGS the parentheses, `hide({` ...
852
+ // `})`, `close($.E & {` ... `})`, when it is a container, or an
853
+ // expression the author did not break that ends in one, and the
854
+ // arguments before it fit on the opener's line: the container decides
855
+ // its own lines. Otherwise the parenthesis opens a block: one argument
856
+ // per line one level in, the closer alone at the opener's level. A
857
+ // call whose last argument hugs is hugged in turn, `type(close({` ...
858
+ // `}))`: the schema idiom.
773
859
  function emitCall(w: Writer, node: Node, indent: number): void {
774
860
  const items = 'call' === node.t ? node.args! : node.inner!
775
861
  const open = ('call' === node.t ? node.name! : '') + '('
776
- if (1 === items.length && ('map' === items[0].t || 'list' === items[0].t)) {
777
- w.text(open)
778
- emitValue(w, items[0], indent)
779
- w.text(')')
780
- return
781
- }
782
862
  const one = inlineSeq(items)
783
- if (undefined !== one) {
863
+ if (undefined !== one && !items.some(holdsContainer)) {
784
864
  w.text(open + one + ')')
785
865
  return
786
866
  }
867
+ const last = items[items.length - 1]
868
+ if (0 < items.length && hugs(last)) {
869
+ const head = inlineSeq(items.slice(0, -1))
870
+ const lead = '' === head ? '' : head + sepOf(last)
871
+ if (undefined !== head && ('' === head || w.width() + width(open + lead) <= BUDGET)) {
872
+ w.text(open + lead)
873
+ emitValue(w, last, indent)
874
+ w.text(')')
875
+ return
876
+ }
877
+ }
787
878
  w.text(open)
788
879
  let noted = false
789
880
  for (let k = 0; k < items.length; k++) {
@@ -804,7 +895,8 @@ function emitCall(w: Writer, node: Node, indent: number): void {
804
895
  }
805
896
  w.open(indent + 2, false)
806
897
  emitValue(w, it, indent + 2)
807
- if (items.slice(k + 1).some((x) => 'note' !== x.t)) {
898
+ const next = items.slice(k + 1).find((x) => 'note' !== x.t)
899
+ if (undefined !== next && next.sep) {
808
900
  w.text(',')
809
901
  }
810
902
  noted = false
@@ -813,10 +905,45 @@ function emitCall(w: Writer, node: Node, indent: number): void {
813
905
  w.text(')')
814
906
  }
815
907
 
908
+ // Whether a node holds a container anywhere: the argument has a
909
+ // several-line form of its own.
910
+ function holdsContainer(node: Node): boolean {
911
+ switch (node.t) {
912
+ case 'map':
913
+ case 'list':
914
+ return true
915
+ case 'call':
916
+ return node.args!.some(holdsContainer)
917
+ case 'paren':
918
+ return node.inner!.some(holdsContainer)
919
+ case 'expr':
920
+ return node.items!.some(holdsContainer)
921
+ default:
922
+ return false
923
+ }
924
+ }
925
+
926
+ // Whether a last argument hugs the parentheses: a container; an
927
+ // expression with no break and no comment whose last operand is one;
928
+ // a call whose own last argument does.
929
+ function hugs(node: Node): boolean {
930
+ if ('map' === node.t || 'list' === node.t) {
931
+ return true
932
+ }
933
+ if ('call' === node.t) {
934
+ return 0 < node.args!.length && hugs(node.args![node.args!.length - 1])
935
+ }
936
+ return 'expr' === node.t &&
937
+ node.items!.every((it) => 'note' !== it.t && !('op' === it.t && it.brk)) &&
938
+ hugs(node.items![node.items!.length - 1])
939
+ }
940
+
816
941
  // A container on several lines (§3.5): the opener ends its line, the
817
942
  // entries are statements one level in, the closer stands alone. An
818
943
  // empty container is inline whatever the budget says.
819
- function emitBlock(w: Writer, open: string, close: string, node: Node, indent: number): void {
944
+ function emitBlock(
945
+ w: Writer, open: string, close: string, node: Node, indent: number, stmt: Stmt | undefined
946
+ ): void {
820
947
  if (0 === node.body!.length && undefined === node.open) {
821
948
  w.text(open + close)
822
949
  return
@@ -825,7 +952,7 @@ function emitBlock(w: Writer, open: string, close: string, node: Node, indent: n
825
952
  if (undefined !== node.open) {
826
953
  w.text(' ' + node.open)
827
954
  }
828
- emitBody(w, node.body!, indent + 2)
955
+ emitBody(w, node.body!, indent + 2, stmt)
829
956
  w.open(indent, false)
830
957
  w.text(close)
831
958
  }
@@ -882,13 +1009,443 @@ function emitExpr(w: Writer, items: Node[], indent: number): void {
882
1009
  }
883
1010
  }
884
1011
 
885
- function emit(root: Node[]): string {
1012
+
1013
+ // ---------------------------------------------------------------------
1014
+ // The lawful tier (§3.4): repeat the prefix, and merge what repeats.
1015
+ //
1016
+ // Both rewrites rest on the meet. `s: a: 1` / `s: b: 2` is one document
1017
+ // with `s: { a:1 b:2 }`, because a key written twice is a meet and the
1018
+ // meet of two maps with disjoint keys is their union. So they apply
1019
+ // only to a PLAIN map in STATEMENT position -- an entry of the root, or
1020
+ // of a map that is itself the plain value of such an entry -- and never
1021
+ // to a map that is an operand, an argument or a list element, where
1022
+ // splitting it would change the document (`close({a:1})` /
1023
+ // `close({b:2})` does not evaluate at all). And every statement the
1024
+ // tier rewrites is checked by unification, locally (§7.3): the spelling
1025
+ // before and the spelling after must come to the same meet, or the
1026
+ // statement keeps the spelling before. The check is the engine's
1027
+ // agreement, not the formatter's self-check -- the engine's own repros
1028
+ // hold maps whose two spellings it evaluates differently -- so failing
1029
+ // it is no refusal.
1030
+
1031
+ // The check of one rewrite: the spelling before and the spelling after.
1032
+ type Meet = (before: string, after: string) => boolean
1033
+
1034
+ // Statement position: the check, and whether the statement being laid
1035
+ // out stands inside one that is checked as a whole, which covers it.
1036
+ // Undefined anywhere else -- a list, an operand, an argument.
1037
+ type Stmt = { meet: Meet, covered: boolean }
1038
+
1039
+ // The entries of a plain map value: a braced map, or a chain, which is
1040
+ // a one-entry map. A map with a comment on its opener keeps its braces
1041
+ // (§3.7), so it is not plain here; nor is a map holding an include,
1042
+ // which the local check cannot follow.
1043
+ function plainEntries(v: Node): Node[] | undefined {
1044
+ if ('pair' === v.t) {
1045
+ return [v]
1046
+ }
1047
+ if ('map' !== v.t || undefined !== v.open || v.body!.some((e) => 'include' === e.t)) {
1048
+ return undefined
1049
+ }
1050
+ return v.body
1051
+ }
1052
+
1053
+ // The entries of a statement as they stand once it is merged into a
1054
+ // wider map: its trailing comment sunk onto its last entry, so that it
1055
+ // travels with the entry it stood beside. Undefined where the value is
1056
+ // not a plain map, or the comment has no entry to sit on.
1057
+ function members(p: Node): Node[] | undefined {
1058
+ const entries = plainEntries(p.value!)
1059
+ if (undefined === entries || undefined === p.trail) {
1060
+ return entries
1061
+ }
1062
+ const last = entries[entries.length - 1]
1063
+ if (undefined === last || ('pair' !== last.t && 'spread' !== last.t)) {
1064
+ return undefined
1065
+ }
1066
+ const trail = undefined === last.trail ? p.trail : last.trail + ' ' + p.trail
1067
+ return entries.slice(0, -1).concat([{ ...last, trail }])
1068
+ }
1069
+
1070
+ // Adjacent statements naming one key, whose values are plain maps, are
1071
+ // one map: their entries in order, with the comments and blank lines
1072
+ // between the statements travelling with the statement they preceded.
1073
+ // Only ADJACENT statements merge -- a `server:` line, something else,
1074
+ // then another `server:` line stays as it is, because merging them
1075
+ // would move a statement, and the formatter never reorders (§3.13).
1076
+ // Nor do two statements merge into a map with two spreads: the engine
1077
+ // keeps those as a conjunction, which is not the meet of the two maps.
1078
+ // The tree is not changed: a merged statement is a new node that keeps
1079
+ // the statements it replaces as its `orig`, its spelling before, and a
1080
+ // statement merged somewhere below is copied the same way.
1081
+ function mergeRuns(body: Node[]): Node[] {
1082
+ const out: Node[] = []
1083
+ let i = 0
1084
+ while (i < body.length) {
1085
+ const first = body[i]
1086
+ const entries = 'pair' === first.t ? members(first) : undefined
1087
+ if (undefined === entries) {
1088
+ out.push('pair' === first.t ? mergeDeep(first) : first)
1089
+ i++
1090
+ continue
1091
+ }
1092
+ const group = [first]
1093
+ let merged = entries
1094
+ let carry: Node[] = []
1095
+ let j = i + 1
1096
+ for (; j < body.length; j++) {
1097
+ const n = body[j]
1098
+ if ('comment' === n.t || 'blank' === n.t) {
1099
+ carry.push(n)
1100
+ continue
1101
+ }
1102
+ const more = 'pair' === n.t && n.key === first.key && n.opt === first.opt
1103
+ ? members(n) : undefined
1104
+ if (undefined === more || (spreads(merged) && spreads(more))) {
1105
+ break
1106
+ }
1107
+ group.push(...carry, n)
1108
+ merged = merged.concat(carry, more)
1109
+ carry = []
1110
+ }
1111
+ if (1 === group.length) {
1112
+ out.push(mergeDeep(first))
1113
+ i++
1114
+ continue
1115
+ }
1116
+ out.push({
1117
+ t: 'pair', key: first.key, opt: first.opt, alias: first.alias,
1118
+ value: { t: 'map', body: mergeRuns(merged) }, orig: group,
1119
+ })
1120
+ i = j - carry.length
1121
+ }
1122
+ return out
1123
+ }
1124
+
1125
+ function spreads(entries: Node[]): boolean {
1126
+ return entries.some((e) => 'spread' === e.t)
1127
+ }
1128
+
1129
+ // The merge down a statement's plain-map spine: a chain's inner pair,
1130
+ // or the entries of a map value, are statements of the map they are
1131
+ // in. The statement itself where nothing below it merged.
1132
+ function mergeDeep(p: Node): Node {
1133
+ const v = p.value!
1134
+ const entries = plainEntries(v)
1135
+ if (undefined === entries) {
1136
+ return p
1137
+ }
1138
+ const body = mergeRuns(entries)
1139
+ if (body.length === entries.length && body.every((n, k) => n === entries[k])) {
1140
+ return p
1141
+ }
1142
+ return { ...p, value: 'pair' === v.t ? body[0] : { ...v, body }, orig: [p] }
1143
+ }
1144
+
1145
+ // The lines of a map repeated under a prefix (§3.4, rule 2): every
1146
+ // entry written with the prefix in front of it as one line, or --
1147
+ // where an entry's value is a map that does not fit -- repeated further
1148
+ // under the longer prefix. Comments and blank lines are kept where
1149
+ // they stood. Undefined where an entry cannot be one line: a list that
1150
+ // does not fit, a value that spans lines, a comment closing the map
1151
+ // (which a repeat could not keep in the map) -- and where the map holds
1152
+ // two spreads, which repeated would be two maps, and a different meet.
1153
+ type Line = { t: 'text' | 'comment' | 'blank', text?: string }
1154
+
1155
+ function repeatLines(entries: Node[], prefix: string, indent: number): Line[] | undefined {
1156
+ if (0 === entries.length || 'comment' === entries[entries.length - 1].t ||
1157
+ 1 < entries.filter((e) => 'spread' === e.t).length) {
1158
+ return undefined
1159
+ }
1160
+ const out: Line[] = []
1161
+ for (const e of entries) {
1162
+ if ('blank' === e.t) {
1163
+ out.push({ t: 'blank' })
1164
+ continue
1165
+ }
1166
+ if ('comment' === e.t) {
1167
+ out.push({ t: 'comment', text: e.text })
1168
+ continue
1169
+ }
1170
+ const trail = undefined === e.trail ? '' : ' ' + e.trail
1171
+ if ('spread' === e.t) {
1172
+ // The repeated spread entry is a one-entry map holding only a
1173
+ // spread, so by D1's exception it keeps its braces.
1174
+ const s = inline(e.value!, true)
1175
+ if (undefined === s || !fits(indent, prefix + '{ &: ' + s + ' }')) {
1176
+ return undefined
1177
+ }
1178
+ out.push({ t: 'text', text: prefix + '{ &: ' + s + ' }' + trail })
1179
+ continue
1180
+ }
1181
+ const head = prefix + pairHead(e, false)
1182
+ const s = inline(chain(e.value!), false)
1183
+ if (undefined !== s && fits(indent, head + s)) {
1184
+ out.push({ t: 'text', text: head + s + trail })
1185
+ continue
1186
+ }
1187
+ const sub = plainEntries(e.value!)
1188
+ if (undefined === sub) {
1189
+ return undefined
1190
+ }
1191
+ const lines = repeatLines(sub, head, indent)
1192
+ if (undefined === lines) {
1193
+ return undefined
1194
+ }
1195
+ if ('' !== trail) {
1196
+ lines[lines.length - 1].text += trail
1197
+ }
1198
+ out.push(...lines)
1199
+ }
1200
+ return out
1201
+ }
1202
+
1203
+ function fits(indent: number, text: string): boolean {
1204
+ return indent + width(text) <= BUDGET
1205
+ }
1206
+
1207
+ // A pair in statement position, by §3.4. `prefix` is what stands
1208
+ // before it on its line: the heads of the chain it hangs from, not yet
1209
+ // written. Its value is laid out by §3.5 unless it is a plain map, and
1210
+ // then in this order: a chain, when the map holds exactly one pair
1211
+ // (D1); one line, when that fits the budget; the key repeated over the
1212
+ // entries, when every entry can be one line that way; a braced block
1213
+ // otherwise, whose entries are statements in turn. Whether the
1214
+ // statement was rewritten by this tier -- merged, or repeated -- is
1215
+ // returned, and the outermost such statement is checked: its spelling
1216
+ // on the page against what the syntactic tier writes for the
1217
+ // statements it came from, at the same indentation, which is what
1218
+ // stays on the page when the check fails.
1219
+ function emitStatement(w: Writer, p: Node, indent: number, stmt: Stmt, prefix: string): boolean {
1220
+ const mark = w.mark()
1221
+ let rewritten = undefined !== p.orig
1222
+ const entries = plainEntries(p.value!)
1223
+ const head = prefix + pairHead(p, false)
1224
+ const s = undefined === entries ? undefined : inline(p.value!, false)
1225
+ if (undefined === entries) {
1226
+ w.text(prefix)
1227
+ emitValue(w, p, indent)
1228
+ }
1229
+ else if (1 === entries.length && 'pair' === entries[0].t) {
1230
+ rewritten = emitStatement(w, entries[0], indent, { meet: stmt.meet, covered: true }, head)
1231
+ || rewritten
1232
+ }
1233
+ else if (undefined !== s && fits(indent, head + s)) {
1234
+ w.text(head + s)
1235
+ }
1236
+ else {
1237
+ const lines = repeatLines(entries, head, indent)
1238
+ if (undefined !== lines) {
1239
+ let pending = false
1240
+ let count = 0
1241
+ for (const line of lines) {
1242
+ if ('blank' === line.t) {
1243
+ pending = 0 < count
1244
+ continue
1245
+ }
1246
+ if (0 < count) {
1247
+ w.open(indent, pending)
1248
+ }
1249
+ pending = false
1250
+ count++
1251
+ w.text(line.text!)
1252
+ }
1253
+ rewritten = true
1254
+ }
1255
+ else {
1256
+ w.text(head)
1257
+ emitBlock(w, '{', '}', p.value!, indent, { meet: stmt.meet, covered: stmt.covered || rewritten })
1258
+ }
1259
+ }
1260
+ if (undefined !== p.trail) {
1261
+ w.text(' ' + p.trail)
1262
+ }
1263
+ if (rewritten && !stmt.covered) {
1264
+ const before = emitAt(p.orig ?? [p], indent)
1265
+ if (!stmt.meet(before, w.since(mark))) {
1266
+ w.replace(mark, before)
1267
+ }
1268
+ }
1269
+ return rewritten
1270
+ }
1271
+
1272
+ // The syntactic tier's spelling of some statements at an indentation:
1273
+ // a rewrite's spelling before.
1274
+ function emitAt(nodes: Node[], indent: number): string {
1275
+ const w = new Writer()
1276
+ emitBody(w, nodes, indent, undefined)
1277
+ return w.finish()
1278
+ }
1279
+
1280
+ // The document: by the syntactic tier alone, or with the lawful tier
1281
+ // over it when given its check.
1282
+ function emit(root: Node[], meet: Meet | undefined): string {
886
1283
  const w = new Writer()
887
- emitBody(w, root, 0)
1284
+ emitBody(w, undefined === meet ? root : mergeRuns(root), 0,
1285
+ undefined === meet ? undefined : { meet, covered: false })
888
1286
  return w.finish()
889
1287
  }
890
1288
 
891
1289
 
1290
+ // ---------------------------------------------------------------------
1291
+ // The lint (§4): what the formatter points at and never touches. Two
1292
+ // rules, both advice: the formatter never renames a key (§4.1) and
1293
+ // never introduces an alias (§4.2), and a rule with a mechanical fix
1294
+ // that keeps the document would belong to §3 instead (§4.3).
1295
+
1296
+ // The shape width at which a repeat is worth an alias (§4.2): below
1297
+ // it, `{ a:1 }` twice is the shorter spelling. Measured over the use
1298
+ // cases when the lint landed (§7.10).
1299
+ const REPEAT_MIN_WIDTH = 40
1300
+
1301
+ function lintOf(root: Node[], text: string): LintFinding[] {
1302
+ const out: LintFinding[] = []
1303
+ const nodes = root.map(lintNode)
1304
+ for (const n of nodes) {
1305
+ keyCase(n, text, out)
1306
+ }
1307
+ repeats(nodes, text, out)
1308
+ out.sort((a, b) => a.line - b.line || a.col - b.col)
1309
+ return out
1310
+ }
1311
+
1312
+ // The tree the lint walks: a chain's inner pair as the one-entry map
1313
+ // it is, so that `a: {b: 1}` and `a: b: 1` -- one document to the
1314
+ // formatter -- are one shape to the lint.
1315
+ function lintNode(node: Node): Node {
1316
+ if ('pair' === node.t && 'pair' === node.value!.t) {
1317
+ return { ...node, value: { t: 'map', body: [node.value!], at: node.value!.at } }
1318
+ }
1319
+ return node
1320
+ }
1321
+
1322
+ function lintChildren(node: Node): Node[] {
1323
+ switch (node.t) {
1324
+ case 'pair':
1325
+ case 'spread':
1326
+ return [lintNode(node).value!]
1327
+ case 'map':
1328
+ case 'list':
1329
+ return node.body!.map(lintNode)
1330
+ case 'call':
1331
+ return node.args!
1332
+ case 'paren':
1333
+ return node.inner!
1334
+ case 'expr':
1335
+ return node.items!
1336
+ default:
1337
+ return []
1338
+ }
1339
+ }
1340
+
1341
+ // Line and column, 1-based, of a source index.
1342
+ function lineCol(text: string, at: number): { line: number, col: number } {
1343
+ const before = text.slice(0, at)
1344
+ return { line: before.split('\n').length, col: at - before.lastIndexOf('\n') }
1345
+ }
1346
+
1347
+ // D4 (§4.1): keys are lower-case words, or CamelCase when a key is
1348
+ // several. A bare key holding `_`, or beginning with two capitals, is
1349
+ // reported with the spelling that would follow the form; a quoted key
1350
+ // is a deliberate spelling and a key of underscores alone names
1351
+ // nothing the rule can respell.
1352
+ function keyCase(node: Node, text: string, out: LintFinding[]): void {
1353
+ if ('pair' === node.t && BARE.test(node.key!) && /[A-Za-z]/.test(node.key!)) {
1354
+ const why = node.key!.includes('_') ? 'holds an underscore'
1355
+ : /^[A-Z][A-Z]/.test(node.key!) ? 'begins with capitals' : ''
1356
+ if ('' !== why) {
1357
+ out.push({
1358
+ rule: 'style/key-case', ...lineCol(text, node.at!),
1359
+ message: `key ${node.key} ${why}; ${camel(node.key!)} would follow the form`,
1360
+ })
1361
+ }
1362
+ }
1363
+ for (const child of lintChildren(node)) {
1364
+ keyCase(child, text, out)
1365
+ }
1366
+ }
1367
+
1368
+ // The key as lower-case words or CamelCase: `credit_cents` is
1369
+ // `creditCents`, `HTTP_PORT` is `httpPort`, `HTTPServer` is
1370
+ // `httpServer`, `ID` is `id`.
1371
+ function camel(key: string): string {
1372
+ const words = key.split('_').filter((w) => '' !== w)
1373
+ .map((w) => /^[A-Z]+$/.test(w) ? w.toLowerCase() : w)
1374
+ const head = words[0].replace(/^[A-Z]+(?=[A-Z][a-z])/, (run) => run.toLowerCase())
1375
+ return head.charAt(0).toLowerCase() + head.slice(1) +
1376
+ words.slice(1).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')
1377
+ }
1378
+
1379
+ // D3 (§4.2): a shape written twice can drift, and an alias names it
1380
+ // once. Every map or list whose shape recurs in the file, and whose
1381
+ // shape is REPEAT_MIN_WIDTH or wider, is reported once, at its first
1382
+ // site, with the count and the other sites; the naming is the
1383
+ // author's. A repeat inside a repeat is the outer one's: the walk does
1384
+ // not descend into a shape it reports.
1385
+ function repeats(nodes: Node[], text: string, out: LintFinding[]): void {
1386
+ const counts = new Map<string, number>()
1387
+ const tally = (node: Node): void => {
1388
+ if ('map' === node.t || 'list' === node.t) {
1389
+ const s = shape(node)
1390
+ counts.set(s, (counts.get(s) ?? 0) + 1)
1391
+ }
1392
+ lintChildren(node).forEach(tally)
1393
+ }
1394
+ nodes.forEach(tally)
1395
+ const sites = new Map<string, Node[]>()
1396
+ const visit = (node: Node): void => {
1397
+ if ('map' === node.t || 'list' === node.t) {
1398
+ const s = shape(node)
1399
+ if (2 <= counts.get(s)! && REPEAT_MIN_WIDTH <= width(s)) {
1400
+ sites.set(s, (sites.get(s) ?? []).concat([node]))
1401
+ return
1402
+ }
1403
+ }
1404
+ lintChildren(node).forEach(visit)
1405
+ }
1406
+ nodes.forEach(visit)
1407
+ for (const found of sites.values()) {
1408
+ if (2 <= found.length) {
1409
+ const [first, ...rest] = found.map((n) => lineCol(text, n.at!))
1410
+ out.push({
1411
+ rule: 'style/repeat', ...first,
1412
+ message: `this ${found[0].t} is written ${found.length} times (again at ` +
1413
+ rest.map((p) => p.line + ':' + p.col).join(', ') +
1414
+ '); an alias would name it once',
1415
+ })
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ // A node's shape: its spelling with the layout, the comments and, for
1421
+ // a map, the order of its entries taken out, so that two spellings of
1422
+ // one value are one shape, as they are one canon.
1423
+ function shape(node: Node): string {
1424
+ switch (node.t) {
1425
+ case 'map':
1426
+ return '{' + node.body!.filter(shaped).map((e) => shape(lintNode(e))).sort().join(' ') + '}'
1427
+ case 'list':
1428
+ return '[' + node.body!.filter(shaped).map((e) => shape(lintNode(e))).join(' ') + ']'
1429
+ case 'pair':
1430
+ return node.key! + (node.opt ? '?' : '') + ':' + shape(node.value!)
1431
+ case 'spread':
1432
+ return '&:' + shape(node.value!)
1433
+ case 'call':
1434
+ return node.name! + '(' + node.args!.filter(shaped).map(shape).join(',') + ')'
1435
+ case 'paren':
1436
+ return '(' + node.inner!.filter(shaped).map(shape).join(',') + ')'
1437
+ case 'expr':
1438
+ return node.items!.filter(shaped).map(shape).join('')
1439
+ default:
1440
+ return node.text!
1441
+ }
1442
+ }
1443
+
1444
+ function shaped(node: Node): boolean {
1445
+ return 'comment' !== node.t && 'blank' !== node.t && 'note' !== node.t
1446
+ }
1447
+
1448
+
892
1449
  // ---------------------------------------------------------------------
893
1450
  // The verb's library surface
894
1451
 
@@ -897,13 +1454,42 @@ function lf(text: string): string {
897
1454
  }
898
1455
 
899
1456
  // The check: the output parses, and to the same tree. Pre-unification
900
- // canon is that tree, positions aside, and every rewrite of this tier
901
- // leaves it unchanged (§7.3).
1457
+ // canon is that tree, positions aside, and every rewrite of the
1458
+ // syntactic tier leaves it unchanged (§7.3).
902
1459
  function sameDocument(root: any, after: string): boolean {
903
1460
  const p = parseDoc(after, undefined, undefined)
904
1461
  return undefined === p.errors && root.canon === p.root.canon
905
1462
  }
906
1463
 
1464
+ // The check of a lawful rewrite: the spelling before and the spelling
1465
+ // after, evaluated in isolation, come to the same canon, the same
1466
+ // kinds of failure, and the same outcome of generation (§7.3). Local,
1467
+ // so it needs no include and no capability, and it applies whether or
1468
+ // not the document as a whole evaluates. The kinds, not the count: how
1469
+ // often one unresolved reference is reported depends on the order the
1470
+ // meet took. Generation too, because the engine generates from more
1471
+ // than the canon: a meet of maps with a nil member has refused a key
1472
+ // the same map written once generates.
1473
+ function sameByMeet(before: string, after: string): boolean {
1474
+ return meetOf(before) === meetOf(after)
1475
+ }
1476
+
1477
+ function meetOf(text: string): string {
1478
+ const aontu = engine()
1479
+ const ctx = aontu.ctx({ collect: true })
1480
+ const v: any = aontu.unify(text, undefined, ctx)
1481
+ const gen = aontu.ctx({ collect: true })
1482
+ const out = aontu.generate(text, undefined, gen)
1483
+ const outcome = undefined !== out ? 'generated'
1484
+ : 0 < ctx.err.length ? kinds(ctx.err) : gen.err[0].why
1485
+ return v.canon + '\n' + kinds(ctx.err) + '\n' + outcome
1486
+ }
1487
+
1488
+ function kinds(errs: any[]): string {
1489
+ const whys: string[] = errs.map((e) => e.why)
1490
+ return whys.filter((x, i) => i === whys.indexOf(x)).sort().join(',')
1491
+ }
1492
+
907
1493
  function depthFinding(): VetFinding {
908
1494
  return {
909
1495
  code: 'max_depth',
@@ -941,19 +1527,25 @@ export function format(src: string, opts?: FormatOptions, hooks?: FormatHooks):
941
1527
  return { verdict: 'error', errors: parsed.errors }
942
1528
  }
943
1529
  const reader = new Reader(toks)
944
- const root = reader.body('', false).body
1530
+ const root = unwrap(reader.body('', false).body)
945
1531
  if (reader.deep) {
946
1532
  return { verdict: 'error', errors: [depthFinding()] }
947
1533
  }
948
- const out = emit(unwrap(root))
1534
+ // The syntactic tier first, checked against the parse tree; then the
1535
+ // lawful tier over it, each rewrite checked by the meet.
1536
+ const plain = emit(root, undefined)
949
1537
  const same = hooks?.same ?? sameDocument
950
- if (!same(parsed.root, out)) {
1538
+ if (!same(parsed.root, plain)) {
951
1539
  return {
952
1540
  verdict: 'error',
953
- errors: [checkFinding(opts?.path, parsed.root.canon, out)],
1541
+ errors: [checkFinding(opts?.path, parsed.root.canon, plain)],
954
1542
  }
955
1543
  }
956
- return { verdict: 'formatted', text: out, changed: out !== src }
1544
+ const out = emit(root, hooks?.meet ?? sameByMeet)
1545
+ return {
1546
+ verdict: 'formatted', text: out, changed: out !== src,
1547
+ findings: opts?.lint ? lintOf(root, text) : [],
1548
+ }
957
1549
  }
958
1550
 
959
1551