@taliesinb/mash 0.0.0-stage → 0.1.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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
@@ -0,0 +1,601 @@
1
+ /**
2
+ * One-line summaries: a snippet squeezed into a target width while keeping its
3
+ * *shape* (which commands run, how they are combined), e.g.
4
+ *
5
+ * cd …/harness-port && pnpm run build … > …/build.log 2>&1 && echo done
6
+ *
7
+ * The summary is a tree of structure tokens (command names, operators,
8
+ * keywords) and reducible parts. Starting from everything shown (with
9
+ * multi-line strings cut to their first line, here-doc bodies and comments
10
+ * omitted), it repeatedly applies the reduction with the lowest cost per
11
+ * column it saves (counting at most the columns still needed) until the line
12
+ * fits. Costs encode the priorities, cheapest first:
13
+ *
14
+ * shorten long strings, substitutions, words (head and closing quote kept) 1
15
+ * paths and redirection targets → their tail (`…/file.txt`) 1.2
16
+ * collapse assignment values (`FOO=…`; short ones cost double) 4
17
+ * hide plain arguments (strings, words) ┐ hidden runs merge into 3
18
+ * hide long flags (`--frozen-lockfile`) │ one `…` whose tooltip 3.5
19
+ * shorten path tails further │ lists them 4
20
+ * hide paths │ 6
21
+ * hide short flags (`-rn`) ┘ 8
22
+ * hide subcommands (`git status`) 80
23
+ * collapse compound bodies (`for …; do …; done`) 60
24
+ * drop trailing statements of a list (`a; b; …`) 100
25
+ * command names that are paths → their tail 150
26
+ * (and finally a hard cut at the width)
27
+ *
28
+ * Hiding an item of 6 columns or fewer costs double. Command names,
29
+ * operators, keywords and `]`/`]]` are never reduced.
30
+ *
31
+ * Every `…` keeps what it hides: truncated atoms carry the full text as a
32
+ * tooltip, hidden arguments and collapsed bodies carry their source.
33
+ */
34
+
35
+ import type * as A from '../parser/ast.ts'
36
+ import { parse } from '../parser/parser.ts'
37
+ import { Highlight, simpleCommandRoles } from './highlight.ts'
38
+
39
+ export interface SummaryOptions {
40
+ /** Target width in columns (default 80). */
41
+ width?: number
42
+ }
43
+
44
+ export interface SummaryResult {
45
+ /** The summary as plain text (`…` marks truncation). At most `width` columns, never a newline. */
46
+ text: string
47
+ /** `<span class="mash sum">…</span>`: highlighted, with `el` ellipsis spans and `title` tooltips. */
48
+ html: string
49
+ /** Number of reductions applied. */
50
+ reductions: number
51
+ }
52
+
53
+ type Kind = 'cmd' | 'sc' | 'flag' | 'lflag' | 'path' | 'str' | 'word' | 'val' | 'target'
54
+
55
+ interface Atom { k: 'atom'; start: number; end: number; kind: Kind; limit?: number; tail?: boolean; pos: number }
56
+ interface Tok { k: 'tok'; text: string; cls?: string }
57
+ interface ArgItem { node: Atom; hidden: boolean; fixed?: boolean }
58
+ interface Args { k: 'args'; items: ArgItem[] }
59
+ interface Elide { k: 'elide'; inner: S; start: number; end: number; collapsed: boolean; size: number }
60
+ interface List { k: 'list'; items: S[]; seps: string[]; spans: [number, number][]; keep: number; pos: number }
61
+ interface Fold { k: 'fold'; start: number; end: number }
62
+ type S = Tok | Atom | Args | Elide | List | Fold | S[]
63
+
64
+ interface Piece {
65
+ text: string
66
+ src?: [number, number]
67
+ cls?: string
68
+ el?: boolean
69
+ title?: string
70
+ /** Pieces of one truncated atom share a wrapper (its full text as tooltip). */
71
+ wrap?: { title: string }
72
+ }
73
+
74
+ const ELLIPSIS = '…'
75
+ const tok = (text: string, cls?: string): Tok => ({ k: 'tok', text, cls })
76
+ const SP = tok(' ')
77
+
78
+ /** Parse and summarize. Unparsable input falls back to a whitespace-collapsed, cut source. */
79
+ export function summarize(source: string, options: SummaryOptions = {}): SummaryResult {
80
+ let script: A.Script
81
+ try {
82
+ script = parse(source)
83
+ } catch {
84
+ const width = options.width ?? 80
85
+ const flat = source.replace(/\s+/g, ' ').trim()
86
+ const text = flat.length <= width ? flat : flat.slice(0, Math.max(0, width - 1)) + ELLIPSIS
87
+ const html = flat.length <= width
88
+ ? `<span class="mash sum">${esc(flat)}</span>`
89
+ : `<span class="mash sum">${esc(flat.slice(0, Math.max(0, width - 1)))}<span class="el" title="${esc(source)}">${ELLIPSIS}</span></span>`
90
+ return { text, html, reductions: 0 }
91
+ }
92
+ return summarizeScript(script, source, options)
93
+ }
94
+
95
+ export function summarizeScript(script: A.Script, source: string, options: SummaryOptions = {}): SummaryResult {
96
+ const width = Math.max(4, options.width ?? 80)
97
+ const b = new Builder(source)
98
+ const root = b.list(script.body)
99
+ const atomWidth = (a: Atom): number => atomPieces(a, source).reduce((n, p) => n + p.text.length, 0)
100
+
101
+ const lineWidth = (): number => render(root, source).reduce((n, p) => n + p.text.length, 0)
102
+ const undos: (() => void)[] = []
103
+ for (let iter = 0; iter < 5000; iter++) {
104
+ const overflow = lineWidth() - width
105
+ if (overflow <= 0) break
106
+ const cands: Cand[] = []
107
+ collect(root, cands, { atomWidth, src: source, width: (n) => render(n, source).reduce((m, p) => m + p.text.length, 0) })
108
+ let best: Cand | null = null
109
+ let bestScore = Infinity
110
+ for (const c of cands) {
111
+ if (c.saving <= 0) continue
112
+ const score = c.cost / Math.min(c.saving, overflow + 1)
113
+ if (score < bestScore || (score === bestScore && best && c.key > best.key)) {
114
+ best = c
115
+ bestScore = score
116
+ }
117
+ }
118
+ if (!best) break
119
+ undos.push(best.apply())
120
+ }
121
+ // Relax: greedy steps can overshoot. Newest first, undo each reduction the
122
+ // line no longer needs (restoring the exact state when it is still needed:
123
+ // reductions of one atom do not commute).
124
+ let reductions = undos.length
125
+ if (lineWidth() <= width) {
126
+ for (let i = undos.length - 1; i >= 0; i--) {
127
+ const snap = snapshot(root)
128
+ undos[i]()
129
+ if (lineWidth() > width) restore(root, snap)
130
+ else reductions--
131
+ }
132
+ }
133
+ let pieces = render(root, source)
134
+ if (pieces.reduce((n, p) => n + p.text.length, 0) > width) {
135
+ pieces = hardCut(pieces, width, source)
136
+ reductions++
137
+ }
138
+ const hl = new Highlight(script, source)
139
+ return { text: pieces.map((p) => p.text).join(''), html: toHtml(pieces, hl), reductions }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // AST → summary tree
144
+ // ---------------------------------------------------------------------------
145
+
146
+ class Builder {
147
+ readonly src: string
148
+ private pos = 0
149
+
150
+ constructor(src: string) {
151
+ this.src = src
152
+ }
153
+
154
+ private atom(span: A.Span, kind: Kind): Atom {
155
+ return { k: 'atom', start: span.start, end: span.end, kind, pos: this.pos++ }
156
+ }
157
+
158
+ private classify(w: A.Word): Kind {
159
+ const text = this.src.slice(w.span.start, w.span.end)
160
+ if (/^--./.test(text)) return 'lflag'
161
+ if (/^-[^-\s]/.test(text)) return 'flag'
162
+ const bare = text.replace(/^["']|["']$/g, '')
163
+ // paths: start like one (`/`, `~`, `./`, `$HOME/`, `dir/`), no blanks, no regex/substitution syntax
164
+ if (/^(~|\.{0,2}\/|\$\{?\w+\}?\/|[\w.@+-]+\/)/.test(bare) && !/[\s^\\|()`]/.test(bare) && !/^\w\/.*\/.*\/\w*$/.test(bare)) return 'path'
165
+ if (w.parts.every((p) => p.type === 'Lit' || p.type === 'Escaped')) return 'word'
166
+ return 'str'
167
+ }
168
+
169
+ list(stmts: A.Stmt[]): S {
170
+ const items = stmts.map((s) => this.stmt(s))
171
+ const seps = stmts.slice(0, -1).map((s) => (s.background ? ' ' : '; '))
172
+ return { k: 'list', items, seps, spans: stmts.map((s) => [s.span.start, s.span.end]), keep: stmts.length, pos: this.pos++ }
173
+ }
174
+
175
+ private stmt(s: A.Stmt): S {
176
+ return s.background ? [this.command(s.command), SP, tok('&', 'op')] : this.command(s.command)
177
+ }
178
+
179
+ private body(stmts: A.Stmt[]): S {
180
+ if (stmts.length === 0) return []
181
+ const start = stmts[0].span.start
182
+ const end = stmts[stmts.length - 1].span.end
183
+ return { k: 'elide', inner: this.list(stmts), start, end, collapsed: false, size: end - start }
184
+ }
185
+
186
+ private redirs(rs: A.Redirect[]): S {
187
+ return rs.map((r) => {
188
+ if (r.heredoc) return [SP, tok(this.src.slice(r.span.start, r.span.end), 'rd'), { k: 'fold', start: r.heredoc.body.span.start, end: r.heredoc.body.span.end } as Fold]
189
+ const opText = this.src.slice(r.span.start, r.target.span.start)
190
+ if (/^\d*[<>]&$/.test(opText.trim()) || r.target.span.end <= r.target.span.start) return [SP, tok(this.src.slice(r.span.start, r.span.end), 'rd')]
191
+ return [SP, tok(opText, 'rd'), this.atom(r.target.span, this.classify(r.target) === 'path' ? 'target' : 'val')]
192
+ })
193
+ }
194
+
195
+ private assign(a: A.Assign): S {
196
+ const valueStart = a.value ? a.value.span.start : a.span.end
197
+ const name = tok(this.src.slice(a.span.start, valueStart), 'asg')
198
+ if (!a.value || a.value.span.end <= a.value.span.start) return name
199
+ return [name, this.atom(a.value.span, 'val')]
200
+ }
201
+
202
+ private command(c: A.Command): S {
203
+ switch (c.type) {
204
+ case 'SimpleCommand': {
205
+ const out: S[] = []
206
+ for (const a of c.assigns) out.push(this.assign(a), SP)
207
+ const items: ArgItem[] = []
208
+ let name: S | null = null
209
+ let roles: ReturnType<typeof simpleCommandRoles> = []
210
+ try {
211
+ roles = simpleCommandRoles(c)
212
+ } catch {
213
+ // display only
214
+ }
215
+ c.words.forEach((w, i) => {
216
+ if (w.type === 'Assign') {
217
+ items.push({ node: { ...this.atom(w.span, 'str') }, hidden: false })
218
+ return
219
+ }
220
+ if (!name) {
221
+ name = this.atom(w.span, 'cmd')
222
+ return
223
+ }
224
+ const text = this.src.slice(w.span.start, w.span.end)
225
+ // known roles refine the guess: files and directories shorten to their tail, patterns and code to their head
226
+ const role = roles[i]
227
+ let kind = this.classify(w)
228
+ if ((role === 'dir' || role === 'file') && kind !== 'flag' && kind !== 'lflag') kind = 'path'
229
+ else if ((role === 'pattern' || role === 'code') && kind === 'path') kind = 'str'
230
+ else if (role === 'subcommand') kind = 'sc'
231
+ items.push({ node: this.atom(w.span, kind), hidden: false, fixed: text === ']' || text === ']]' })
232
+ })
233
+ if (name) out.push(name)
234
+ else if (out.length) out.pop()
235
+ if (items.length) out.push(SP, { k: 'args', items })
236
+ out.push(this.redirs(c.redirs))
237
+ return out
238
+ }
239
+ case 'Pipeline': {
240
+ const prefix = this.src.slice(c.span.start, c.stages[0].span.start).trim().replace(/\s+/g, ' ')
241
+ const out: S[] = prefix ? [tok(prefix, 'kw'), SP] : []
242
+ c.stages.forEach((st, i) => {
243
+ if (i > 0) out.push(SP, tok(c.pipeStderr[i - 1] ? '|&' : '|', 'op'), SP)
244
+ out.push(this.command(st))
245
+ })
246
+ return out
247
+ }
248
+ case 'AndOr': {
249
+ const out: S[] = []
250
+ c.items.forEach((it, i) => {
251
+ if (i > 0) out.push(SP, tok(c.ops[i - 1], 'op'), SP)
252
+ out.push(this.command(it))
253
+ })
254
+ return out
255
+ }
256
+ case 'FunctionDef':
257
+ return [tok(`${c.name}()`, 'fn'), SP, this.command(c.body)]
258
+ case 'Group':
259
+ return [tok('{', 'kw'), SP, this.body(c.body), tok(';', 'op'), SP, tok('}', 'kw'), this.redirs(c.redirs)]
260
+ case 'Subshell':
261
+ return [tok('(', 'kw'), SP, this.body(c.body), SP, tok(')', 'kw'), this.redirs(c.redirs)]
262
+ case 'If': {
263
+ const out: S[] = []
264
+ c.clauses.forEach((cl, i) => {
265
+ out.push(i === 0 ? tok('if', 'kw') : [tok(';', 'op'), SP, tok('elif', 'kw')], SP, this.list(cl.cond), tok(';', 'op'), SP, tok('then', 'kw'), SP, this.body(cl.body))
266
+ })
267
+ if (c.elseBody) out.push(tok(';', 'op'), SP, tok('else', 'kw'), SP, this.body(c.elseBody))
268
+ out.push(tok(';', 'op'), SP, tok('fi', 'kw'), this.redirs(c.redirs))
269
+ return out
270
+ }
271
+ case 'Loop':
272
+ return [tok(c.kind, 'kw'), SP, this.list(c.cond), tok(';', 'op'), SP, tok('do', 'kw'), SP, this.body(c.body), tok(';', 'op'), SP, tok('done', 'kw'), this.redirs(c.redirs)]
273
+ case 'For':
274
+ case 'Select': {
275
+ const head: S[] = [tok(c.type === 'For' ? 'for' : 'select', 'kw'), SP, tok(c.variable, 'var')]
276
+ if (c.items !== null) {
277
+ head.push(SP, tok('in', 'kw'))
278
+ if (c.items.length) head.push(SP, { k: 'args', items: c.items.map((w) => ({ node: this.atom(w.span, this.classify(w)), hidden: false })) })
279
+ }
280
+ return [head, tok(';', 'op'), SP, tok('do', 'kw'), SP, this.body(c.body), tok(';', 'op'), SP, tok('done', 'kw'), this.redirs(c.redirs)]
281
+ }
282
+ case 'ArithFor': {
283
+ const exprs = [c.init, c.cond, c.step].filter((e): e is A.ArithExpr => e !== null)
284
+ const from = exprs.length ? exprs[exprs.length - 1].span.end : c.span.start
285
+ const close = this.src.indexOf('))', from)
286
+ return [this.atom({ start: c.span.start, end: close + 2 }, 'str'), tok(';', 'op'), SP, tok('do', 'kw'), SP, this.body(c.body), tok(';', 'op'), SP, tok('done', 'kw'), this.redirs(c.redirs)]
287
+ }
288
+ case 'Case': {
289
+ const items: S[] = []
290
+ c.items.forEach((it, i) => {
291
+ if (i > 0) items.push(SP)
292
+ it.patterns.forEach((p, j) => {
293
+ if (j > 0) items.push(tok('|', 'op'))
294
+ items.push(this.atom(p.span, 'word'))
295
+ })
296
+ items.push(tok(')', 'op'))
297
+ if (it.body.length) items.push(SP, this.list(it.body))
298
+ items.push(SP, tok(it.terminator, 'op'))
299
+ })
300
+ const start = c.items[0]?.span.start ?? c.span.start
301
+ const end = c.items[c.items.length - 1]?.span.end ?? c.span.start
302
+ const region: S = c.items.length ? { k: 'elide', inner: items, start, end, collapsed: false, size: end - start } : []
303
+ return [tok('case', 'kw'), SP, this.atom(c.word.span, 'word'), SP, tok('in', 'kw'), SP, region, SP, tok('esac', 'kw'), this.redirs(c.redirs)]
304
+ }
305
+ case 'ArithCommand':
306
+ case 'CondCommand': {
307
+ let end = c.redirs.length > 0 ? c.redirs[0].span.start : c.span.end
308
+ while (end > c.span.start && /\s/.test(this.src[end - 1])) end--
309
+ return [this.atom({ start: c.span.start, end }, 'str'), this.redirs(c.redirs)]
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Rendering
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function closeLen(src: string, a: Atom): number {
320
+ if (a.end - a.start <= 2 || a.kind === 'path' || a.kind === 'target') return 0
321
+ return `'")}\``.includes(src[a.end - 1]) ? 1 : 0
322
+ }
323
+
324
+ /** The pieces showing an atom under its current limit. */
325
+ function atomPieces(a: Atom, src: string): Piece[] {
326
+ const { start: s, end: e } = a
327
+ const full = src.slice(s, e)
328
+ const nlAt = full.indexOf('\n')
329
+ const lineEnd = nlAt >= 0 ? s + nlAt : e
330
+ const forced = nlAt >= 0
331
+ const wrap = { title: full }
332
+ // paths keep their tail whenever they are shortened
333
+ const tailMode = a.tail || (a.limit !== undefined && (a.kind === 'path' || a.kind === 'target'))
334
+ if (tailMode && !forced) {
335
+ // path tail: `…/file.txt`, or the last limit-1 characters
336
+ let slash = -1
337
+ for (let i = e - 2; i > s; i--) {
338
+ if (src[i] === '/') {
339
+ slash = i
340
+ break
341
+ }
342
+ }
343
+ let from = slash > s ? slash : s
344
+ if (a.limit !== undefined && e - from + 1 > a.limit) from = Math.max(s, e - Math.max(1, a.limit - 1))
345
+ if (from <= s) return [{ text: full, src: [s, e] }]
346
+ return [{ text: ELLIPSIS, el: true, wrap }, { text: src.slice(from, e), src: [from, e], wrap }]
347
+ }
348
+ if (!forced && (a.limit === undefined || a.limit >= e - s)) return [{ text: full, src: [s, e] }]
349
+ const close = closeLen(src, a)
350
+ const visibleEnd = forced ? lineEnd : e
351
+ let headEnd = visibleEnd
352
+ if (a.limit !== undefined) headEnd = Math.min(visibleEnd, s + Math.max(0, a.limit - 1 - close))
353
+ // never end the head inside a line with trailing blanks
354
+ while (headEnd > s && /\s/.test(src[headEnd - 1])) headEnd--
355
+ const out: Piece[] = []
356
+ if (headEnd > s) out.push({ text: src.slice(s, headEnd), src: [s, headEnd], wrap })
357
+ out.push({ text: ELLIPSIS, el: true, wrap })
358
+ if (close && (a.limit === undefined || a.limit > 1)) out.push({ text: src.slice(e - close, e), src: [e - close, e], wrap })
359
+ return out
360
+ }
361
+
362
+ function render(node: S, src: string, out: Piece[] = []): Piece[] {
363
+ if (Array.isArray(node)) {
364
+ for (const n of node) render(n, src, out)
365
+ return out
366
+ }
367
+ switch (node.k) {
368
+ case 'tok':
369
+ out.push({ text: node.text, cls: node.cls })
370
+ break
371
+ case 'atom':
372
+ out.push(...atomPieces(node, src))
373
+ break
374
+ case 'fold':
375
+ out.push({ text: ELLIPSIS, el: true, title: src.slice(node.start, node.end) })
376
+ break
377
+ case 'elide':
378
+ if (node.collapsed) out.push({ text: ELLIPSIS, el: true, title: src.slice(node.start, node.end) })
379
+ else render(node.inner, src, out)
380
+ break
381
+ case 'list': {
382
+ const n = node.items.length
383
+ for (let i = 0; i < Math.min(node.keep, n); i++) {
384
+ if (i > 0) out.push({ text: node.seps[i - 1], cls: node.seps[i - 1].trim() ? 'op' : undefined })
385
+ render(node.items[i], src, out)
386
+ }
387
+ if (node.keep < n) {
388
+ if (node.keep > 0) out.push({ text: node.seps[node.keep - 1], cls: 'op' })
389
+ out.push({ text: ELLIPSIS, el: true, title: src.slice(node.spans[node.keep][0], node.spans[n - 1][1]) })
390
+ }
391
+ break
392
+ }
393
+ case 'args': {
394
+ let i = 0
395
+ let first = true
396
+ while (i < node.items.length) {
397
+ if (!first) out.push({ text: ' ' })
398
+ first = false
399
+ const it = node.items[i]
400
+ if (!it.hidden) {
401
+ render(it.node, src, out)
402
+ i++
403
+ continue
404
+ }
405
+ let j = i
406
+ while (j < node.items.length && node.items[j].hidden) j++
407
+ out.push({ text: ELLIPSIS, el: true, title: src.slice(node.items[i].node.start, node.items[j - 1].node.end) })
408
+ i = j
409
+ }
410
+ break
411
+ }
412
+ }
413
+ return out
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Reductions
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /** A possible reduction: `cost` (information lost) and the columns it saves. `apply` returns its undo. */
421
+ type Cand = { cost: number; saving: number; key: number; apply: () => () => void }
422
+ interface Ctx {
423
+ atomWidth: (a: Atom) => number
424
+ width: (n: S) => number
425
+ src: string
426
+ }
427
+
428
+ function collect(node: S, cands: Cand[], ctx: Ctx, inArgs = false): void {
429
+ if (Array.isArray(node)) {
430
+ for (const n of node) collect(n, cands, ctx)
431
+ return
432
+ }
433
+ switch (node.k) {
434
+ case 'atom':
435
+ atomCands(node, cands, ctx, inArgs)
436
+ return
437
+ case 'elide': {
438
+ if (node.collapsed) return
439
+ collect(node.inner, cands, ctx)
440
+ cands.push({ cost: 60, saving: ctx.width(node.inner) - 1, key: node.size, apply: () => { node.collapsed = true; return () => { node.collapsed = false } } })
441
+ return
442
+ }
443
+ case 'list': {
444
+ const shown = Math.min(node.keep, node.items.length)
445
+ for (let i = 0; i < shown; i++) collect(node.items[i], cands, ctx)
446
+ if (node.keep > 1) {
447
+ const last = node.keep - 1
448
+ // dropping the last shown statement: when others are already dropped, it merges into their `…`
449
+ const saving = ctx.width(node.items[last]) + (node.keep < node.items.length ? node.seps[last - 1].length : -1)
450
+ cands.push({ cost: 100, saving, key: node.pos, apply: () => { node.keep--; return () => { node.keep++ } } })
451
+ }
452
+ return
453
+ }
454
+ case 'args':
455
+ node.items.forEach((it, i) => {
456
+ if (it.hidden) return
457
+ collect(it.node, cands, ctx, true)
458
+ if (it.fixed) return
459
+ const w = ctx.atomWidth(it.node)
460
+ // short items say a lot per column: hiding them costs double
461
+ // subcommands are nearly structure (`git status`): hidden only after compound bodies collapse
462
+ const cost = { str: 3, word: 3, val: 3, cmd: 3, sc: 80, lflag: 3.5, target: 6, path: 6, flag: 8 }[it.node.kind] * (w <= 6 ? 2 : 1)
463
+ const merges = (node.items[i - 1]?.hidden ? 1 : 0) + (node.items[i + 1]?.hidden ? 1 : 0)
464
+ // alone it becomes `…`; next to a hidden run it disappears with its space
465
+ const saving = merges === 0 ? w - 1 : merges === 1 ? w + 1 : w + 3
466
+ cands.push({ cost, saving, key: it.node.pos, apply: () => { it.hidden = true; return () => { it.hidden = false } } })
467
+ })
468
+ return
469
+ default:
470
+ return
471
+ }
472
+ }
473
+
474
+ function atomCands(a: Atom, cands: Cand[], ctx: Ctx, inArgs: boolean): void {
475
+ const w = ctx.atomWidth(a)
476
+ const shorten = (cost: number, minKeep: number): void => {
477
+ // shorten by half at a time (at least to minKeep); re-scored every round
478
+ const limit = Math.max(minKeep, Math.ceil(w / 2))
479
+ if (limit < w) cands.push({ cost, saving: w - limit, key: a.pos, apply: () => { const old = a.limit; a.limit = limit; return () => { a.limit = old } } })
480
+ }
481
+ const toTail = (cost: number): void => {
482
+ a.tail = true
483
+ const tw = ctx.atomWidth(a)
484
+ a.tail = false
485
+ if (tw < w) cands.push({ cost, saving: w - tw, key: a.pos, apply: () => { a.tail = true; return () => { a.tail = false } } })
486
+ }
487
+ switch (a.kind) {
488
+ case 'str':
489
+ case 'word':
490
+ if (w > 12) shorten(1, 12)
491
+ return
492
+ case 'path':
493
+ case 'target':
494
+ if (w <= 12) return // short paths (`/dev/null`) are clearer whole; they can still be hidden as arguments
495
+ if (!a.tail) toTail(1.2)
496
+ else shorten(4, a.kind === 'target' ? 6 : 8)
497
+ return
498
+ case 'val':
499
+ if (w > 12) shorten(1, 12)
500
+ else if (w > 1) cands.push({ cost: w <= 6 ? 8 : 4, saving: w - 1, key: a.pos, apply: () => { const old = a.limit; a.limit = 1; return () => { a.limit = old } } })
501
+ return
502
+ case 'cmd':
503
+ if (inArgs) return
504
+ // only command names that are paths (`/usr/local/bin/x`, `./scripts/build.sh`) shrink
505
+ if (!ctx.src.slice(a.start, a.end).includes('/')) return
506
+ if (!a.tail) toTail(150)
507
+ else shorten(150, 6)
508
+ return
509
+ default:
510
+ return
511
+ }
512
+ }
513
+
514
+ /** The reduction state of a tree (limits, tails, hidden args, collapsed bodies, list lengths). */
515
+ function snapshot(node: S, out: unknown[] = []): unknown[] {
516
+ if (Array.isArray(node)) {
517
+ for (const n of node) snapshot(n, out)
518
+ return out
519
+ }
520
+ switch (node.k) {
521
+ case 'atom': out.push(node.limit, node.tail); break
522
+ case 'elide': out.push(node.collapsed); snapshot(node.inner, out); break
523
+ case 'list': out.push(node.keep); for (const it of node.items) snapshot(it, out); break
524
+ case 'args': for (const it of node.items) { out.push(it.hidden); snapshot(it.node, out) } break
525
+ default: break
526
+ }
527
+ return out
528
+ }
529
+
530
+ function restore(node: S, snap: unknown[], at = { i: 0 }): void {
531
+ if (Array.isArray(node)) {
532
+ for (const n of node) restore(n, snap, at)
533
+ return
534
+ }
535
+ switch (node.k) {
536
+ case 'atom': node.limit = snap[at.i++] as number | undefined; node.tail = snap[at.i++] as boolean | undefined; break
537
+ case 'elide': node.collapsed = snap[at.i++] as boolean; restore(node.inner, snap, at); break
538
+ case 'list': node.keep = snap[at.i++] as number; for (const it of node.items) restore(it, snap, at); break
539
+ case 'args': for (const it of node.items) { it.hidden = snap[at.i++] as boolean; restore(it.node, snap, at) } break
540
+ default: break
541
+ }
542
+ }
543
+
544
+ /** Cut the rendered line at `width` columns, ending in an ellipsis that holds the rest. */
545
+ function hardCut(pieces: Piece[], width: number, src: string): Piece[] {
546
+ const out: Piece[] = []
547
+ let used = 0
548
+ const budget = width - 1
549
+ let restStart: number | null = null
550
+ for (const p of pieces) {
551
+ if (used + p.text.length <= budget) {
552
+ out.push(p)
553
+ used += p.text.length
554
+ continue
555
+ }
556
+ const room = budget - used
557
+ if (room > 0) {
558
+ const text = p.text.slice(0, room)
559
+ out.push({ ...p, text, src: p.src ? [p.src[0], p.src[0] + room] : undefined, el: false })
560
+ used += room
561
+ }
562
+ if (p.src && restStart === null) restStart = p.src[0] + Math.max(0, room)
563
+ break
564
+ }
565
+ out.push({ text: ELLIPSIS, el: true, title: restStart !== null ? src.slice(restStart) : src })
566
+ return out
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // HTML
571
+ // ---------------------------------------------------------------------------
572
+
573
+ const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
574
+
575
+ function pieceHtml(p: Piece, hl: Highlight): string {
576
+ if (p.el) return `<span class="el"${p.title !== undefined ? ` title="${esc(p.title)}"` : ''}>${esc(p.text)}</span>`
577
+ if (p.src) {
578
+ return hl.runs(p.src[0], p.src[1]).map((r) => {
579
+ const cls = r.cls || p.cls
580
+ return cls ? `<span class="${cls}">${esc(r.text)}</span>` : esc(r.text)
581
+ }).join('')
582
+ }
583
+ return p.cls ? `<span class="${p.cls}">${esc(p.text)}</span>` : esc(p.text)
584
+ }
585
+
586
+ function toHtml(pieces: Piece[], hl: Highlight): string {
587
+ let html = ''
588
+ for (let i = 0; i < pieces.length; i++) {
589
+ const p = pieces[i]
590
+ if (p.wrap) {
591
+ let inner = ''
592
+ let j = i
593
+ while (j < pieces.length && pieces[j].wrap === p.wrap) inner += pieceHtml(pieces[j++], hl)
594
+ html += `<span class="tr" title="${esc(p.wrap.title)}">${inner}</span>`
595
+ i = j - 1
596
+ continue
597
+ }
598
+ html += pieceHtml(p, hl)
599
+ }
600
+ return `<span class="mash sum">${html}</span>`
601
+ }