@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,441 @@
1
+ /**
2
+ * HTML-native pretty-printing: the same layout as `format`, emitted as
3
+ * syntax-highlighted spans, with best-effort truncation of over-long words so
4
+ * lines fit the width. Truncated output is for display only (it is not valid
5
+ * bash); hidden text stays in the DOM and a click can reveal it.
6
+ *
7
+ * Truncation is a small search, not an optimum: render, find lines over the
8
+ * width, shorten the most suitable atom on each (quoted strings and
9
+ * substitutions first, never command names or flags), re-render, repeat until
10
+ * nothing changes. Multi-line atoms (quoted strings, here-doc bodies) get
11
+ * their continuation lines clipped to the width and are collapsed to their
12
+ * first lines when they are longer than `maxLines`.
13
+ */
14
+
15
+ import type * as A from '../parser/ast.ts'
16
+ import { literalOf, parse } from '../parser/parser.ts'
17
+ import { analyzeScript } from '../analysis/analyze.ts'
18
+ import { concrete, hole, known } from '../analysis/value.ts'
19
+ import { defaultBehaviors } from '../behaviors/index.ts'
20
+ import type { BehaviorRegistry } from '../behaviors/registry.ts'
21
+ import type { AtomDisplay, AtomInfo, Doc, RenderResult } from './doc.ts'
22
+ import { render } from './doc.ts'
23
+ import { Highlight, simpleCommandRoles } from './highlight.ts'
24
+ import { buildDoc, type FormatOptions } from './print.ts'
25
+
26
+ export interface HtmlOptions extends FormatOptions {
27
+ /** Shorten over-long atoms so lines fit `width` (default true). */
28
+ truncate?: boolean
29
+ /** Lines shown of a multi-line string or here-doc body before collapsing (default 8). */
30
+ maxLines?: number
31
+ /** Never shorten an atom's first line below this many columns (default 16). */
32
+ minKeep?: number
33
+ /** Re-layout rounds (default 12). */
34
+ maxIterations?: number
35
+ /**
36
+ * Prefer shortening over breaking (default true): a line that fits once its
37
+ * strings and paths are shortened is not wrapped; the full text stays in
38
+ * the DOM (and tooltip). False lays out like `format`, then shortens.
39
+ */
40
+ preferTruncation?: boolean
41
+ /**
42
+ * Resolve file and directory arguments statically (default true): the
43
+ * working directory at each command comes from the static analysis (it
44
+ * follows `cd`), starting from `cwd` (unknown when not given). Known paths
45
+ * get a tooltip (`as written → /absolute/path` when they differ beyond `~`)
46
+ * and a `data-href="file://…"` that {@link attachHtmlHandlers} opens.
47
+ */
48
+ paths?: boolean
49
+ /** Working directory the snippet starts in. */
50
+ cwd?: string
51
+ /** Home directory for `~` (default: $HOME). */
52
+ home?: string
53
+ }
54
+
55
+ /** A file or directory argument, as written and (when known statically) absolute. */
56
+ interface PathInfo {
57
+ written: string
58
+ abs?: string
59
+ }
60
+
61
+ /** `/a/./b/../c` → `/a/c` (absolute paths). */
62
+ function normalizePath(p: string): string {
63
+ const out: string[] = []
64
+ for (const part of p.split('/')) {
65
+ if (part === '' || part === '.') continue
66
+ if (part === '..') out.pop()
67
+ else out.push(part)
68
+ }
69
+ return '/' + out.join('/') + (p.endsWith('/') && out.length ? '/' : '')
70
+ }
71
+
72
+ let sharedBehaviors: BehaviorRegistry | null = null
73
+
74
+ /**
75
+ * File and directory arguments (and redirection targets) by word id, with
76
+ * their absolute path when the static analysis knows the working directory.
77
+ */
78
+ function staticPaths(script: A.Script, source: string, cwd: string | undefined, home: string | undefined): Map<number, PathInfo> {
79
+ const out = new Map<number, PathInfo>()
80
+ const cwdAt = new Map<number, string | undefined>()
81
+ try {
82
+ const report = analyzeScript(script, source, {
83
+ cwd: cwd ? known(cwd) : hole('path', 'cwd'),
84
+ env: home ? { HOME: home } : {},
85
+ policy: { mode: 'danger-full-access', workspaceRoot: cwd ?? '/', writableTemp: true, network: 'allow', unknownCommands: 'allow' },
86
+ behaviors: (sharedBehaviors ??= defaultBehaviors()),
87
+ })
88
+ for (const c of report.commands) cwdAt.set(c.nodeId, concrete(c.cwd))
89
+ } catch {
90
+ // display only: paths stay unresolved
91
+ }
92
+ const absOf = (w: string, dir: string | undefined): string | undefined => {
93
+ if (/[*?[\]{}$`\\]/.test(w)) return undefined // globs and anything unexpanded
94
+ if (w.startsWith('/')) return normalizePath(w)
95
+ if (w === '~' || w.startsWith('~/')) return home ? normalizePath(home + w.slice(1)) : undefined
96
+ if (w.startsWith('~')) return undefined // ~user
97
+ return dir ? normalizePath(`${dir}/${w}`) : undefined
98
+ }
99
+ const add = (id: number, w: string | null, dir: string | undefined): void => {
100
+ if (w === null || w === '') return
101
+ out.set(id, { written: w, abs: absOf(w, dir) })
102
+ }
103
+ const visit = (n: unknown): void => {
104
+ if (Array.isArray(n)) {
105
+ for (const x of n) visit(x)
106
+ return
107
+ }
108
+ if (n === null || typeof n !== 'object') return
109
+ const node = n as A.Node & Record<string, unknown>
110
+ if (node.type === 'SimpleCommand') {
111
+ const c = node as unknown as A.SimpleCommand
112
+ const dir = cwdAt.get(c.id)
113
+ let roles: ReturnType<typeof simpleCommandRoles> = []
114
+ try {
115
+ roles = simpleCommandRoles(c)
116
+ } catch {
117
+ // display only
118
+ }
119
+ c.words.forEach((w, i) => {
120
+ if (w.type === 'Word' && (roles[i] === 'file' || roles[i] === 'dir')) add(w.id, literalOf(w), dir)
121
+ })
122
+ for (const r of c.redirs) {
123
+ if (r.heredoc || /&\s*$/.test(source.slice(r.span.start, r.target.span.start))) continue
124
+ add(r.target.id, literalOf(r.target), dir)
125
+ }
126
+ }
127
+ for (const [k, v] of Object.entries(node)) if (k !== 'span' && v && typeof v === 'object') visit(v)
128
+ }
129
+ visit(script.body)
130
+ return out
131
+ }
132
+
133
+ /** The tooltip for a path: `as written → absolute` when they differ beyond `~`, else the path. */
134
+ function pathTitle(p: PathInfo, home: string | undefined): string {
135
+ if (!p.abs) return p.written
136
+ const tilde = home && (p.written === '~' || p.written.startsWith('~/')) ? home + p.written.slice(1) : p.written
137
+ return tilde === p.abs || tilde === p.abs.replace(/\/$/, '') ? p.abs : `${p.written} → ${p.abs}`
138
+ }
139
+
140
+ const fileHref = (abs: string): string => 'file://' + encodeURI(abs).replace(/#/g, '%23').replace(/\?/g, '%3F')
141
+
142
+ export interface HtmlResult {
143
+ /** `<pre class="mash">…</pre>` */
144
+ html: string
145
+ /** The displayed text (with `…` where truncated). */
146
+ text: string
147
+ /** Number of atoms shortened or collapsed. */
148
+ truncated: number
149
+ /** Lines still wider than `width` (unbreakable code, not truncatable). */
150
+ overflowing: number
151
+ }
152
+
153
+ const ELLIPSIS = '…'
154
+
155
+ interface Atom extends AtomInfo {
156
+ start: number
157
+ end: number
158
+ }
159
+
160
+ /** Atoms referenced by a document, with their source ranges. */
161
+ function collectAtoms(doc: Doc, out = new Map<number, Atom>()): Map<number, Atom> {
162
+ if (typeof doc === 'string') return out
163
+ if (Array.isArray(doc)) {
164
+ for (const d of doc) collectAtoms(d, out)
165
+ return out
166
+ }
167
+ switch (doc.t) {
168
+ case 'src':
169
+ case 'heredoc':
170
+ if (doc.atom) out.set(doc.atom.id, { ...doc.atom, start: doc.start, end: doc.end })
171
+ break
172
+ case 'group': case 'indent': case 'mark': collectAtoms(doc.doc, out); break
173
+ case 'fill': for (const p of doc.parts) collectAtoms(p, out); break
174
+ case 'ifBreak': collectAtoms(doc.broken, out); collectAtoms(doc.flat, out); break
175
+ case 'suffix': collectAtoms(doc.doc, out); break
176
+ default: break
177
+ }
178
+ return out
179
+ }
180
+
181
+ /**
182
+ * How an atom is shown under the current limits, or null when it is shown in
183
+ * full. `first` limits the width of its first line; continuation lines are
184
+ * clipped to `cols` (they start at column 0); more than `maxLines` lines collapse.
185
+ */
186
+ /** The narrowest a path atom gets: `…/basename` (whole last component). */
187
+ function tailFloor(source: string, a: Atom): number {
188
+ const slash = source.lastIndexOf('/', a.end - 2)
189
+ return slash > a.start ? Math.min(a.end - a.start, 1 + a.end - slash) : a.end - a.start
190
+ }
191
+
192
+ /** A path shortened from the left at a `/` where possible: `…/pages/395.html`. */
193
+ function tailDisplay(source: string, a: Atom, limit: number): AtomDisplay | null {
194
+ const full = a.end - a.start
195
+ if (limit >= full) return null
196
+ let from = a.end - Math.max(1, limit - 1)
197
+ const slash = source.indexOf('/', from)
198
+ if (slash >= 0 && slash < a.end - 1) from = slash
199
+ if (from <= a.start) return null
200
+ const parts: AtomDisplay['parts'] = [
201
+ { kind: 'hide', start: a.start, end: from },
202
+ { kind: 'mark', text: ELLIPSIS },
203
+ { kind: 'show', start: from, end: a.end },
204
+ ]
205
+ return { text: ELLIPSIS + source.slice(from, a.end), parts }
206
+ }
207
+
208
+ function displayFor(source: string, a: Atom, first: number, cols: number, maxLines: number): AtomDisplay | null {
209
+ if (a.tail) return tailDisplay(source, a, first)
210
+ const lines: [number, number][] = []
211
+ let pos = a.start
212
+ while (pos <= a.end) {
213
+ const nlAt = source.indexOf('\n', pos)
214
+ const e = nlAt === -1 || nlAt >= a.end ? a.end : nlAt
215
+ lines.push([pos, e])
216
+ if (e >= a.end) break
217
+ pos = e + 1
218
+ }
219
+ // a here-doc body ends with a newline: drop the empty line after it
220
+ if (lines.length > 1 && lines[lines.length - 1][0] === lines[lines.length - 1][1] && a.end > a.start && source[a.end - 1] === '\n') lines.pop()
221
+ const lastChar = source[a.end - 1]
222
+ const closeLen = a.kind === 'word' && a.end - a.start > 2 && `'")}\``.includes(lastChar) ? 1 : 0
223
+ const parts: AtomDisplay['parts'] = []
224
+ let hidden = false
225
+ const clip = (ls: number, le: number, limit: number, close: number): void => {
226
+ if (le - ls <= limit) {
227
+ parts.push({ kind: 'show', start: ls, end: le })
228
+ return
229
+ }
230
+ const keep = Math.max(1, limit - 1 - close)
231
+ parts.push({ kind: 'show', start: ls, end: ls + keep })
232
+ parts.push({ kind: 'hide', start: ls + keep, end: le - close })
233
+ parts.push({ kind: 'mark', text: ELLIPSIS })
234
+ if (close) parts.push({ kind: 'show', start: le - close, end: le })
235
+ hidden = true
236
+ }
237
+ const newlineAfter = (i: number): void => {
238
+ const e = lines[i][1]
239
+ if (e < a.end && source[e] === '\n') parts.push({ kind: 'show', start: e, end: e + 1 })
240
+ }
241
+ const n = lines.length
242
+ const limitOf = (i: number): number => (i === 0 && a.kind !== 'hd' ? first : cols)
243
+ const closeOf = (i: number): number => (i === n - 1 ? closeLen : 0)
244
+ let shown: number[]
245
+ let collapseFrom = -1
246
+ let collapseTo = -1
247
+ if (n > maxLines) {
248
+ hidden = true
249
+ if (a.kind === 'hd') {
250
+ shown = [...Array(maxLines).keys()]
251
+ collapseFrom = maxLines
252
+ collapseTo = n
253
+ } else {
254
+ shown = [...Array(Math.max(1, maxLines - 1)).keys(), n - 1]
255
+ collapseFrom = Math.max(1, maxLines - 1)
256
+ collapseTo = n - 1
257
+ }
258
+ } else shown = [...Array(n).keys()]
259
+ for (const i of shown) {
260
+ if (i === collapseTo && collapseFrom >= 0) {
261
+ const hs = lines[collapseFrom][0]
262
+ parts.push({ kind: 'hide', start: hs, end: lines[i][0] })
263
+ parts.push({ kind: 'mark', text: `⋯ ${collapseTo - collapseFrom} more lines\n` })
264
+ }
265
+ clip(lines[i][0], lines[i][1], limitOf(i), closeOf(i))
266
+ if (i !== n - 1 || a.kind === 'hd') newlineAfter(i)
267
+ }
268
+ if (collapseFrom >= 0 && collapseTo === n) {
269
+ const hs = lines[collapseFrom][0]
270
+ parts.push({ kind: 'hide', start: hs, end: a.end })
271
+ parts.push({ kind: 'mark', text: `⋯ ${collapseTo - collapseFrom} more lines\n` })
272
+ }
273
+ if (!hidden) return null
274
+ const text = parts.map((p) => (p.kind === 'mark' ? p.text : p.kind === 'show' ? source.slice(p.start, p.end) : '')).join('')
275
+ return { text, parts }
276
+ }
277
+
278
+ const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
279
+
280
+ /** Pretty-print `source` as highlighted (and, by default, truncated) HTML. */
281
+ export function formatHtml(source: string, options: HtmlOptions = {}): HtmlResult {
282
+ return printScriptHtml(parse(source), source, options)
283
+ }
284
+
285
+ export function printScriptHtml(script: A.Script, source: string, options: HtmlOptions = {}): HtmlResult {
286
+ const truncate = options.truncate ?? true
287
+ const maxLines = options.maxLines ?? 8
288
+ const minKeep = options.minKeep ?? 16
289
+ const maxIterations = options.maxIterations ?? 12
290
+ const { doc, opts } = buildDoc(script, source, options)
291
+ const width = opts.width
292
+ const atoms = collectAtoms(doc)
293
+ const limits = new Map<number, number>()
294
+ // how far each atom may shrink: paths to `…/basename`, other words to minKeep
295
+ const floor = new Map<number, number>()
296
+ for (const a of atoms.values()) {
297
+ if (a.kind !== 'word' || a.weight <= 0) continue
298
+ const firstLine = source.slice(a.start, a.end).split('\n')[0].length
299
+ if (source.slice(a.start, a.end).includes('\n')) continue
300
+ floor.set(a.id, a.tail ? tailFloor(source, a) : Math.min(firstLine, minKeep))
301
+ }
302
+ // prefer shortening atoms over breaking lines: layout measures atoms at their floor
303
+ const minWidth = truncate && (options.preferTruncation ?? true) ? floor : undefined
304
+ const hl = new Highlight(script, source)
305
+
306
+ let display = new Map<number, AtomDisplay>()
307
+ let r: RenderResult = render(doc, width, { source })
308
+ for (let iter = 0; truncate && iter < maxIterations; iter++) {
309
+ display = new Map()
310
+ for (const a of atoms.values()) {
311
+ const d = displayFor(source, a, limits.get(a.id) ?? Infinity, width, maxLines)
312
+ if (d) display.set(a.id, d)
313
+ }
314
+ r = render(doc, width, { source, display, minWidth })
315
+ const byLine = new Map<number, { id: number; width: number }[]>()
316
+ for (const [id, pos] of r.atoms) {
317
+ const list = byLine.get(pos.line) ?? []
318
+ list.push({ id, width: pos.width })
319
+ byLine.set(pos.line, list)
320
+ }
321
+ let changed = false
322
+ r.text.split('\n').forEach((line, i) => {
323
+ let over = line.length - width
324
+ if (over <= 0) return
325
+ // shorten the line's atoms, most suitable first (weight × width), each down to its floor, until the overflow is covered
326
+ const cands = (byLine.get(i) ?? [])
327
+ .map((c) => ({ ...c, a: atoms.get(c.id), lo: floor.get(c.id) ?? minKeep }))
328
+ .filter((c) => c.a && c.a.kind !== 'hd' && c.a.weight > 0 && c.width > c.lo)
329
+ .sort((x, y) => y.a!.weight * y.width - x.a!.weight * x.width)
330
+ for (const c of cands) {
331
+ if (over <= 0) break
332
+ const limit = Math.max(c.lo, c.width - over)
333
+ if (limit < (limits.get(c.id) ?? Infinity) && limit < c.width) {
334
+ limits.set(c.id, limit)
335
+ over -= c.width - limit
336
+ changed = true
337
+ }
338
+ }
339
+ })
340
+ if (!changed) break
341
+ }
342
+
343
+ const highlighted = (start: number, end: number, fallback?: string): string =>
344
+ hl.runs(start, end).map((run) => {
345
+ const cls = run.cls || fallback || ''
346
+ return cls ? `<span class="${cls}">${esc(run.text)}</span>` : esc(run.text)
347
+ }).join('')
348
+
349
+ const home = options.home ?? (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.HOME
350
+ const paths = options.paths === false ? new Map<number, PathInfo>() : staticPaths(script, source, options.cwd, home)
351
+ const TITLE_MAX = 2000
352
+
353
+ let html = ''
354
+ for (const p of r.pieces) {
355
+ const d = p.atom !== undefined ? display.get(p.atom) : undefined
356
+ const path = p.atom !== undefined ? paths.get(p.atom) : undefined
357
+ if (d || path) {
358
+ // one wrapper per atom: path role (whole thing underlined), tooltip, link, truncation state
359
+ const a = atoms.get(p.atom!)
360
+ const classes: string[] = []
361
+ if (path) {
362
+ const last = p.src ? hl.runs(p.src[1] - 1, p.src[1])[0]?.cls : undefined
363
+ classes.push('pth', last === 'dir' ? 'dir' : 'file')
364
+ }
365
+ if (d) classes.push('tc')
366
+ const full = a ? source.slice(a.start, a.end) : p.text
367
+ const title = path ? pathTitle(path, home) : full.length > TITLE_MAX ? full.slice(0, TITLE_MAX) + ELLIPSIS : full
368
+ const href = path?.abs ? ` data-href="${esc(fileHref(path.abs))}"` : ''
369
+ html += `<span class="${classes.join(' ')}" title="${esc(title)}"${href}>`
370
+ if (d) {
371
+ for (const part of d.parts) {
372
+ if (part.kind === 'show') html += highlighted(part.start, part.end, p.cls)
373
+ else if (part.kind === 'hide') html += `<span class="hid">${highlighted(part.start, part.end, p.cls)}</span>`
374
+ else if (part.kind === 'mark') html += `<span class="el">${esc(part.text)}</span>`
375
+ }
376
+ } else if (p.src) html += highlighted(p.src[0], p.src[1], p.cls)
377
+ else html += esc(p.text)
378
+ html += '</span>'
379
+ } else if (p.src) html += highlighted(p.src[0], p.src[1], p.cls)
380
+ else html += p.cls ? `<span class="${p.cls}">${esc(p.text)}</span>` : esc(p.text)
381
+ }
382
+ const overflowing = r.text.split('\n').filter((l) => l.length > width).length
383
+ return { html: `<pre class="mash">${html}</pre>`, text: r.text, truncated: display.size, overflowing }
384
+ }
385
+
386
+ /** A DOM element, as far as the click handler needs one (no DOM types in this package). */
387
+ interface ElementLike {
388
+ closest(selector: string): (ElementLike & { dataset: Record<string, string | undefined>; classList: { toggle(c: string): void } }) | null
389
+ }
390
+
391
+ /**
392
+ * Click behavior for HTML output under `root`: a statically known path opens
393
+ * in a new tab (`file://`; browsers allow that from file:// pages, not from
394
+ * http ones), any other shortened item expands or collapses.
395
+ */
396
+ export function attachHtmlHandlers(root: { addEventListener(type: 'click', fn: (e: { target: unknown; preventDefault(): void }) => void): void }): void {
397
+ root.addEventListener('click', (e) => {
398
+ const t = e.target as ElementLike | null
399
+ const link = t?.closest?.('.mash [data-href]')
400
+ if (link) {
401
+ e.preventDefault()
402
+ ;(globalThis as { open?: (url: string, target: string, features: string) => unknown }).open?.(link.dataset.href!, '_blank', 'noopener')
403
+ return
404
+ }
405
+ t?.closest?.('.mash .tc')?.classList.toggle('open')
406
+ })
407
+ }
408
+
409
+ /** {@link attachHtmlHandlers} as a script for pages without a module loader: `<script>${HTML_SCRIPT}</script>`. */
410
+ export const HTML_SCRIPT = `(${attachHtmlHandlers.toString()})(document)`
411
+
412
+ /** Default styles (light and dark). Attach the click handlers with {@link attachHtmlHandlers} or {@link HTML_SCRIPT}. */
413
+ export const HTML_CSS = `
414
+ .mash { --kw:#a626a4; --op:#0184bc; --cmd:#4078f2; --flag:#986801; --str:#50a14f; --esc:#c18401; --var:#e45649;
415
+ --sub:#0184bc; --ar:#986801; --asg:#e45649; --rd:#0184bc; --hd:#50a14f; --cm:#a0a1a7; --fn:#4078f2; --el:#a0a1a7;
416
+ --dir:#00838f; --file:#3f51b5; --re:#d84315; --code:#7b5e57; --sc:#7e3fd8;
417
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; margin: 0; tab-size: 4; }
418
+ @media (prefers-color-scheme: dark) {
419
+ .mash { --kw:#c678dd; --op:#56b6c2; --cmd:#61afef; --flag:#d19a66; --str:#98c379; --esc:#e5c07b; --var:#e06c75;
420
+ --sub:#56b6c2; --ar:#d19a66; --asg:#e06c75; --rd:#56b6c2; --hd:#98c379; --cm:#7f848e; --fn:#61afef; --el:#7f848e;
421
+ --dir:#4dd0e1; --file:#9fa8ff; --re:#ff8a65; --code:#c8b5ad; --sc:#c49bff; }
422
+ }
423
+ .mash .kw { color: var(--kw); } .mash .op { color: var(--op); } .mash .cmd { color: var(--cmd); font-weight: 600; }
424
+ .mash .flag { color: var(--flag); } .mash .str { color: var(--str); } .mash .esc { color: var(--esc); }
425
+ .mash .var { color: var(--var); } .mash .sub { color: var(--sub); font-weight: 600; } .mash .ar, .mash .num { color: var(--ar); }
426
+ .mash .asg { color: var(--asg); } .mash .rd { color: var(--rd); } .mash .hd { color: var(--hd); }
427
+ .mash .pth { text-decoration: underline dotted; text-underline-offset: 3px; }
428
+ .mash .pth.dir { text-decoration-color: color-mix(in srgb, var(--dir) 55%, transparent); } .mash .pth.file { text-decoration-color: color-mix(in srgb, var(--file) 55%, transparent); }
429
+ .mash [data-href] { cursor: pointer; } .mash [data-href]:hover { background: color-mix(in srgb, currentColor 12%, transparent); border-radius: 3px; }
430
+ .mash .pth .dir, .mash .pth .file { text-decoration: none; }
431
+ /* paths: dotted underline (both kinds), told apart by color */
432
+ .mash .dir, .mash .file { text-decoration: underline dotted; text-underline-offset: 3px; }
433
+ .mash .dir { color: var(--dir); text-decoration-color: color-mix(in srgb, var(--dir) 45%, transparent); }
434
+ .mash .file { color: var(--file); text-decoration-color: color-mix(in srgb, var(--file) 45%, transparent); }
435
+ .mash .re { color: var(--re); } .mash .code { color: var(--code); } .mash .sc { color: var(--sc); font-weight: 600; }
436
+ .mash .cm { color: var(--cm); font-style: italic; } .mash .fn { color: var(--fn); font-weight: 600; } .mash .ct { color: var(--el); }
437
+ .mash .tc .hid { display: none; } .mash .tc.open .hid { display: inline; } .mash .tc.open > .el { display: none; }
438
+ .mash.sum { display: inline-block; white-space: pre; max-width: 100%; overflow: hidden; vertical-align: bottom; }
439
+ .mash.sum .el { cursor: help; } .mash .tr:hover { background: color-mix(in srgb, var(--el) 15%, transparent); border-radius: 3px; }
440
+ .mash .el { color: var(--el); cursor: pointer; border-radius: 3px; } .mash .el:hover { background: color-mix(in srgb, var(--el) 20%, transparent); }
441
+ `