aontu 0.55.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 (128) hide show
  1. package/README.md +3 -3
  2. package/dist/agentsmd.d.ts +1 -0
  3. package/dist/agentsmd.js +10 -3
  4. package/dist/agentsmd.js.map +1 -1
  5. package/dist/aontu.d.ts +4 -2
  6. package/dist/aontu.js +5 -2
  7. package/dist/aontu.js.map +1 -1
  8. package/dist/cli.d.ts +10 -3
  9. package/dist/cli.js +311 -53
  10. package/dist/cli.js.map +1 -1
  11. package/dist/diff.d.ts +1 -0
  12. package/dist/diff.js +3 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/escape.d.ts +5 -0
  15. package/dist/escape.js +455 -0
  16. package/dist/escape.js.map +1 -0
  17. package/dist/format.d.ts +26 -0
  18. package/dist/format.js +1489 -0
  19. package/dist/format.js.map +1 -0
  20. package/dist/hints.js +39 -1
  21. package/dist/hints.js.map +1 -1
  22. package/dist/jsonschema.d.ts +1 -0
  23. package/dist/jsonschema.js +3 -2
  24. package/dist/jsonschema.js.map +1 -1
  25. package/dist/lang.js +84 -6
  26. package/dist/lang.js.map +1 -1
  27. package/dist/lsp.d.ts +2 -1
  28. package/dist/lsp.js +4 -4
  29. package/dist/lsp.js.map +1 -1
  30. package/dist/mcp-server.js +2 -2
  31. package/dist/mcp-server.js.map +1 -1
  32. package/dist/mcp.js +6 -4
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/mod-tool.js +8 -7
  35. package/dist/mod-tool.js.map +1 -1
  36. package/dist/mod.js +6 -6
  37. package/dist/mod.js.map +1 -1
  38. package/dist/patch.d.ts +1 -0
  39. package/dist/patch.js +1 -0
  40. package/dist/patch.js.map +1 -1
  41. package/dist/query.d.ts +1 -0
  42. package/dist/query.js +4 -3
  43. package/dist/query.js.map +1 -1
  44. package/dist/reach.d.ts +1 -0
  45. package/dist/reach.js +3 -2
  46. package/dist/reach.js.map +1 -1
  47. package/dist/relation.d.ts +1 -0
  48. package/dist/relation.js +3 -2
  49. package/dist/relation.js.map +1 -1
  50. package/dist/sigdecl.js +1 -1
  51. package/dist/sigdecl.js.map +1 -1
  52. package/dist/std.js +2 -1
  53. package/dist/std.js.map +1 -1
  54. package/dist/subsume.d.ts +1 -0
  55. package/dist/subsume.js +3 -2
  56. package/dist/subsume.js.map +1 -1
  57. package/dist/trim.d.ts +1 -0
  58. package/dist/trim.js +3 -2
  59. package/dist/trim.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/dist/type.d.ts +1 -0
  62. package/dist/type.js.map +1 -1
  63. package/dist/utility.d.ts +8 -2
  64. package/dist/utility.js +9 -1
  65. package/dist/utility.js.map +1 -1
  66. package/dist/val/EachFuncVal.js +1 -2
  67. package/dist/val/EachFuncVal.js.map +1 -1
  68. package/dist/val/EmitFuncVal.d.ts +23 -0
  69. package/dist/val/EmitFuncVal.js +261 -0
  70. package/dist/val/EmitFuncVal.js.map +1 -0
  71. package/dist/val/FilterFuncVal.js +1 -2
  72. package/dist/val/FilterFuncVal.js.map +1 -1
  73. package/dist/val/FuncBaseVal.d.ts +1 -0
  74. package/dist/val/FuncBaseVal.js +16 -0
  75. package/dist/val/FuncBaseVal.js.map +1 -1
  76. package/dist/val/PackFuncVal.js +1 -2
  77. package/dist/val/PackFuncVal.js.map +1 -1
  78. package/dist/val/PlaceVal.d.ts +3 -1
  79. package/dist/val/PlaceVal.js +8 -6
  80. package/dist/val/PlaceVal.js.map +1 -1
  81. package/dist/val/StrFuncVal.d.ts +32 -0
  82. package/dist/val/StrFuncVal.js +292 -0
  83. package/dist/val/StrFuncVal.js.map +1 -0
  84. package/dist/vet.d.ts +1 -0
  85. package/dist/vet.js +1 -1
  86. package/dist/vet.js.map +1 -1
  87. package/dist/view.d.ts +2 -1
  88. package/dist/view.js +379 -8
  89. package/dist/view.js.map +1 -1
  90. package/grammar/aontu.abnf +156 -0
  91. package/grammar/aontu.gbnf +4 -3
  92. package/grammar/aontu.lark +4 -3
  93. package/grammar/aontu.tmLanguage.json +1 -1
  94. package/package.json +2 -1
  95. package/skill/grammar-card.md +5 -2
  96. package/src/agentsmd.ts +15 -3
  97. package/src/aontu.ts +8 -1
  98. package/src/cli.ts +365 -58
  99. package/src/diff.ts +7 -2
  100. package/src/escape.ts +371 -0
  101. package/src/format.ts +1728 -0
  102. package/src/hints.ts +53 -1
  103. package/src/jsonschema.ts +7 -1
  104. package/src/lang.ts +93 -6
  105. package/src/lsp.ts +8 -6
  106. package/src/mcp-server.ts +3 -2
  107. package/src/mcp.ts +6 -4
  108. package/src/mod-tool.ts +9 -8
  109. package/src/mod.ts +6 -6
  110. package/src/patch.ts +7 -0
  111. package/src/query.ts +8 -4
  112. package/src/reach.ts +7 -2
  113. package/src/relation.ts +7 -2
  114. package/src/sigdecl.ts +1 -1
  115. package/src/std.ts +2 -1
  116. package/src/subsume.ts +7 -2
  117. package/src/trim.ts +7 -2
  118. package/src/type.ts +8 -0
  119. package/src/utility.ts +27 -2
  120. package/src/val/EachFuncVal.ts +1 -3
  121. package/src/val/EmitFuncVal.ts +401 -0
  122. package/src/val/FilterFuncVal.ts +1 -3
  123. package/src/val/FuncBaseVal.ts +18 -0
  124. package/src/val/PackFuncVal.ts +1 -3
  125. package/src/val/PlaceVal.ts +8 -6
  126. package/src/val/StrFuncVal.ts +334 -0
  127. package/src/vet.ts +9 -3
  128. package/src/view.ts +442 -9
