@namzu/sdk 44.2.0 → 45.0.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.
- package/CHANGELOG.md +49 -0
- package/dist/authorization/command-line.d.ts +66 -19
- package/dist/authorization/command-line.d.ts.map +1 -1
- package/dist/authorization/command-line.js +130 -270
- package/dist/authorization/command-line.js.map +1 -1
- package/dist/authorization/gate.d.ts +7 -0
- package/dist/authorization/gate.d.ts.map +1 -1
- package/dist/authorization/gate.js +1 -1
- package/dist/authorization/gate.js.map +1 -1
- package/dist/authorization/rules.d.ts +10 -1
- package/dist/authorization/rules.d.ts.map +1 -1
- package/dist/authorization/rules.js +21 -5
- package/dist/authorization/rules.js.map +1 -1
- package/dist/authorization/shell-lexer.d.ts +138 -0
- package/dist/authorization/shell-lexer.d.ts.map +1 -0
- package/dist/authorization/shell-lexer.js +2143 -0
- package/dist/authorization/shell-lexer.js.map +1 -0
- package/dist/authorization/skill-grant.d.ts +182 -0
- package/dist/authorization/skill-grant.d.ts.map +1 -0
- package/dist/authorization/skill-grant.js +314 -0
- package/dist/authorization/skill-grant.js.map +1 -0
- package/dist/persona/assembler.d.ts.map +1 -1
- package/dist/persona/assembler.js +5 -2
- package/dist/persona/assembler.js.map +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +1 -1
- package/dist/prompt/coding-agent-doctrine.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +2 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/public-types.d.ts +3 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.d.ts +2 -2
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +6 -2
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +34 -40
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +81 -51
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +7 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +6 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +7 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +48 -0
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts +11 -0
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +32 -0
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +3 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +1 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/skills/loader.d.ts +8 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +7 -1
- package/dist/skills/loader.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +18 -6
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/skill.d.ts +2 -9
- package/dist/tools/builtins/skill.d.ts.map +1 -1
- package/dist/tools/builtins/skill.js +59 -51
- package/dist/tools/builtins/skill.js.map +1 -1
- package/dist/tools/command-shell.d.ts +90 -0
- package/dist/tools/command-shell.d.ts.map +1 -0
- package/dist/tools/command-shell.js +129 -0
- package/dist/tools/command-shell.js.map +1 -0
- package/dist/tools/defineTool.d.ts +11 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +29 -1
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/hitl/index.d.ts +23 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/provider/stream.d.ts +10 -0
- package/dist/types/provider/stream.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +67 -5
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +17 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +32 -2
- package/dist/utils/frontmatter.js.map +1 -1
- package/package.json +1 -1
- package/src/authorization/command-line.ts +148 -293
- package/src/authorization/gate.ts +8 -0
- package/src/authorization/rules.ts +33 -4
- package/src/authorization/shell-lexer.ts +2319 -0
- package/src/authorization/skill-grant.ts +400 -0
- package/src/persona/assembler.ts +5 -2
- package/src/prompt/coding-agent-doctrine.ts +1 -1
- package/src/public-runtime.ts +9 -0
- package/src/public-tools.ts +2 -1
- package/src/public-types.ts +7 -0
- package/src/runtime/jobs/registry.ts +19 -11
- package/src/runtime/query/executor.ts +99 -55
- package/src/runtime/query/index.ts +7 -0
- package/src/runtime/query/iteration/index.ts +5 -0
- package/src/runtime/query/iteration/phases/context.ts +7 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +45 -0
- package/src/runtime/query/review-policy.ts +53 -0
- package/src/runtime/query/tooling.ts +4 -0
- package/src/skills/loader.ts +8 -1
- package/src/tools/builtins/bash.ts +24 -6
- package/src/tools/builtins/skill.ts +74 -53
- package/src/tools/command-shell.ts +166 -0
- package/src/tools/defineTool.ts +33 -1
- package/src/types/hitl/index.ts +21 -0
- package/src/types/provider/stream.ts +10 -0
- package/src/types/tool/index.ts +67 -5
- package/src/utils/frontmatter.ts +52 -2
|
@@ -0,0 +1,2319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One reading of a bash command line, shared by everything that authorizes
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* ## Why there is exactly one
|
|
6
|
+
*
|
|
7
|
+
* Permission rules decide on the commands a line runs, so something has to say
|
|
8
|
+
* what those commands are. That used to be three separate walkers — one cut
|
|
9
|
+
* the line into commands, one split a command into words, one looked for
|
|
10
|
+
* output redirections — and each carried its own copy of bash's quoting rules.
|
|
11
|
+
* Every rule added to one had to be added to the others, and every review
|
|
12
|
+
* found a place where it had not been: `$'…'` first, then `$$'…'`. Each miss
|
|
13
|
+
* was the same defect: two readers disagreeing about where a quote ends, so
|
|
14
|
+
* one of them took a command the shell runs for quoted text. The fix for that
|
|
15
|
+
* class is not a fourth copy. It is a single lexer whose output every caller
|
|
16
|
+
* reads.
|
|
17
|
+
*
|
|
18
|
+
* ## What it reads
|
|
19
|
+
*
|
|
20
|
+
* The bash (5.x, non-POSIX mode) grammar, as far as authorization needs it:
|
|
21
|
+
* quoting in all its forms (`'…'`, `"…"`, `\`, `$'…'` with its escapes
|
|
22
|
+
* decoded, `$"…"`), line continuation, every expansion's extent (`$name`,
|
|
23
|
+
* the special parameters, `${…}` with nested quotes, `$(…)`, `$((…))`,
|
|
24
|
+
* `$[…]`, backticks, `<(…)` and `>(…)`), control operators, redirections
|
|
25
|
+
* including here-documents (whose bodies are consumed, never read as
|
|
26
|
+
* commands), comments, reserved words and compound commands (`( )`, `{ }`,
|
|
27
|
+
* `if`, `while`, `until`, `for`, `select`, `case`, `[[ ]]`), and a nested
|
|
28
|
+
* `bash -c '<payload>'`, whose decoded payload is read the same way.
|
|
29
|
+
*
|
|
30
|
+
* The result is the list of simple commands with each word as bash produces
|
|
31
|
+
* it after quote removal and before expansion, plus a flag on every word whose
|
|
32
|
+
* text is not its runtime value (it contains a parameter, command or
|
|
33
|
+
* arithmetic expansion, a glob, a brace expansion, a tilde, or a
|
|
34
|
+
* locale-dependent escape). A word without that flag is exactly the argument
|
|
35
|
+
* bash passes. `packages/sdk/src/authorization/__tests__/shell-lexer-bash.test.ts`
|
|
36
|
+
* checks that against the bash on the machine running the tests.
|
|
37
|
+
*
|
|
38
|
+
* ## Failing closed
|
|
39
|
+
*
|
|
40
|
+
* {@link ShellLexResult.opaque} is set when the command list is not a
|
|
41
|
+
* complete account of what the line runs: a command or process substitution,
|
|
42
|
+
* an arithmetic context that could evaluate code held in a variable, a
|
|
43
|
+
* syntax error, an unterminated quote, a construct this module does not model
|
|
44
|
+
* (`coproc`, `for ((…))`, a compound array assignment, a function
|
|
45
|
+
* definition), a command that changes how later text is parsed (`shopt`,
|
|
46
|
+
* `enable`, `set -o posix`, …), or a nesting depth past the limit. Nothing is
|
|
47
|
+
* guessed. When the lexer is unsure it says so, and the caller refuses to
|
|
48
|
+
* grant on that line.
|
|
49
|
+
*
|
|
50
|
+
* It is a lexer, not an interpreter. It does not know what `env`, `xargs` or
|
|
51
|
+
* `sudo` do with their arguments, and it does not know what `eval` or `source`
|
|
52
|
+
* will run; callers treat those as they see fit.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** One word of a command, before expansion and after quote removal. */
|
|
56
|
+
export interface ShellWord {
|
|
57
|
+
/** The word as written in the source, quotes and escapes included. */
|
|
58
|
+
readonly text: string
|
|
59
|
+
/**
|
|
60
|
+
* The word after quote removal, with `$'…'` escapes decoded. Expansions
|
|
61
|
+
* that happen at runtime are left as written (`$HOME` stays `$HOME`), and
|
|
62
|
+
* {@link expands} says so.
|
|
63
|
+
*/
|
|
64
|
+
readonly value: string
|
|
65
|
+
/**
|
|
66
|
+
* True when {@link value} is not the runtime word: the word contains an
|
|
67
|
+
* expansion, a glob, a brace expansion, a tilde prefix, or a
|
|
68
|
+
* locale-dependent quote or escape.
|
|
69
|
+
*/
|
|
70
|
+
readonly expands: boolean
|
|
71
|
+
/** True when any part of the word was quoted or escaped. */
|
|
72
|
+
readonly quoted: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A redirection: `2>&1`, `> file`, `<<EOF` and the rest. */
|
|
76
|
+
export interface ShellRedirection {
|
|
77
|
+
/**
|
|
78
|
+
* The operator: `<`, `>`, `>>`, `>|`, `<>`, `&>`, `&>>`, `<&`, `>&`, `<<`,
|
|
79
|
+
* `<<-` or `<<<`.
|
|
80
|
+
*/
|
|
81
|
+
readonly operator: string
|
|
82
|
+
/** The descriptor it names before the operator (`2`, `{fd}`), if any. */
|
|
83
|
+
readonly fd?: string
|
|
84
|
+
/** The target word. For a here-document, its delimiter. */
|
|
85
|
+
readonly target: ShellWord
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One simple command. */
|
|
89
|
+
export interface ShellCommand {
|
|
90
|
+
/**
|
|
91
|
+
* Every word, leading assignments included. `words[assignments]` is the
|
|
92
|
+
* command name when there is one.
|
|
93
|
+
*/
|
|
94
|
+
readonly words: readonly ShellWord[]
|
|
95
|
+
/** How many leading words are variable assignments (`A=1 cmd`). */
|
|
96
|
+
readonly assignments: number
|
|
97
|
+
readonly redirections: readonly ShellRedirection[]
|
|
98
|
+
/**
|
|
99
|
+
* The command's source text, from its first token to its last, taken from
|
|
100
|
+
* the string it was read from (the payload, for a nested shell).
|
|
101
|
+
*/
|
|
102
|
+
readonly text: string
|
|
103
|
+
/**
|
|
104
|
+
* Where it was found: the line itself, a command or process substitution
|
|
105
|
+
* (which also makes the line opaque), or the payload of a nested
|
|
106
|
+
* `bash -c`.
|
|
107
|
+
*/
|
|
108
|
+
readonly origin: 'line' | 'substitution' | 'shell'
|
|
109
|
+
/** How many `bash -c` payloads enclose it. */
|
|
110
|
+
readonly depth: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ShellLexResult {
|
|
114
|
+
/** Every simple command found, in the order its parse completed. */
|
|
115
|
+
readonly commands: readonly ShellCommand[]
|
|
116
|
+
/**
|
|
117
|
+
* Every redirection in the line, including those on compound commands
|
|
118
|
+
* (`{ a; } > f`) that belong to no single simple command.
|
|
119
|
+
*/
|
|
120
|
+
readonly redirections: readonly ShellRedirection[]
|
|
121
|
+
/** True when {@link commands} may not be everything the line runs. */
|
|
122
|
+
readonly opaque: boolean
|
|
123
|
+
/** False when parsing stopped early: a syntax error or an unsupported construct. */
|
|
124
|
+
readonly complete: boolean
|
|
125
|
+
/** Why the line is opaque, for diagnostics. Empty when it is not. */
|
|
126
|
+
readonly reasons: readonly string[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Shells whose `-c` argument is another command line, by basename. The
|
|
131
|
+
* payload is decoded as bash reads it; for `dash` and friends the bash reading
|
|
132
|
+
* is a close approximation, and the line is only as exact as that.
|
|
133
|
+
*/
|
|
134
|
+
export const NESTED_SHELLS: ReadonlySet<string> = new Set([
|
|
135
|
+
'sh',
|
|
136
|
+
'bash',
|
|
137
|
+
'zsh',
|
|
138
|
+
'dash',
|
|
139
|
+
'ksh',
|
|
140
|
+
'ash',
|
|
141
|
+
'mksh',
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
/** How many `bash -c` payloads deep the lexer follows before it gives up. */
|
|
145
|
+
const MAX_SHELL_DEPTH = 4
|
|
146
|
+
/** How deep compound commands and expansions may nest before it gives up. */
|
|
147
|
+
const MAX_NESTING = 100
|
|
148
|
+
|
|
149
|
+
import type { ShellDialect } from '../types/tool/index.js'
|
|
150
|
+
|
|
151
|
+
export type { ShellDialect }
|
|
152
|
+
|
|
153
|
+
export interface ShellLexOptions {
|
|
154
|
+
/** Default `bash`. */
|
|
155
|
+
readonly dialect?: ShellDialect
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function lexShellCommandLine(line: string, options: ShellLexOptions = {}): ShellLexResult {
|
|
159
|
+
const context = new Context(line.length)
|
|
160
|
+
context.setDialect(line, options.dialect ?? 'bash')
|
|
161
|
+
try {
|
|
162
|
+
lexInto(line, context, 'line', 0)
|
|
163
|
+
} catch {
|
|
164
|
+
// A defect here, or a stack exhausted by nesting, must not read as a
|
|
165
|
+
// complete account of the line.
|
|
166
|
+
context.complete = false
|
|
167
|
+
context.opaque('internal error')
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
commands: context.commands,
|
|
171
|
+
redirections: context.redirections,
|
|
172
|
+
opaque: context.reasons.size > 0,
|
|
173
|
+
complete: context.complete,
|
|
174
|
+
reasons: [...context.reasons],
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class Context {
|
|
179
|
+
readonly commands: ShellCommand[] = []
|
|
180
|
+
readonly redirections: ShellRedirection[] = []
|
|
181
|
+
readonly reasons = new Set<string>()
|
|
182
|
+
complete = true
|
|
183
|
+
/**
|
|
184
|
+
* Characters the lexer may re-read. A `((` is read once as arithmetic and,
|
|
185
|
+
* when that fails, again as two subshells; unbounded, nested attempts make
|
|
186
|
+
* the lexer quadratic. Past the budget the line is opaque.
|
|
187
|
+
*/
|
|
188
|
+
private rescans: number
|
|
189
|
+
|
|
190
|
+
constructor(length: number) {
|
|
191
|
+
this.rescans = 4 * length + 4096
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
opaque(reason: string): void {
|
|
195
|
+
this.reasons.add(reason)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private readonly dialects = new Map<string, ShellDialect>()
|
|
199
|
+
|
|
200
|
+
/** The dialect a string is read in. A string read two ways gets the stricter. */
|
|
201
|
+
setDialect(src: string, dialect: ShellDialect): void {
|
|
202
|
+
if (this.dialects.get(src) !== 'sh') this.dialects.set(src, dialect)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
dialectFor(src: string): ShellDialect {
|
|
206
|
+
return this.dialects.get(src) ?? 'sh'
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private readonly sources = new Map<string, SourceState>()
|
|
210
|
+
|
|
211
|
+
/** Per-string state shared by every parser reading that string. */
|
|
212
|
+
source(src: string): SourceState {
|
|
213
|
+
let state = this.sources.get(src)
|
|
214
|
+
if (state === undefined) {
|
|
215
|
+
state = { lastNewline: src.lastIndexOf('\n'), finalLineRaw: false }
|
|
216
|
+
this.sources.set(src, state)
|
|
217
|
+
}
|
|
218
|
+
return state
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Charge `count` re-read characters; stops the parse when spent. */
|
|
222
|
+
rescan(count: number): void {
|
|
223
|
+
this.rescans -= count
|
|
224
|
+
if (this.rescans < 0) throw new Stop('too complex to read')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Bash appends a newline to each line it reads from a string. For a final line
|
|
230
|
+
* that ends in a backslash it appends a second backslash instead, so the
|
|
231
|
+
* backslash stays literal, but only when that line was read outside a single
|
|
232
|
+
* quote. When the last newline of the string is inside `'…'` or `$'…'`, the
|
|
233
|
+
* final line was read inside the quote, and a trailing backslash becomes a
|
|
234
|
+
* line continuation that disappears (measured, bash 5.3).
|
|
235
|
+
*/
|
|
236
|
+
interface SourceState {
|
|
237
|
+
readonly lastNewline: number
|
|
238
|
+
finalLineRaw: boolean
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Stops the parse. Whatever was read so far stays; the line becomes opaque. */
|
|
242
|
+
class Stop extends Error {
|
|
243
|
+
constructor(readonly reason: string) {
|
|
244
|
+
super(reason)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function lexInto(
|
|
249
|
+
source: string,
|
|
250
|
+
context: Context,
|
|
251
|
+
origin: ShellCommand['origin'],
|
|
252
|
+
depth: number,
|
|
253
|
+
): void {
|
|
254
|
+
const parser = new Parser(source, 0, context, origin, depth, 0)
|
|
255
|
+
try {
|
|
256
|
+
parser.program()
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (!(error instanceof Stop)) throw error
|
|
259
|
+
context.complete = false
|
|
260
|
+
context.opaque(error.reason)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Tokens
|
|
266
|
+
|
|
267
|
+
type Token =
|
|
268
|
+
| {
|
|
269
|
+
readonly kind: 'word'
|
|
270
|
+
readonly start: number
|
|
271
|
+
readonly end: number
|
|
272
|
+
readonly word: ShellWord
|
|
273
|
+
readonly reservedOk: boolean
|
|
274
|
+
}
|
|
275
|
+
| {
|
|
276
|
+
readonly kind: 'op'
|
|
277
|
+
readonly start: number
|
|
278
|
+
readonly end: number
|
|
279
|
+
readonly op: string
|
|
280
|
+
readonly fd?: string
|
|
281
|
+
}
|
|
282
|
+
| { readonly kind: 'newline'; readonly start: number; readonly end: number }
|
|
283
|
+
| { readonly kind: 'eof'; readonly start: number; readonly end: number }
|
|
284
|
+
/** `(( … ))` at command position. */
|
|
285
|
+
| { readonly kind: 'arith'; readonly start: number; readonly end: number }
|
|
286
|
+
|
|
287
|
+
const REDIRECTIONS = new Set([
|
|
288
|
+
'<',
|
|
289
|
+
'>',
|
|
290
|
+
'>>',
|
|
291
|
+
'>|',
|
|
292
|
+
'<>',
|
|
293
|
+
'&>',
|
|
294
|
+
'&>>',
|
|
295
|
+
'<&',
|
|
296
|
+
'>&',
|
|
297
|
+
'<<',
|
|
298
|
+
'<<-',
|
|
299
|
+
'<<<',
|
|
300
|
+
])
|
|
301
|
+
|
|
302
|
+
/** Operators after which bash recognises a reserved word. */
|
|
303
|
+
const RESERVED_AFTER_OPS = new Set([';', ';;', ';&', ';;&', '&', '&&', '||', '|', '|&', '(', ')'])
|
|
304
|
+
/** Reserved words after which bash recognises another one. */
|
|
305
|
+
const RESERVED_AFTER_WORDS = new Set([
|
|
306
|
+
'!',
|
|
307
|
+
'{',
|
|
308
|
+
'}',
|
|
309
|
+
'do',
|
|
310
|
+
'done',
|
|
311
|
+
'elif',
|
|
312
|
+
'else',
|
|
313
|
+
'esac',
|
|
314
|
+
'fi',
|
|
315
|
+
'if',
|
|
316
|
+
'then',
|
|
317
|
+
'time',
|
|
318
|
+
'until',
|
|
319
|
+
'while',
|
|
320
|
+
])
|
|
321
|
+
const RESERVED = new Set([
|
|
322
|
+
'!',
|
|
323
|
+
'{',
|
|
324
|
+
'}',
|
|
325
|
+
'case',
|
|
326
|
+
'coproc',
|
|
327
|
+
'do',
|
|
328
|
+
'done',
|
|
329
|
+
'elif',
|
|
330
|
+
'else',
|
|
331
|
+
'esac',
|
|
332
|
+
'fi',
|
|
333
|
+
'for',
|
|
334
|
+
'function',
|
|
335
|
+
'if',
|
|
336
|
+
'select',
|
|
337
|
+
'then',
|
|
338
|
+
'time',
|
|
339
|
+
'until',
|
|
340
|
+
'while',
|
|
341
|
+
'[[',
|
|
342
|
+
']]',
|
|
343
|
+
'in',
|
|
344
|
+
])
|
|
345
|
+
|
|
346
|
+
/** A compound command starts here (for `coproc NAME compound`). */
|
|
347
|
+
const COMPOUND_AFTER = /^(?:\(|(?:\{|if|while|until|for|select|case|\[\[)(?=[\s;&|()<>]|$))/
|
|
348
|
+
/** Builtins whose arguments may be array assignments. */
|
|
349
|
+
const ASSIGNMENT_BUILTINS = new Set(['declare', 'typeset', 'local', 'export', 'readonly'])
|
|
350
|
+
|
|
351
|
+
const WORD_BREAK = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>'])
|
|
352
|
+
|
|
353
|
+
const NAME_START = /[A-Za-z_]/
|
|
354
|
+
const NAME_CHAR = /[A-Za-z0-9_]/
|
|
355
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/
|
|
356
|
+
|
|
357
|
+
interface PendingHeredoc {
|
|
358
|
+
readonly delimiter: string
|
|
359
|
+
readonly stripTabs: boolean
|
|
360
|
+
readonly quoted: boolean
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type Last =
|
|
364
|
+
| { readonly kind: 'start' | 'newline' | 'other' }
|
|
365
|
+
| { readonly kind: 'op'; readonly op: string }
|
|
366
|
+
| { readonly kind: 'word'; readonly reserved: string | null }
|
|
367
|
+
|
|
368
|
+
/** A mutable word under construction. */
|
|
369
|
+
class WordBuilder {
|
|
370
|
+
value = ''
|
|
371
|
+
expands = false
|
|
372
|
+
quoted = false
|
|
373
|
+
/** A brace expansion, which POSIX shells do not perform. */
|
|
374
|
+
brace = false
|
|
375
|
+
/** Unquoted `{` seen, and whether a `,` or `..` followed it: brace expansion. */
|
|
376
|
+
private braceOpen = false
|
|
377
|
+
private braceSeparator = false
|
|
378
|
+
/** The previous unquoted character, for tilde and brace detection. */
|
|
379
|
+
private previous = ''
|
|
380
|
+
private empty = true
|
|
381
|
+
|
|
382
|
+
literal(char: string): void {
|
|
383
|
+
this.value += char
|
|
384
|
+
this.empty = false
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** An unquoted character, which may be special to expansion. */
|
|
388
|
+
unquoted(char: string): void {
|
|
389
|
+
if (char === '*' || char === '?' || char === '[') this.expands = true
|
|
390
|
+
if (char === '~' && (this.empty || this.previous === '=' || this.previous === ':')) {
|
|
391
|
+
this.expands = true
|
|
392
|
+
}
|
|
393
|
+
if (char === '{') {
|
|
394
|
+
this.braceOpen = true
|
|
395
|
+
} else if (this.braceOpen && (char === ',' || (char === '.' && this.previous === '.'))) {
|
|
396
|
+
this.braceSeparator = true
|
|
397
|
+
} else if (char === '}' && this.braceOpen && this.braceSeparator) {
|
|
398
|
+
this.expands = true
|
|
399
|
+
this.brace = true
|
|
400
|
+
}
|
|
401
|
+
this.previous = char
|
|
402
|
+
this.literal(char)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Quoted text: never special. */
|
|
406
|
+
quotedText(text: string): void {
|
|
407
|
+
this.quoted = true
|
|
408
|
+
this.previous = ''
|
|
409
|
+
this.value += text
|
|
410
|
+
this.empty = false
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** An expansion kept as written. */
|
|
414
|
+
expansion(text: string): void {
|
|
415
|
+
this.expands = true
|
|
416
|
+
this.previous = ''
|
|
417
|
+
this.value += text
|
|
418
|
+
this.empty = false
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// The parser. One instance per string and per nesting of `$(…)`; the token
|
|
424
|
+
// stream is lazy so that a substitution can hand its position back.
|
|
425
|
+
|
|
426
|
+
class Parser {
|
|
427
|
+
private peeked: Token | null = null
|
|
428
|
+
private last: Last = { kind: 'start' }
|
|
429
|
+
private readonly heredocs: PendingHeredoc[] = []
|
|
430
|
+
private readonly source: SourceState
|
|
431
|
+
|
|
432
|
+
constructor(
|
|
433
|
+
private readonly src: string,
|
|
434
|
+
public pos: number,
|
|
435
|
+
private readonly context: Context,
|
|
436
|
+
private readonly origin: ShellCommand['origin'],
|
|
437
|
+
private readonly depth: number,
|
|
438
|
+
private readonly nesting: number,
|
|
439
|
+
) {
|
|
440
|
+
if (nesting > MAX_NESTING) throw new Stop('nesting too deep')
|
|
441
|
+
this.source = context.source(src)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private get dialect(): ShellDialect {
|
|
445
|
+
return this.context.dialectFor(this.src)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* A construct bash reads differently from a POSIX shell. In the `sh`
|
|
450
|
+
* dialect the line is opaque: which of the two runs it is not known.
|
|
451
|
+
*/
|
|
452
|
+
private bashOnly(what: string): void {
|
|
453
|
+
if (this.dialect === 'sh') this.context.opaque(`not POSIX sh: ${what}`)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A string read inside this one (a backtick or here-document body) keeps its dialect. */
|
|
457
|
+
private inherit(body: string): void {
|
|
458
|
+
this.context.setDialect(body, this.dialect)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Record a single-quoted region; see {@link SourceState}. */
|
|
462
|
+
private singleQuoted(open: number, close: number): void {
|
|
463
|
+
const last = this.source.lastNewline
|
|
464
|
+
if (last > open && last < close) this.source.finalLineRaw = true
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- grammar ----------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
/** The whole string: a list, possibly empty, up to the end. */
|
|
470
|
+
program(): void {
|
|
471
|
+
this.newlines()
|
|
472
|
+
while (this.peek().kind !== 'eof') {
|
|
473
|
+
this.andOr()
|
|
474
|
+
const token = this.peek()
|
|
475
|
+
if (token.kind === 'eof') break
|
|
476
|
+
if (
|
|
477
|
+
token.kind === 'newline' ||
|
|
478
|
+
(token.kind === 'op' && (token.op === ';' || token.op === '&'))
|
|
479
|
+
) {
|
|
480
|
+
this.take()
|
|
481
|
+
this.newlines()
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
throw this.unexpected(token)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A `$(…)` body: a list up to the matching `)`. Returns the offset just
|
|
490
|
+
* past it.
|
|
491
|
+
*/
|
|
492
|
+
substitution(): number {
|
|
493
|
+
this.newlines()
|
|
494
|
+
const first = this.peek()
|
|
495
|
+
if (!(first.kind === 'op' && first.op === ')')) {
|
|
496
|
+
this.list((token) => token.kind === 'op' && token.op === ')')
|
|
497
|
+
}
|
|
498
|
+
const close = this.take()
|
|
499
|
+
if (close.kind !== 'op' || close.op !== ')') throw this.unexpected(close)
|
|
500
|
+
return close.end
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* A `${ …; }` body: a list, possibly empty, up to a `}` where a command
|
|
505
|
+
* could start. That `}` closes the substitution even with more of the
|
|
506
|
+
* word after it: `a${ }b` is the word `ab`.
|
|
507
|
+
*/
|
|
508
|
+
braceSubstitution(): number {
|
|
509
|
+
this.closesOnBrace = true
|
|
510
|
+
const closing = (token: Token): boolean => token.kind === 'op' && token.op === '}'
|
|
511
|
+
this.newlines()
|
|
512
|
+
if (!closing(this.peek())) this.list(closing)
|
|
513
|
+
return this.take().end
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Set while reading a `${ …; }` body. */
|
|
517
|
+
private closesOnBrace = false
|
|
518
|
+
/** Open `{ …; }` groups, whose `}` is theirs and not the substitution's. */
|
|
519
|
+
private braceDepth = 0
|
|
520
|
+
|
|
521
|
+
/** After `{`: the list and its closing `}`. */
|
|
522
|
+
private braceGroup(): void {
|
|
523
|
+
this.braceDepth += 1
|
|
524
|
+
try {
|
|
525
|
+
this.list((t) => this.isReserved(t, '}'))
|
|
526
|
+
} finally {
|
|
527
|
+
this.braceDepth -= 1
|
|
528
|
+
}
|
|
529
|
+
this.take()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* A compound list: one or more and-or lists separated by `;`, `&` or
|
|
534
|
+
* newlines, stopping before a token `ends` accepts.
|
|
535
|
+
*/
|
|
536
|
+
private list(ends: (token: Token) => boolean): void {
|
|
537
|
+
this.newlines()
|
|
538
|
+
if (ends(this.peek())) throw this.unexpected(this.peek())
|
|
539
|
+
for (;;) {
|
|
540
|
+
this.andOr()
|
|
541
|
+
const token = this.peek()
|
|
542
|
+
if (ends(token)) return
|
|
543
|
+
if (
|
|
544
|
+
token.kind === 'newline' ||
|
|
545
|
+
(token.kind === 'op' && (token.op === ';' || token.op === '&'))
|
|
546
|
+
) {
|
|
547
|
+
this.take()
|
|
548
|
+
this.newlines()
|
|
549
|
+
if (ends(this.peek())) return
|
|
550
|
+
continue
|
|
551
|
+
}
|
|
552
|
+
throw this.unexpected(token)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private andOr(): void {
|
|
557
|
+
this.pipeline()
|
|
558
|
+
for (;;) {
|
|
559
|
+
const token = this.peek()
|
|
560
|
+
if (token.kind === 'op' && (token.op === '&&' || token.op === '||')) {
|
|
561
|
+
this.take()
|
|
562
|
+
this.newlines()
|
|
563
|
+
this.pipeline()
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private pipeline(): void {
|
|
571
|
+
// `time`, `time -p` and any number of `!` may lead a pipeline, and a
|
|
572
|
+
// pipeline of just `time` or `!` is valid.
|
|
573
|
+
let prefixed = false
|
|
574
|
+
for (;;) {
|
|
575
|
+
const token = this.peek()
|
|
576
|
+
if (this.isReserved(token, '!')) {
|
|
577
|
+
this.take()
|
|
578
|
+
prefixed = true
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
if (this.isReserved(token, 'time')) {
|
|
582
|
+
this.bashOnly('time')
|
|
583
|
+
this.take()
|
|
584
|
+
prefixed = true
|
|
585
|
+
const option = this.peek()
|
|
586
|
+
if (option.kind === 'word' && option.word.text.startsWith('-')) {
|
|
587
|
+
// In POSIX mode, which is how bash runs as `/bin/sh`, `time`
|
|
588
|
+
// before a word starting with `-` is not a reserved word but
|
|
589
|
+
// the command `time`. The two modes disagree about what runs.
|
|
590
|
+
this.context.opaque('time with an option')
|
|
591
|
+
}
|
|
592
|
+
if (
|
|
593
|
+
option.kind === 'word' &&
|
|
594
|
+
!option.word.quoted &&
|
|
595
|
+
(option.word.value === '-p' || option.word.value === '--')
|
|
596
|
+
) {
|
|
597
|
+
this.take()
|
|
598
|
+
this.last = { kind: 'word', reserved: 'time' }
|
|
599
|
+
}
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
break
|
|
603
|
+
}
|
|
604
|
+
// `!` or `time` with nothing after it is valid only before a newline,
|
|
605
|
+
// a `;` or the end of the input. (Bash 5.3 also accepts a `&`; 5.2
|
|
606
|
+
// does not, and reading it as an error is the direction that fails
|
|
607
|
+
// closed.)
|
|
608
|
+
if (prefixed) {
|
|
609
|
+
const next = this.peek()
|
|
610
|
+
if (next.kind === 'eof' || next.kind === 'newline' || (next.kind === 'op' && next.op === ';'))
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
this.command()
|
|
614
|
+
for (;;) {
|
|
615
|
+
const token = this.peek()
|
|
616
|
+
if (token.kind === 'op' && (token.op === '|' || token.op === '|&')) {
|
|
617
|
+
this.take()
|
|
618
|
+
this.newlines()
|
|
619
|
+
this.afterPipe = true
|
|
620
|
+
this.command()
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private command(): void {
|
|
628
|
+
this.level += 1
|
|
629
|
+
try {
|
|
630
|
+
if (this.nestingNow > MAX_NESTING) throw new Stop('nesting too deep')
|
|
631
|
+
this.commandAt()
|
|
632
|
+
} finally {
|
|
633
|
+
this.level -= 1
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Set between a `|` and the command after it, where `time` is a plain word. */
|
|
638
|
+
private afterPipe = false
|
|
639
|
+
|
|
640
|
+
private commandAt(): void {
|
|
641
|
+
const afterPipe = this.afterPipe
|
|
642
|
+
this.afterPipe = false
|
|
643
|
+
const token = this.peek()
|
|
644
|
+
if (token.kind === 'arith') {
|
|
645
|
+
this.take()
|
|
646
|
+
this.redirectionsAfterCompound()
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
if (token.kind === 'op' && token.op === '(') {
|
|
650
|
+
this.take()
|
|
651
|
+
this.list((t) => t.kind === 'op' && t.op === ')')
|
|
652
|
+
this.expectOp(')')
|
|
653
|
+
this.redirectionsAfterCompound()
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
if (
|
|
657
|
+
token.kind === 'word' &&
|
|
658
|
+
token.reservedOk &&
|
|
659
|
+
token.word.text.replace(/\\\n/g, '') === '[['
|
|
660
|
+
) {
|
|
661
|
+
this.bashOnly('[[')
|
|
662
|
+
this.conditional()
|
|
663
|
+
this.redirectionsAfterCompound()
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
if (
|
|
667
|
+
token.kind === 'word' &&
|
|
668
|
+
token.reservedOk &&
|
|
669
|
+
!token.word.quoted &&
|
|
670
|
+
!token.word.expands &&
|
|
671
|
+
!(afterPipe && token.word.value === 'time')
|
|
672
|
+
) {
|
|
673
|
+
switch (token.word.value) {
|
|
674
|
+
case '{':
|
|
675
|
+
this.take()
|
|
676
|
+
this.braceGroup()
|
|
677
|
+
this.redirectionsAfterCompound()
|
|
678
|
+
return
|
|
679
|
+
case 'if':
|
|
680
|
+
this.ifCommand()
|
|
681
|
+
this.redirectionsAfterCompound()
|
|
682
|
+
return
|
|
683
|
+
case 'while':
|
|
684
|
+
case 'until':
|
|
685
|
+
this.take()
|
|
686
|
+
this.list((t) => this.isReserved(t, 'do'))
|
|
687
|
+
this.take()
|
|
688
|
+
this.list((t) => this.isReserved(t, 'done'))
|
|
689
|
+
this.take()
|
|
690
|
+
this.redirectionsAfterCompound()
|
|
691
|
+
return
|
|
692
|
+
case 'for':
|
|
693
|
+
case 'select':
|
|
694
|
+
if (token.word.value === 'select') this.bashOnly('select')
|
|
695
|
+
this.forCommand()
|
|
696
|
+
this.redirectionsAfterCompound()
|
|
697
|
+
return
|
|
698
|
+
case 'case':
|
|
699
|
+
this.caseCommand()
|
|
700
|
+
this.redirectionsAfterCompound()
|
|
701
|
+
return
|
|
702
|
+
case 'function':
|
|
703
|
+
this.bashOnly('function')
|
|
704
|
+
this.context.opaque('function definition')
|
|
705
|
+
this.take()
|
|
706
|
+
this.functionBody(true)
|
|
707
|
+
return
|
|
708
|
+
case 'coproc': {
|
|
709
|
+
this.bashOnly('coproc')
|
|
710
|
+
// `coproc [NAME] command`: runs in the background, and its
|
|
711
|
+
// descriptors land in a variable. Read the command; opaque.
|
|
712
|
+
this.context.opaque('coproc')
|
|
713
|
+
this.take()
|
|
714
|
+
const next = this.peek()
|
|
715
|
+
if (
|
|
716
|
+
next.kind === 'word' &&
|
|
717
|
+
COMPOUND_AFTER.test(this.src.slice(this.skipBlanks(next.end)))
|
|
718
|
+
) {
|
|
719
|
+
this.take()
|
|
720
|
+
const body = this.peek()
|
|
721
|
+
if (body.kind === 'word') this.peeked = { ...body, reservedOk: true }
|
|
722
|
+
}
|
|
723
|
+
this.command()
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
case '}':
|
|
727
|
+
case 'then':
|
|
728
|
+
case 'else':
|
|
729
|
+
case 'elif':
|
|
730
|
+
case 'fi':
|
|
731
|
+
case 'do':
|
|
732
|
+
case 'done':
|
|
733
|
+
case 'esac':
|
|
734
|
+
case '!':
|
|
735
|
+
case 'time':
|
|
736
|
+
case 'in':
|
|
737
|
+
case ']]':
|
|
738
|
+
throw this.unexpected(token)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
this.simpleCommand()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private ifCommand(): void {
|
|
745
|
+
this.take()
|
|
746
|
+
this.list((t) => this.isReserved(t, 'then'))
|
|
747
|
+
this.take()
|
|
748
|
+
this.list(
|
|
749
|
+
(t) => this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
|
|
750
|
+
)
|
|
751
|
+
for (;;) {
|
|
752
|
+
const token = this.take()
|
|
753
|
+
if (this.isReserved(token, 'fi')) return
|
|
754
|
+
if (this.isReserved(token, 'elif')) {
|
|
755
|
+
this.list((t) => this.isReserved(t, 'then'))
|
|
756
|
+
this.take()
|
|
757
|
+
this.list(
|
|
758
|
+
(t) =>
|
|
759
|
+
this.isReserved(t, 'elif') || this.isReserved(t, 'else') || this.isReserved(t, 'fi'),
|
|
760
|
+
)
|
|
761
|
+
continue
|
|
762
|
+
}
|
|
763
|
+
// else
|
|
764
|
+
this.list((t) => this.isReserved(t, 'fi'))
|
|
765
|
+
this.take()
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private forCommand(): void {
|
|
771
|
+
this.take()
|
|
772
|
+
const header = this.skipBlanks(this.pos)
|
|
773
|
+
let token: Token
|
|
774
|
+
if (this.peeked === null && this.src.startsWith('((', header)) {
|
|
775
|
+
// `for (( init; test; step ))`: arithmetic throughout.
|
|
776
|
+
const end = this.scanArithmetic(header + 2)
|
|
777
|
+
if (end < 0) throw new Stop('syntax error: arithmetic for loop')
|
|
778
|
+
this.bashOnly('for ((…))')
|
|
779
|
+
this.arithmeticContent(this.src.slice(header + 2, end - 2), header + 2)
|
|
780
|
+
this.pos = end
|
|
781
|
+
this.last = { kind: 'other' }
|
|
782
|
+
this.newlines()
|
|
783
|
+
token = this.peek()
|
|
784
|
+
if (token.kind === 'op' && token.op === ';') {
|
|
785
|
+
this.take()
|
|
786
|
+
this.newlines()
|
|
787
|
+
}
|
|
788
|
+
this.loopBody()
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
const name = this.takePlainWord()
|
|
792
|
+
if (name.kind !== 'word') throw this.unexpected(name)
|
|
793
|
+
this.newlines()
|
|
794
|
+
token = this.peek()
|
|
795
|
+
if (token.kind === 'word' && !token.word.quoted && token.word.value === 'in') {
|
|
796
|
+
this.take()
|
|
797
|
+
// Inside a case statement, bash reads `esac` right after `in` as
|
|
798
|
+
// the end of the case.
|
|
799
|
+
const first = this.peekPlainWord()
|
|
800
|
+
if (
|
|
801
|
+
this.caseDepth > 0 &&
|
|
802
|
+
first.kind === 'word' &&
|
|
803
|
+
!first.word.quoted &&
|
|
804
|
+
first.word.value === 'esac'
|
|
805
|
+
) {
|
|
806
|
+
throw this.unexpected(first)
|
|
807
|
+
}
|
|
808
|
+
for (;;) {
|
|
809
|
+
token = this.takePlainWord()
|
|
810
|
+
if (token.kind === 'word') continue
|
|
811
|
+
if (token.kind === 'newline' || (token.kind === 'op' && token.op === ';')) break
|
|
812
|
+
throw this.unexpected(token)
|
|
813
|
+
}
|
|
814
|
+
this.newlines()
|
|
815
|
+
} else if (token.kind === 'op' && token.op === ';') {
|
|
816
|
+
this.take()
|
|
817
|
+
this.newlines()
|
|
818
|
+
}
|
|
819
|
+
this.loopBody()
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** `do list done`, or bash's `{ list }` in its place. */
|
|
823
|
+
private loopBody(): void {
|
|
824
|
+
const token = this.peek()
|
|
825
|
+
if (this.isReservedAnywhere(token, 'do')) {
|
|
826
|
+
this.take()
|
|
827
|
+
this.list((t) => this.isReserved(t, 'done'))
|
|
828
|
+
this.take()
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
if (this.isReservedAnywhere(token, '{')) {
|
|
832
|
+
this.bashOnly('a { } loop body')
|
|
833
|
+
this.take()
|
|
834
|
+
this.braceGroup()
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
throw this.unexpected(token)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private caseDepth = 0
|
|
841
|
+
|
|
842
|
+
private caseCommand(): void {
|
|
843
|
+
this.caseDepth += 1
|
|
844
|
+
try {
|
|
845
|
+
this.caseBody()
|
|
846
|
+
} finally {
|
|
847
|
+
this.caseDepth -= 1
|
|
848
|
+
this.assignmentSyntax = true
|
|
849
|
+
this.allowCompound = true
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private caseBody(): void {
|
|
854
|
+
this.take()
|
|
855
|
+
const subject = this.takePlainWord()
|
|
856
|
+
if (subject.kind !== 'word') throw this.unexpected(subject)
|
|
857
|
+
this.newlines()
|
|
858
|
+
const keyword = this.take()
|
|
859
|
+
if (keyword.kind !== 'word' || keyword.word.quoted || keyword.word.value !== 'in') {
|
|
860
|
+
throw this.unexpected(keyword)
|
|
861
|
+
}
|
|
862
|
+
// Patterns are never assignments.
|
|
863
|
+
const patterns = (): void => {
|
|
864
|
+
this.assignmentSyntax = false
|
|
865
|
+
this.allowCompound = false
|
|
866
|
+
}
|
|
867
|
+
patterns()
|
|
868
|
+
this.newlines()
|
|
869
|
+
for (;;) {
|
|
870
|
+
patterns()
|
|
871
|
+
let token = this.peek()
|
|
872
|
+
if (
|
|
873
|
+
token.kind === 'word' &&
|
|
874
|
+
!token.word.quoted &&
|
|
875
|
+
!token.word.expands &&
|
|
876
|
+
token.word.value === 'esac'
|
|
877
|
+
) {
|
|
878
|
+
this.take()
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
if (token.kind === 'op' && token.op === '(') {
|
|
882
|
+
this.take()
|
|
883
|
+
token = this.peek()
|
|
884
|
+
}
|
|
885
|
+
// One or more patterns separated by `|`, then `)`.
|
|
886
|
+
for (;;) {
|
|
887
|
+
patterns()
|
|
888
|
+
const pattern = this.take()
|
|
889
|
+
if (pattern.kind !== 'word') throw this.unexpected(pattern)
|
|
890
|
+
const next = this.take()
|
|
891
|
+
if (next.kind === 'op' && next.op === '|') continue
|
|
892
|
+
if (next.kind === 'op' && next.op === ')') break
|
|
893
|
+
throw this.unexpected(next)
|
|
894
|
+
}
|
|
895
|
+
this.assignmentSyntax = true
|
|
896
|
+
this.allowCompound = true
|
|
897
|
+
this.newlines()
|
|
898
|
+
token = this.peek()
|
|
899
|
+
const endsClause = (t: Token): boolean =>
|
|
900
|
+
(t.kind === 'op' && (t.op === ';;' || t.op === ';&' || t.op === ';;&')) ||
|
|
901
|
+
(t.kind === 'word' && !t.word.quoted && !t.word.expands && t.word.value === 'esac')
|
|
902
|
+
if (!endsClause(token)) this.list(endsClause)
|
|
903
|
+
token = this.take()
|
|
904
|
+
if (token.kind === 'word') return // esac
|
|
905
|
+
patterns()
|
|
906
|
+
this.newlines()
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* `[[ … ]]`. Nothing in it runs a command except a substitution, which
|
|
912
|
+
* word reading already records. Its operands are arithmetic in places
|
|
913
|
+
* (`-eq`), which can evaluate code held in a variable, so it is opaque.
|
|
914
|
+
*/
|
|
915
|
+
private conditional(): void {
|
|
916
|
+
this.take()
|
|
917
|
+
this.context.opaque('conditional expression')
|
|
918
|
+
this.assignmentSyntax = false
|
|
919
|
+
this.allowCompound = false
|
|
920
|
+
for (;;) {
|
|
921
|
+
const token = this.takeConditional()
|
|
922
|
+
if (token.kind === 'eof') throw new Stop('unterminated [[')
|
|
923
|
+
if (token.kind === 'word' && !token.word.quoted && token.word.value === ']]') {
|
|
924
|
+
// Like `((…))`, a finished `[[…]]` accepts a reserved word next.
|
|
925
|
+
this.last = { kind: 'word', reserved: '}' }
|
|
926
|
+
this.assignmentSyntax = true
|
|
927
|
+
this.allowCompound = true
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** `name () body` or `function name [()] body`. */
|
|
934
|
+
private functionBody(keyword: boolean): void {
|
|
935
|
+
if (keyword) {
|
|
936
|
+
const name = this.take()
|
|
937
|
+
if (name.kind !== 'word') throw this.unexpected(name)
|
|
938
|
+
}
|
|
939
|
+
const open = this.peek()
|
|
940
|
+
if (open.kind === 'op' && open.op === '(') {
|
|
941
|
+
this.take()
|
|
942
|
+
this.expectOp(')')
|
|
943
|
+
} else if (!keyword) {
|
|
944
|
+
throw this.unexpected(open)
|
|
945
|
+
}
|
|
946
|
+
this.newlines()
|
|
947
|
+
// The body must be a compound command; the reserved word is recognised
|
|
948
|
+
// here whatever came before it.
|
|
949
|
+
const body = this.peek()
|
|
950
|
+
if (
|
|
951
|
+
body.kind === 'word' &&
|
|
952
|
+
!body.word.quoted &&
|
|
953
|
+
['{', 'if', 'while', 'until', 'for', 'select', 'case', '[['].includes(body.word.value)
|
|
954
|
+
) {
|
|
955
|
+
this.peeked = { ...body, reservedOk: true }
|
|
956
|
+
this.command()
|
|
957
|
+
return
|
|
958
|
+
}
|
|
959
|
+
if (body.kind === 'op' && body.op === '(') {
|
|
960
|
+
this.command()
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
if (body.kind === 'arith') {
|
|
964
|
+
this.command()
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
throw this.unexpected(body)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
private redirectionsAfterCompound(): void {
|
|
971
|
+
for (;;) {
|
|
972
|
+
const token = this.peek()
|
|
973
|
+
if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
|
|
974
|
+
this.take()
|
|
975
|
+
this.context.redirections.push(this.redirectionTarget(token))
|
|
976
|
+
continue
|
|
977
|
+
}
|
|
978
|
+
if (token.kind === 'word') throw this.unexpected(token)
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private simpleCommand(): void {
|
|
984
|
+
const words: ShellWord[] = []
|
|
985
|
+
const redirections: ShellRedirection[] = []
|
|
986
|
+
let assignments = 0
|
|
987
|
+
let afterAssignment = false
|
|
988
|
+
let start = -1
|
|
989
|
+
let end = -1
|
|
990
|
+
for (;;) {
|
|
991
|
+
// `NAME=(…)` and `NAME[…]` are read as assignment syntax only where an
|
|
992
|
+
// assignment can stand: first in the command (after any leading
|
|
993
|
+
// redirections), or right after another assignment. An array also
|
|
994
|
+
// counts as an argument of `declare` and its kin.
|
|
995
|
+
this.assignmentSyntax =
|
|
996
|
+
words.length === 0 || (words.length === assignments && afterAssignment)
|
|
997
|
+
this.allowCompound =
|
|
998
|
+
this.assignmentSyntax || ASSIGNMENT_BUILTINS.has(words[assignments]?.value ?? '')
|
|
999
|
+
const token = this.peek()
|
|
1000
|
+
if (token.kind === 'word') {
|
|
1001
|
+
this.take()
|
|
1002
|
+
if (start < 0) start = token.start
|
|
1003
|
+
end = token.end
|
|
1004
|
+
afterAssignment = false
|
|
1005
|
+
if (words.length === assignments && ASSIGNMENT.test(joined(token.word.text))) {
|
|
1006
|
+
assignments += 1
|
|
1007
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\[|\+=)/.test(joined(token.word.text)))
|
|
1008
|
+
this.bashOnly('array or += assignment')
|
|
1009
|
+
afterAssignment = true
|
|
1010
|
+
const subscript = /^[A-Za-z_][A-Za-z0-9_]*\[([^\]]*)\]/.exec(joined(token.word.text))
|
|
1011
|
+
if (subscript && !/^\d*$/.test(subscript[1] as string))
|
|
1012
|
+
this.context.opaque('arithmetic subscript')
|
|
1013
|
+
}
|
|
1014
|
+
words.push(token.word)
|
|
1015
|
+
continue
|
|
1016
|
+
}
|
|
1017
|
+
if (token.kind === 'op' && REDIRECTIONS.has(token.op)) {
|
|
1018
|
+
this.take()
|
|
1019
|
+
if (start < 0) start = token.start
|
|
1020
|
+
afterAssignment = false
|
|
1021
|
+
// A bash 5 quirk, measured: in a command that so far has only
|
|
1022
|
+
// redirections, the target of a later `&>>` is read as an
|
|
1023
|
+
// assignment word, subscript included, and rejected when it is
|
|
1024
|
+
// shaped like one.
|
|
1025
|
+
const quirk = token.op === '&>>' && words.length === 0 && redirections.length > 0
|
|
1026
|
+
const redirection = this.redirectionTarget(token, quirk)
|
|
1027
|
+
if (quirk && ASSIGNMENT.test(joined(redirection.target.text)))
|
|
1028
|
+
throw new Stop('syntax error')
|
|
1029
|
+
end = this.lastEnd
|
|
1030
|
+
redirections.push(redirection)
|
|
1031
|
+
this.context.redirections.push(redirection)
|
|
1032
|
+
continue
|
|
1033
|
+
}
|
|
1034
|
+
if (
|
|
1035
|
+
token.kind === 'op' &&
|
|
1036
|
+
token.op === '(' &&
|
|
1037
|
+
words.length === 1 &&
|
|
1038
|
+
assignments === 0 &&
|
|
1039
|
+
redirections.length === 0
|
|
1040
|
+
) {
|
|
1041
|
+
// `name ( ) compound`: a function definition. Its body runs only
|
|
1042
|
+
// when something calls it, under a name no rule sees.
|
|
1043
|
+
this.context.opaque('function definition')
|
|
1044
|
+
this.functionBody(false)
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
break
|
|
1048
|
+
}
|
|
1049
|
+
this.allowCompound = true
|
|
1050
|
+
this.assignmentSyntax = true
|
|
1051
|
+
if (start < 0) throw this.unexpected(this.peek())
|
|
1052
|
+
const command: ShellCommand = {
|
|
1053
|
+
words,
|
|
1054
|
+
assignments,
|
|
1055
|
+
redirections,
|
|
1056
|
+
text: this.src.slice(start, end),
|
|
1057
|
+
origin: this.origin,
|
|
1058
|
+
depth: this.depth,
|
|
1059
|
+
}
|
|
1060
|
+
this.context.commands.push(command)
|
|
1061
|
+
this.inspect(command)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Take the next token where no assignment syntax applies: a redirection
|
|
1066
|
+
* target, a `for` name or word, a `case` subject.
|
|
1067
|
+
*/
|
|
1068
|
+
private takePlainWord(): Token {
|
|
1069
|
+
const assignmentSyntax = this.assignmentSyntax
|
|
1070
|
+
const allowCompound = this.allowCompound
|
|
1071
|
+
this.assignmentSyntax = false
|
|
1072
|
+
this.allowCompound = false
|
|
1073
|
+
try {
|
|
1074
|
+
return this.take()
|
|
1075
|
+
} finally {
|
|
1076
|
+
this.assignmentSyntax = assignmentSyntax
|
|
1077
|
+
this.allowCompound = allowCompound
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
private peekPlainWord(): Token {
|
|
1082
|
+
const assignmentSyntax = this.assignmentSyntax
|
|
1083
|
+
const allowCompound = this.allowCompound
|
|
1084
|
+
this.assignmentSyntax = false
|
|
1085
|
+
this.allowCompound = false
|
|
1086
|
+
try {
|
|
1087
|
+
return this.peek()
|
|
1088
|
+
} finally {
|
|
1089
|
+
this.assignmentSyntax = assignmentSyntax
|
|
1090
|
+
this.allowCompound = allowCompound
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** The end offset of the last token taken. */
|
|
1095
|
+
private lastEnd = 0
|
|
1096
|
+
/** Whether a word may be a compound array assignment here. */
|
|
1097
|
+
private allowCompound = true
|
|
1098
|
+
/** Whether `NAME[…]` is read as a subscript here, spaces and all. */
|
|
1099
|
+
private assignmentSyntax = true
|
|
1100
|
+
|
|
1101
|
+
private redirectionTarget(
|
|
1102
|
+
operator: Token & { kind: 'op' },
|
|
1103
|
+
assignmentSyntax = false,
|
|
1104
|
+
): ShellRedirection {
|
|
1105
|
+
const target = assignmentSyntax ? this.take() : this.takePlainWord()
|
|
1106
|
+
if (target.kind !== 'word') throw this.unexpected(target)
|
|
1107
|
+
if (
|
|
1108
|
+
(operator.op === '>&' || operator.op === '<&') &&
|
|
1109
|
+
(target.word.quoted || target.word.expands)
|
|
1110
|
+
) {
|
|
1111
|
+
// Bash 5.2 expands the target of `>&` twice: `x >&2'$(cmd)'` and
|
|
1112
|
+
// `x >&2${v:-'$(cmd)'}` run `cmd`, which the parse saw quoted
|
|
1113
|
+
// (measured; fixed in 5.3, and 5.2 is what current Debian and
|
|
1114
|
+
// Ubuntu ship).
|
|
1115
|
+
this.context.opaque('quoted or expanding target of >& or <&')
|
|
1116
|
+
}
|
|
1117
|
+
if (operator.op === '<<' || operator.op === '<<-') {
|
|
1118
|
+
if (target.word.value.includes('\n')) {
|
|
1119
|
+
// Bash starts the body at the newline inside the delimiter, so
|
|
1120
|
+
// the rest of the line is body text. Reading on as commands
|
|
1121
|
+
// reports more than runs, which is the safe way to be wrong.
|
|
1122
|
+
this.context.opaque('here-document delimiter spans lines')
|
|
1123
|
+
} else
|
|
1124
|
+
this.heredocs.push({
|
|
1125
|
+
delimiter: target.word.value,
|
|
1126
|
+
stripTabs: operator.op === '<<-',
|
|
1127
|
+
quoted: target.word.quoted,
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
operator: operator.op,
|
|
1132
|
+
...(operator.fd !== undefined ? { fd: operator.fd } : {}),
|
|
1133
|
+
target: target.word,
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* What a finished simple command means for the rest of the line: a nested
|
|
1139
|
+
* shell to read, or a command that changes how bash parses what follows.
|
|
1140
|
+
*/
|
|
1141
|
+
private inspect(command: ShellCommand): void {
|
|
1142
|
+
for (const word of command.words) {
|
|
1143
|
+
// Either variable, set in the line, changes how bash parses the rest.
|
|
1144
|
+
if (word.value.includes('POSIXLY_CORRECT=') || word.value.includes('BASH_COMPAT=')) {
|
|
1145
|
+
this.context.opaque('parser setting')
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
const head = command.words[command.assignments]
|
|
1149
|
+
if (head === undefined || head.expands) return
|
|
1150
|
+
const name = basename(head.value)
|
|
1151
|
+
if (
|
|
1152
|
+
name !== 'shopt' &&
|
|
1153
|
+
name !== 'enable' &&
|
|
1154
|
+
name !== 'set' &&
|
|
1155
|
+
name !== 'busybox' &&
|
|
1156
|
+
!NESTED_SHELLS.has(name)
|
|
1157
|
+
)
|
|
1158
|
+
return
|
|
1159
|
+
const words = command.words.slice(command.assignments)
|
|
1160
|
+
if (name === 'shopt' || name === 'enable') {
|
|
1161
|
+
this.context.opaque('parser setting')
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
if (name === 'set') {
|
|
1165
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
1166
|
+
const word = words[i] as ShellWord
|
|
1167
|
+
if (word.expands) this.context.opaque('parser setting')
|
|
1168
|
+
if (/^[-+][A-Za-z]*k/.test(word.value) || word.value === '--posix')
|
|
1169
|
+
this.context.opaque('parser setting')
|
|
1170
|
+
if (
|
|
1171
|
+
/^[-+][A-Za-z]*o$/.test(word.value) &&
|
|
1172
|
+
/^(?:posix|keyword)$/.test(words[i + 1]?.value ?? '')
|
|
1173
|
+
) {
|
|
1174
|
+
this.context.opaque('parser setting')
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
if (name === 'busybox' && words[1] !== undefined && !words[1].expands) {
|
|
1180
|
+
if (NESTED_SHELLS.has(basename(words[1].value))) this.nestedShell(words.slice(1))
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
if (!NESTED_SHELLS.has(name)) return
|
|
1184
|
+
this.nestedShell(words)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* `bash [options] -c payload [name args…]`: the payload is the first
|
|
1189
|
+
* non-option argument once `-c` has been seen among the options.
|
|
1190
|
+
*/
|
|
1191
|
+
private nestedShell(words: readonly ShellWord[]): void {
|
|
1192
|
+
const shell = basename(words[0]?.value ?? '')
|
|
1193
|
+
let command = false
|
|
1194
|
+
let payload: ShellWord | undefined
|
|
1195
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
1196
|
+
const word = words[i] as ShellWord
|
|
1197
|
+
if (word.expands) {
|
|
1198
|
+
this.context.opaque('nested shell option')
|
|
1199
|
+
return
|
|
1200
|
+
}
|
|
1201
|
+
const value = word.value
|
|
1202
|
+
if (value === '--' || value === '-') {
|
|
1203
|
+
payload = words[i + 1]
|
|
1204
|
+
break
|
|
1205
|
+
}
|
|
1206
|
+
if (value.startsWith('--')) {
|
|
1207
|
+
if (value === '--rcfile' || value === '--init-file') i += 1
|
|
1208
|
+
continue
|
|
1209
|
+
}
|
|
1210
|
+
if (/^[-+][A-Za-z]+$/.test(value)) {
|
|
1211
|
+
if (value.startsWith('-') && value.includes('c')) command = true
|
|
1212
|
+
// `-o name`, `-O name`: the option takes the next word.
|
|
1213
|
+
if (/[oO]$/.test(value)) i += 1
|
|
1214
|
+
continue
|
|
1215
|
+
}
|
|
1216
|
+
payload = word
|
|
1217
|
+
break
|
|
1218
|
+
}
|
|
1219
|
+
if (!command) return
|
|
1220
|
+
if (payload === undefined) {
|
|
1221
|
+
this.context.opaque('nested shell without a command')
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
if (payload.expands) {
|
|
1225
|
+
this.context.opaque('nested shell command is expanded at runtime')
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
if (this.depth + 1 >= MAX_SHELL_DEPTH) {
|
|
1229
|
+
this.context.opaque('nested shells too deep')
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
const origin = this.origin === 'substitution' ? 'substitution' : 'shell'
|
|
1233
|
+
this.context.rescan(payload.value.length)
|
|
1234
|
+
// `bash -c` is read as bash. Another shell is read in the dialect
|
|
1235
|
+
// that holds for every POSIX shell; zsh and ksh go beyond POSIX in
|
|
1236
|
+
// ways this lexer does not model, so their payloads are opaque, and
|
|
1237
|
+
// still read for what a deny rule can see.
|
|
1238
|
+
this.context.setDialect(payload.value, shell === 'bash' ? 'bash' : 'sh')
|
|
1239
|
+
if (shell === 'zsh' || shell === 'ksh' || shell === 'mksh')
|
|
1240
|
+
this.context.opaque(`nested ${shell} is not modeled`)
|
|
1241
|
+
const inner = new Parser(
|
|
1242
|
+
payload.value,
|
|
1243
|
+
0,
|
|
1244
|
+
this.context,
|
|
1245
|
+
origin,
|
|
1246
|
+
this.depth + 1,
|
|
1247
|
+
this.nestingNow + 1,
|
|
1248
|
+
)
|
|
1249
|
+
try {
|
|
1250
|
+
inner.program()
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
if (!(error instanceof Stop)) throw error
|
|
1253
|
+
// The payload does not parse. Bash would refuse it, but this
|
|
1254
|
+
// reading may be wrong about that, so it is opaque rather than empty.
|
|
1255
|
+
this.context.opaque(`nested shell: ${error.reason}`)
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Compound commands nest on the JS stack; bound how deep. */
|
|
1260
|
+
private level = 0
|
|
1261
|
+
private get nestingNow(): number {
|
|
1262
|
+
return this.nesting + this.level
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private expectOp(op: string): Token {
|
|
1266
|
+
const token = this.take()
|
|
1267
|
+
if (token.kind !== 'op' || token.op !== op) throw this.unexpected(token)
|
|
1268
|
+
return token
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private newlines(): void {
|
|
1272
|
+
while (this.peek().kind === 'newline') this.take()
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
private isReserved(token: Token, name: string): boolean {
|
|
1276
|
+
return (
|
|
1277
|
+
token.kind === 'word' &&
|
|
1278
|
+
token.reservedOk &&
|
|
1279
|
+
!token.word.quoted &&
|
|
1280
|
+
!token.word.expands &&
|
|
1281
|
+
token.word.value === name
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** A reserved word recognised by position in a `for` header regardless of what preceded it. */
|
|
1286
|
+
private isReservedAnywhere(token: Token, name: string): boolean {
|
|
1287
|
+
return (
|
|
1288
|
+
token.kind === 'word' &&
|
|
1289
|
+
!token.word.quoted &&
|
|
1290
|
+
!token.word.expands &&
|
|
1291
|
+
token.word.value === name
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private unexpected(token: Token): Stop {
|
|
1296
|
+
if (token.kind === 'eof') return new Stop('syntax error: unexpected end of input')
|
|
1297
|
+
return new Stop('syntax error')
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// --- tokens -----------------------------------------------------------
|
|
1301
|
+
|
|
1302
|
+
private peek(): Token {
|
|
1303
|
+
if (this.peeked === null) this.peeked = this.read()
|
|
1304
|
+
return this.peeked
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
private take(): Token {
|
|
1308
|
+
const token = this.peek()
|
|
1309
|
+
this.peeked = null
|
|
1310
|
+
this.lastEnd = token.end
|
|
1311
|
+
if (token.kind === 'word') {
|
|
1312
|
+
const reserved =
|
|
1313
|
+
token.reservedOk &&
|
|
1314
|
+
!token.word.quoted &&
|
|
1315
|
+
!token.word.expands &&
|
|
1316
|
+
RESERVED.has(token.word.value)
|
|
1317
|
+
? token.word.value
|
|
1318
|
+
: null
|
|
1319
|
+
this.last = { kind: 'word', reserved }
|
|
1320
|
+
} else if (token.kind === 'op') {
|
|
1321
|
+
this.last = { kind: 'op', op: token.op }
|
|
1322
|
+
} else if (token.kind === 'newline') {
|
|
1323
|
+
this.last = { kind: 'newline' }
|
|
1324
|
+
} else {
|
|
1325
|
+
this.last = { kind: 'other' }
|
|
1326
|
+
}
|
|
1327
|
+
return token
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
private reservedOk(): boolean {
|
|
1331
|
+
const last = this.last
|
|
1332
|
+
switch (last.kind) {
|
|
1333
|
+
case 'start':
|
|
1334
|
+
case 'newline':
|
|
1335
|
+
return true
|
|
1336
|
+
case 'op':
|
|
1337
|
+
return RESERVED_AFTER_OPS.has(last.op)
|
|
1338
|
+
case 'word':
|
|
1339
|
+
return last.reserved !== null && RESERVED_AFTER_WORDS.has(last.reserved)
|
|
1340
|
+
default:
|
|
1341
|
+
return false
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Skip any `\<newline>` pairs at `at`: bash removes them before tokenizing. */
|
|
1346
|
+
private cont(at: number): number {
|
|
1347
|
+
let i = at
|
|
1348
|
+
for (;;) {
|
|
1349
|
+
if (this.src[i] !== '\\') return i
|
|
1350
|
+
const next = i + 1
|
|
1351
|
+
if (this.src[next] === '\n') i += 2
|
|
1352
|
+
else if (next === this.src.length && this.source.finalLineRaw) {
|
|
1353
|
+
this.bashOnly('a trailing backslash')
|
|
1354
|
+
i += 1
|
|
1355
|
+
} else return i
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
private skipBlanks(at: number): number {
|
|
1360
|
+
let i = this.cont(at)
|
|
1361
|
+
while (this.src[i] === ' ' || this.src[i] === '\t') i = this.cont(i + 1)
|
|
1362
|
+
return i
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private read(): Token {
|
|
1366
|
+
const src = this.src
|
|
1367
|
+
for (;;) {
|
|
1368
|
+
const start = this.skipBlanks(this.pos)
|
|
1369
|
+
this.pos = start
|
|
1370
|
+
if (start >= src.length) return { kind: 'eof', start, end: start }
|
|
1371
|
+
const char = src[start] as string
|
|
1372
|
+
if (char === '#') {
|
|
1373
|
+
// A comment runs to the newline; continuations do not extend it.
|
|
1374
|
+
let i = start
|
|
1375
|
+
while (i < src.length && src[i] !== '\n') i += 1
|
|
1376
|
+
this.pos = i
|
|
1377
|
+
continue
|
|
1378
|
+
}
|
|
1379
|
+
if (char === '\n') {
|
|
1380
|
+
this.pos = start + 1
|
|
1381
|
+
this.readHeredocs()
|
|
1382
|
+
return { kind: 'newline', start, end: start + 1 }
|
|
1383
|
+
}
|
|
1384
|
+
if (char === '}' && this.closesOnBrace && this.braceDepth === 0 && this.reservedOk()) {
|
|
1385
|
+
this.pos = start + 1
|
|
1386
|
+
return { kind: 'op', start, end: start + 1, op: '}' }
|
|
1387
|
+
}
|
|
1388
|
+
// After `<&` or `>&`, bash takes a `-` as a token of its own: `3<&-a`
|
|
1389
|
+
// closes descriptor 3 and `a` is the next word.
|
|
1390
|
+
if (
|
|
1391
|
+
char === '-' &&
|
|
1392
|
+
this.last.kind === 'op' &&
|
|
1393
|
+
(this.last.op === '<&' || this.last.op === '>&')
|
|
1394
|
+
) {
|
|
1395
|
+
const after = src[this.cont(start + 1)]
|
|
1396
|
+
if (after !== undefined && !WORD_BREAK.has(after))
|
|
1397
|
+
this.bashOnly('a word glued to <&- or >&-')
|
|
1398
|
+
this.pos = start + 1
|
|
1399
|
+
return {
|
|
1400
|
+
kind: 'word',
|
|
1401
|
+
start,
|
|
1402
|
+
end: start + 1,
|
|
1403
|
+
word: { text: '-', value: '-', expands: false, quoted: false },
|
|
1404
|
+
reservedOk: false,
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return this.operatorOrWord(start)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Read the operator starting at `start`, or a word. */
|
|
1412
|
+
private operatorOrWord(start: number): Token {
|
|
1413
|
+
const src = this.src
|
|
1414
|
+
const char = src[start] as string
|
|
1415
|
+
const n1 = this.cont(start + 1)
|
|
1416
|
+
const c1 = src[n1]
|
|
1417
|
+
const op = (text: string, end: number, fd?: string): Token => {
|
|
1418
|
+
this.pos = end
|
|
1419
|
+
return fd === undefined
|
|
1420
|
+
? { kind: 'op', start, end, op: text }
|
|
1421
|
+
: { kind: 'op', start, end, op: text, fd }
|
|
1422
|
+
}
|
|
1423
|
+
switch (char) {
|
|
1424
|
+
case ';': {
|
|
1425
|
+
if (c1 === ';') {
|
|
1426
|
+
const n2 = this.cont(n1 + 1)
|
|
1427
|
+
if (src[n2] === '&') {
|
|
1428
|
+
this.bashOnly(';;&')
|
|
1429
|
+
return op(';;&', n2 + 1)
|
|
1430
|
+
}
|
|
1431
|
+
return op(';;', n1 + 1)
|
|
1432
|
+
}
|
|
1433
|
+
if (c1 === '&') {
|
|
1434
|
+
this.bashOnly(';&')
|
|
1435
|
+
return op(';&', n1 + 1)
|
|
1436
|
+
}
|
|
1437
|
+
return op(';', start + 1)
|
|
1438
|
+
}
|
|
1439
|
+
case '&': {
|
|
1440
|
+
if (c1 === '&') return op('&&', n1 + 1)
|
|
1441
|
+
if (c1 === '>') {
|
|
1442
|
+
const n2 = this.cont(n1 + 1)
|
|
1443
|
+
this.bashOnly('&> and &>>')
|
|
1444
|
+
if (src[n2] === '>') return op('&>>', n2 + 1)
|
|
1445
|
+
return op('&>', n1 + 1)
|
|
1446
|
+
}
|
|
1447
|
+
return op('&', start + 1)
|
|
1448
|
+
}
|
|
1449
|
+
case '|': {
|
|
1450
|
+
if (c1 === '|') return op('||', n1 + 1)
|
|
1451
|
+
if (c1 === '&') {
|
|
1452
|
+
this.bashOnly('|&')
|
|
1453
|
+
return op('|&', n1 + 1)
|
|
1454
|
+
}
|
|
1455
|
+
return op('|', start + 1)
|
|
1456
|
+
}
|
|
1457
|
+
case '(': {
|
|
1458
|
+
if (c1 === '(' && this.reservedOk()) {
|
|
1459
|
+
const end = this.arithmeticCommand(n1 + 1)
|
|
1460
|
+
if (end >= 0) {
|
|
1461
|
+
this.pos = end
|
|
1462
|
+
this.bashOnly('((…))')
|
|
1463
|
+
return { kind: 'arith', start, end }
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return op('(', start + 1)
|
|
1467
|
+
}
|
|
1468
|
+
case ')':
|
|
1469
|
+
return op(')', start + 1)
|
|
1470
|
+
case '<':
|
|
1471
|
+
case '>': {
|
|
1472
|
+
if (c1 === '(') return this.word(start)
|
|
1473
|
+
return this.redirectionOperator(start, undefined)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return this.word(start)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
private redirectionOperator(start: number, fd: string | undefined): Token {
|
|
1480
|
+
const src = this.src
|
|
1481
|
+
const char = src[start] as string
|
|
1482
|
+
const n1 = this.cont(start + 1)
|
|
1483
|
+
const c1 = src[n1]
|
|
1484
|
+
const done = (text: string, end: number): Token => {
|
|
1485
|
+
this.pos = end
|
|
1486
|
+
return fd === undefined
|
|
1487
|
+
? { kind: 'op', start, end, op: text }
|
|
1488
|
+
: { kind: 'op', start, end, op: text, fd }
|
|
1489
|
+
}
|
|
1490
|
+
if (char === '<') {
|
|
1491
|
+
if (c1 === '<') {
|
|
1492
|
+
const n2 = this.cont(n1 + 1)
|
|
1493
|
+
if (src[n2] === '<') {
|
|
1494
|
+
this.bashOnly('<<<')
|
|
1495
|
+
return done('<<<', n2 + 1)
|
|
1496
|
+
}
|
|
1497
|
+
if (src[n2] === '-') return done('<<-', n2 + 1)
|
|
1498
|
+
return done('<<', n1 + 1)
|
|
1499
|
+
}
|
|
1500
|
+
if (c1 === '>') return done('<>', n1 + 1)
|
|
1501
|
+
if (c1 === '&') return done('<&', n1 + 1)
|
|
1502
|
+
return done('<', start + 1)
|
|
1503
|
+
}
|
|
1504
|
+
if (c1 === '>') return done('>>', n1 + 1)
|
|
1505
|
+
if (c1 === '|') return done('>|', n1 + 1)
|
|
1506
|
+
if (c1 === '&') return done('>&', n1 + 1)
|
|
1507
|
+
return done('>', start + 1)
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Tokens inside `[[ … ]]`, where `<`, `>` and `(` are operands. */
|
|
1511
|
+
private takeConditional(): Token {
|
|
1512
|
+
this.peeked = null
|
|
1513
|
+
const start = this.skipBlanks(this.pos)
|
|
1514
|
+
this.pos = start
|
|
1515
|
+
const src = this.src
|
|
1516
|
+
if (start >= src.length) return { kind: 'eof', start, end: start }
|
|
1517
|
+
const char = src[start] as string
|
|
1518
|
+
if (char === '\n') {
|
|
1519
|
+
this.pos = start + 1
|
|
1520
|
+
this.readHeredocs()
|
|
1521
|
+
return { kind: 'newline', start, end: start + 1 }
|
|
1522
|
+
}
|
|
1523
|
+
if (char === '<' || char === '>' || char === '(' || char === ')' || char === ';') {
|
|
1524
|
+
this.pos = start + 1
|
|
1525
|
+
return { kind: 'op', start, end: start + 1, op: char }
|
|
1526
|
+
}
|
|
1527
|
+
if (char === '&' || char === '|') {
|
|
1528
|
+
const n1 = this.cont(start + 1)
|
|
1529
|
+
const end = src[n1] === char ? n1 + 1 : start + 1
|
|
1530
|
+
this.pos = end
|
|
1531
|
+
return { kind: 'op', start, end, op: src.slice(start, end) }
|
|
1532
|
+
}
|
|
1533
|
+
const token = this.word(start)
|
|
1534
|
+
return token
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* `(( … ))` at command position. Returns the offset past the closing
|
|
1539
|
+
* `))`, or -1 when the parentheses do not close that way, in which case
|
|
1540
|
+
* bash reads the text as nested subshells instead.
|
|
1541
|
+
*/
|
|
1542
|
+
private arithmeticCommand(from: number): number {
|
|
1543
|
+
const scanned = this.scanArithmetic(from)
|
|
1544
|
+
if (scanned < 0) return -1
|
|
1545
|
+
const content = this.src.slice(from, scanned - 2)
|
|
1546
|
+
this.arithmeticContent(content, from)
|
|
1547
|
+
return scanned
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Scan arithmetic text from `from` to the `))` that closes it at depth
|
|
1552
|
+
* zero. Returns the offset past `))`, or -1.
|
|
1553
|
+
*/
|
|
1554
|
+
private scanArithmetic(from: number): number {
|
|
1555
|
+
const src = this.src
|
|
1556
|
+
let depth = 0
|
|
1557
|
+
let i = from
|
|
1558
|
+
while (i < src.length) {
|
|
1559
|
+
i = this.cont(i)
|
|
1560
|
+
const char = src[i]
|
|
1561
|
+
if (char === undefined) return -1
|
|
1562
|
+
if (char === '\\') {
|
|
1563
|
+
i += 2
|
|
1564
|
+
continue
|
|
1565
|
+
}
|
|
1566
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
1567
|
+
const close = src.indexOf(char, i + 1)
|
|
1568
|
+
if (close < 0) {
|
|
1569
|
+
this.context.rescan(src.length - from)
|
|
1570
|
+
return -1
|
|
1571
|
+
}
|
|
1572
|
+
i = close + 1
|
|
1573
|
+
continue
|
|
1574
|
+
}
|
|
1575
|
+
if (char === '(') depth += 1
|
|
1576
|
+
else if (char === ')') {
|
|
1577
|
+
if (depth === 0) {
|
|
1578
|
+
const next = this.cont(i + 1)
|
|
1579
|
+
this.context.rescan(i - from)
|
|
1580
|
+
return src[next] === ')' ? next + 1 : -1
|
|
1581
|
+
}
|
|
1582
|
+
depth -= 1
|
|
1583
|
+
}
|
|
1584
|
+
i += 1
|
|
1585
|
+
}
|
|
1586
|
+
this.context.rescan(i - from)
|
|
1587
|
+
return -1
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Arithmetic evaluates variables recursively, and a variable holding
|
|
1592
|
+
* `a[$(cmd)]` runs `cmd`. Only arithmetic on literals is transparent.
|
|
1593
|
+
*/
|
|
1594
|
+
private arithmeticContent(content: string, at: number): void {
|
|
1595
|
+
const plain = content.replace(/\\\n/g, '')
|
|
1596
|
+
if (/^[0-9\s+\-*/%()<>=!&|^~?:,]*$/.test(plain)) return
|
|
1597
|
+
this.context.opaque('arithmetic')
|
|
1598
|
+
if (/\$\(|`/.test(plain)) {
|
|
1599
|
+
// Read the substitution for the commands it runs.
|
|
1600
|
+
this.scanForSubstitutions(at, at + content.length)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** Record the commands inside any `$(…)` or backticks in [from, to). */
|
|
1605
|
+
private scanForSubstitutions(from: number, to: number): void {
|
|
1606
|
+
let i = from
|
|
1607
|
+
while (i < to) {
|
|
1608
|
+
const char = this.src[i]
|
|
1609
|
+
if (char === '$' && this.src[i + 1] === '(' && this.src[i + 2] !== '(') {
|
|
1610
|
+
const inner = new Parser(
|
|
1611
|
+
this.src,
|
|
1612
|
+
i + 2,
|
|
1613
|
+
this.context,
|
|
1614
|
+
'substitution',
|
|
1615
|
+
this.depth,
|
|
1616
|
+
this.nestingNow + 1,
|
|
1617
|
+
)
|
|
1618
|
+
try {
|
|
1619
|
+
i = inner.substitution()
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
if (!(error instanceof Stop)) throw error
|
|
1622
|
+
return
|
|
1623
|
+
}
|
|
1624
|
+
continue
|
|
1625
|
+
}
|
|
1626
|
+
if (char === '`') {
|
|
1627
|
+
const end = this.backtick(i, new WordBuilder(), false)
|
|
1628
|
+
i = end
|
|
1629
|
+
continue
|
|
1630
|
+
}
|
|
1631
|
+
i += 1
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// --- here-documents ---------------------------------------------------
|
|
1636
|
+
|
|
1637
|
+
/** Consume the bodies of here-documents opened on the line just ended. */
|
|
1638
|
+
private readHeredocs(): void {
|
|
1639
|
+
const src = this.src
|
|
1640
|
+
while (this.heredocs.length > 0) {
|
|
1641
|
+
const heredoc = this.heredocs.shift() as PendingHeredoc
|
|
1642
|
+
let i = this.pos
|
|
1643
|
+
const bodyStart = i
|
|
1644
|
+
for (;;) {
|
|
1645
|
+
if (i >= src.length) break
|
|
1646
|
+
let line = ''
|
|
1647
|
+
let j = i
|
|
1648
|
+
for (;;) {
|
|
1649
|
+
const newline = src.indexOf('\n', j)
|
|
1650
|
+
const lineEnd = newline < 0 ? src.length : newline
|
|
1651
|
+
const piece = src.slice(j, lineEnd)
|
|
1652
|
+
// In an unquoted body, a backslash that escapes the newline
|
|
1653
|
+
// joins the lines before the delimiter test.
|
|
1654
|
+
if (!heredoc.quoted && newline >= 0 && trailingBackslashes(piece) % 2 === 1) {
|
|
1655
|
+
this.bashOnly('a line continuation in a here-document')
|
|
1656
|
+
line += piece.slice(0, -1)
|
|
1657
|
+
j = newline + 1
|
|
1658
|
+
continue
|
|
1659
|
+
}
|
|
1660
|
+
line += piece
|
|
1661
|
+
j = newline < 0 ? src.length : newline + 1
|
|
1662
|
+
break
|
|
1663
|
+
}
|
|
1664
|
+
const test = heredoc.stripTabs ? line.replace(/^\t+/, '') : line
|
|
1665
|
+
i = j
|
|
1666
|
+
if (test === heredoc.delimiter) break
|
|
1667
|
+
}
|
|
1668
|
+
if (!heredoc.quoted) {
|
|
1669
|
+
const body = src.slice(bodyStart, i)
|
|
1670
|
+
if (/\$[({[]|`/.test(body)) this.heredocBody(body)
|
|
1671
|
+
}
|
|
1672
|
+
this.pos = i
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** An unquoted here-document body expands; read it for substitutions. */
|
|
1677
|
+
private heredocBody(body: string): void {
|
|
1678
|
+
// Parameter expansion in a body runs nothing; substitution and
|
|
1679
|
+
// arithmetic might. Read with double-quote rules, where `"` is plain.
|
|
1680
|
+
this.context.rescan(body.length)
|
|
1681
|
+
this.inherit(body)
|
|
1682
|
+
const reader = new Parser(body, 0, this.context, this.origin, this.depth, this.nestingNow + 1)
|
|
1683
|
+
try {
|
|
1684
|
+
let i = 0
|
|
1685
|
+
const builder = new WordBuilder()
|
|
1686
|
+
while (i < body.length) {
|
|
1687
|
+
const char = body[i]
|
|
1688
|
+
if (char === '\\') {
|
|
1689
|
+
i += 2
|
|
1690
|
+
continue
|
|
1691
|
+
}
|
|
1692
|
+
if (char === '$') {
|
|
1693
|
+
i = reader.dollar(i, builder, true)
|
|
1694
|
+
continue
|
|
1695
|
+
}
|
|
1696
|
+
if (char === '`') {
|
|
1697
|
+
i = reader.backtick(i, builder, true)
|
|
1698
|
+
continue
|
|
1699
|
+
}
|
|
1700
|
+
i += 1
|
|
1701
|
+
}
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
if (!(error instanceof Stop)) throw error
|
|
1704
|
+
this.context.opaque(`here-document: ${error.reason}`)
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// --- words ------------------------------------------------------------
|
|
1709
|
+
|
|
1710
|
+
private word(start: number): Token {
|
|
1711
|
+
const reservedOk = this.reservedOk()
|
|
1712
|
+
const src = this.src
|
|
1713
|
+
const builder = new WordBuilder()
|
|
1714
|
+
let i = start
|
|
1715
|
+
for (;;) {
|
|
1716
|
+
i = this.cont(i)
|
|
1717
|
+
if (i >= src.length) break
|
|
1718
|
+
const char = src[i] as string
|
|
1719
|
+
if (WORD_BREAK.has(char)) {
|
|
1720
|
+
// `<(…)` and `>(…)` are words of their own.
|
|
1721
|
+
// `<(…)` and `>(…)` are read as part of the word, even mid-word.
|
|
1722
|
+
if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
|
|
1723
|
+
i = this.processSubstitution(i, builder)
|
|
1724
|
+
continue
|
|
1725
|
+
}
|
|
1726
|
+
if (
|
|
1727
|
+
char === '(' &&
|
|
1728
|
+
this.allowCompound &&
|
|
1729
|
+
/^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=$/.test(joined(src.slice(start, i)))
|
|
1730
|
+
) {
|
|
1731
|
+
this.bashOnly('array assignment')
|
|
1732
|
+
i = this.compoundArray(i + 1, builder)
|
|
1733
|
+
continue
|
|
1734
|
+
}
|
|
1735
|
+
break
|
|
1736
|
+
}
|
|
1737
|
+
if (
|
|
1738
|
+
char === '[' &&
|
|
1739
|
+
this.assignmentSyntax &&
|
|
1740
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(joined(src.slice(start, i)))
|
|
1741
|
+
) {
|
|
1742
|
+
// `NAME[…]` where an assignment may stand: bash reads the
|
|
1743
|
+
// subscript as one unit, blanks and quotes included.
|
|
1744
|
+
this.bashOnly('subscript')
|
|
1745
|
+
const close = this.subscript(i + 1)
|
|
1746
|
+
builder.expansion(src.slice(i, close))
|
|
1747
|
+
i = close
|
|
1748
|
+
continue
|
|
1749
|
+
}
|
|
1750
|
+
if (char === '\\') {
|
|
1751
|
+
if (i + 1 >= src.length) {
|
|
1752
|
+
// A trailing backslash is a literal backslash.
|
|
1753
|
+
this.bashOnly('a trailing backslash')
|
|
1754
|
+
builder.literal('\\')
|
|
1755
|
+
i += 1
|
|
1756
|
+
continue
|
|
1757
|
+
}
|
|
1758
|
+
builder.quotedText(src[i + 1] as string)
|
|
1759
|
+
i += 2
|
|
1760
|
+
continue
|
|
1761
|
+
}
|
|
1762
|
+
if (char === "'") {
|
|
1763
|
+
const close = src.indexOf("'", i + 1)
|
|
1764
|
+
if (close < 0) throw new Stop('unterminated quote')
|
|
1765
|
+
this.singleQuoted(i, close)
|
|
1766
|
+
builder.quotedText(src.slice(i + 1, close))
|
|
1767
|
+
i = close + 1
|
|
1768
|
+
continue
|
|
1769
|
+
}
|
|
1770
|
+
if (char === '"') {
|
|
1771
|
+
i = this.doubleQuoted(i + 1, builder)
|
|
1772
|
+
continue
|
|
1773
|
+
}
|
|
1774
|
+
if (char === '`') {
|
|
1775
|
+
i = this.backtick(i, builder, false)
|
|
1776
|
+
continue
|
|
1777
|
+
}
|
|
1778
|
+
if (char === '$') {
|
|
1779
|
+
i = this.dollar(i, builder, false)
|
|
1780
|
+
continue
|
|
1781
|
+
}
|
|
1782
|
+
builder.unquoted(char)
|
|
1783
|
+
i += 1
|
|
1784
|
+
}
|
|
1785
|
+
this.pos = i
|
|
1786
|
+
if (builder.brace) this.bashOnly('brace expansion')
|
|
1787
|
+
const word: ShellWord = {
|
|
1788
|
+
text: src.slice(start, i),
|
|
1789
|
+
value: builder.value,
|
|
1790
|
+
expands: builder.expands,
|
|
1791
|
+
quoted: builder.quoted,
|
|
1792
|
+
}
|
|
1793
|
+
// A descriptor before a redirection operator: `2>`, `{fd}>`.
|
|
1794
|
+
const next = src[i]
|
|
1795
|
+
if ((next === '<' || next === '>') && src[this.cont(i + 1)] !== '(') {
|
|
1796
|
+
const fd = joined(word.text)
|
|
1797
|
+
if (/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) this.bashOnly('{name} redirection')
|
|
1798
|
+
if (/^\d+$/.test(fd) || /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(fd)) {
|
|
1799
|
+
const operator = this.redirectionOperator(i, fd)
|
|
1800
|
+
return { ...operator, start }
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return { kind: 'word', start, end: i, word, reservedOk }
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* After the `(` of `NAME=(…)`: read the element words to the closing `)`.
|
|
1808
|
+
* Returns the offset past it. The whole word is marked as expanding: an
|
|
1809
|
+
* array is not one argument.
|
|
1810
|
+
*/
|
|
1811
|
+
private compoundArray(from: number, builder: WordBuilder): number {
|
|
1812
|
+
const src = this.src
|
|
1813
|
+
let i = from
|
|
1814
|
+
for (;;) {
|
|
1815
|
+
i = this.cont(i)
|
|
1816
|
+
if (i >= src.length) throw new Stop('syntax error: unterminated array')
|
|
1817
|
+
const char = src[i] as string
|
|
1818
|
+
if (char === ' ' || char === '\t' || char === '\n') {
|
|
1819
|
+
i += 1
|
|
1820
|
+
continue
|
|
1821
|
+
}
|
|
1822
|
+
if (char === '#') {
|
|
1823
|
+
while (i < src.length && src[i] !== '\n') i += 1
|
|
1824
|
+
continue
|
|
1825
|
+
}
|
|
1826
|
+
if (char === ')') {
|
|
1827
|
+
builder.expansion(src.slice(from - 1, i + 1))
|
|
1828
|
+
return i + 1
|
|
1829
|
+
}
|
|
1830
|
+
if (
|
|
1831
|
+
WORD_BREAK.has(char) &&
|
|
1832
|
+
!((char === '<' || char === '>') && src[this.cont(i + 1)] === '(')
|
|
1833
|
+
) {
|
|
1834
|
+
throw new Stop('syntax error in array')
|
|
1835
|
+
}
|
|
1836
|
+
const element = this.word(i)
|
|
1837
|
+
if (element.kind !== 'word') throw new Stop('syntax error in array')
|
|
1838
|
+
const subscript = /^\[([^\]]*)\]\+?=/.exec(element.word.text)
|
|
1839
|
+
if (subscript && !/^\d*$/.test(subscript[1] as string))
|
|
1840
|
+
this.context.opaque('arithmetic subscript')
|
|
1841
|
+
i = element.end
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** After the `[` of `NAME[`: returns the offset past the matching `]`. */
|
|
1846
|
+
private subscript(from: number): number {
|
|
1847
|
+
const src = this.src
|
|
1848
|
+
const scratch = new WordBuilder()
|
|
1849
|
+
let depth = 0
|
|
1850
|
+
let i = from
|
|
1851
|
+
for (;;) {
|
|
1852
|
+
i = this.cont(i)
|
|
1853
|
+
if (i >= src.length) throw new Stop('syntax error: unterminated subscript')
|
|
1854
|
+
const char = src[i] as string
|
|
1855
|
+
if (char === '\\') {
|
|
1856
|
+
i += 2
|
|
1857
|
+
continue
|
|
1858
|
+
}
|
|
1859
|
+
if (char === "'") {
|
|
1860
|
+
const close = src.indexOf("'", i + 1)
|
|
1861
|
+
if (close < 0) throw new Stop('unterminated quote')
|
|
1862
|
+
this.singleQuoted(i, close)
|
|
1863
|
+
i = close + 1
|
|
1864
|
+
continue
|
|
1865
|
+
}
|
|
1866
|
+
if (char === '"') {
|
|
1867
|
+
i = this.doubleQuoted(i + 1, scratch)
|
|
1868
|
+
continue
|
|
1869
|
+
}
|
|
1870
|
+
if (char === '`') {
|
|
1871
|
+
i = this.backtick(i, scratch, false)
|
|
1872
|
+
continue
|
|
1873
|
+
}
|
|
1874
|
+
if (char === '$') {
|
|
1875
|
+
i = this.dollar(i, scratch, false)
|
|
1876
|
+
continue
|
|
1877
|
+
}
|
|
1878
|
+
if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
|
|
1879
|
+
// Measured: `b[<(cmd)]` runs `cmd`.
|
|
1880
|
+
i = this.processSubstitution(i, scratch)
|
|
1881
|
+
continue
|
|
1882
|
+
}
|
|
1883
|
+
if (char === '[') depth += 1
|
|
1884
|
+
else if (char === ']') {
|
|
1885
|
+
if (depth === 0) return i + 1
|
|
1886
|
+
depth -= 1
|
|
1887
|
+
}
|
|
1888
|
+
i += 1
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** After an opening `"`: returns the offset past the closing one. */
|
|
1893
|
+
private doubleQuoted(from: number, builder: WordBuilder): number {
|
|
1894
|
+
const src = this.src
|
|
1895
|
+
let i = from
|
|
1896
|
+
builder.quotedText('')
|
|
1897
|
+
for (;;) {
|
|
1898
|
+
i = this.cont(i)
|
|
1899
|
+
if (i >= src.length) throw new Stop('unterminated quote')
|
|
1900
|
+
const char = src[i] as string
|
|
1901
|
+
if (char === '"') return i + 1
|
|
1902
|
+
if (char === '\\') {
|
|
1903
|
+
const next = src[i + 1]
|
|
1904
|
+
if (next === '$' || next === '`' || next === '"' || next === '\\') {
|
|
1905
|
+
builder.quotedText(next)
|
|
1906
|
+
i += 2
|
|
1907
|
+
continue
|
|
1908
|
+
}
|
|
1909
|
+
builder.quotedText('\\')
|
|
1910
|
+
i += 1
|
|
1911
|
+
continue
|
|
1912
|
+
}
|
|
1913
|
+
if (char === '$') {
|
|
1914
|
+
i = this.dollar(i, builder, true)
|
|
1915
|
+
continue
|
|
1916
|
+
}
|
|
1917
|
+
if (char === '`') {
|
|
1918
|
+
i = this.backtick(i, builder, true)
|
|
1919
|
+
continue
|
|
1920
|
+
}
|
|
1921
|
+
builder.quotedText(char)
|
|
1922
|
+
i += 1
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/** A `$` at `at`. Returns the offset past whatever it introduces. */
|
|
1927
|
+
dollar(at: number, builder: WordBuilder, inDouble: boolean): number {
|
|
1928
|
+
const src = this.src
|
|
1929
|
+
const n = this.cont(at + 1)
|
|
1930
|
+
const next = src[n]
|
|
1931
|
+
if (next === "'" && !inDouble) {
|
|
1932
|
+
this.bashOnly("$'…'")
|
|
1933
|
+
return this.ansiC(n + 1, builder)
|
|
1934
|
+
}
|
|
1935
|
+
if (next === '"' && !inDouble) {
|
|
1936
|
+
this.bashOnly('$"…"')
|
|
1937
|
+
// `$"…"` is translated through the message catalogue at runtime.
|
|
1938
|
+
const inner = new WordBuilder()
|
|
1939
|
+
const end = this.doubleQuoted(n + 1, inner)
|
|
1940
|
+
builder.expansion(src.slice(at, end))
|
|
1941
|
+
builder.quoted = true
|
|
1942
|
+
if (inner.expands) builder.expands = true
|
|
1943
|
+
return end
|
|
1944
|
+
}
|
|
1945
|
+
if (next === '{') {
|
|
1946
|
+
const n2 = this.cont(n + 1)
|
|
1947
|
+
const inner = src[n2]
|
|
1948
|
+
if (inner === ' ' || inner === '\t' || inner === '\n' || inner === '|') {
|
|
1949
|
+
// Bash 5.3's `${ list; }` and `${| list; }`: a command
|
|
1950
|
+
// substitution that runs in the current shell.
|
|
1951
|
+
this.context.opaque('command substitution')
|
|
1952
|
+
const parser = new Parser(
|
|
1953
|
+
src,
|
|
1954
|
+
inner === '|' ? n2 + 1 : n2,
|
|
1955
|
+
this.context,
|
|
1956
|
+
'substitution',
|
|
1957
|
+
this.depth,
|
|
1958
|
+
this.nestingNow + 1,
|
|
1959
|
+
)
|
|
1960
|
+
const end = parser.braceSubstitution()
|
|
1961
|
+
builder.expansion(src.slice(at, end))
|
|
1962
|
+
return end
|
|
1963
|
+
}
|
|
1964
|
+
const end = this.parameterBraces(n + 1, inDouble)
|
|
1965
|
+
builder.expansion(src.slice(at, end))
|
|
1966
|
+
return end
|
|
1967
|
+
}
|
|
1968
|
+
if (next === '(') {
|
|
1969
|
+
const n2 = this.cont(n + 1)
|
|
1970
|
+
if (src[n2] === '(') {
|
|
1971
|
+
const end = this.scanArithmetic(n2 + 1)
|
|
1972
|
+
if (end >= 0) {
|
|
1973
|
+
this.arithmeticContent(src.slice(n2 + 1, end - 2), n2 + 1)
|
|
1974
|
+
builder.expansion(src.slice(at, end))
|
|
1975
|
+
return end
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
this.context.opaque('command substitution')
|
|
1979
|
+
const inner = new Parser(
|
|
1980
|
+
src,
|
|
1981
|
+
n + 1,
|
|
1982
|
+
this.context,
|
|
1983
|
+
'substitution',
|
|
1984
|
+
this.depth,
|
|
1985
|
+
this.nestingNow + 1,
|
|
1986
|
+
)
|
|
1987
|
+
const end = inner.substitution()
|
|
1988
|
+
builder.expansion(src.slice(at, end))
|
|
1989
|
+
return end
|
|
1990
|
+
}
|
|
1991
|
+
if (next === '[') {
|
|
1992
|
+
this.bashOnly('$[…]')
|
|
1993
|
+
const end = this.matchBracket(n + 1)
|
|
1994
|
+
this.arithmeticContent(src.slice(n + 1, end - 1), n + 1)
|
|
1995
|
+
builder.expansion(src.slice(at, end))
|
|
1996
|
+
return end
|
|
1997
|
+
}
|
|
1998
|
+
if (next !== undefined && NAME_START.test(next)) {
|
|
1999
|
+
let i = n + 1
|
|
2000
|
+
for (;;) {
|
|
2001
|
+
i = this.cont(i)
|
|
2002
|
+
if (i < src.length && NAME_CHAR.test(src[i] as string)) i += 1
|
|
2003
|
+
else break
|
|
2004
|
+
}
|
|
2005
|
+
builder.expansion(src.slice(at, i))
|
|
2006
|
+
return i
|
|
2007
|
+
}
|
|
2008
|
+
if (next === '$' && inDouble) {
|
|
2009
|
+
// Bash's parser pairs `$$` inside double quotes, so a `(` or `{`
|
|
2010
|
+
// after it is literal text as far as the extent of the string goes.
|
|
2011
|
+
// Its expander does not: it re-scans the string and reads the
|
|
2012
|
+
// second `$` as the start of `$(…)` or `${…}`, and so
|
|
2013
|
+
// `"$${x:-"'$(cmd)'"}"` runs `cmd`, which the parse had seen as
|
|
2014
|
+
// single-quoted. Nothing read from the source can say what that
|
|
2015
|
+
// runs, so the line is opaque.
|
|
2016
|
+
const after = src[this.cont(n + 1)]
|
|
2017
|
+
if (after === '(' || after === '{') this.context.opaque('$$ before ( or { in double quotes')
|
|
2018
|
+
}
|
|
2019
|
+
if (next !== undefined && /[0-9$?!#@*-]/.test(next)) {
|
|
2020
|
+
builder.expansion(src.slice(at, n + 1))
|
|
2021
|
+
return n + 1
|
|
2022
|
+
}
|
|
2023
|
+
// A lone `$` is literal.
|
|
2024
|
+
if (inDouble) builder.quotedText('$')
|
|
2025
|
+
else builder.unquoted('$')
|
|
2026
|
+
return at + 1
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** `$[…]`: returns the offset past the matching `]`. */
|
|
2030
|
+
private matchBracket(from: number): number {
|
|
2031
|
+
const src = this.src
|
|
2032
|
+
let depth = 0
|
|
2033
|
+
let i = from
|
|
2034
|
+
for (;;) {
|
|
2035
|
+
i = this.cont(i)
|
|
2036
|
+
if (i >= src.length) throw new Stop('unterminated $[')
|
|
2037
|
+
const char = src[i] as string
|
|
2038
|
+
if (char === '\\') {
|
|
2039
|
+
i += 2
|
|
2040
|
+
continue
|
|
2041
|
+
}
|
|
2042
|
+
if (char === '[') depth += 1
|
|
2043
|
+
else if (char === ']') {
|
|
2044
|
+
if (depth === 0) return i + 1
|
|
2045
|
+
depth -= 1
|
|
2046
|
+
}
|
|
2047
|
+
i += 1
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* After `${`: returns the offset past the matching `}`. The content is
|
|
2053
|
+
* checked against the forms whose evaluation runs nothing; anything else
|
|
2054
|
+
* (indirection, a subscript or offset evaluated as arithmetic, a
|
|
2055
|
+
* transformation) is opaque.
|
|
2056
|
+
*/
|
|
2057
|
+
private parameterBraces(from: number, inDouble: boolean): number {
|
|
2058
|
+
const src = this.src
|
|
2059
|
+
let i = from
|
|
2060
|
+
const scratch = new WordBuilder()
|
|
2061
|
+
for (;;) {
|
|
2062
|
+
i = this.cont(i)
|
|
2063
|
+
if (i >= src.length) throw new Stop('unterminated ${')
|
|
2064
|
+
const char = src[i] as string
|
|
2065
|
+
if (char === '}') break
|
|
2066
|
+
if (char === '\\') {
|
|
2067
|
+
i += 2
|
|
2068
|
+
continue
|
|
2069
|
+
}
|
|
2070
|
+
if (char === "'") {
|
|
2071
|
+
if (inDouble) {
|
|
2072
|
+
// Whether a single quote inside `${…}` inside double quotes
|
|
2073
|
+
// quotes depends on the operator and the shell's mode.
|
|
2074
|
+
this.context.opaque('single quote in parameter expansion')
|
|
2075
|
+
i += 1
|
|
2076
|
+
continue
|
|
2077
|
+
}
|
|
2078
|
+
const close = src.indexOf("'", i + 1)
|
|
2079
|
+
if (close < 0) throw new Stop('unterminated quote')
|
|
2080
|
+
this.singleQuoted(i, close)
|
|
2081
|
+
i = close + 1
|
|
2082
|
+
continue
|
|
2083
|
+
}
|
|
2084
|
+
if (char === '"') {
|
|
2085
|
+
i = this.doubleQuoted(i + 1, scratch)
|
|
2086
|
+
continue
|
|
2087
|
+
}
|
|
2088
|
+
if (char === '`') {
|
|
2089
|
+
i = this.backtick(i, scratch, inDouble)
|
|
2090
|
+
continue
|
|
2091
|
+
}
|
|
2092
|
+
if (char === '$') {
|
|
2093
|
+
i = this.dollar(i, scratch, inDouble)
|
|
2094
|
+
continue
|
|
2095
|
+
}
|
|
2096
|
+
if ((char === '<' || char === '>') && src[this.cont(i + 1)] === '(') {
|
|
2097
|
+
// Measured: unquoted, `${x:-<(cmd)}` runs `cmd`. Inside double
|
|
2098
|
+
// quotes bash still parses it, and rejects a malformed one.
|
|
2099
|
+
i = this.processSubstitution(i, scratch)
|
|
2100
|
+
continue
|
|
2101
|
+
}
|
|
2102
|
+
i += 1
|
|
2103
|
+
}
|
|
2104
|
+
const content = src.slice(from, i).replace(/\\\n/g, '')
|
|
2105
|
+
if (!SAFE_PARAMETER.test(content)) this.context.opaque('parameter expansion')
|
|
2106
|
+
else if (!POSIX_PARAMETER.test(content)) this.bashOnly('parameter expansion')
|
|
2107
|
+
return i + 1
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/** After `$'`: decode to the closing quote. */
|
|
2111
|
+
private ansiC(from: number, builder: WordBuilder): number {
|
|
2112
|
+
const src = this.src
|
|
2113
|
+
let i = from
|
|
2114
|
+
let decoded = ''
|
|
2115
|
+
let exact = true
|
|
2116
|
+
for (;;) {
|
|
2117
|
+
if (i >= src.length) throw new Stop('unterminated quote')
|
|
2118
|
+
const char = src[i] as string
|
|
2119
|
+
if (char === "'") break
|
|
2120
|
+
if (char !== '\\') {
|
|
2121
|
+
decoded += char
|
|
2122
|
+
i += 1
|
|
2123
|
+
continue
|
|
2124
|
+
}
|
|
2125
|
+
const letter = src[i + 1]
|
|
2126
|
+
if (letter === undefined) throw new Stop('unterminated quote')
|
|
2127
|
+
i += 2
|
|
2128
|
+
const simple = ANSI_C_SIMPLE[letter]
|
|
2129
|
+
if (simple !== undefined) {
|
|
2130
|
+
decoded += simple
|
|
2131
|
+
continue
|
|
2132
|
+
}
|
|
2133
|
+
if (letter >= '0' && letter <= '7') {
|
|
2134
|
+
let digits = letter
|
|
2135
|
+
while (digits.length < 3 && /[0-7]/.test(src[i] ?? '')) {
|
|
2136
|
+
digits += src[i]
|
|
2137
|
+
i += 1
|
|
2138
|
+
}
|
|
2139
|
+
const code = Number.parseInt(digits, 8) & 0xff
|
|
2140
|
+
if (code === 0 || code > 0x7f) exact = false
|
|
2141
|
+
decoded += String.fromCharCode(code)
|
|
2142
|
+
continue
|
|
2143
|
+
}
|
|
2144
|
+
if (letter === 'x' && src[i] === '{') {
|
|
2145
|
+
// `\x{HHH…}`: any number of digits, the closing brace optional,
|
|
2146
|
+
// the value truncated to a byte.
|
|
2147
|
+
i += 1
|
|
2148
|
+
let digits = ''
|
|
2149
|
+
while (/[0-9A-Fa-f]/.test(src[i] ?? '')) {
|
|
2150
|
+
digits += src[i]
|
|
2151
|
+
i += 1
|
|
2152
|
+
}
|
|
2153
|
+
if (src[i] === '}') i += 1
|
|
2154
|
+
const code = digits === '' ? 0 : Number.parseInt(digits.slice(-2), 16)
|
|
2155
|
+
if (code === 0 || code > 0x7f) exact = false
|
|
2156
|
+
decoded += String.fromCharCode(code)
|
|
2157
|
+
continue
|
|
2158
|
+
}
|
|
2159
|
+
if (letter === 'x') {
|
|
2160
|
+
let digits = ''
|
|
2161
|
+
while (digits.length < 2 && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
|
|
2162
|
+
digits += src[i]
|
|
2163
|
+
i += 1
|
|
2164
|
+
}
|
|
2165
|
+
if (digits === '') {
|
|
2166
|
+
decoded += '\\x'
|
|
2167
|
+
continue
|
|
2168
|
+
}
|
|
2169
|
+
const code = Number.parseInt(digits, 16)
|
|
2170
|
+
if (code === 0 || code > 0x7f) exact = false
|
|
2171
|
+
decoded += String.fromCharCode(code)
|
|
2172
|
+
continue
|
|
2173
|
+
}
|
|
2174
|
+
if (letter === 'u' || letter === 'U') {
|
|
2175
|
+
const max = letter === 'u' ? 4 : 8
|
|
2176
|
+
let digits = ''
|
|
2177
|
+
while (digits.length < max && /[0-9A-Fa-f]/.test(src[i] ?? '')) {
|
|
2178
|
+
digits += src[i]
|
|
2179
|
+
i += 1
|
|
2180
|
+
}
|
|
2181
|
+
if (digits === '') {
|
|
2182
|
+
decoded += `\\${letter}`
|
|
2183
|
+
continue
|
|
2184
|
+
}
|
|
2185
|
+
const code = Number.parseInt(digits, 16)
|
|
2186
|
+
// Beyond ASCII the result depends on the locale's encoding.
|
|
2187
|
+
if (code === 0 || code > 0x7f) exact = false
|
|
2188
|
+
decoded += code <= 0x10ffff ? String.fromCodePoint(code) : ''
|
|
2189
|
+
continue
|
|
2190
|
+
}
|
|
2191
|
+
if (letter === 'c') {
|
|
2192
|
+
const target = src[i]
|
|
2193
|
+
if (target === undefined || target === "'") {
|
|
2194
|
+
// `\c` with nothing to control: bash keeps it.
|
|
2195
|
+
decoded += '\\c'
|
|
2196
|
+
continue
|
|
2197
|
+
}
|
|
2198
|
+
i += 1
|
|
2199
|
+
if (target === '\\' && src[i] === '\\') i += 1
|
|
2200
|
+
const code = target === '?' ? 0x7f : target.toUpperCase().charCodeAt(0) & 0x1f
|
|
2201
|
+
if (code === 0 || target.charCodeAt(0) > 0x7f) exact = false
|
|
2202
|
+
decoded += String.fromCharCode(code)
|
|
2203
|
+
continue
|
|
2204
|
+
}
|
|
2205
|
+
// Unknown escapes stay as written.
|
|
2206
|
+
decoded += `\\${letter}`
|
|
2207
|
+
}
|
|
2208
|
+
this.singleQuoted(from - 1, i)
|
|
2209
|
+
builder.quotedText(decoded)
|
|
2210
|
+
if (!exact) builder.expands = true
|
|
2211
|
+
return i + 1
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* A backtick substitution starting at `at`. The body is unescaped the way
|
|
2216
|
+
* bash does it and read as a command line of its own. Returns the offset
|
|
2217
|
+
* past the closing backtick.
|
|
2218
|
+
*/
|
|
2219
|
+
backtick(at: number, builder: WordBuilder, inDouble: boolean): number {
|
|
2220
|
+
const src = this.src
|
|
2221
|
+
let i = at + 1
|
|
2222
|
+
let body = ''
|
|
2223
|
+
for (;;) {
|
|
2224
|
+
if (i >= src.length) throw new Stop('unterminated backtick')
|
|
2225
|
+
const char = src[i] as string
|
|
2226
|
+
if (char === '`') break
|
|
2227
|
+
if (char === '\\') {
|
|
2228
|
+
const next = src[i + 1]
|
|
2229
|
+
if (next === '$' || next === '`' || next === '\\' || (inDouble && next === '"')) {
|
|
2230
|
+
body += next
|
|
2231
|
+
i += 2
|
|
2232
|
+
continue
|
|
2233
|
+
}
|
|
2234
|
+
body += char
|
|
2235
|
+
i += 1
|
|
2236
|
+
continue
|
|
2237
|
+
}
|
|
2238
|
+
body += char
|
|
2239
|
+
i += 1
|
|
2240
|
+
}
|
|
2241
|
+
this.context.opaque('command substitution')
|
|
2242
|
+
builder.expansion(src.slice(at, i + 1))
|
|
2243
|
+
this.context.rescan(body.length)
|
|
2244
|
+
this.inherit(body)
|
|
2245
|
+
const inner = new Parser(body, 0, this.context, 'substitution', this.depth, this.nestingNow + 1)
|
|
2246
|
+
try {
|
|
2247
|
+
inner.program()
|
|
2248
|
+
} catch (error) {
|
|
2249
|
+
if (!(error instanceof Stop)) throw error
|
|
2250
|
+
}
|
|
2251
|
+
return i + 1
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/** `<(…)` or `>(…)` at `at`. */
|
|
2255
|
+
private processSubstitution(at: number, builder: WordBuilder): number {
|
|
2256
|
+
const open = this.cont(at + 1)
|
|
2257
|
+
this.context.opaque('process substitution')
|
|
2258
|
+
const inner = new Parser(
|
|
2259
|
+
this.src,
|
|
2260
|
+
open + 1,
|
|
2261
|
+
this.context,
|
|
2262
|
+
'substitution',
|
|
2263
|
+
this.depth,
|
|
2264
|
+
this.nestingNow + 1,
|
|
2265
|
+
)
|
|
2266
|
+
const end = inner.substitution()
|
|
2267
|
+
builder.expansion(this.src.slice(at, end))
|
|
2268
|
+
return end
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* `${…}` contents that evaluate nothing but a parameter: a name, a positional
|
|
2274
|
+
* or special parameter, optionally its length, a literal subscript, a literal
|
|
2275
|
+
* substring offset, or one of the default/assign/error/alternative, pattern
|
|
2276
|
+
* removal, substitution and case operators followed by any word (the word
|
|
2277
|
+
* was already read for substitutions).
|
|
2278
|
+
*/
|
|
2279
|
+
const SAFE_PARAMETER =
|
|
2280
|
+
/^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[(?:\d+|@|\*)\])?|\d+|[@*#?$!-])(?:(?::?[-=+?]|##?|%%?|\/[/#%]?|\^\^?|,,?)[\s\S]*|:\s*-?\d+\s*(?::\s*-?\d+\s*)?)?)$/
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* `${…}` forms POSIX defines, with a word free of quotes and escapes, whose
|
|
2284
|
+
* handling inside `${…}` differs between shells.
|
|
2285
|
+
*/
|
|
2286
|
+
const POSIX_PARAMETER =
|
|
2287
|
+
/^(?:#?(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])|(?:[A-Za-z_][A-Za-z0-9_]*|\d+|[@*#?$!-])(?::?[-=+?]|##?|%%?)[^'"\\`]*)$/
|
|
2288
|
+
|
|
2289
|
+
const ANSI_C_SIMPLE: Readonly<Record<string, string>> = {
|
|
2290
|
+
a: '\x07',
|
|
2291
|
+
b: '\b',
|
|
2292
|
+
e: '\x1b',
|
|
2293
|
+
E: '\x1b',
|
|
2294
|
+
f: '\f',
|
|
2295
|
+
n: '\n',
|
|
2296
|
+
r: '\r',
|
|
2297
|
+
t: '\t',
|
|
2298
|
+
v: '\v',
|
|
2299
|
+
'\\': '\\',
|
|
2300
|
+
"'": "'",
|
|
2301
|
+
'"': '"',
|
|
2302
|
+
'?': '?',
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/** Source text with its line continuations removed, as bash reads it. */
|
|
2306
|
+
function joined(text: string): string {
|
|
2307
|
+
return text.includes('\\\n') ? text.replace(/\\\n/g, '') : text
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function trailingBackslashes(text: string): number {
|
|
2311
|
+
let count = 0
|
|
2312
|
+
for (let i = text.length - 1; i >= 0 && text[i] === '\\'; i -= 1) count += 1
|
|
2313
|
+
return count
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
export function basename(word: string): string {
|
|
2317
|
+
const cut = word.lastIndexOf('/')
|
|
2318
|
+
return cut < 0 ? word : word.slice(cut + 1)
|
|
2319
|
+
}
|