@taliesinb/mash 0.0.0-stage → 0.1.1

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