package/src/format.ts ADDED
@@ -0,0 +1,1728 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+
3
+ // THE SOURCE FORMATTER (docs/design/FMT.0.md): `aontu fmt`, in the
4
+ // tradition of gofmt. One agreed form for Aontu source, so that layout
5
+ // is never argued about and a diff shows only what changed.
6
+ //
7
+ // It reads the token stream the parser reads -- the lex subscriber the
8
+ // parser stack exposes -- so it sees what the value tree throws away:
9
+ // comments, blank lines, the quote a string used, the spelling of a
10
+ // number. From that stream it builds a layout tree, decides the shape
11
+ // of every container by the rules of the note's §3, and emits. Before
12
+ // returning it re-parses what it wrote and compares the two parse
13
+ // trees: a formatter that cannot prove its output is the same document
14
+ // refuses rather than return it.
15
+ //
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.
21
+ //
22
+ // The Go twin is go/format.go, function for function; the shared
23
+ // behaviour is test/spec/fmt.tsv, executed by both spec runners.
24
+
25
+ import { Aontu } from './aontu'
26
+ import { failureFinding } from './vet'
27
+ import type { VetFinding } from './vet'
28
+ import type { Resolver } from './type'
29
+
30
+
31
+ // The packing budget (§3.1). It decides which of two legal spellings
32
+ // to use, one line or several, and nothing else: the formatter never
33
+ // breaks a line, so a value wider than this stays as wide as it is.
34
+ const BUDGET = 80
35
+
36
+ // THE DEPTH BUDGET. The layout is recursive, as the tree it reads is,
37
+ // and the canonical port's stack is finite: past the evaluation budget
38
+ // of 1000 levels -- the depth at which unification itself refuses --
39
+ // the formatter stops reading and refuses, so a pathological document
40
+ // is a finding rather than a crash.
41
+ const MAX_DEPTH = 1000
42
+
43
+ export type FormatOptions = {
44
+ // The file's name, for the site of a parse failure.
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
58
+ }
59
+
60
+ // The self-check, injectable so the refusal it guards can be exercised
61
+ // (the `hooks` precedent of `view`): a formatter that is right never
62
+ // takes that arm on its own.
63
+ export type FormatHooks = {
64
+ same?: (root: any, after: string) => boolean
65
+ meet?: (before: string, after: string) => boolean
66
+ }
67
+
68
+ export type FormatReport =
69
+ | { verdict: 'formatted', text: string, changed: boolean, findings: LintFinding[] }
70
+ | { verdict: 'error', errors: VetFinding[] }
71
+
72
+
73
+ // ---------------------------------------------------------------------
74
+ // The tokens
75
+
76
+ type Tok = { name: string, src: string, val: any, sI: number }
77
+
78
+ // EVERY INCLUDE RESOLVES TO NOTHING. The formatter reads the file it is
79
+ // given and no other (§3.13), so `@"..."` is answered from memory with
80
+ // an empty source: the directive parses, the include is a token like
81
+ // any other, and no capability is needed because no file is read.
82
+ const stubResolver: Resolver = ((spec: any) => ({
83
+ ...spec, kind: 'aon', full: '__fmt__.aon', src: '', found: true, search: [],
84
+ })) as any
85
+
86
+ // ONE ENGINE, ONE SUBSCRIBER. The parser's subscriber list is
87
+ // append-only, so the subscription is made once and writes to
88
+ // whichever sink the current parse installed; the sink is cleared
89
+ // before the parse returns, so the check's re-parse collects nothing.
90
+ let ENGINE: Aontu | undefined
91
+ let SINK: Tok[] | undefined
92
+
93
+ function engine(): Aontu {
94
+ if (undefined === ENGINE) {
95
+ ENGINE = new Aontu({ resolver: stubResolver })
96
+ ENGINE.lang.jsonic.sub({
97
+ lex: (tkn: any) => {
98
+ // Spaces carry nothing the layout needs, and the end token
99
+ // arrives once per nested parse -- the stub's empty includes
100
+ // among them -- so both are dropped here rather than skipped
101
+ // everywhere below.
102
+ if (undefined !== SINK && '#SP' !== tkn.name && '#ZZ' !== tkn.name) {
103
+ SINK.push({ name: tkn.name, src: tkn.src, val: tkn.val, sI: tkn.sI })
104
+ }
105
+ },
106
+ })
107
+ }
108
+ return ENGINE
109
+ }
110
+
111
+
112
+ type Parsed = { root?: any, errors?: VetFinding[] }
113
+
114
+ // One parse, with the token stream collected when a sink is given. The
115
+ // failure shape is the one every verb reports (`view`'s load).
116
+ function parseDoc(src: string, path: string | undefined, sink: Tok[] | undefined): Parsed {
117
+ const aontu = engine()
118
+ const ctx = aontu.ctx({ collect: true })
119
+ SINK = sink
120
+ let parsed: any
121
+ try {
122
+ parsed = aontu.parse(src, undefined === path ? undefined : { path }, ctx)
123
+ }
124
+ finally {
125
+ SINK = undefined
126
+ }
127
+ if (0 < ctx.err.length) {
128
+ return { errors: [failureFinding(ctx, path, parsed)] }
129
+ }
130
+ return { root: parsed }
131
+ }
132
+
133
+
134
+ // ---------------------------------------------------------------------
135
+ // The layout tree
136
+
137
+ // One node shape for the whole tree, so the Go twin is one struct:
138
+ // the kind says which fields are meaningful.
139
+ type Node = {
140
+ t: 'pair' | 'spread' | 'include' | 'comment' | 'blank' | 'map' | 'list'
141
+ | 'atom' | 'call' | 'paren' | 'expr' | 'op' | 'prefix' | 'note'
142
+
143
+ // atom, include, comment, note, op, prefix: the text as written,
144
+ // normalised where §3.9 says (quotes), and nothing else.
145
+ text?: string
146
+
147
+ // pair: the key as it will be written, the optional marker, and the
148
+ // value; spread: the value.
149
+ key?: string
150
+ opt?: boolean
151
+ value?: Node
152
+
153
+ // map, list: the entries, and the comment on the opener's line.
154
+ body?: Node[]
155
+ open?: string
156
+
157
+ // call: the name and the arguments; paren: what it groups, which the
158
+ // parser reads as a call's argument list does (commas and all).
159
+ name?: string
160
+ args?: Node[]
161
+ inner?: Node[]
162
+
163
+ // expr: operands, binary operators, prefix operators and notes (a
164
+ // comment inside the expression), in source order.
165
+ items?: Node[]
166
+
167
+ // op: the author broke the line at this operator (§3.11).
168
+ brk?: boolean
169
+
170
+ // A comment on the last line of this entry.
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
182
+ }
183
+
184
+ const BINARY: Record<string, boolean> = { '#E&': true, '#E|': true, '#E+': true }
185
+ const PREFIX: Record<string, boolean> = { '#E*': true, '#E-': true }
186
+ const KEYISH: Record<string, boolean> = { '#TX': true, '#ST': true, '#NR': true, '#VL': true }
187
+ const CLOSER: Record<string, boolean> = { '#CB': true, '#CS': true, '#E)': true }
188
+
189
+ // The parts of one atom: a reference is `$`, dots and segments lexed
190
+ // one by one, and a bare word with a dot in it is the same run; what
191
+ // was adjacent in the source stays glued.
192
+ const GLUE: Record<string, boolean> = {
193
+ '#TX': true, '#ST': true, '#NR': true, '#VL': true, '#E.': true, '#E$': true,
194
+ }
195
+
196
+ const BARE = /^[A-Za-z_][A-Za-z0-9_]*$/
197
+
198
+ // A single-quoted string becomes double-quoted unless it holds a double
199
+ // quote, which the swap would have to escape (§3.9). The body is copied
200
+ // as written: the escapes are the same under both quotes.
201
+ function normStr(src: string): string {
202
+ if ("'" === src[0]) {
203
+ const body = src.slice(1, -1)
204
+ return body.includes('"') ? src : '"' + body + '"'
205
+ }
206
+ return src
207
+ }
208
+
209
+ function atomText(tok: Tok): string {
210
+ return '#ST' === tok.name ? normStr(tok.src) : tok.src
211
+ }
212
+
213
+ // A quoted key whose text is a legal bare key is written bare; the
214
+ // keywords are legal keys too (`string: 1` is the key `string`), so no
215
+ // word is reserved. Anything else keeps its spelling.
216
+ function keyText(tok: Tok): string {
217
+ if ('#ST' === tok.name) {
218
+ return BARE.test(tok.val) ? tok.val : normStr(tok.src)
219
+ }
220
+ return tok.src
221
+ }
222
+
223
+ function newlines(src: string): number {
224
+ return src.split('\n').length - 1
225
+ }
226
+
227
+
228
+ class Reader {
229
+ T: Tok[]
230
+ i = 0
231
+ depth = 0
232
+ // Past the depth budget: the reader answers '' for every token from
233
+ // here on, so every loop unwinds, and the document is refused.
234
+ deep = false
235
+
236
+ constructor(toks: Tok[]) {
237
+ this.T = toks
238
+ }
239
+
240
+ // The name of the token k ahead, or '' past the end.
241
+ name(k: number): string {
242
+ const t = this.T[this.i + k]
243
+ return this.deep || undefined === t ? '' : t.name
244
+ }
245
+
246
+ // The offset of the next token that is not a line run or a comment.
247
+ significant(): number {
248
+ let k = 0
249
+ while ('#LN' === this.name(k) || '#CM' === this.name(k)) {
250
+ k++
251
+ }
252
+ return k
253
+ }
254
+
255
+ // A key followed by a colon, the optional marker allowed between.
256
+ atKey(): boolean {
257
+ return KEYISH[this.name(0)] && ('#CL' === this.name(1) ||
258
+ ('#QM' === this.name(1) && '#CL' === this.name(2)))
259
+ }
260
+
261
+ // The entries of a container up to its closer, or of the document up
262
+ // to its end. Comments attach by the rules of §3.7: on the line of
263
+ // the entry that precedes them, or of the opener, they trail it;
264
+ // alone on a line they stand as entries and precede what follows.
265
+ body(close: string, opened: boolean): { body: Node[], open?: string } {
266
+ const body: Node[] = []
267
+ let open: string | undefined
268
+ let last: Node | undefined
269
+ let opener = opened
270
+ // Nothing since the opener or the last comma: a comma here is an
271
+ // empty element, which the parser reads as nil in a list.
272
+ let gap = true
273
+ for (;;) {
274
+ const n = this.name(0)
275
+ // The closer, or the end: the parser accepts a container the
276
+ // source never closed (`a: {` is `{"a":{}}`).
277
+ if ('' === n || n === close) {
278
+ break
279
+ }
280
+ if ('#LN' === n) {
281
+ if (1 < newlines(this.T[this.i].src) && 0 < body.length &&
282
+ 'blank' !== body[body.length - 1].t) {
283
+ body.push({ t: 'blank' })
284
+ }
285
+ last = undefined
286
+ opener = false
287
+ this.i++
288
+ continue
289
+ }
290
+ if ('#CA' === n) {
291
+ if (gap && '#CS' === close) {
292
+ const nil: Node = { t: 'atom', text: 'nil' }
293
+ body.push(nil)
294
+ last = nil
295
+ }
296
+ gap = true
297
+ this.i++
298
+ continue
299
+ }
300
+ if ('#CM' === n) {
301
+ const text = this.T[this.i].src
302
+ if (undefined !== last) {
303
+ last.trail = text
304
+ }
305
+ else if (opener) {
306
+ open = text
307
+ }
308
+ else {
309
+ body.push({ t: 'comment', text })
310
+ }
311
+ this.i++
312
+ continue
313
+ }
314
+ if (CLOSER[n]) {
315
+ // A closer that is not this container's: the parser ignores a
316
+ // stray one at the root (`a: 1 }` is `{"a":1}`), and so does
317
+ // this.
318
+ this.i++
319
+ continue
320
+ }
321
+ const e = this.entry()
322
+ body.push(e)
323
+ last = e
324
+ opener = false
325
+ gap = false
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
+ }
332
+ return { body, open }
333
+ }
334
+
335
+ // One entry: an include, a spread, a pair, or -- as a list element or
336
+ // at the root -- a value.
337
+ entry(): Node {
338
+ const n = this.name(0)
339
+ const at = this.T[this.i].sI
340
+ if ('#OD_multisource' === n) {
341
+ const text = '@' + normStr(this.T[this.i + 1].src)
342
+ this.i += 2
343
+ return { t: 'include', text, at }
344
+ }
345
+ if ('#E&' === n && '#CL' === this.name(1)) {
346
+ this.i += 2
347
+ return { t: 'spread', value: this.value(), at }
348
+ }
349
+ if (this.atKey()) {
350
+ const tok = this.T[this.i]
351
+ const opt = '#QM' === this.name(1)
352
+ this.i += opt ? 3 : 2
353
+ return { t: 'pair', key: keyText(tok), opt, value: this.value(), at }
354
+ }
355
+ return this.value()
356
+ }
357
+
358
+ // A value: operands and operators up to whatever ends it -- a
359
+ // separator, a closer, the end, or a line run that no operator
360
+ // continues past.
361
+ value(): Node {
362
+ if (MAX_DEPTH < ++this.depth) {
363
+ this.deep = true
364
+ }
365
+ const v = this.valueAt()
366
+ this.depth--
367
+ return v
368
+ }
369
+
370
+ valueAt(): Node {
371
+ const items: Node[] = []
372
+ for (;;) {
373
+ const n = this.name(0)
374
+ if ('' === n || '#CA' === n || CLOSER[n]) {
375
+ break
376
+ }
377
+ // An operand directly after an operand is the next element of a
378
+ // list, `[1 -2]`, `[{a:1} {b:2}]`: this value is complete.
379
+ if (!this.open(items) && !BINARY[n] && '#LN' !== n && '#CM' !== n) {
380
+ break
381
+ }
382
+ const at = this.T[this.i].sI
383
+ if ('#E&' === n && '#CL' === this.name(1)) {
384
+ if (0 === items.length) {
385
+ // A chain through a spread, `a: &: integer`. The braces are
386
+ // the agreed spelling (X-7), so it is read as the map it is.
387
+ this.i += 2
388
+ return { t: 'map', body: [{ t: 'spread', value: this.value(), at }], at }
389
+ }
390
+ // A sibling spread in a list, `[1 &: 2]`: this value is complete.
391
+ break
392
+ }
393
+ if ('#LN' === n) {
394
+ // A break the author put before the value, after an operator
395
+ // (`a: 1 &\n 2`) or before one (`a: 1\n | 2`), or after a
396
+ // comment inside the value; anything else ends the value.
397
+ if (this.open(items) || BINARY[this.name(this.significant())]) {
398
+ this.i++
399
+ continue
400
+ }
401
+ break
402
+ }
403
+ if ('#CM' === n) {
404
+ // A comment inside the value: after the colon, after an
405
+ // operator, or on a line the value continues past. Otherwise
406
+ // it trails the statement and the caller attaches it.
407
+ if (this.open(items) || BINARY[this.name(this.significant())]) {
408
+ items.push({ t: 'note', text: this.T[this.i].src, at })
409
+ this.i++
410
+ continue
411
+ }
412
+ break
413
+ }
414
+ if (BINARY[n]) {
415
+ items.push({
416
+ t: 'op', text: this.T[this.i].src,
417
+ brk: '#LN' === this.name(-1) || '#LN' === this.name(1), at,
418
+ })
419
+ this.i++
420
+ continue
421
+ }
422
+ if (PREFIX[n]) {
423
+ items.push({ t: 'prefix', text: this.T[this.i].src, at })
424
+ this.i++
425
+ continue
426
+ }
427
+ if ('#E(' === n) {
428
+ this.i++
429
+ const inner = this.seq()
430
+ this.i++
431
+ items.push({ t: 'paren', inner, at })
432
+ continue
433
+ }
434
+ if ('#TX' === n && '#E(' === this.name(1)) {
435
+ const name = this.T[this.i].src
436
+ this.i += 2
437
+ const args = this.seq()
438
+ this.i++
439
+ items.push({ t: 'call', name, args, at })
440
+ continue
441
+ }
442
+ if ('#OB' === n) {
443
+ this.i++
444
+ const m = this.body('#CB', true)
445
+ this.i++
446
+ items.push({ t: 'map', body: m.body, open: m.open, at })
447
+ continue
448
+ }
449
+ if ('#OS' === n) {
450
+ this.i++
451
+ const l = this.body('#CS', true)
452
+ this.i++
453
+ items.push({ t: 'list', body: l.body, open: l.open, at })
454
+ continue
455
+ }
456
+ if ('#OD_multisource' === n) {
457
+ items.push({ t: 'include', text: '@' + normStr(this.T[this.i + 1].src), at })
458
+ this.i += 2
459
+ continue
460
+ }
461
+ if (this.atKey()) {
462
+ // A pair in value position is a chain, `a: b: 1`, and it is
463
+ // the whole of the value.
464
+ items.push(this.entry())
465
+ break
466
+ }
467
+ items.push(this.atom())
468
+ }
469
+ if (1 === items.length && 'op' !== items[0].t && 'prefix' !== items[0].t &&
470
+ 'note' !== items[0].t) {
471
+ return items[0]
472
+ }
473
+ // An empty value, `a:`, is an expression with nothing in it.
474
+ return { t: 'expr', items, at: items[0]?.at }
475
+ }
476
+
477
+ // Whether the expression so far wants an operand: nothing yet, or an
478
+ // operator, a prefix or a comment last.
479
+ open(items: Node[]): boolean {
480
+ if (0 === items.length) {
481
+ return true
482
+ }
483
+ const t = items[items.length - 1].t
484
+ return 'op' === t || 'prefix' === t || 'note' === t
485
+ }
486
+
487
+ // The token under the cursor, and the parts glued to it.
488
+ atom(): Node {
489
+ const at = this.T[this.i].sI
490
+ let text = atomText(this.T[this.i])
491
+ this.i++
492
+ while (GLUE[this.name(0)] &&
493
+ this.T[this.i - 1].sI + this.T[this.i - 1].src.length === this.T[this.i].sI) {
494
+ text += atomText(this.T[this.i])
495
+ this.i++
496
+ }
497
+ return { t: 'atom', text, at }
498
+ }
499
+
500
+ // A call's arguments, or a parenthesis's contents, up to the closing
501
+ // parenthesis: values separated by commas, with a comment among them
502
+ // kept as a note.
503
+ seq(): Node[] {
504
+ const out: Node[] = []
505
+ let gap = true
506
+ let comma = false
507
+ for (;;) {
508
+ const n = this.name(0)
509
+ if ('' === n || CLOSER[n]) {
510
+ break
511
+ }
512
+ if ('#LN' === n) {
513
+ this.i++
514
+ continue
515
+ }
516
+ if ('#CA' === n) {
517
+ if (gap) {
518
+ out.push({ t: 'atom', text: 'nil', sep: comma })
519
+ }
520
+ gap = true
521
+ comma = true
522
+ this.i++
523
+ continue
524
+ }
525
+ if ('#CM' === n) {
526
+ out.push({ t: 'note', text: this.T[this.i].src })
527
+ this.i++
528
+ continue
529
+ }
530
+ const v = this.value()
531
+ v.sep = comma
532
+ out.push(v)
533
+ gap = false
534
+ comma = false
535
+ }
536
+ return out
537
+ }
538
+ }
539
+
540
+
541
+ // THE ROOT MAP HAS NO BRACES (§3.12). A document written as one braced
542
+ // map is its entries; the comments on the braces' lines become entries
543
+ // of their own, where nothing is lost.
544
+ function unwrap(root: Node[]): Node[] {
545
+ const entries = root.filter((n) => 'comment' !== n.t && 'blank' !== n.t)
546
+ if (1 !== entries.length || 'map' !== entries[0].t) {
547
+ return root
548
+ }
549
+ const m = entries[0]
550
+ const out: Node[] = []
551
+ for (const n of root) {
552
+ if (n !== m) {
553
+ out.push(n)
554
+ continue
555
+ }
556
+ if (undefined !== m.open) {
557
+ out.push({ t: 'comment', text: m.open })
558
+ }
559
+ out.push(...m.body!)
560
+ if (undefined !== m.trail) {
561
+ out.push({ t: 'comment', text: m.trail })
562
+ }
563
+ }
564
+ return out
565
+ }
566
+
567
+
568
+ // ---------------------------------------------------------------------
569
+ // The layout
570
+
571
+ // D1: a one-pair map in value position is written as a chain, and a
572
+ // one-pair map as a list element as a pair element. A map whose only
573
+ // entry is a spread keeps its braces (X-7), and one holding a comment
574
+ // keeps them too, because the comment needs the lines. A trailing
575
+ // comment on the map's line joins the pair's own.
576
+ function chain(node: Node): Node {
577
+ if ('map' !== node.t || undefined !== node.open || 1 !== node.body!.length ||
578
+ 'pair' !== node.body![0].t) {
579
+ return node
580
+ }
581
+ const p = node.body![0]
582
+ if (undefined === node.trail) {
583
+ return p
584
+ }
585
+ return { ...p, trail: undefined === p.trail ? node.trail : p.trail + ' ' + node.trail }
586
+ }
587
+
588
+ function width(s: string): number {
589
+ return Array.from(s).length
590
+ }
591
+
592
+ function pairHead(node: Node, tight: boolean): string {
593
+ return node.key! + (node.opt ? '?' : '') + (tight ? ':' : ': ')
594
+ }
595
+
596
+ // The one-line spelling of a node, or undefined where it has none: a
597
+ // comment, a blank line, a break the author kept, a string that spans
598
+ // lines. `tight` is the inline form of a pair, `a:1`, used inside a
599
+ // container; a statement's pair is `a: 1`.
600
+ function inline(node: Node, tight: boolean): string | undefined {
601
+ if (undefined !== node.trail) {
602
+ return undefined
603
+ }
604
+ switch (node.t) {
605
+ case 'atom':
606
+ case 'include':
607
+ return node.text!.includes('\n') ? undefined : node.text
608
+ case 'pair': {
609
+ const v = inline(chain(node.value!), tight)
610
+ return undefined === v ? undefined : pairHead(node, tight) + v
611
+ }
612
+ case 'spread': {
613
+ // `{ &: integer }`, padded inside braces too: the marker reads as
614
+ // a marker and not as a key.
615
+ const v = inline(node.value!, tight)
616
+ return undefined === v ? undefined : '&: ' + v
617
+ }
618
+ case 'map':
619
+ case 'list': {
620
+ if (undefined !== node.open) {
621
+ return undefined
622
+ }
623
+ const parts: string[] = []
624
+ for (const e of node.body!) {
625
+ const s = inline('list' === node.t ? chain(e) : e, true)
626
+ if (undefined === s) {
627
+ return undefined
628
+ }
629
+ parts.push(s)
630
+ }
631
+ if ('list' === node.t) {
632
+ return '[' + parts.join(' ') + ']'
633
+ }
634
+ return 0 === parts.length ? '{}' : '{ ' + parts.join(' ') + ' }'
635
+ }
636
+ case 'call': {
637
+ const a = inlineSeq(node.args!)
638
+ return undefined === a ? undefined : node.name + '(' + a + ')'
639
+ }
640
+ case 'paren': {
641
+ const a = inlineSeq(node.inner!)
642
+ return undefined === a ? undefined : '(' + a + ')'
643
+ }
644
+ case 'expr':
645
+ return inlineExpr(node.items!)
646
+ default:
647
+ // comment, blank: never on a line with anything else.
648
+ return undefined
649
+ }
650
+ }
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.
655
+ function inlineSeq(items: Node[]): string | undefined {
656
+ let out = ''
657
+ for (let k = 0; k < items.length; k++) {
658
+ const s = inline(items[k], true)
659
+ if (undefined === s) {
660
+ return undefined
661
+ }
662
+ out += (0 === k ? '' : sepOf(items[k])) + s
663
+ }
664
+ return out
665
+ }
666
+
667
+ function sepOf(node: Node): string {
668
+ return node.sep ? ', ' : ' '
669
+ }
670
+
671
+ // Binary operators spaced, prefixes tight (§3.11). An operand is
672
+ // never directly after an operand: the reader ends a value there.
673
+ function inlineExpr(items: Node[]): string | undefined {
674
+ let out = ''
675
+ for (const it of items) {
676
+ if ('note' === it.t || ('op' === it.t && it.brk)) {
677
+ return undefined
678
+ }
679
+ if ('op' === it.t) {
680
+ out += ' ' + it.text + ' '
681
+ continue
682
+ }
683
+ if ('prefix' === it.t) {
684
+ out += it.text
685
+ continue
686
+ }
687
+ const s = inline(it, true)
688
+ if (undefined === s) {
689
+ return undefined
690
+ }
691
+ out += s
692
+ }
693
+ return out
694
+ }
695
+
696
+
697
+ class Writer {
698
+ lines: string[] = []
699
+ line = ''
700
+ started = false
701
+
702
+ // A new line at an indentation, after a blank one when asked.
703
+ open(indent: number, blank: boolean): void {
704
+ if (this.started) {
705
+ this.lines.push(rtrim(this.line))
706
+ if (blank) {
707
+ this.lines.push('')
708
+ }
709
+ }
710
+ this.line = ' '.repeat(indent)
711
+ this.started = true
712
+ }
713
+
714
+ text(s: string): void {
715
+ this.line += s
716
+ }
717
+
718
+ // Nothing on the line yet but its indentation.
719
+ fresh(): boolean {
720
+ return '' === this.line.trim()
721
+ }
722
+
723
+ width(): number {
724
+ return width(this.line)
725
+ }
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
+
747
+ finish(): string {
748
+ if (!this.started) {
749
+ return ''
750
+ }
751
+ this.lines.push(rtrim(this.line))
752
+ return this.lines.join('\n') + '\n'
753
+ }
754
+ }
755
+
756
+ // A line never ends in a space: an operator the author left dangling
757
+ // (`a: 1 &`, which the parser accepts) would otherwise leave one.
758
+ function rtrim(s: string): string {
759
+ return s.replace(/ +$/, '')
760
+ }
761
+
762
+
763
+ // The entries of a body, one per line at the indentation, with the
764
+ // blank lines the author kept between them (§3.8) -- never at the
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 {
770
+ let pending = false
771
+ let count = 0
772
+ for (const node of body) {
773
+ if ('blank' === node.t) {
774
+ pending = 0 < count
775
+ continue
776
+ }
777
+ w.open(indent, pending)
778
+ pending = false
779
+ count++
780
+ if ('comment' === node.t) {
781
+ w.text(node.text!)
782
+ continue
783
+ }
784
+ if (undefined !== stmt && 'pair' === node.t) {
785
+ emitStatement(w, node, indent, stmt, '')
786
+ continue
787
+ }
788
+ const e = chain(node)
789
+ emitValue(w, e, indent)
790
+ if (undefined !== e.trail) {
791
+ w.text(' ' + e.trail)
792
+ }
793
+ }
794
+ }
795
+
796
+ // A value onto the current line: its one-line spelling when there is
797
+ // one and it fits the budget, and otherwise its several-line form,
798
+ // which for a scalar is the same text, too wide and unbreakable.
799
+ function emitValue(w: Writer, node: Node, indent: number): void {
800
+ const s = inline(node, false)
801
+ if (undefined !== s && w.width() + width(s) <= BUDGET) {
802
+ w.text(s)
803
+ return
804
+ }
805
+ switch (node.t) {
806
+ case 'pair': {
807
+ w.text(pairHead(node, false))
808
+ const v = chain(node.value!)
809
+ emitValue(w, v, indent)
810
+ if (undefined !== v.trail) {
811
+ w.text(' ' + v.trail)
812
+ }
813
+ return
814
+ }
815
+ case 'spread':
816
+ w.text('&: ')
817
+ emitValue(w, node.value!, indent)
818
+ return
819
+ case 'map':
820
+ emitBlock(w, '{', '}', node, indent, undefined)
821
+ return
822
+ case 'list':
823
+ emitBlock(w, '[', ']', node, indent, undefined)
824
+ return
825
+ case 'expr':
826
+ emitExpr(w, node.items!, indent)
827
+ return
828
+ case 'call':
829
+ case 'paren':
830
+ emitCall(w, node, indent)
831
+ return
832
+ default:
833
+ w.text(node.text!)
834
+ }
835
+ }
836
+
837
+ // A call, or a parenthesis, that has no one-line form or is too wide
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.
849
+ function emitCall(w: Writer, node: Node, indent: number): void {
850
+ const items = 'call' === node.t ? node.args! : node.inner!
851
+ const open = ('call' === node.t ? node.name! : '') + '('
852
+ const one = inlineSeq(items)
853
+ if (undefined !== one && !items.some(holdsContainer)) {
854
+ w.text(open + one + ')')
855
+ return
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
+ }
868
+ w.text(open)
869
+ let noted = false
870
+ for (let k = 0; k < items.length; k++) {
871
+ const it = items[k]
872
+ if ('note' === it.t) {
873
+ // A comment among the arguments trails the line it was on -- the
874
+ // opener's, or an argument's -- and one that followed another
875
+ // comment keeps its own line.
876
+ if (noted) {
877
+ w.open(indent + 2, false)
878
+ w.text(it.text!)
879
+ }
880
+ else {
881
+ w.text(' ' + it.text)
882
+ }
883
+ noted = true
884
+ continue
885
+ }
886
+ w.open(indent + 2, false)
887
+ emitValue(w, it, indent + 2)
888
+ const next = items.slice(k + 1).find((x) => 'note' !== x.t)
889
+ if (undefined !== next && next.sep) {
890
+ w.text(',')
891
+ }
892
+ noted = false
893
+ }
894
+ w.open(indent, false)
895
+ w.text(')')
896
+ }
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
+
931
+ // A container on several lines (§3.5): the opener ends its line, the
932
+ // entries are statements one level in, the closer stands alone. An
933
+ // empty container is inline whatever the budget says.
934
+ function emitBlock(
935
+ w: Writer, open: string, close: string, node: Node, indent: number, stmt: Stmt | undefined
936
+ ): void {
937
+ if (0 === node.body!.length && undefined === node.open) {
938
+ w.text(open + close)
939
+ return
940
+ }
941
+ w.text(open)
942
+ if (undefined !== node.open) {
943
+ w.text(' ' + node.open)
944
+ }
945
+ emitBody(w, node.body!, indent + 2, stmt)
946
+ w.open(indent, false)
947
+ w.text(close)
948
+ }
949
+
950
+ // An expression that has no one-line form, or one too wide for the
951
+ // budget: the author's breaks are kept, each at its operator, which
952
+ // leads its continuation line (§3.11). The continuation is one level
953
+ // in when the expression follows a key on its line, and level with
954
+ // the first operand when the expression has the line to itself -- an
955
+ // argument of a block call, say -- so a disjunction of alternatives
956
+ // reads as the list it is. A container operand that does not fit from
957
+ // where it stands is a block whose closer lines up with the line that
958
+ // opened it.
959
+ function emitExpr(w: Writer, items: Node[], indent: number): void {
960
+ const cont = w.fresh() ? indent : indent + 2
961
+ // Whether the last item was an operand: a comment after one is a
962
+ // space away, and after an operator or the colon it is not. An
963
+ // operand is never directly after an operand (the reader ends a
964
+ // value there), so operands need no such check.
965
+ let operand = false
966
+ let cur = indent
967
+ for (const it of items) {
968
+ if ('op' === it.t) {
969
+ if (it.brk) {
970
+ cur = cont
971
+ if (!w.fresh()) {
972
+ w.open(cur, false)
973
+ }
974
+ w.text(it.text + ' ')
975
+ }
976
+ else {
977
+ w.text(' ' + it.text + ' ')
978
+ }
979
+ operand = false
980
+ continue
981
+ }
982
+ if ('prefix' === it.t) {
983
+ w.text(it.text!)
984
+ operand = false
985
+ continue
986
+ }
987
+ if ('note' === it.t) {
988
+ if (operand) {
989
+ w.text(' ')
990
+ }
991
+ w.text(it.text!)
992
+ cur = cont
993
+ w.open(cur, false)
994
+ operand = false
995
+ continue
996
+ }
997
+ emitValue(w, it, cur)
998
+ operand = true
999
+ }
1000
+ }
1001
+
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 {
1273
+ const w = new Writer()
1274
+ emitBody(w, undefined === meet ? root : mergeRuns(root), 0,
1275
+ undefined === meet ? undefined : { meet, covered: false })
1276
+ return w.finish()
1277
+ }
1278
+
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
+
1439
+ // ---------------------------------------------------------------------
1440
+ // The verb's library surface
1441
+
1442
+ function lf(text: string): string {
1443
+ return text.split('\r\n').join('\n')
1444
+ }
1445
+
1446
+ // The check: the output parses, and to the same tree. Pre-unification
1447
+ // canon is that tree, positions aside, and every rewrite of the
1448
+ // syntactic tier leaves it unchanged (§7.3).
1449
+ function sameDocument(root: any, after: string): boolean {
1450
+ const p = parseDoc(after, undefined, undefined)
1451
+ return undefined === p.errors && root.canon === p.root.canon
1452
+ }
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
+
1483
+ function depthFinding(): VetFinding {
1484
+ return {
1485
+ code: 'max_depth',
1486
+ class: 'budget',
1487
+ severity: 'error',
1488
+ path: '$',
1489
+ message: `The document nests more than ${MAX_DEPTH} levels deep, past what the formatter reads.`,
1490
+ sites: [],
1491
+ }
1492
+ }
1493
+
1494
+ function checkFinding(path: string | undefined, expected: string, actual: string): VetFinding {
1495
+ return {
1496
+ code: 'format_check',
1497
+ class: 'internal',
1498
+ severity: 'error',
1499
+ path: '$',
1500
+ message: 'The formatted text is not the same document, so nothing was written.',
1501
+ note: 'a formatter defect: please report it with the source' +
1502
+ (undefined === path ? '' : ' (' + path + ')'),
1503
+ sites: [],
1504
+ expected,
1505
+ actual,
1506
+ }
1507
+ }
1508
+
1509
+ // Format one document. The text is the agreed form of the source;
1510
+ // `changed` says whether it differs from what was given, which is
1511
+ // what `--check` and `--list` report.
1512
+ export function format(src: string, opts?: FormatOptions, hooks?: FormatHooks): FormatReport {
1513
+ const text = lf(src)
1514
+ const toks: Tok[] = []
1515
+ const parsed = parseDoc(text, opts?.path, toks)
1516
+ if (undefined !== parsed.errors) {
1517
+ return { verdict: 'error', errors: parsed.errors }
1518
+ }
1519
+ const reader = new Reader(toks)
1520
+ const root = unwrap(reader.body('', false).body)
1521
+ if (reader.deep) {
1522
+ return { verdict: 'error', errors: [depthFinding()] }
1523
+ }
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)
1527
+ const same = hooks?.same ?? sameDocument
1528
+ if (!same(parsed.root, plain)) {
1529
+ return {
1530
+ verdict: 'error',
1531
+ errors: [checkFinding(opts?.path, parsed.root.canon, plain)],
1532
+ }
1533
+ }
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
+ }
1539
+ }
1540
+
1541
+
1542
+ // ---------------------------------------------------------------------
1543
+ // The unified diff of `--diff`
1544
+
1545
+ // A patience diff: lines unique to both sides, in order, are the
1546
+ // anchors, and the gaps between them recurse. Not always the shortest
1547
+ // edit script, but linear in space, and the same script from both
1548
+ // ports, which is what a shared golden needs.
1549
+
1550
+ type Edit = { op: ' ' | '-' | '+', text: string }
1551
+
1552
+ // The lines of a text, with a marker on the last when the text does
1553
+ // not end in a newline: such a line never equals its
1554
+ // newline-terminated twin, which is how the diff reports the
1555
+ // difference, and the marker is rendered as diff renders it. NUL,
1556
+ // which no source line ends in.
1557
+ const NO_NEWLINE = String.fromCharCode(0)
1558
+
1559
+ function textLines(text: string): string[] {
1560
+ if ('' === text) {
1561
+ return []
1562
+ }
1563
+ const lines = text.split('\n')
1564
+ if ('' === lines[lines.length - 1]) {
1565
+ lines.pop()
1566
+ }
1567
+ else {
1568
+ lines[lines.length - 1] += NO_NEWLINE
1569
+ }
1570
+ return lines
1571
+ }
1572
+
1573
+ // The longest chain of anchors in order on both sides: patience
1574
+ // sorting over the right-hand positions, with the left already
1575
+ // ascending.
1576
+ function longestChain(pairs: [number, number][]): [number, number][] {
1577
+ const tails: number[] = []
1578
+ const prev: number[] = []
1579
+ for (let k = 0; k < pairs.length; k++) {
1580
+ const j = pairs[k][1]
1581
+ let lo = 0
1582
+ let hi = tails.length
1583
+ while (lo < hi) {
1584
+ const mid = (lo + hi) >> 1
1585
+ if (pairs[tails[mid]][1] < j) {
1586
+ lo = mid + 1
1587
+ }
1588
+ else {
1589
+ hi = mid
1590
+ }
1591
+ }
1592
+ prev[k] = 0 < lo ? tails[lo - 1] : -1
1593
+ tails[lo] = k
1594
+ }
1595
+ const out: [number, number][] = []
1596
+ let k = 0 === tails.length ? -1 : tails[tails.length - 1]
1597
+ while (0 <= k) {
1598
+ out.push(pairs[k])
1599
+ k = prev[k]
1600
+ }
1601
+ return out.reverse()
1602
+ }
1603
+
1604
+ function patience(
1605
+ a: string[], x0: number, x1: number, b: string[], y0: number, y1: number, out: Edit[]
1606
+ ): void {
1607
+ while (x0 < x1 && y0 < y1 && a[x0] === b[y0]) {
1608
+ out.push({ op: ' ', text: a[x0] })
1609
+ x0++
1610
+ y0++
1611
+ }
1612
+ let tail = 0
1613
+ while (x0 < x1 - tail && y0 < y1 - tail && a[x1 - 1 - tail] === b[y1 - 1 - tail]) {
1614
+ tail++
1615
+ }
1616
+ x1 -= tail
1617
+ y1 -= tail
1618
+
1619
+ const countA = new Map<string, number>()
1620
+ const countB = new Map<string, number>()
1621
+ const posB = new Map<string, number>()
1622
+ for (let x = x0; x < x1; x++) {
1623
+ countA.set(a[x], (countA.get(a[x]) ?? 0) + 1)
1624
+ }
1625
+ for (let y = y0; y < y1; y++) {
1626
+ countB.set(b[y], (countB.get(b[y]) ?? 0) + 1)
1627
+ posB.set(b[y], y)
1628
+ }
1629
+ const pairs: [number, number][] = []
1630
+ for (let x = x0; x < x1; x++) {
1631
+ if (1 === countA.get(a[x]) && 1 === countB.get(a[x])) {
1632
+ pairs.push([x, posB.get(a[x])!])
1633
+ }
1634
+ }
1635
+ const anchors = longestChain(pairs)
1636
+
1637
+ if (0 === anchors.length) {
1638
+ for (let x = x0; x < x1; x++) {
1639
+ out.push({ op: '-', text: a[x] })
1640
+ }
1641
+ for (let y = y0; y < y1; y++) {
1642
+ out.push({ op: '+', text: b[y] })
1643
+ }
1644
+ }
1645
+ else {
1646
+ let x = x0
1647
+ let y = y0
1648
+ for (const [ax, ay] of anchors) {
1649
+ patience(a, x, ax, b, y, ay, out)
1650
+ out.push({ op: ' ', text: a[ax] })
1651
+ x = ax + 1
1652
+ y = ay + 1
1653
+ }
1654
+ patience(a, x, x1, b, y, y1, out)
1655
+ }
1656
+
1657
+ for (let k = 0; k < tail; k++) {
1658
+ out.push({ op: ' ', text: a[x1 + k] })
1659
+ }
1660
+ }
1661
+
1662
+ // The diff in unified format, three lines of context, the file named
1663
+ // on both sides. Empty when the texts are the same.
1664
+ export function unifiedDiff(name: string, before: string, after: string): string {
1665
+ const a = textLines(before)
1666
+ const b = textLines(after)
1667
+ const edits: Edit[] = []
1668
+ patience(a, 0, a.length, b, 0, b.length, edits)
1669
+
1670
+ // Hunks: changes closer than twice the context share one.
1671
+ const hunks: [number, number][] = []
1672
+ for (let k = 0; k < edits.length; k++) {
1673
+ if (' ' === edits[k].op) {
1674
+ continue
1675
+ }
1676
+ const last = hunks[hunks.length - 1]
1677
+ if (undefined !== last && k - last[1] <= 6) {
1678
+ last[1] = k
1679
+ }
1680
+ else {
1681
+ hunks.push([k, k])
1682
+ }
1683
+ }
1684
+ if (0 === hunks.length) {
1685
+ return ''
1686
+ }
1687
+
1688
+ const out: string[] = ['--- a/' + name, '+++ b/' + name]
1689
+ let ai = 0
1690
+ let bi = 0
1691
+ let next = 0
1692
+ for (const [s, e] of hunks) {
1693
+ const from = Math.max(s - 3, 0)
1694
+ const to = Math.min(e + 4, edits.length)
1695
+ // Everything between two hunks is context -- a change would have
1696
+ // opened a hunk -- so both sides advance together.
1697
+ for (; next < from; next++) {
1698
+ ai++
1699
+ bi++
1700
+ }
1701
+ let alen = 0
1702
+ let blen = 0
1703
+ const lines: string[] = []
1704
+ for (let k = from; k < to; k++) {
1705
+ const ed = edits[k]
1706
+ if ('+' !== ed.op) {
1707
+ alen++
1708
+ }
1709
+ if ('-' !== ed.op) {
1710
+ blen++
1711
+ }
1712
+ if (ed.text.endsWith(NO_NEWLINE)) {
1713
+ lines.push(ed.op + ed.text.slice(0, -1))
1714
+ lines.push('\')
1715
+ }
1716
+ else {
1717
+ lines.push(ed.op + ed.text)
1718
+ }
1719
+ }
1720
+ out.push('@@ -' + (0 === alen ? ai : ai + 1) + ',' + alen +
1721
+ ' +' + (0 === blen ? bi : bi + 1) + ',' + blen + ' @@')
1722
+ out.push(...lines)
1723
+ ai += alen
1724
+ bi += blen
1725
+ next = to
1726
+ }
1727
+ return out.join('\n') + '\n'
1728
+ }