@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,267 @@
1
+ /**
2
+ * Shell state: variables (with bash's dynamic scoping), functions, options,
3
+ * positional parameters and the working directory. A subshell gets a deep
4
+ * copy via {@link ShellState.clone}.
5
+ */
6
+
7
+ import * as os from 'node:os'
8
+ import type * as A from '../parser/ast.ts'
9
+
10
+ /** The host process's umask (read once; children inherit it unless the script changes it). */
11
+ export const HOST_UMASK: number = (() => {
12
+ const m = process.umask(0o022)
13
+ process.umask(m)
14
+ return m
15
+ })()
16
+
17
+ export type VarValue =
18
+ | { kind: 'string'; value: string }
19
+ | { kind: 'indexed'; values: Map<number, string> }
20
+ | { kind: 'assoc'; values: Map<string, string> }
21
+
22
+ export interface Variable {
23
+ /** null: declared but unset (`local x`, `declare -a x` before assignment keeps kind via attrs). */
24
+ value: VarValue | null
25
+ exported: boolean
26
+ readonly: boolean
27
+ integer: boolean
28
+ lower: boolean
29
+ upper: boolean
30
+ /** `declare -a` / `-A` without a value yet. */
31
+ declaredKind?: 'indexed' | 'assoc'
32
+ }
33
+
34
+ export interface FunctionEntry {
35
+ def: A.FunctionDef
36
+ /** `export -f`: passed to child bash processes (BASH_FUNC_name%%) and inline scripts. */
37
+ exported?: boolean
38
+ }
39
+
40
+ export interface ShellOptions {
41
+ errexit: boolean
42
+ nounset: boolean
43
+ pipefail: boolean
44
+ xtrace: boolean
45
+ noglob: boolean
46
+ noclobber: boolean
47
+ allexport: boolean
48
+ noexec: boolean
49
+ verbose: boolean
50
+ }
51
+
52
+ export const SET_O_NAMES: Record<string, keyof ShellOptions> = {
53
+ errexit: 'errexit', nounset: 'nounset', pipefail: 'pipefail', xtrace: 'xtrace', noglob: 'noglob',
54
+ noclobber: 'noclobber', allexport: 'allexport', noexec: 'noexec', verbose: 'verbose',
55
+ }
56
+
57
+ export const SET_FLAGS: Record<string, keyof ShellOptions> = {
58
+ e: 'errexit', u: 'nounset', x: 'xtrace', f: 'noglob', C: 'noclobber', a: 'allexport', n: 'noexec', v: 'verbose',
59
+ }
60
+
61
+ export const KNOWN_SHOPTS = [
62
+ 'nullglob', 'failglob', 'dotglob', 'globstar', 'nocaseglob', 'nocasematch', 'extglob', 'lastpipe', 'patsub_replacement',
63
+ 'expand_aliases', 'inherit_errexit', 'globasciiranges', 'checkwinsize', 'histappend', 'sourcepath', 'xpg_echo',
64
+ ] as const
65
+
66
+ function cloneValue(v: VarValue | null): VarValue | null {
67
+ if (v === null) return null
68
+ if (v.kind === 'string') return { kind: 'string', value: v.value }
69
+ if (v.kind === 'indexed') return { kind: 'indexed', values: new Map(v.values) }
70
+ return { kind: 'assoc', values: new Map(v.values) }
71
+ }
72
+
73
+ function cloneVar(v: Variable): Variable {
74
+ return { ...v, value: cloneValue(v.value) }
75
+ }
76
+
77
+ /** bash's OSTYPE / HOSTTYPE / MACHTYPE for the host (`darwin24` / `linux-gnu`, `x86_64`, ...). */
78
+ function hostTypeVars(): Record<string, string> {
79
+ const darwin = process.platform === 'darwin'
80
+ const hosttype = process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? (darwin ? 'arm64' : 'aarch64') : process.arch
81
+ const ostype = darwin ? `darwin${os.release().split('.')[0]}` : process.platform === 'linux' ? 'linux-gnu' : process.platform
82
+ const vendor = darwin ? 'apple' : hosttype === 'x86_64' ? 'pc' : 'unknown'
83
+ return { OSTYPE: ostype, HOSTTYPE: hosttype, MACHTYPE: `${hosttype}-${vendor}-${ostype}` }
84
+ }
85
+
86
+ /**
87
+ * The variables a new bash process sets for itself (after importing the
88
+ * environment): the top-level shell and nested scripts run in the VM.
89
+ */
90
+ export function initShellVars(state: ShellState): void {
91
+ const set = (k: string, value: VarValue): void => void state.globals.set(k, newVar(value))
92
+ set('BASH_VERSION', { kind: 'string', value: '5.2.0(1)-mash' })
93
+ // like bash's set_if_not: values from the environment win
94
+ for (const [k, v] of Object.entries(hostTypeVars())) if (!state.globals.has(k)) set(k, { kind: 'string', value: v })
95
+ set('PS4', { kind: 'string', value: '+ ' })
96
+ set('IFS', { kind: 'string', value: ' \t\n' })
97
+ set('OPTIND', { kind: 'string', value: '1' })
98
+ // PIPESTATUS is empty until the first command runs (bash 3.2 and 5.2 alike;
99
+ // the "(0)" seen in an earlier CI run came from commands in a sourced ~/.bashrc)
100
+ }
101
+
102
+ export function newVar(value: VarValue | null, attrs: Partial<Variable> = {}): Variable {
103
+ return { value, exported: false, readonly: false, integer: false, lower: false, upper: false, ...attrs }
104
+ }
105
+
106
+ export class ShellState {
107
+ globals = new Map<string, Variable>()
108
+ /** Function-local scopes, innermost last. */
109
+ locals: Map<string, Variable>[] = []
110
+ functions = new Map<string, FunctionEntry>()
111
+ options: ShellOptions = {
112
+ errexit: false, nounset: false, pipefail: false, xtrace: false, noglob: false,
113
+ noclobber: false, allexport: false, noexec: false, verbose: false,
114
+ }
115
+ // patsub_replacement: bash 5.2's default (`&` in ${x/p/r} is the match)
116
+ shopts = new Set<string>(['globasciiranges', 'sourcepath', 'patsub_replacement'])
117
+ positional: string[] = []
118
+ arg0 = 'mash'
119
+ cwd: string
120
+ lastStatus = 0
121
+ lastBgPid: number | null = null
122
+ traps = new Map<string, string>()
123
+ /**
124
+ * In a subshell, the parent's traps, which `trap`/`trap -p` still list until
125
+ * the subshell changes a trap (bash 4.2+; lets `$(trap -p)` save them).
126
+ */
127
+ parentTraps: Map<string, string> | undefined = undefined
128
+ dirStack: string[] = []
129
+ /** Command hash table (name → resolved path). */
130
+ hash = new Map<string, string>()
131
+ aliases = new Map<string, string>()
132
+ /** Function call stack (for FUNCNAME). */
133
+ funcStack: string[] = []
134
+ /** File creation mask applied to redirections and child processes. */
135
+ umask = HOST_UMASK
136
+ /** Alias expansion depth guard. */
137
+ aliasDepth = 0
138
+
139
+ constructor(cwd: string) {
140
+ this.cwd = cwd
141
+ }
142
+
143
+ clone(): ShellState {
144
+ const s = new ShellState(this.cwd)
145
+ for (const [k, v] of this.globals) s.globals.set(k, cloneVar(v))
146
+ s.locals = this.locals.map((scope) => {
147
+ const m = new Map<string, Variable>()
148
+ for (const [k, v] of scope) m.set(k, cloneVar(v))
149
+ return m
150
+ })
151
+ s.functions = new Map(this.functions)
152
+ s.options = { ...this.options }
153
+ s.shopts = new Set(this.shopts)
154
+ s.positional = [...this.positional]
155
+ s.arg0 = this.arg0
156
+ s.lastStatus = this.lastStatus
157
+ s.lastBgPid = this.lastBgPid
158
+ s.traps = new Map(this.traps)
159
+ s.parentTraps = this.parentTraps
160
+ s.dirStack = [...this.dirStack]
161
+ s.hash = new Map(this.hash)
162
+ s.aliases = new Map(this.aliases)
163
+ s.funcStack = [...this.funcStack]
164
+ s.umask = this.umask
165
+ return s
166
+ }
167
+
168
+ /** Find a variable following dynamic scope (innermost function scope first). */
169
+ lookup(name: string): Variable | undefined {
170
+ for (let i = this.locals.length - 1; i >= 0; i--) {
171
+ const v = this.locals[i].get(name)
172
+ if (v) return v
173
+ }
174
+ return this.globals.get(name)
175
+ }
176
+
177
+ /** The scope map holding `name`, or the global scope if none does. */
178
+ scopeOf(name: string): Map<string, Variable> {
179
+ for (let i = this.locals.length - 1; i >= 0; i--) {
180
+ if (this.locals[i].has(name)) return this.locals[i]
181
+ }
182
+ return this.globals
183
+ }
184
+
185
+ /** String value of a scalar (or element 0 of an array), undefined if unset. */
186
+ getString(name: string): string | undefined {
187
+ const v = this.lookup(name)
188
+ if (!v || v.value === null) return undefined
189
+ if (v.value.kind === 'string') return v.value.value
190
+ if (v.value.kind === 'indexed') return v.value.values.get(0)
191
+ return v.value.values.get('0')
192
+ }
193
+
194
+ isSet(name: string): boolean {
195
+ const v = this.lookup(name)
196
+ return v !== undefined && v.value !== null
197
+ }
198
+
199
+ /** Declare a local in the innermost function scope (or global when not in a function). */
200
+ declareLocal(name: string): Variable {
201
+ const scope = this.locals.length > 0 ? this.locals[this.locals.length - 1] : this.globals
202
+ let v = scope.get(name)
203
+ if (!v) {
204
+ v = newVar(null)
205
+ scope.set(name, v)
206
+ }
207
+ return v
208
+ }
209
+
210
+ /** Get or create (in the scope that holds it, else global) the variable record. */
211
+ ensure(name: string): Variable {
212
+ const scope = this.scopeOf(name)
213
+ let v = scope.get(name)
214
+ if (!v) {
215
+ v = newVar(null)
216
+ if (this.options.allexport) v.exported = true
217
+ scope.set(name, v)
218
+ }
219
+ return v
220
+ }
221
+
222
+ unset(name: string): boolean {
223
+ const scope = this.scopeOf(name)
224
+ const v = scope.get(name)
225
+ if (v?.readonly) return false
226
+ if (scope === this.globals) scope.delete(name)
227
+ else if (v) {
228
+ // bash: unsetting a local makes it unset in that scope (still shadows)
229
+ v.value = null
230
+ }
231
+ return true
232
+ }
233
+
234
+ /** The environment passed to child processes: exported scalars. */
235
+ environment(): Record<string, string> {
236
+ const env: Record<string, string> = {}
237
+ const visit = (scope: Map<string, Variable>): void => {
238
+ for (const [k, v] of scope) {
239
+ if (!v.exported) {
240
+ // a non-exported local shadows an exported global of the same name
241
+ if (k in env && scope !== this.globals) delete env[k]
242
+ continue
243
+ }
244
+ if (v.value === null) continue
245
+ if (v.value.kind === 'string') env[k] = v.value.value
246
+ else if (v.value.kind === 'indexed') env[k] = v.value.values.get(0) ?? ''
247
+ }
248
+ }
249
+ visit(this.globals)
250
+ for (const scope of this.locals) visit(scope)
251
+ return env
252
+ }
253
+
254
+ /** All variable names visible (for `${!prefix*}`, `set`, `declare -p`). */
255
+ visibleNames(): string[] {
256
+ const names = new Set<string>(this.globals.keys())
257
+ for (const scope of this.locals) for (const k of scope.keys()) names.add(k)
258
+ return [...names].sort()
259
+ }
260
+ }
261
+
262
+ /** Format `$-` from the options. */
263
+ export function dashFlags(state: ShellState): string {
264
+ let s = 'h'
265
+ for (const [flag, opt] of Object.entries(SET_FLAGS)) if (state.options[opt]) s += flag
266
+ return s + 'B'
267
+ }
package/src/vm/test.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * `[[ ... ]]` and `test` / `[` evaluation.
3
+ */
4
+
5
+ import * as fs from 'node:fs'
6
+ import * as path from 'node:path'
7
+ import type * as A from '../parser/ast.ts'
8
+ import { ArithError } from './arith.ts'
9
+ import type { Interpreter } from './interp.ts'
10
+ import { matchPattern } from './pattern.ts'
11
+ import { CommandError, type Proc } from './proc.ts'
12
+ import { newVar, SET_O_NAMES } from './state.ts'
13
+
14
+ function statSafe(p: string, link = false): fs.Stats | null {
15
+ try {
16
+ return link ? fs.lstatSync(p) : fs.statSync(p)
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ function access(p: string, mode: number): boolean {
23
+ try {
24
+ fs.accessSync(p, mode)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ export function fileTest(op: string, operand: string, proc: Proc): boolean {
32
+ const p = path.resolve(proc.state.cwd, operand)
33
+ switch (op) {
34
+ case '-e': case '-a': return statSafe(p) !== null
35
+ case '-f': return statSafe(p)?.isFile() ?? false
36
+ case '-d': return statSafe(p)?.isDirectory() ?? false
37
+ case '-h': case '-L': return statSafe(p, true)?.isSymbolicLink() ?? false
38
+ case '-s': return (statSafe(p)?.size ?? 0) > 0
39
+ case '-r': return access(p, fs.constants.R_OK)
40
+ case '-w': return access(p, fs.constants.W_OK)
41
+ case '-x': return access(p, fs.constants.X_OK)
42
+ case '-p': return statSafe(p)?.isFIFO() ?? false
43
+ case '-S': return statSafe(p)?.isSocket() ?? false
44
+ case '-b': return statSafe(p)?.isBlockDevice() ?? false
45
+ case '-c': return statSafe(p)?.isCharacterDevice() ?? false
46
+ case '-g': return ((statSafe(p)?.mode ?? 0) & 0o2000) !== 0
47
+ case '-u': return ((statSafe(p)?.mode ?? 0) & 0o4000) !== 0
48
+ case '-k': return ((statSafe(p)?.mode ?? 0) & 0o1000) !== 0
49
+ case '-O': return statSafe(p)?.uid === process.getuid?.()
50
+ case '-G': return statSafe(p)?.gid === process.getgid?.()
51
+ case '-N': {
52
+ const s = statSafe(p)
53
+ return s !== null && s.mtimeMs > s.atimeMs
54
+ }
55
+ case '-t': return false
56
+ default: throw new CommandError(`${op}: unary operator expected`, 2)
57
+ }
58
+ }
59
+
60
+ function unaryTest(op: string, operand: string, proc: Proc): boolean {
61
+ switch (op) {
62
+ case '-n': return operand !== ''
63
+ case '-z': return operand === ''
64
+ case '-v': {
65
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[(.*)\])?$/.exec(operand)
66
+ if (!m) return false
67
+ const v = proc.state.lookup(m[1])
68
+ if (!v || v.value === null) return false
69
+ if (m[2] === undefined) return true
70
+ if (v.value.kind === 'indexed') return v.value.values.has(Number(m[2]))
71
+ if (v.value.kind === 'assoc') return v.value.values.has(m[2])
72
+ return m[2] === '0'
73
+ }
74
+ case '-o': {
75
+ const opt = SET_O_NAMES[operand]
76
+ return opt !== undefined && proc.state.options[opt]
77
+ }
78
+ case '-t': return false
79
+ default: return fileTest(op, operand, proc)
80
+ }
81
+ }
82
+
83
+ function intOf(s: string): bigint {
84
+ const t = s.trim()
85
+ if (!/^[+-]?\d+$/.test(t)) throw new CommandError(`${s}: integer expression expected`, 2)
86
+ return BigInt(t)
87
+ }
88
+
89
+ function binaryTest(op: string, a: string, b: string, proc: Proc): boolean {
90
+ switch (op) {
91
+ case '=': case '==': return a === b
92
+ case '!=': return a !== b
93
+ case '<': return a < b
94
+ case '>': return a > b
95
+ case '-eq': return intOf(a) === intOf(b)
96
+ case '-ne': return intOf(a) !== intOf(b)
97
+ case '-lt': return intOf(a) < intOf(b)
98
+ case '-le': return intOf(a) <= intOf(b)
99
+ case '-gt': return intOf(a) > intOf(b)
100
+ case '-ge': return intOf(a) >= intOf(b)
101
+ case '-nt': case '-ot': {
102
+ const sa = statSafe(path.resolve(proc.state.cwd, a))
103
+ const sb = statSafe(path.resolve(proc.state.cwd, b))
104
+ if (op === '-nt') return sa !== null && (sb === null || sa.mtimeMs > sb.mtimeMs)
105
+ return sb !== null && (sa === null || sa.mtimeMs < sb.mtimeMs)
106
+ }
107
+ case '-ef': {
108
+ const sa = statSafe(path.resolve(proc.state.cwd, a))
109
+ const sb = statSafe(path.resolve(proc.state.cwd, b))
110
+ return sa !== null && sb !== null && sa.dev === sb.dev && sa.ino === sb.ino
111
+ }
112
+ default: throw new CommandError(`${op}: binary operator expected`, 2)
113
+ }
114
+ }
115
+
116
+ /** POSIX ERE → JS regex source (character classes, mostly). */
117
+ function ereToJs(re: string): string {
118
+ return re.replace(/\[:(alpha|digit|alnum|upper|lower|space|blank|punct|xdigit|word|cntrl|print|graph):\]/g, (_, c: string) => ({
119
+ alpha: 'a-zA-Z', digit: '0-9', alnum: 'a-zA-Z0-9', upper: 'A-Z', lower: 'a-z', space: '\\s', blank: ' \\t',
120
+ punct: '!-\\/:-@\\[-`{-~', xdigit: '0-9a-fA-F', word: '\\w', cntrl: '\\x00-\\x1f', print: '\\x20-\\x7e', graph: '\\x21-\\x7e',
121
+ } as Record<string, string>)[c])
122
+ }
123
+
124
+ async function expandRegex(word: A.Word, proc: Proc, interp: Interpreter): Promise<string> {
125
+ let out = ''
126
+ for (const part of word.parts) {
127
+ const w: A.Word = { ...word, parts: [part] }
128
+ const s = await interp.expander.expandString(w, proc)
129
+ const quoted = part.type === 'SglQuoted' || part.type === 'DblQuoted' || part.type === 'Escaped'
130
+ out += quoted ? s.replace(/[\\^$.*+?()[\]{}|/]/g, (c) => '\\' + c) : s
131
+ }
132
+ return out
133
+ }
134
+
135
+ const ARITH_OPS = new Set(['-eq', '-ne', '-lt', '-le', '-gt', '-ge'])
136
+
137
+ export async function evalCond(e: A.CondExpr, proc: Proc, interp: Interpreter): Promise<boolean> {
138
+ switch (e.type) {
139
+ case 'CondGroup': return evalCond(e.expr, proc, interp)
140
+ case 'CondNot': return !(await evalCond(e.operand, proc, interp))
141
+ case 'CondAndOr': {
142
+ const l = await evalCond(e.left, proc, interp)
143
+ if (e.op === '&&') return l && (await evalCond(e.right, proc, interp))
144
+ return l || (await evalCond(e.right, proc, interp))
145
+ }
146
+ case 'CondWord': return (await interp.expander.expandString(e.word, proc)) !== ''
147
+ case 'CondUnary': return unaryTest(e.op, await interp.expander.expandString(e.operand, proc), proc)
148
+ case 'CondBinary': {
149
+ const left = await interp.expander.expandString(e.left, proc)
150
+ if (e.op === '==' || e.op === '=' || e.op === '!=') {
151
+ const pat = await interp.expander.expandPattern(e.right, proc)
152
+ const m = matchPattern(left, pat, { nocase: proc.state.shopts.has('nocasematch') })
153
+ return e.op === '!=' ? !m : m
154
+ }
155
+ if (e.op === '=~') {
156
+ const src = await expandRegex(e.right, proc, interp)
157
+ let re: RegExp
158
+ try {
159
+ re = new RegExp(ereToJs(src), proc.state.shopts.has('nocasematch') ? 'i' : '')
160
+ } catch {
161
+ throw new CommandError(`invalid regular expression: ${src}`, 2)
162
+ }
163
+ const m = re.exec(left)
164
+ const values = new Map<number, string>()
165
+ if (m) m.forEach((g, i) => values.set(i, g ?? ''))
166
+ proc.state.globals.set('BASH_REMATCH', newVar({ kind: 'indexed', values }))
167
+ return m !== null
168
+ }
169
+ const right = await interp.expander.expandString(e.right, proc)
170
+ if (ARITH_OPS.has(e.op)) {
171
+ try {
172
+ const a = await interp.arithText(left, proc)
173
+ const b = await interp.arithText(right, proc)
174
+ return binaryTest(e.op, String(a), String(b), proc)
175
+ } catch (err) {
176
+ if (err instanceof ArithError) throw new CommandError(err.message, 2)
177
+ throw err
178
+ }
179
+ }
180
+ return binaryTest(e.op, left, right, proc)
181
+ }
182
+ }
183
+ }
184
+
185
+ const UNARY = new Set(['-a', '-b', '-c', '-d', '-e', '-f', '-g', '-h', '-k', '-p', '-r', '-s', '-t', '-u', '-w', '-x', '-G', '-L', '-N', '-O', '-S', '-z', '-n', '-o', '-v'])
186
+ const BINARY = new Set(['=', '==', '!=', '<', '>', '-eq', '-ne', '-lt', '-le', '-gt', '-ge', '-nt', '-ot', '-ef'])
187
+
188
+ /** `test` / `[`: returns 0 (true), 1 (false); throws CommandError(2) on syntax errors. */
189
+ export function evalTest(args: string[], proc: Proc): number {
190
+ const tooMany = (): never => {
191
+ throw new CommandError('too many arguments', 2)
192
+ }
193
+ const parseOr = (a: string[]): boolean => {
194
+ // split on -o at top level (lowest precedence), respecting parens
195
+ let depth = 0
196
+ for (let i = a.length - 1; i >= 0; i--) {
197
+ if (a[i] === ')') depth++
198
+ else if (a[i] === '(') depth--
199
+ else if (a[i] === '-o' && depth === 0 && i > 0 && i < a.length - 1) return parseOr(a.slice(0, i)) || parseAnd(a.slice(i + 1))
200
+ }
201
+ return parseAnd(a)
202
+ }
203
+ const parseAnd = (a: string[]): boolean => {
204
+ let depth = 0
205
+ for (let i = a.length - 1; i >= 0; i--) {
206
+ if (a[i] === ')') depth++
207
+ else if (a[i] === '(') depth--
208
+ else if (a[i] === '-a' && depth === 0 && i > 0 && i < a.length - 1) return parseAnd(a.slice(0, i)) && prim(a.slice(i + 1), true)
209
+ }
210
+ return prim(a, true)
211
+ }
212
+ const prim = (a: string[], fromLogic = false): boolean => {
213
+ switch (a.length) {
214
+ case 0: return false
215
+ case 1: return a[0] !== ''
216
+ case 2:
217
+ if (a[0] === '!') return !prim(a.slice(1))
218
+ if (UNARY.has(a[0])) return unaryTest(a[0], a[1], proc)
219
+ throw new CommandError(`${a[0]}: unary operator expected`, 2)
220
+ case 3:
221
+ if (BINARY.has(a[1])) return binaryTest(a[1], a[0], a[2], proc)
222
+ if (a[0] === '!') return !prim(a.slice(1))
223
+ if (a[0] === '(' && a[2] === ')') return prim([a[1]])
224
+ if (a[1] === '-a' || a[1] === '-o') return parseOr(a)
225
+ throw new CommandError(`${a[1]}: binary operator expected`, 2)
226
+ case 4:
227
+ if (a[0] === '!') return !prim(a.slice(1))
228
+ if (a[0] === '(' && a[3] === ')') return prim(a.slice(1, 3))
229
+ if (fromLogic || (!a.includes('-a') && !a.includes('-o'))) tooMany()
230
+ return parseOr(a)
231
+ default:
232
+ if (a[0] === '!' && !a.includes('-a') && !a.includes('-o')) return !prim(a.slice(1))
233
+ if (a[0] === '(' && a[a.length - 1] === ')') {
234
+ // only if parens balance as a group
235
+ let depth = 0
236
+ let wraps = true
237
+ for (let i = 0; i < a.length - 1; i++) {
238
+ if (a[i] === '(') depth++
239
+ else if (a[i] === ')') depth--
240
+ if (depth === 0) {
241
+ wraps = false
242
+ break
243
+ }
244
+ }
245
+ if (wraps) return prim(a.slice(1, -1))
246
+ }
247
+ if (fromLogic || (!a.includes('-a') && !a.includes('-o'))) tooMany()
248
+ return parseOr(a)
249
+ }
250
+ }
251
+ const hasLogic = args.length > 4 && (args.includes('-a') || args.includes('-o'))
252
+ return (hasLogic ? parseOr(args) : prim(args)) ? 0 : 1
253
+ }