@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,528 @@
1
+ /**
2
+ * Pretty-printer: renders a parsed script as readable bash that parses to the
3
+ * same AST (see docs/pretty-printer.md).
4
+ *
5
+ * Words, assignment values, redirections, here-doc bodies, arithmetic and
6
+ * `[[ ]]` expressions are emitted as their exact source text; only the layout
7
+ * (newlines, ` \` continuations, indentation) is regenerated.
8
+ */
9
+
10
+ import type * as A from '../parser/ast.ts'
11
+ import { parse, literalOf } from '../parser/parser.ts'
12
+ import type { ArgRole } from '../behaviors/types.ts'
13
+ import { simpleCommandRoles } from './highlight.ts'
14
+ import {
15
+ type AtomInfo, type Doc, blankLine, compoundGroup, cont, contForced, fill, group, hard, heredoc, indent, join, mark, nl, render, src, suffix, tok,
16
+ } from './doc.ts'
17
+
18
+ export interface FormatOptions {
19
+ /** Target line width (default 80). Single words longer than this overflow. */
20
+ width?: number
21
+ /** Indentation step (default 2). Wrapped arguments are indented twice this. */
22
+ indent?: number
23
+ /** Where `|`, `&&`, `||` go when a chain is broken: end of line (default) or start (with `\` continuations). */
24
+ operators?: 'trailing' | 'leading'
25
+ /**
26
+ * Prefix assignments (`FOO=1 BAR=2 cmd`): `auto` (default) puts each on its
27
+ * own line when there are two or more, or when the command does not fit;
28
+ * `always` does so for any number; `inline` only when it does not fit.
29
+ */
30
+ assignments?: 'auto' | 'always' | 'inline'
31
+ /** Compound commands: `auto` (default) keeps short ones on one line when they fit; `expanded` always expands. */
32
+ compounds?: 'auto' | 'expanded'
33
+ /** Reformat multi-line `$(…)` / `<(…)` bodies inside words (default false: verbatim). */
34
+ formatSubstitutions?: boolean
35
+ /** Keep at most this many blank lines between statements (default 1; 0 drops them). */
36
+ blankLines?: number
37
+ /** `function` keyword: normalize to `name()` (default) or preserve the original header. */
38
+ functionKeyword?: 'normalize' | 'preserve'
39
+ /**
40
+ * Top-level statements that share a source line (`a; b; c`). bash reads a
41
+ * script line by line: an expansion error (bad arithmetic, readonly
42
+ * assignment) discards the rest of the *logical* line, and an alias is only
43
+ * active from the next line. So:
44
+ * - `continuation` (default): one per line joined by `; \` — exactly the same behavior;
45
+ * - `newline`: plain separate lines (differs only after such an error);
46
+ * - `preserve`: keep them on one line.
47
+ * Inside compound commands and `$(…)` layout never matters (they are parsed whole).
48
+ */
49
+ statements?: 'continuation' | 'newline' | 'preserve'
50
+ }
51
+
52
+ export interface FormatResult {
53
+ text: string
54
+ /** Output range of every statement and command, by AST node id. */
55
+ map: Map<number, { start: number; end: number }>
56
+ }
57
+
58
+ /** Options with defaults applied. */
59
+ export type Opts = Required<FormatOptions>
60
+
61
+ const DEFAULTS: Opts = {
62
+ width: 80, indent: 2, operators: 'trailing', assignments: 'auto', compounds: 'auto',
63
+ formatSubstitutions: false, blankLines: 1, functionKeyword: 'normalize', statements: 'continuation',
64
+ }
65
+
66
+ /** Parse and pretty-print `source`. Throws ParseError on invalid input. */
67
+ export function format(source: string, options: FormatOptions = {}): FormatResult {
68
+ return printScript(parse(source), source, options)
69
+ }
70
+
71
+ /** Pretty-print an already parsed script; `source` must be the text it was parsed from. */
72
+ export function printScript(script: A.Script, source: string, options: FormatOptions = {}): FormatResult {
73
+ const { doc, opts } = buildDoc(script, source, options)
74
+ const r = render(doc, opts.width, { source })
75
+ return { text: r.text, map: r.map }
76
+ }
77
+
78
+ /** The layout document for a script (shared by the text and HTML renderers). */
79
+ export function buildDoc(script: A.Script, source: string, options: FormatOptions = {}): { doc: Doc; opts: Opts } {
80
+ const opts = { ...DEFAULTS, ...options }
81
+ const p = new Printer(source, script.comments ?? [], opts)
82
+ p.lineHazard = usesLineSensitiveBuiltins(script)
83
+ const doc = p.topLevel(script)
84
+ const leftover = p.remainingComments()
85
+ return { doc: leftover.length ? [doc, hard, join(hard, leftover)] : doc, opts }
86
+ }
87
+
88
+ /** `alias` and `shopt` change how *later lines* are parsed: never split their lines (docs/pretty-printer.md §5). */
89
+ function usesLineSensitiveBuiltins(script: A.Script): boolean {
90
+ let found = false
91
+ const visit = (n: unknown): void => {
92
+ if (found || n === null || typeof n !== 'object') return
93
+ if (Array.isArray(n)) {
94
+ for (const x of n) visit(x)
95
+ return
96
+ }
97
+ const node = n as { type?: string; words?: (A.Word | A.Assign)[] }
98
+ if (node.type === 'SimpleCommand') {
99
+ const w = node.words?.[0]
100
+ const name = w && w.type === 'Word' ? literalOf(w) : null
101
+ if (name === 'alias' || name === 'shopt' || name === 'unalias') found = true
102
+ }
103
+ for (const [k, v] of Object.entries(node)) if (k !== 'span' && v && typeof v === 'object') visit(v)
104
+ }
105
+ visit(script.body)
106
+ return found
107
+ }
108
+
109
+ type Body = A.Stmt[]
110
+
111
+ const kw = (text: string): Doc => tok(text, 'kw')
112
+ const op = (text: string): Doc => tok(text, 'op')
113
+
114
+ /** The plain text of a simple document (src/tok/string), or null. */
115
+ function plain(d: Doc, source: string): string | null {
116
+ if (typeof d === 'string') return d
117
+ if (Array.isArray(d)) return null
118
+ if (d.t === 'src') return source.slice(d.start, d.end)
119
+ if (d.t === 'tok') return d.text
120
+ return null
121
+ }
122
+
123
+ class Printer {
124
+ readonly src: string
125
+ readonly opts: Opts
126
+ lineHazard = false
127
+ private readonly comments: A.Comment[]
128
+ private readonly used: boolean[]
129
+
130
+ constructor(source: string, comments: A.Comment[], opts: Opts) {
131
+ this.src = source
132
+ this.comments = comments
133
+ this.used = comments.map(() => false)
134
+ this.opts = opts
135
+ }
136
+
137
+ /** Verbatim source, consuming the comments inside it. */
138
+ private verbatim(span: A.Span, atom?: AtomInfo, cls?: string): Doc {
139
+ this.consume(span)
140
+ return src(span.start, span.end, atom, cls)
141
+ }
142
+
143
+ private commentDoc(c: A.Comment): Doc {
144
+ const i = this.comments.indexOf(c)
145
+ return src(c.span.start, c.span.end, { id: -(i + 1), kind: 'cm', weight: 0.8 }, 'cm')
146
+ }
147
+
148
+ // -------------------------------------------------------------------------
149
+ // Comments
150
+ // -------------------------------------------------------------------------
151
+
152
+ /** Mark comments inside a verbatim span as printed (they are part of the text). */
153
+ private consume(span: A.Span): void {
154
+ this.comments.forEach((c, i) => {
155
+ if (!this.used[i] && c.span.start >= span.start && c.span.end <= span.end) this.used[i] = true
156
+ })
157
+ }
158
+
159
+ /** Unprinted comments that start before `offset`, in order (marked printed). */
160
+ private take(offset: number): A.Comment[] {
161
+ const out: A.Comment[] = []
162
+ this.comments.forEach((c, i) => {
163
+ if (!this.used[i] && c.span.start < offset) {
164
+ this.used[i] = true
165
+ out.push(c)
166
+ }
167
+ })
168
+ return out
169
+ }
170
+
171
+ /** The unprinted comment that follows `end` on the same source line, if any. */
172
+ private trailing(end: number, limit: number): A.Comment | null {
173
+ for (let i = 0; i < this.comments.length; i++) {
174
+ const c = this.comments[i]
175
+ if (this.used[i] || c.span.start < end || c.span.start >= limit) continue
176
+ if (this.src.slice(end, c.span.start).includes('\n')) return null
177
+ this.used[i] = true
178
+ return c
179
+ }
180
+ return null
181
+ }
182
+
183
+ private trailingDoc(c: A.Comment): Doc {
184
+ return suffix([' ', this.commentDoc(c)])
185
+ }
186
+
187
+ remainingComments(): Doc[] {
188
+ return this.take(Infinity).map((c) => this.commentDoc(c))
189
+ }
190
+
191
+ // -------------------------------------------------------------------------
192
+ // Statement lists
193
+ // -------------------------------------------------------------------------
194
+
195
+ topLevel(script: A.Script): Doc {
196
+ return this.list(script.body, script.span.end, true, true)
197
+ }
198
+
199
+ /**
200
+ * A statement list. `alwaysBreak` for the script top level; otherwise the
201
+ * separators are `nl` breaks that print `; ` when the enclosing group fits.
202
+ */
203
+ private list(stmts: Body, endOffset: number, alwaysBreak: boolean, top = false): Doc {
204
+ const parts: Doc[] = []
205
+ stmts.forEach((s, i) => {
206
+ const leading = this.take(s.span.start)
207
+ if (i > 0) {
208
+ const prev = stmts[i - 1]
209
+ const between = this.src.slice(prev.span.end, s.span.start)
210
+ // the same *logical* line: backslash-newline continuations do not end it
211
+ const sameLine = !between.replace(/\\\n/g, '').includes('\n')
212
+ if (top && sameLine && leading.length === 0 && this.opts.statements === 'continuation') {
213
+ parts.push(prev.background ? '' : op(';'), contForced)
214
+ } else if (sameLine && leading.length === 0 && ((top && this.opts.statements === 'preserve') || (this.lineHazard && this.opts.statements === 'newline'))) {
215
+ parts.push(prev.background ? ' ' : [op(';'), ' '])
216
+ } else {
217
+ const blank = this.opts.blankLines > 0 && /\n[ \t]*\n/.test(between)
218
+ if (alwaysBreak || leading.length > 0) parts.push(blank ? blankLine : hard)
219
+ else parts.push(nl(prev.background ? ' ' : '; ', blank))
220
+ }
221
+ }
222
+ for (const c of leading) parts.push(this.commentDoc(c), hard)
223
+ parts.push(this.stmt(s))
224
+ const next = stmts[i + 1]
225
+ const t = this.trailing(s.span.end, next ? next.span.start : endOffset)
226
+ if (t) parts.push(this.trailingDoc(t))
227
+ })
228
+ return parts
229
+ }
230
+
231
+ /** Does a body qualify to stay on one line (`compounds: 'auto'`)? */
232
+ private simpleBody(body: Body): boolean {
233
+ if (this.opts.compounds === 'expanded' || body.length !== 1) return false
234
+ const c = body[0].command
235
+ return c.type === 'SimpleCommand' || c.type === 'Pipeline' || c.type === 'AndOr'
236
+ }
237
+
238
+ private stmt(s: A.Stmt): Doc {
239
+ return mark(s.id, [this.command(s.command), s.background ? [' ', op('&')] : ''])
240
+ }
241
+
242
+ // -------------------------------------------------------------------------
243
+ // Commands
244
+ // -------------------------------------------------------------------------
245
+
246
+ private command(c: A.Command): Doc {
247
+ return mark(c.id, this.commandInner(c))
248
+ }
249
+
250
+ private commandInner(c: A.Command): Doc {
251
+ switch (c.type) {
252
+ case 'SimpleCommand': return this.simple(c)
253
+ case 'Pipeline': return this.pipeline(c)
254
+ case 'AndOr': return this.andOr(c)
255
+ case 'FunctionDef': return this.functionDef(c)
256
+ default: return [this.compound(c), this.compoundRedirs(c)]
257
+ }
258
+ }
259
+
260
+ /** Truncation preference for an argument word: strings and substitutions first. */
261
+ /**
262
+ * Truncation preference for an argument word: strings and substitutions
263
+ * first, then paths (shortened from the left), then plain words. Command
264
+ * names, flags and subcommands are never shortened.
265
+ */
266
+ private wordAtom(w: A.Word, role: 'cmd' | 'arg', argRole?: ArgRole): AtomInfo | undefined {
267
+ if (role === 'cmd' || argRole === 'cmd' || argRole === 'subcommand') return undefined
268
+ const text = this.src.slice(w.span.start, w.span.end)
269
+ if (/^-/.test(text) && !/[=\s]/.test(text)) return undefined
270
+ if ((argRole === 'file' || argRole === 'dir') && text.includes('/') && !text.includes('\n')) return { id: w.id, kind: 'word', weight: 0.9, tail: true }
271
+ const rich = w.parts.some((p) => p.type !== 'Lit' && p.type !== 'Escaped')
272
+ return { id: w.id, kind: 'word', weight: rich ? 1 : 0.6 }
273
+ }
274
+
275
+ private wordDoc(w: A.Word, role: 'cmd' | 'arg' = 'arg', argRole?: ArgRole): Doc {
276
+ if (this.opts.formatSubstitutions) {
277
+ const d = this.wordWithSubstitutions(w)
278
+ if (d) return d
279
+ }
280
+ return this.verbatim(w.span, this.wordAtom(w, role, argRole))
281
+ }
282
+
283
+ /** Words containing multi-line `$(…)` / `<(…)`: format their bodies, keep the rest verbatim. */
284
+ private wordWithSubstitutions(w: A.Word): Doc | null {
285
+ const substs: (A.CmdSubst | A.ProcSubst)[] = []
286
+ const visit = (parts: A.WordPart[]): void => {
287
+ for (const p of parts) {
288
+ if ((p.type === 'CmdSubst' && !p.backquote) || p.type === 'ProcSubst') {
289
+ if (this.src.slice(p.span.start, p.span.end).includes('\n') && p.body.length > 0) substs.push(p)
290
+ } else if (p.type === 'DblQuoted') visit(p.parts)
291
+ }
292
+ }
293
+ visit(w.parts)
294
+ if (substs.length === 0) return null
295
+ const out: Doc[] = []
296
+ let cursor = w.span.start
297
+ for (const s of substs) {
298
+ out.push(this.verbatim({ start: cursor, end: s.span.start }))
299
+ const open = s.type === 'CmdSubst' ? '$(' : `${s.direction}(`
300
+ out.push(tok(open, 'sub'), indent([hard, this.list(s.body, s.span.end, true)], this.opts.indent), hard, tok(')', 'sub'))
301
+ this.take(s.span.end) // comments right before `)`
302
+ cursor = s.span.end
303
+ }
304
+ out.push(this.verbatim({ start: cursor, end: w.span.end }))
305
+ return out
306
+ }
307
+
308
+ /** `name=value`: the `name=` part verbatim, the value as a word (so `$(…)` in it can be formatted). */
309
+ private assignDoc(a: A.Assign): Doc {
310
+ if (a.value?.type !== 'Word') return this.verbatim(a.span)
311
+ return [this.verbatim({ start: a.span.start, end: a.value.span.start }), this.wordDoc(a.value)]
312
+ }
313
+
314
+ /** The separator before a closing keyword (`done`, `fi`, `}`): `; ` flat, or ` ` after a background `&`. */
315
+ private closer(body: Body): Doc {
316
+ return nl(body[body.length - 1]?.background ? ' ' : '; ')
317
+ }
318
+
319
+ private redirDoc(r: A.Redirect): Doc {
320
+ if (!r.heredoc) {
321
+ // `> path`: the target is a path atom (an fd duplication like `2>&1` stays whole)
322
+ const op = { start: r.span.start, end: r.target.span.start }
323
+ const target = this.src.slice(r.target.span.start, r.target.span.end)
324
+ if (r.target.span.end > r.target.span.start && !/&\s*$/.test(this.src.slice(op.start, op.end)) && !target.includes('\n')) {
325
+ const tail = target.includes('/')
326
+ return [this.verbatim(op), this.verbatim(r.target.span, { id: r.target.id, kind: 'word', weight: tail ? 0.9 : 0.6, tail })]
327
+ }
328
+ return this.verbatim(r.span)
329
+ }
330
+ const text = this.verbatim(r.span)
331
+ const b = r.heredoc.body.span
332
+ this.consume(b)
333
+ return [text, heredoc(b.start, b.end, r.heredoc.delimiter, { id: r.id, kind: 'hd', weight: 1 })]
334
+ }
335
+
336
+ private simple(c: A.SimpleCommand): Doc {
337
+ const assigns = c.assigns.map((a) => this.assignDoc(a))
338
+ let first = true
339
+ let roles: (ArgRole | undefined)[] = []
340
+ try {
341
+ roles = simpleCommandRoles(c)
342
+ } catch {
343
+ // display only
344
+ }
345
+ const words = c.words.map((w, i) => {
346
+ if (w.type === 'Assign') return this.assignDoc(w)
347
+ const d = this.wordDoc(w, first ? 'cmd' : 'arg', roles[i])
348
+ first = false
349
+ return d
350
+ })
351
+ const redirs = c.redirs.map((r) => this.redirDoc(r))
352
+ // keep `-o value`, `-qm value` (combined short flags) and `--opt value` together when wrapping
353
+ const units: Doc[] = []
354
+ for (let i = 0; i < words.length; i++) {
355
+ const w = plain(words[i], this.src)
356
+ const next = i + 1 < words.length ? plain(words[i + 1], this.src) : null
357
+ if (i > 0 && w !== null && next !== null && /^(-[A-Za-z]+|--[A-Za-z][\w-]*)$/.test(w) && !next.startsWith('-')) {
358
+ units.push([words[i], ' ', words[i + 1]])
359
+ i++
360
+ } else units.push(words[i])
361
+ }
362
+ units.push(...redirs)
363
+ // the command name, then arguments filled onto lines and indented twice
364
+ const filled: Doc = units.length <= 1 ? (units[0] ?? '') : [units[0], indent(fill(fillParts(units.slice(1))), this.opts.indent * 2)]
365
+ if (assigns.length === 0) return group(filled)
366
+ if (units.length === 0) return group(join(cont, assigns))
367
+ const ownLines = this.opts.assignments === 'always' || (this.opts.assignments === 'auto' && assigns.length >= 2)
368
+ if (ownLines) return [join(contForced, assigns), contForced, group(filled)]
369
+ return group([join(cont, assigns), cont, group(filled)])
370
+ }
371
+
372
+ private operatorChain(items: A.Command[], ops: string[]): Doc {
373
+ const leading = this.opts.operators === 'leading'
374
+ const out: Doc[] = [this.command(items[0])]
375
+ const rest: Doc[] = []
376
+ for (let i = 1; i < items.length; i++) {
377
+ const o = ops[i - 1]
378
+ const comments = this.take(items[i].span.start)
379
+ if (leading && comments.length === 0) {
380
+ rest.push(cont, op(o), ' ', this.command(items[i]))
381
+ } else {
382
+ rest.push(' ', op(o))
383
+ for (const cm of comments) rest.push(this.trailingDoc(cm))
384
+ rest.push(nl(' '), this.command(items[i]))
385
+ }
386
+ }
387
+ out.push(indent(rest, this.opts.indent))
388
+ return group(out)
389
+ }
390
+
391
+ private pipeline(c: A.Pipeline): Doc {
392
+ const first = c.stages[0]
393
+ const prefix = this.src.slice(c.span.start, first.span.start).trim().replace(/\s+/g, ' ')
394
+ const ops = c.pipeStderr.map((e) => (e ? '|&' : '|'))
395
+ const chain = c.stages.length === 1 ? this.command(first) : this.operatorChain(c.stages, ops)
396
+ return prefix ? [kw(prefix), ' ', chain] : chain
397
+ }
398
+
399
+ private andOr(c: A.AndOr): Doc {
400
+ return this.operatorChain(c.items, c.ops)
401
+ }
402
+
403
+ private functionDef(f: A.FunctionDef): Doc {
404
+ const header = this.opts.functionKeyword === 'preserve'
405
+ ? tok(this.src.slice(f.span.start, f.body.span.start).trim().replace(/\s+/g, ' '), 'fn')
406
+ : tok(`${f.name}()`, 'fn')
407
+ return [header, ' ', this.command(f.body)]
408
+ }
409
+
410
+ private compoundRedirs(c: A.CompoundCommand): Doc {
411
+ if (c.redirs.length === 0) return ''
412
+ return [' ', join(' ', c.redirs.map((r) => this.redirDoc(r)))]
413
+ }
414
+
415
+ /** Source of a compound up to its first redirection (verbatim forms: `(( ))`, `[[ ]]`). */
416
+ private compoundText(c: A.CompoundCommand): Doc {
417
+ let end = c.redirs.length > 0 ? c.redirs[0].span.start : c.span.end
418
+ while (end > c.span.start && /\s/.test(this.src[end - 1])) end--
419
+ return this.verbatim({ start: c.span.start, end }, { id: c.id, kind: 'word', weight: 0.3 })
420
+ }
421
+
422
+ private body(body: Body, endOffset: number): Doc {
423
+ return indent([nl(' '), this.list(body, endOffset, false)], this.opts.indent)
424
+ }
425
+
426
+ private compound(c: A.CompoundCommand): Doc {
427
+ switch (c.type) {
428
+ case 'Group':
429
+ return compoundGroup([kw('{'), this.body(c.body, c.span.end), this.closer(c.body), kw('}')], !this.groupable(c.body))
430
+ case 'Subshell':
431
+ return compoundGroup([kw('('), this.body(c.body, c.span.end), nl(' '), kw(')')], !this.groupable(c.body))
432
+ case 'If': {
433
+ const parts: Doc[] = []
434
+ const flatOk = c.clauses.every((cl) => this.simpleBody(cl.body) && cl.cond.length === 1) && (c.elseBody === null || this.simpleBody(c.elseBody))
435
+ c.clauses.forEach((cl, i) => {
436
+ if (i > 0) parts.push(this.closer(c.clauses[i - 1].body), kw('elif'), ' ')
437
+ else parts.push(kw('if'), ' ')
438
+ parts.push(this.condList(cl.cond, cl.body[0]?.span.start ?? c.span.end), op(';'), ' ', kw('then'), this.body(cl.body, c.span.end))
439
+ })
440
+ const lastBody = c.clauses[c.clauses.length - 1].body
441
+ if (c.elseBody) parts.push(this.closer(lastBody), kw('else'), this.body(c.elseBody, c.span.end))
442
+ parts.push(this.closer(c.elseBody ?? lastBody), kw('fi'))
443
+ return compoundGroup(parts, !flatOk)
444
+ }
445
+ case 'Loop':
446
+ return compoundGroup([kw(c.kind), ' ', this.condList(c.cond, c.body[0]?.span.start ?? c.span.end), op(';'), ' ', kw('do'), this.body(c.body, c.span.end), this.closer(c.body), kw('done')], !this.simpleBody(c.body))
447
+ case 'For':
448
+ case 'Select': {
449
+ const head: Doc[] = [kw(c.type === 'For' ? 'for' : 'select'), ' ', tok(c.variable, 'var')]
450
+ if (c.items !== null) head.push(' ', kw('in'), ...c.items.flatMap((w) => [' ', this.wordDoc(w)]))
451
+ return compoundGroup([head, op(';'), ' ', kw('do'), this.body(c.body, c.span.end), this.closer(c.body), kw('done')], !this.simpleBody(c.body))
452
+ }
453
+ case 'ArithFor':
454
+ return compoundGroup([this.arithForHead(c), op(';'), ' ', kw('do'), this.body(c.body, c.span.end), this.closer(c.body), kw('done')], !this.simpleBody(c.body))
455
+ case 'Case':
456
+ return this.caseDoc(c)
457
+ case 'ArithCommand':
458
+ case 'CondCommand':
459
+ return this.compoundText(c)
460
+ }
461
+ }
462
+
463
+ private groupable(body: Body): boolean {
464
+ if (this.opts.compounds === 'expanded') return false
465
+ return body.length > 0 && body.every((s) => {
466
+ const t = s.command.type
467
+ return t === 'SimpleCommand' || t === 'Pipeline' || t === 'AndOr'
468
+ }) && body.length <= 3
469
+ }
470
+
471
+ /**
472
+ * `if`/`while` conditions: statements joined by `; ` (a trailing comment
473
+ * breaks the line). `limit` is where the body starts: a comment after it
474
+ * belongs to the body or the whole compound, not to the condition.
475
+ */
476
+ private condList(stmts: Body, limit: number): Doc {
477
+ const parts: Doc[] = []
478
+ stmts.forEach((s, i) => {
479
+ const leading = this.take(s.span.start)
480
+ if (i > 0) parts.push(leading.length ? hard : [op(';'), ' '])
481
+ for (const cm of leading) parts.push(this.commentDoc(cm), hard)
482
+ parts.push(this.stmt(s))
483
+ const next = stmts[i + 1]
484
+ const t = this.trailing(s.span.end, next ? next.span.start : limit)
485
+ if (t) parts.push(this.trailingDoc(t))
486
+ })
487
+ return parts
488
+ }
489
+
490
+ private arithForHead(c: A.ArithFor): Doc {
491
+ const exprs = [c.init, c.cond, c.step].filter((e): e is A.ArithExpr => e !== null)
492
+ const from = exprs.length ? exprs[exprs.length - 1].span.end : c.span.start
493
+ const close = this.src.indexOf('))', from)
494
+ return this.verbatim({ start: c.span.start, end: close + 2 })
495
+ }
496
+
497
+ private caseDoc(c: A.Case): Doc {
498
+ const ind = this.opts.indent
499
+ const word = this.wordDoc(c.word)
500
+ const items: Doc[] = []
501
+ for (const item of c.items) {
502
+ const leading = this.take(item.span.start)
503
+ for (const cm of leading) items.push(hard, this.commentDoc(cm))
504
+ const pats = join(op('|'), item.patterns.map((p) => this.wordDoc(p)))
505
+ const term = op(item.terminator)
506
+ if (item.body.length === 0) {
507
+ items.push(hard, [pats, op(')'), ' ', term])
508
+ continue
509
+ }
510
+ const body = this.list(item.body, item.span.end, false)
511
+ items.push(hard, compoundGroup([pats, op(')'), indent([nl(' '), body, nl(' '), term], ind)], !this.simpleBody(item.body)))
512
+ }
513
+ const tail = this.take(c.span.end)
514
+ for (const cm of tail) items.push(hard, this.commentDoc(cm))
515
+ return group([kw('case'), ' ', word, ' ', kw('in'), indent(items, ind), hard, kw('esac')], true)
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Arguments as fill parts: `['', cont, a, cont, b, …]`. Fill alternates
521
+ * content and separator; the empty first content lets the separator between
522
+ * the command name and its first argument break like the others.
523
+ */
524
+ function fillParts(units: Doc[]): Doc[] {
525
+ const parts: Doc[] = ['']
526
+ for (const u of units) parts.push(cont, u)
527
+ return parts
528
+ }