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