@pipelex/sdk 0.23.0 → 0.25.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/dist/artifacts.d.ts +4 -1
- package/dist/artifacts.js +6 -1
- package/dist/artifacts.js.map +1 -1
- package/dist/client.d.ts +7 -13
- package/dist/client.js +15 -16
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +30 -5
- package/dist/errors.js +9 -5
- package/dist/errors.js.map +1 -1
- package/dist/hooks/check-core.d.ts +97 -8
- package/dist/hooks/check-core.js +164 -15
- package/dist/hooks/check-core.js.map +1 -1
- package/dist/hooks/claude-mthds-check.d.ts +9 -1
- package/dist/hooks/claude-mthds-check.js +48 -31
- package/dist/hooks/claude-mthds-check.js.map +1 -1
- package/dist/hooks/patch-envelope.d.ts +66 -0
- package/dist/hooks/patch-envelope.js +127 -0
- package/dist/hooks/patch-envelope.js.map +1 -0
- package/dist/hooks/shell-script.d.ts +92 -0
- package/dist/hooks/shell-script.js +1304 -0
- package/dist/hooks/shell-script.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/product-models.d.ts +1 -9
- package/dist/product-models.js +1 -1
- package/dist/runs.d.ts +10 -1
- package/dist/runs.js +17 -1
- package/dist/runs.js.map +1 -1
- package/dist/timers.d.ts +11 -0
- package/dist/timers.js +14 -0
- package/dist/timers.js.map +1 -0
- package/dist/upload-grant.d.ts +38 -15
- package/dist/upload-grant.js +200 -67
- package/dist/upload-grant.js.map +1 -1
- package/dist/upload.js +9 -2
- package/dist/upload.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +4 -4
- package/docs/artifact-download.md +1 -1
- package/docs/hook-bundle.md +88 -0
- package/docs/input-preparation.md +14 -8
- package/package.json +2 -2
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reading of a POSIX shell script that tells, for an offset in it, which
|
|
3
|
+
* directory the patch command reading that offset runs in.
|
|
4
|
+
*
|
|
5
|
+
* Codex reports a patch run through the shell as a `Bash` call carrying the
|
|
6
|
+
* whole script, and a relative path in that patch is relative to wherever the
|
|
7
|
+
* script had moved when `apply_patch` ran. This module lexes the script far
|
|
8
|
+
* enough to follow `cd`: commands, quotes, heredocs, substitutions, scopes,
|
|
9
|
+
* branches and loops. It is not a shell parser. Past what it reads, it
|
|
10
|
+
* answers "unknown" rather than guessing, and a script it cannot lex at all
|
|
11
|
+
* is "unparsed".
|
|
12
|
+
*
|
|
13
|
+
* - Text is placed only where a patch command reads it: the words and
|
|
14
|
+
* heredoc bodies of an `apply_patch` command, and the text of a command
|
|
15
|
+
* feeding one through a pipeline or a substitution in its words. Text that
|
|
16
|
+
* any other command holds, such as a variable, a file, `bash -c` or `eval`,
|
|
17
|
+
* is unknown, since this reading does not follow whatever reads it later.
|
|
18
|
+
* - `cd DIR` with one literal operand (optionally after `-L`, `-P` or `--`)
|
|
19
|
+
* moves the directory, and is assumed to succeed; any other `cd`, and
|
|
20
|
+
* `pushd`, `popd`, `eval`, `source`, `.` or a function definition, makes
|
|
21
|
+
* it unknown. An absolute `cd` makes an unknown directory known again.
|
|
22
|
+
* - The reading follows the path on which each command succeeds, so a
|
|
23
|
+
* negated `cd` fails on it. What runs only on the other path (after `||`,
|
|
24
|
+
* in `elif` and `else`, after a negated `cd` and `&&`, and on the path a
|
|
25
|
+
* loop's condition takes to end or skip it) starts where the shell stood
|
|
26
|
+
* when nothing had moved it, and from an unknown directory otherwise.
|
|
27
|
+
* - `( … )`, `$( … )`, `<( … )` and `>( … )` are scopes: a `cd` inside one
|
|
28
|
+
* ends at its closing parenthesis. A list sent to the background, and every
|
|
29
|
+
* member of a pipeline but the last, run in subshells in bash and zsh
|
|
30
|
+
* alike. zsh runs a pipeline's last member in the current shell and bash
|
|
31
|
+
* does not, so a directory change there makes the directory after it
|
|
32
|
+
* unknown.
|
|
33
|
+
* - `{ … }` and the separators `;`, newline and `&&` are followed in order.
|
|
34
|
+
* Where only one of several paths runs, after `||`, between the branches of
|
|
35
|
+
* `if` and `case`, and across the passes of a loop, the directory is kept
|
|
36
|
+
* when every path leaves it the same, and is unknown otherwise.
|
|
37
|
+
* - Heredoc bodies, quoted strings, comments and the expression of a `[[ … ]]`
|
|
38
|
+
* are never read as commands. A heredoc body belongs to the command that
|
|
39
|
+
* opened it.
|
|
40
|
+
* - The text a command reads is the script's after the shell's quoting, which
|
|
41
|
+
* the reading also tells: a patch in double quotes or in an unquoted heredoc
|
|
42
|
+
* has its backslash escapes removed and its `$` and backtick expansions run.
|
|
43
|
+
*/
|
|
44
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
45
|
+
/**
|
|
46
|
+
* Read `script` as a POSIX shell script run from `sessionDir`, or return
|
|
47
|
+
* `"unparsed"` when it holds an unterminated quote, substitution or backtick,
|
|
48
|
+
* a heredoc inside backticks, a parenthesis that closes nothing, or any other
|
|
49
|
+
* syntax error this reading can see.
|
|
50
|
+
*/
|
|
51
|
+
export function readShellScript(script, sessionDir) {
|
|
52
|
+
let extents;
|
|
53
|
+
let quoted;
|
|
54
|
+
try {
|
|
55
|
+
const parser = new ScriptParser(script);
|
|
56
|
+
const walk = new DirectoryWalk(WALK_BUDGET_FLOOR + WALK_BUDGET_PER_CHARACTER * script.length);
|
|
57
|
+
walk.list(parser.parse(), sessionDir);
|
|
58
|
+
extents = walk.extents;
|
|
59
|
+
quoted = parser.quoted;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// An Unparsed signal, or a script nested past the stack: either way the
|
|
63
|
+
// script cannot be read, and "unparsed" is the answer that fails safe.
|
|
64
|
+
return "unparsed";
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
directoryAt(offset) {
|
|
68
|
+
const holder = innermost(extents, offset);
|
|
69
|
+
if (!holder) {
|
|
70
|
+
return { kind: "outside" };
|
|
71
|
+
}
|
|
72
|
+
if (!holder.read) {
|
|
73
|
+
return { kind: "unread" };
|
|
74
|
+
}
|
|
75
|
+
return holder.directory === null
|
|
76
|
+
? { kind: "unknown" }
|
|
77
|
+
: { kind: "directory", path: holder.directory };
|
|
78
|
+
},
|
|
79
|
+
quotingAt(offset) {
|
|
80
|
+
return innermost(quoted, offset)?.quoting ?? "verbatim";
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** The shortest of `spans` holding `offset`, which is the innermost when they nest. */
|
|
85
|
+
function innermost(spans, offset) {
|
|
86
|
+
let holder = null;
|
|
87
|
+
for (const span of spans) {
|
|
88
|
+
if (span.start <= offset && offset < span.end) {
|
|
89
|
+
if (!holder || span.end - span.start < holder.end - holder.start) {
|
|
90
|
+
holder = span;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return holder;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The lines of a patch section as the patch command reads them, given the
|
|
98
|
+
* quoting its header is in, less any line an expansion makes unknown. A line
|
|
99
|
+
* in quotes may close them and open others, as `'\''` writes an apostrophe in
|
|
100
|
+
* single quotes, and is read through each. When a line ends in other quoting
|
|
101
|
+
* than it began in, or in a backslash that joins it to the next, the lines
|
|
102
|
+
* after it are read in quoting this reading does not follow, so no line of
|
|
103
|
+
* the section is known and none is given.
|
|
104
|
+
*/
|
|
105
|
+
export function linesAsRead(lines, quoting) {
|
|
106
|
+
const read = [];
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const asRead = lineAsRead(line, quoting);
|
|
109
|
+
if (asRead === LOST) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (asRead !== null) {
|
|
113
|
+
read.push(asRead);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return read;
|
|
117
|
+
}
|
|
118
|
+
/** What `lineAsRead` gives when the quoting after the line is not the one it began in. */
|
|
119
|
+
const LOST = Symbol("lost");
|
|
120
|
+
/** A character after `$` that makes an expansion of it. */
|
|
121
|
+
const EXPANSION_START = /[\w{(@*#?$!-]/;
|
|
122
|
+
/** A character that ends an unquoted word, or may expand it. */
|
|
123
|
+
const UNQUOTED_BREAK = /[\s;&|<>()*?[\]{}$]/;
|
|
124
|
+
/**
|
|
125
|
+
* One line as read, starting in `quoting`: null when an expansion makes it
|
|
126
|
+
* unknown, and `LOST` when it does not end in the quoting it began in.
|
|
127
|
+
*/
|
|
128
|
+
function lineAsRead(line, quoting) {
|
|
129
|
+
if (quoting === "verbatim") {
|
|
130
|
+
return line;
|
|
131
|
+
}
|
|
132
|
+
const initial = quoting === "single-quoted" ? "single" : quoting === "double-quoted" ? "double" : "heredoc";
|
|
133
|
+
let state = initial;
|
|
134
|
+
let read = "";
|
|
135
|
+
let known = true;
|
|
136
|
+
for (let index = 0; index < line.length; index++) {
|
|
137
|
+
const char = line[index];
|
|
138
|
+
const next = line[index + 1];
|
|
139
|
+
if (state === "single") {
|
|
140
|
+
if (char === "'") {
|
|
141
|
+
state = "unquoted";
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
read += char;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (char === "\\") {
|
|
148
|
+
if (next === undefined) {
|
|
149
|
+
return LOST; // the shell removes it with the newline, joining the next line to this one
|
|
150
|
+
}
|
|
151
|
+
const escapable = state === "double" ? '$`"\\' : state === "heredoc" ? "$`\\" : next;
|
|
152
|
+
if (escapable.includes(next)) {
|
|
153
|
+
read += next;
|
|
154
|
+
index++;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
read += char;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (char === "`" || (char === "$" && next !== undefined && EXPANSION_START.test(next))) {
|
|
161
|
+
known = false;
|
|
162
|
+
}
|
|
163
|
+
else if (state === "heredoc") {
|
|
164
|
+
read += char;
|
|
165
|
+
}
|
|
166
|
+
else if (state === "double") {
|
|
167
|
+
if (char === '"') {
|
|
168
|
+
state = "unquoted";
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
read += char;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (char === "'") {
|
|
175
|
+
state = "single";
|
|
176
|
+
}
|
|
177
|
+
else if (char === '"') {
|
|
178
|
+
state = "double";
|
|
179
|
+
}
|
|
180
|
+
else if (UNQUOTED_BREAK.test(char)) {
|
|
181
|
+
return LOST;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
read += char;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (state !== initial) {
|
|
188
|
+
return LOST;
|
|
189
|
+
}
|
|
190
|
+
return known ? read : null;
|
|
191
|
+
}
|
|
192
|
+
/** The script cannot be read: a syntax error, or a walk past its budget. */
|
|
193
|
+
class Unparsed extends Error {
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Lexer and parser
|
|
197
|
+
/** Reserved words another command follows on the same line. */
|
|
198
|
+
const COMMAND_PREFIXES = new Set(["!", "time"]);
|
|
199
|
+
/** Reserved words that end or divide a compound command, and never name a command. */
|
|
200
|
+
const CLOSING_WORDS = new Set(["then", "elif", "else", "fi", "do", "done", "esac", "}"]);
|
|
201
|
+
/** Characters that end an unquoted word. */
|
|
202
|
+
const WORD_BREAKS = new Set([" ", "\t", "\n", ";", "&", "|", "<", ">", "(", ")"]);
|
|
203
|
+
const REDIRECTION_OPERATORS = ["<<<", "<<-", "<<", "<&", "<>", "<", ">>", ">&", ">|", ">"];
|
|
204
|
+
const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
|
|
205
|
+
class ScriptParser {
|
|
206
|
+
src;
|
|
207
|
+
/** The quoted strings and heredoc bodies read so far. */
|
|
208
|
+
quoted = [];
|
|
209
|
+
pos = 0;
|
|
210
|
+
pending = [];
|
|
211
|
+
constructor(src) {
|
|
212
|
+
this.src = src;
|
|
213
|
+
}
|
|
214
|
+
parse() {
|
|
215
|
+
return this.parseList([]);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Commands up to one of `terminators`, which is left unread: a reserved
|
|
219
|
+
* word where a command would start, `)`, or `;;` for a case branch, which
|
|
220
|
+
* `;&` and `;;&` end too. With no terminator, the list runs to the end.
|
|
221
|
+
*/
|
|
222
|
+
parseList(terminators) {
|
|
223
|
+
const items = [];
|
|
224
|
+
for (;;) {
|
|
225
|
+
this.skipLinebreaks();
|
|
226
|
+
if (this.atEnd()) {
|
|
227
|
+
if (terminators.length > 0) {
|
|
228
|
+
throw new Unparsed();
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (this.atTerminator(terminators)) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
const item = this.parseAndOr();
|
|
236
|
+
items.push(item);
|
|
237
|
+
this.skipBlanks();
|
|
238
|
+
if (this.startsWith("&") && !this.startsWith("&&")) {
|
|
239
|
+
item.background = true;
|
|
240
|
+
this.pos++;
|
|
241
|
+
}
|
|
242
|
+
else if (this.startsWith(";;") || this.startsWith(";&")) {
|
|
243
|
+
if (!terminators.includes(";;")) {
|
|
244
|
+
throw new Unparsed();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else if (this.startsWith(";")) {
|
|
248
|
+
this.pos++;
|
|
249
|
+
}
|
|
250
|
+
else if (!this.atEnd() &&
|
|
251
|
+
!["\n", "#"].includes(this.src[this.pos]) &&
|
|
252
|
+
!this.atTerminator(terminators)) {
|
|
253
|
+
throw new Unparsed();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { items };
|
|
257
|
+
}
|
|
258
|
+
atTerminator(terminators) {
|
|
259
|
+
return terminators.some((terminator) => {
|
|
260
|
+
if (terminator === ")") {
|
|
261
|
+
return this.startsWith(")");
|
|
262
|
+
}
|
|
263
|
+
if (terminator === ";;") {
|
|
264
|
+
return this.startsWith(";;") || this.startsWith(";&");
|
|
265
|
+
}
|
|
266
|
+
return this.atReservedWord(terminator);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
parseAndOr() {
|
|
270
|
+
const item = { pipelines: [this.parsePipeline()], operators: [], background: false };
|
|
271
|
+
for (;;) {
|
|
272
|
+
this.skipBlanks();
|
|
273
|
+
const operator = this.startsWith("&&") ? "&&" : this.startsWith("||") ? "||" : null;
|
|
274
|
+
if (operator === null) {
|
|
275
|
+
return item;
|
|
276
|
+
}
|
|
277
|
+
this.pos += 2;
|
|
278
|
+
this.skipLinebreaks();
|
|
279
|
+
item.operators.push(operator);
|
|
280
|
+
item.pipelines.push(this.parsePipeline());
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
parsePipeline() {
|
|
284
|
+
this.skipBlanks();
|
|
285
|
+
const negated = this.atReservedWord("!");
|
|
286
|
+
if (negated) {
|
|
287
|
+
this.pos++;
|
|
288
|
+
}
|
|
289
|
+
const commands = [this.parseCommand()];
|
|
290
|
+
for (;;) {
|
|
291
|
+
this.skipBlanks();
|
|
292
|
+
if (!this.startsWith("|") || this.startsWith("||")) {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
this.pos += this.startsWith("|&") ? 2 : 1;
|
|
296
|
+
this.skipLinebreaks();
|
|
297
|
+
commands.push(this.parseCommand());
|
|
298
|
+
}
|
|
299
|
+
return { commands, negated };
|
|
300
|
+
}
|
|
301
|
+
parseCommand() {
|
|
302
|
+
this.skipBlanks();
|
|
303
|
+
if (this.startsWith("((")) {
|
|
304
|
+
const command = this.emptyCommand();
|
|
305
|
+
this.pos += 2;
|
|
306
|
+
this.skipArithmetic();
|
|
307
|
+
command.end = this.pos;
|
|
308
|
+
return command;
|
|
309
|
+
}
|
|
310
|
+
if (this.startsWith("(")) {
|
|
311
|
+
this.pos++;
|
|
312
|
+
const body = this.parseList([")"]);
|
|
313
|
+
this.pos++;
|
|
314
|
+
return { kind: "subshell", body, tail: this.parseTail() };
|
|
315
|
+
}
|
|
316
|
+
if (this.atReservedWord("{")) {
|
|
317
|
+
this.pos++;
|
|
318
|
+
const body = this.parseList(["}"]);
|
|
319
|
+
this.pos++;
|
|
320
|
+
return { kind: "group", body, tail: this.parseTail() };
|
|
321
|
+
}
|
|
322
|
+
if (!this.atWordStart() || this.atRedirection()) {
|
|
323
|
+
const command = this.parseSimple(null);
|
|
324
|
+
if (command.kind === "simple" && command.end === command.start) {
|
|
325
|
+
throw new Unparsed(); // an operator where a command should be
|
|
326
|
+
}
|
|
327
|
+
return command;
|
|
328
|
+
}
|
|
329
|
+
const first = this.readWord();
|
|
330
|
+
const reserved = first.quoted || first.expands ? null : first.value;
|
|
331
|
+
if (reserved !== null && COMMAND_PREFIXES.has(reserved)) {
|
|
332
|
+
if (reserved === "time") {
|
|
333
|
+
this.skipBlanks();
|
|
334
|
+
if (/^-p(?![^\s;&|<>()])/.test(this.src.slice(this.pos, this.pos + 3))) {
|
|
335
|
+
this.pos += 2;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return this.parseCommand();
|
|
339
|
+
}
|
|
340
|
+
if (reserved !== null && CLOSING_WORDS.has(reserved)) {
|
|
341
|
+
throw new Unparsed();
|
|
342
|
+
}
|
|
343
|
+
switch (reserved) {
|
|
344
|
+
case "if":
|
|
345
|
+
return this.parseIf();
|
|
346
|
+
case "while":
|
|
347
|
+
case "until":
|
|
348
|
+
return this.parseWhile(reserved === "until");
|
|
349
|
+
case "for":
|
|
350
|
+
case "select":
|
|
351
|
+
return this.parseFor();
|
|
352
|
+
case "case":
|
|
353
|
+
return this.parseCase();
|
|
354
|
+
case "[[":
|
|
355
|
+
return this.parseConditional(first);
|
|
356
|
+
case "function":
|
|
357
|
+
this.skipBlanks();
|
|
358
|
+
if (!this.atWordStart()) {
|
|
359
|
+
throw new Unparsed();
|
|
360
|
+
}
|
|
361
|
+
this.readWord();
|
|
362
|
+
return this.parseFunctionBody();
|
|
363
|
+
}
|
|
364
|
+
return this.parseSimple(first);
|
|
365
|
+
}
|
|
366
|
+
parseIf() {
|
|
367
|
+
const clauses = [];
|
|
368
|
+
for (;;) {
|
|
369
|
+
const condition = this.parseList(["then"]);
|
|
370
|
+
this.readReserved("then");
|
|
371
|
+
clauses.push({ condition, body: this.parseList(["elif", "else", "fi"]) });
|
|
372
|
+
if (this.atReservedWord("elif")) {
|
|
373
|
+
this.readReserved("elif");
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
let otherwise = null;
|
|
377
|
+
if (this.atReservedWord("else")) {
|
|
378
|
+
this.readReserved("else");
|
|
379
|
+
otherwise = this.parseList(["fi"]);
|
|
380
|
+
}
|
|
381
|
+
this.readReserved("fi");
|
|
382
|
+
return { kind: "if", clauses, otherwise, tail: this.parseTail() };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
parseWhile(until) {
|
|
386
|
+
const condition = this.parseList(["do"]);
|
|
387
|
+
this.readReserved("do");
|
|
388
|
+
const body = this.parseList(["done"]);
|
|
389
|
+
this.readReserved("done");
|
|
390
|
+
return { kind: "loop", head: null, condition, until, body, tail: this.parseTail() };
|
|
391
|
+
}
|
|
392
|
+
/** `for NAME [in WORDS]; do … done`, `for (( … )); do … done`, and `select` alike. */
|
|
393
|
+
parseFor() {
|
|
394
|
+
this.skipBlanks();
|
|
395
|
+
const head = this.emptyCommand();
|
|
396
|
+
if (this.startsWith("((")) {
|
|
397
|
+
this.pos += 2;
|
|
398
|
+
this.skipArithmetic();
|
|
399
|
+
head.end = this.pos;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
if (!this.atWordStart()) {
|
|
403
|
+
throw new Unparsed();
|
|
404
|
+
}
|
|
405
|
+
const name = this.readWord();
|
|
406
|
+
head.start = name.start;
|
|
407
|
+
head.end = name.end;
|
|
408
|
+
this.skipLinebreaks();
|
|
409
|
+
if (this.atReservedWord("in")) {
|
|
410
|
+
this.pos += 2;
|
|
411
|
+
for (;;) {
|
|
412
|
+
this.skipBlanks();
|
|
413
|
+
if (!this.atWordStart()) {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
addWord(head, this.readWord());
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
this.skipBlanks();
|
|
421
|
+
if (this.startsWith(";") && !this.startsWith(";;")) {
|
|
422
|
+
this.pos++;
|
|
423
|
+
}
|
|
424
|
+
this.skipLinebreaks();
|
|
425
|
+
this.readReserved("do");
|
|
426
|
+
const body = this.parseList(["done"]);
|
|
427
|
+
this.readReserved("done");
|
|
428
|
+
return { kind: "loop", head, condition: null, until: false, body, tail: this.parseTail() };
|
|
429
|
+
}
|
|
430
|
+
parseCase() {
|
|
431
|
+
this.skipBlanks();
|
|
432
|
+
if (!this.atWordStart()) {
|
|
433
|
+
throw new Unparsed();
|
|
434
|
+
}
|
|
435
|
+
const subject = this.emptyCommand();
|
|
436
|
+
addWord(subject, this.readWord());
|
|
437
|
+
this.skipLinebreaks();
|
|
438
|
+
this.readReserved("in");
|
|
439
|
+
const branches = [];
|
|
440
|
+
for (;;) {
|
|
441
|
+
this.skipLinebreaks();
|
|
442
|
+
if (this.atReservedWord("esac")) {
|
|
443
|
+
this.readReserved("esac");
|
|
444
|
+
return { kind: "case", subject, branches, tail: this.parseTail() };
|
|
445
|
+
}
|
|
446
|
+
const patterns = this.emptyCommand();
|
|
447
|
+
if (this.startsWith("(")) {
|
|
448
|
+
this.pos++;
|
|
449
|
+
}
|
|
450
|
+
for (;;) {
|
|
451
|
+
this.skipBlanks();
|
|
452
|
+
if (!this.atWordStart()) {
|
|
453
|
+
throw new Unparsed();
|
|
454
|
+
}
|
|
455
|
+
addWord(patterns, this.readWord());
|
|
456
|
+
this.skipBlanks();
|
|
457
|
+
if (this.startsWith(")")) {
|
|
458
|
+
this.pos++;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
if (!this.startsWith("|")) {
|
|
462
|
+
throw new Unparsed();
|
|
463
|
+
}
|
|
464
|
+
this.pos++;
|
|
465
|
+
}
|
|
466
|
+
const body = this.parseList([";;", "esac"]);
|
|
467
|
+
const terminator = [";;&", ";;", ";&"].find((candidate) => this.startsWith(candidate));
|
|
468
|
+
this.pos += terminator?.length ?? 0;
|
|
469
|
+
branches.push({ patterns, body, fallsThrough: terminator === ";;&" || terminator === ";&" });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** The words and redirections of a simple command, `first` already read. */
|
|
473
|
+
parseSimple(first) {
|
|
474
|
+
const command = this.emptyCommand();
|
|
475
|
+
if (first) {
|
|
476
|
+
command.start = first.start;
|
|
477
|
+
addWord(command, first);
|
|
478
|
+
}
|
|
479
|
+
for (;;) {
|
|
480
|
+
this.skipBlanks();
|
|
481
|
+
if (this.atEnd()) {
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
const char = this.src[this.pos];
|
|
485
|
+
if (char === "#") {
|
|
486
|
+
this.skipComment();
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
if (this.startsWith("<(") || this.startsWith(">(")) {
|
|
490
|
+
const start = this.pos;
|
|
491
|
+
this.pos += 2;
|
|
492
|
+
const substitution = this.parseList([")"]);
|
|
493
|
+
this.pos++;
|
|
494
|
+
addWord(command, {
|
|
495
|
+
...bareWord(start, this.pos),
|
|
496
|
+
expands: true,
|
|
497
|
+
substitutions: [substitution],
|
|
498
|
+
});
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (this.atRedirection()) {
|
|
502
|
+
this.readRedirection(command);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (char === "(") {
|
|
506
|
+
if (command.words.length === 1 &&
|
|
507
|
+
/^\(\s*\)/.test(this.src.slice(this.pos, this.pos + 64))) {
|
|
508
|
+
return this.parseFunctionBody(); // NAME ( ) BODY
|
|
509
|
+
}
|
|
510
|
+
throw new Unparsed();
|
|
511
|
+
}
|
|
512
|
+
if (WORD_BREAKS.has(char)) {
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
addWord(command, this.readWord());
|
|
516
|
+
}
|
|
517
|
+
return command;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A `[[ … ]]` conditional, `[[` already read, as one command. Inside it,
|
|
521
|
+
* parentheses, `<`, `>`, `&&`, `||` and `|` belong to the expression (a
|
|
522
|
+
* regex after `=~` among them), and a newline does not end it.
|
|
523
|
+
*/
|
|
524
|
+
parseConditional(first) {
|
|
525
|
+
const command = this.emptyCommand();
|
|
526
|
+
command.start = first.start;
|
|
527
|
+
addWord(command, first);
|
|
528
|
+
for (;;) {
|
|
529
|
+
this.skipLinebreaks();
|
|
530
|
+
if (this.atEnd()) {
|
|
531
|
+
throw new Unparsed();
|
|
532
|
+
}
|
|
533
|
+
if (this.atReservedWord("]]")) {
|
|
534
|
+
addWord(command, this.readWord());
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
const char = this.src[this.pos];
|
|
538
|
+
if (char === ";") {
|
|
539
|
+
throw new Unparsed();
|
|
540
|
+
}
|
|
541
|
+
if (WORD_BREAKS.has(char)) {
|
|
542
|
+
this.pos++;
|
|
543
|
+
addWord(command, bareWord(this.pos - 1, this.pos));
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
addWord(command, this.readWord());
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (;;) {
|
|
550
|
+
this.skipBlanks();
|
|
551
|
+
if (!this.atRedirection()) {
|
|
552
|
+
return command;
|
|
553
|
+
}
|
|
554
|
+
this.readRedirection(command);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** The redirections after a compound command's closing word, and nothing else. */
|
|
558
|
+
parseTail() {
|
|
559
|
+
const tail = this.emptyCommand();
|
|
560
|
+
for (;;) {
|
|
561
|
+
this.skipBlanks();
|
|
562
|
+
if (this.startsWith("<(") || this.startsWith(">(") || !this.atRedirection()) {
|
|
563
|
+
return tail;
|
|
564
|
+
}
|
|
565
|
+
this.readRedirection(tail);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** The body of a function whose name was read, with its optional `()`. */
|
|
569
|
+
parseFunctionBody() {
|
|
570
|
+
this.skipBlanks();
|
|
571
|
+
const parentheses = /^\(\s*\)/.exec(this.src.slice(this.pos, this.pos + 64));
|
|
572
|
+
if (parentheses) {
|
|
573
|
+
this.pos += parentheses[0].length;
|
|
574
|
+
}
|
|
575
|
+
this.skipLinebreaks();
|
|
576
|
+
return { kind: "function", body: this.parseCommand() };
|
|
577
|
+
}
|
|
578
|
+
emptyCommand() {
|
|
579
|
+
return {
|
|
580
|
+
kind: "simple",
|
|
581
|
+
words: [],
|
|
582
|
+
start: this.pos,
|
|
583
|
+
end: this.pos,
|
|
584
|
+
heredocs: [],
|
|
585
|
+
substitutions: [],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
readReserved(word) {
|
|
589
|
+
if (!this.atReservedWord(word)) {
|
|
590
|
+
throw new Unparsed();
|
|
591
|
+
}
|
|
592
|
+
this.pos += word.length;
|
|
593
|
+
}
|
|
594
|
+
atRedirection() {
|
|
595
|
+
return /^(?:\d*[<>]|&>)/.test(this.src.slice(this.pos, this.pos + 24));
|
|
596
|
+
}
|
|
597
|
+
readRedirection(command) {
|
|
598
|
+
while (/\d/.test(this.src[this.pos] ?? "")) {
|
|
599
|
+
this.pos++;
|
|
600
|
+
}
|
|
601
|
+
let operator;
|
|
602
|
+
if (this.startsWith("&>")) {
|
|
603
|
+
operator = this.startsWith("&>>") ? "&>>" : "&>";
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
operator = REDIRECTION_OPERATORS.find((candidate) => this.startsWith(candidate));
|
|
607
|
+
}
|
|
608
|
+
this.pos += operator.length;
|
|
609
|
+
this.skipBlanks();
|
|
610
|
+
const heredoc = operator === "<<" || operator === "<<-";
|
|
611
|
+
if (!heredoc && (this.startsWith("<(") || this.startsWith(">("))) {
|
|
612
|
+
// A process substitution as the target, as in `done < <(find …)`.
|
|
613
|
+
this.pos += 2;
|
|
614
|
+
command.substitutions.push(this.parseList([")"]));
|
|
615
|
+
this.pos++;
|
|
616
|
+
command.end = this.pos;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!this.atWordStart()) {
|
|
620
|
+
throw new Unparsed();
|
|
621
|
+
}
|
|
622
|
+
const target = this.readWord();
|
|
623
|
+
command.end = target.end;
|
|
624
|
+
command.substitutions.push(...target.substitutions);
|
|
625
|
+
if (heredoc) {
|
|
626
|
+
this.pending.push({
|
|
627
|
+
delimiter: target.value,
|
|
628
|
+
stripTabs: operator === "<<-",
|
|
629
|
+
quoted: target.quoted,
|
|
630
|
+
owner: command,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** Past a newline, the bodies of the heredocs opened on the line it ends. */
|
|
635
|
+
consumeNewline() {
|
|
636
|
+
this.pos++;
|
|
637
|
+
for (const heredoc of this.pending.splice(0)) {
|
|
638
|
+
const bodyStart = this.pos;
|
|
639
|
+
let bodyEnd = this.src.length;
|
|
640
|
+
while (this.pos < this.src.length) {
|
|
641
|
+
const newline = this.src.indexOf("\n", this.pos);
|
|
642
|
+
const lineEnd = newline === -1 ? this.src.length : newline;
|
|
643
|
+
const next = newline === -1 ? this.src.length : newline + 1;
|
|
644
|
+
let line = this.src.slice(this.pos, lineEnd);
|
|
645
|
+
if (heredoc.stripTabs) {
|
|
646
|
+
line = line.replace(/^\t+/, "");
|
|
647
|
+
}
|
|
648
|
+
if (line === heredoc.delimiter) {
|
|
649
|
+
bodyEnd = this.pos;
|
|
650
|
+
this.pos = next;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
this.pos = next;
|
|
654
|
+
}
|
|
655
|
+
heredoc.owner.heredocs.push([bodyStart, bodyEnd]);
|
|
656
|
+
this.quoted.push({
|
|
657
|
+
start: bodyStart,
|
|
658
|
+
end: bodyEnd,
|
|
659
|
+
quoting: heredoc.quoted ? "verbatim" : "heredoc",
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
readWord() {
|
|
664
|
+
const word = bareWord(this.pos, this.pos);
|
|
665
|
+
while (!this.atEnd()) {
|
|
666
|
+
const char = this.src[this.pos];
|
|
667
|
+
if (WORD_BREAKS.has(char)) {
|
|
668
|
+
if (char === "(" &&
|
|
669
|
+
/^[A-Za-z_][A-Za-z0-9_]*\+?=$/.test(this.src.slice(word.start, this.pos))) {
|
|
670
|
+
this.skipArrayValue(); // NAME=( … )
|
|
671
|
+
word.expands = true;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
if (char === "\\") {
|
|
677
|
+
const escaped = this.src[this.pos + 1];
|
|
678
|
+
this.pos = Math.min(this.pos + 2, this.src.length);
|
|
679
|
+
if (escaped !== "\n") {
|
|
680
|
+
word.quoted = true;
|
|
681
|
+
word.value += escaped ?? "";
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
else if (char === "'") {
|
|
685
|
+
const close = this.src.indexOf("'", this.pos + 1);
|
|
686
|
+
if (close === -1) {
|
|
687
|
+
throw new Unparsed();
|
|
688
|
+
}
|
|
689
|
+
word.value += this.src.slice(this.pos + 1, close);
|
|
690
|
+
word.quoted = true;
|
|
691
|
+
this.quoted.push({ start: this.pos, end: close + 1, quoting: "single-quoted" });
|
|
692
|
+
this.pos = close + 1;
|
|
693
|
+
}
|
|
694
|
+
else if (char === '"') {
|
|
695
|
+
this.readDoubleQuoted(word);
|
|
696
|
+
}
|
|
697
|
+
else if (char === "$") {
|
|
698
|
+
this.readDollar(word, false);
|
|
699
|
+
}
|
|
700
|
+
else if (char === "`") {
|
|
701
|
+
this.skipBackticks();
|
|
702
|
+
word.expands = true;
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
if ("*?[{}".includes(char)) {
|
|
706
|
+
word.pattern = true;
|
|
707
|
+
}
|
|
708
|
+
if ((char === "~" || char === "=") && this.pos === word.start) {
|
|
709
|
+
word.expandsHome = true;
|
|
710
|
+
}
|
|
711
|
+
word.value += char;
|
|
712
|
+
this.pos++;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
word.end = this.pos;
|
|
716
|
+
word.assignment = ASSIGNMENT_PREFIX.test(this.src.slice(word.start, word.end));
|
|
717
|
+
return word;
|
|
718
|
+
}
|
|
719
|
+
readDoubleQuoted(word) {
|
|
720
|
+
word.quoted = true;
|
|
721
|
+
const start = this.pos;
|
|
722
|
+
this.pos++;
|
|
723
|
+
for (;;) {
|
|
724
|
+
if (this.atEnd()) {
|
|
725
|
+
throw new Unparsed();
|
|
726
|
+
}
|
|
727
|
+
const char = this.src[this.pos];
|
|
728
|
+
if (char === '"') {
|
|
729
|
+
this.pos++;
|
|
730
|
+
this.quoted.push({ start, end: this.pos, quoting: "double-quoted" });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (char === "\\") {
|
|
734
|
+
const escaped = this.src[this.pos + 1];
|
|
735
|
+
if (escaped === "\n") {
|
|
736
|
+
this.pos += 2;
|
|
737
|
+
}
|
|
738
|
+
else if (escaped !== undefined && '$`"\\'.includes(escaped)) {
|
|
739
|
+
word.value += escaped;
|
|
740
|
+
this.pos += 2;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
word.value += char;
|
|
744
|
+
this.pos++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
else if (char === "$") {
|
|
748
|
+
this.readDollar(word, true);
|
|
749
|
+
}
|
|
750
|
+
else if (char === "`") {
|
|
751
|
+
this.skipBackticks();
|
|
752
|
+
word.expands = true;
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
word.value += char;
|
|
756
|
+
this.pos++;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** A `$` construct: `$'…'`, `$"…"`, `$(( … ))`, `$( … )`, `${ … }`, or a parameter. */
|
|
761
|
+
readDollar(word, inDoubleQuotes) {
|
|
762
|
+
const next = this.src[this.pos + 1];
|
|
763
|
+
word.expands = true;
|
|
764
|
+
if (next === "'" && !inDoubleQuotes) {
|
|
765
|
+
this.pos += 2;
|
|
766
|
+
for (;;) {
|
|
767
|
+
if (this.atEnd()) {
|
|
768
|
+
throw new Unparsed();
|
|
769
|
+
}
|
|
770
|
+
const char = this.src[this.pos];
|
|
771
|
+
this.pos += char === "\\" ? 2 : 1;
|
|
772
|
+
if (char === "'") {
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
word.value += char;
|
|
776
|
+
}
|
|
777
|
+
word.quoted = true;
|
|
778
|
+
}
|
|
779
|
+
else if (next === '"' && !inDoubleQuotes) {
|
|
780
|
+
this.pos++;
|
|
781
|
+
this.readDoubleQuoted(word);
|
|
782
|
+
}
|
|
783
|
+
else if (next === "(" && this.src[this.pos + 2] === "(") {
|
|
784
|
+
this.pos += 3;
|
|
785
|
+
this.skipArithmetic();
|
|
786
|
+
}
|
|
787
|
+
else if (next === "(") {
|
|
788
|
+
this.pos += 2;
|
|
789
|
+
word.substitutions.push(this.parseList([")"]));
|
|
790
|
+
this.pos++;
|
|
791
|
+
}
|
|
792
|
+
else if (next === "{") {
|
|
793
|
+
this.pos += 2;
|
|
794
|
+
this.skipParameterExpansion(word);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
word.value += "$";
|
|
798
|
+
this.pos++;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** Past the closing `}` of a `${ … }`, collecting the substitutions inside it. */
|
|
802
|
+
skipParameterExpansion(word) {
|
|
803
|
+
for (;;) {
|
|
804
|
+
if (this.atEnd()) {
|
|
805
|
+
throw new Unparsed();
|
|
806
|
+
}
|
|
807
|
+
const char = this.src[this.pos];
|
|
808
|
+
if (char === "}") {
|
|
809
|
+
this.pos++;
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (char === "\\") {
|
|
813
|
+
this.pos += 2;
|
|
814
|
+
}
|
|
815
|
+
else if (char === "'") {
|
|
816
|
+
const close = this.src.indexOf("'", this.pos + 1);
|
|
817
|
+
if (close === -1) {
|
|
818
|
+
throw new Unparsed();
|
|
819
|
+
}
|
|
820
|
+
this.pos = close + 1;
|
|
821
|
+
}
|
|
822
|
+
else if (char === '"') {
|
|
823
|
+
const inner = bareWord(this.pos, this.pos);
|
|
824
|
+
this.readDoubleQuoted(inner);
|
|
825
|
+
word.substitutions.push(...inner.substitutions);
|
|
826
|
+
}
|
|
827
|
+
else if (char === "$") {
|
|
828
|
+
const inner = bareWord(this.pos, this.pos);
|
|
829
|
+
this.readDollar(inner, true);
|
|
830
|
+
word.substitutions.push(...inner.substitutions);
|
|
831
|
+
}
|
|
832
|
+
else if (char === "`") {
|
|
833
|
+
this.skipBackticks();
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
this.pos++;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** Past the `))` closing an arithmetic expansion or command whose `((` was read. */
|
|
841
|
+
skipArithmetic() {
|
|
842
|
+
let depth = 2;
|
|
843
|
+
while (depth > 0) {
|
|
844
|
+
if (this.atEnd()) {
|
|
845
|
+
throw new Unparsed();
|
|
846
|
+
}
|
|
847
|
+
const char = this.src[this.pos];
|
|
848
|
+
if (char === "\\") {
|
|
849
|
+
this.pos++;
|
|
850
|
+
}
|
|
851
|
+
else if (char === "(") {
|
|
852
|
+
depth++;
|
|
853
|
+
}
|
|
854
|
+
else if (char === ")") {
|
|
855
|
+
depth--;
|
|
856
|
+
}
|
|
857
|
+
this.pos++;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** Past the `)` closing an array assignment's value list. */
|
|
861
|
+
skipArrayValue() {
|
|
862
|
+
this.pos++;
|
|
863
|
+
for (;;) {
|
|
864
|
+
this.skipLinebreaks();
|
|
865
|
+
if (this.atEnd()) {
|
|
866
|
+
throw new Unparsed();
|
|
867
|
+
}
|
|
868
|
+
if (this.src[this.pos] === ")") {
|
|
869
|
+
this.pos++;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (!this.atWordStart()) {
|
|
873
|
+
throw new Unparsed();
|
|
874
|
+
}
|
|
875
|
+
this.readWord();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Past a backtick substitution. Its commands run in a subshell, so a `cd`
|
|
880
|
+
* inside cannot move the script, and it is not read; a heredoc inside it
|
|
881
|
+
* would need that reading, so the script is unparsed.
|
|
882
|
+
*/
|
|
883
|
+
skipBackticks() {
|
|
884
|
+
const start = this.pos + 1;
|
|
885
|
+
this.pos++;
|
|
886
|
+
for (;;) {
|
|
887
|
+
if (this.atEnd()) {
|
|
888
|
+
throw new Unparsed();
|
|
889
|
+
}
|
|
890
|
+
const char = this.src[this.pos];
|
|
891
|
+
if (char === "`") {
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
this.pos += char === "\\" ? 2 : 1;
|
|
895
|
+
}
|
|
896
|
+
const body = this.src.slice(start, this.pos);
|
|
897
|
+
this.pos++;
|
|
898
|
+
if (/(?<!<)<<(?!<)/.test(body)) {
|
|
899
|
+
throw new Unparsed();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
skipBlanks() {
|
|
903
|
+
for (;;) {
|
|
904
|
+
const char = this.src[this.pos];
|
|
905
|
+
if (char === " " || char === "\t") {
|
|
906
|
+
this.pos++;
|
|
907
|
+
}
|
|
908
|
+
else if (char === "\\" && this.src[this.pos + 1] === "\n") {
|
|
909
|
+
this.pos += 2;
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
skipComment() {
|
|
917
|
+
const newline = this.src.indexOf("\n", this.pos);
|
|
918
|
+
this.pos = newline === -1 ? this.src.length : newline;
|
|
919
|
+
}
|
|
920
|
+
/** Blanks, comments and newlines, reading heredoc bodies at each newline. */
|
|
921
|
+
skipLinebreaks() {
|
|
922
|
+
for (;;) {
|
|
923
|
+
this.skipBlanks();
|
|
924
|
+
const char = this.src[this.pos];
|
|
925
|
+
if (char === "#") {
|
|
926
|
+
this.skipComment();
|
|
927
|
+
}
|
|
928
|
+
else if (char === "\n") {
|
|
929
|
+
this.consumeNewline();
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
atWordStart() {
|
|
937
|
+
const char = this.src[this.pos];
|
|
938
|
+
return char !== undefined && char !== "#" && !WORD_BREAKS.has(char);
|
|
939
|
+
}
|
|
940
|
+
/** A reserved word (`{`, `fi`, `done`, …) standing alone at the current position. */
|
|
941
|
+
atReservedWord(reserved) {
|
|
942
|
+
if (!this.startsWith(reserved)) {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
const after = this.src[this.pos + reserved.length];
|
|
946
|
+
return after === undefined || WORD_BREAKS.has(after);
|
|
947
|
+
}
|
|
948
|
+
startsWith(text) {
|
|
949
|
+
return this.src.startsWith(text, this.pos);
|
|
950
|
+
}
|
|
951
|
+
atEnd() {
|
|
952
|
+
return this.pos >= this.src.length;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
function bareWord(start, end) {
|
|
956
|
+
return {
|
|
957
|
+
value: "",
|
|
958
|
+
start,
|
|
959
|
+
end,
|
|
960
|
+
quoted: false,
|
|
961
|
+
expands: false,
|
|
962
|
+
pattern: false,
|
|
963
|
+
expandsHome: false,
|
|
964
|
+
assignment: false,
|
|
965
|
+
substitutions: [],
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function isReservedWord(word, reserved) {
|
|
969
|
+
return !word.quoted && !word.expands && reserved.has(word.value);
|
|
970
|
+
}
|
|
971
|
+
function addWord(command, word) {
|
|
972
|
+
command.words.push(word);
|
|
973
|
+
command.substitutions.push(...word.substitutions);
|
|
974
|
+
command.end = word.end;
|
|
975
|
+
}
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// The directory walk
|
|
978
|
+
/**
|
|
979
|
+
* A directory the reading cannot tell. Each one differs from every other, so
|
|
980
|
+
* the walk can see that a command moved the shell even when it knows neither
|
|
981
|
+
* where from nor where to.
|
|
982
|
+
*/
|
|
983
|
+
class UnknownDirectory {
|
|
984
|
+
}
|
|
985
|
+
const UNKNOWN_EFFECT_COMMANDS = new Set(["pushd", "popd", "eval", "source", "."]);
|
|
986
|
+
/**
|
|
987
|
+
* The longest directory the walk follows. `cd` itself refuses a longer path
|
|
988
|
+
* on every system, and the bound keeps a script of chained relative `cd`s
|
|
989
|
+
* linear to read.
|
|
990
|
+
*/
|
|
991
|
+
const PATH_MAX = 4096;
|
|
992
|
+
/** The names `apply_patch` is run by, as Codex puts it on the `PATH`. */
|
|
993
|
+
const PATCH_COMMANDS = new Set(["apply_patch", "applypatch"]);
|
|
994
|
+
/**
|
|
995
|
+
* How many commands a walk may visit: a floor, plus a number per character of
|
|
996
|
+
* the script. A loop whose passes start in different directories is walked
|
|
997
|
+
* twice, so loops nested deep enough could otherwise cost time exponential in
|
|
998
|
+
* their depth; a script past the budget is unparsed.
|
|
999
|
+
*/
|
|
1000
|
+
const WALK_BUDGET_FLOOR = 100_000;
|
|
1001
|
+
const WALK_BUDGET_PER_CHARACTER = 16;
|
|
1002
|
+
class DirectoryWalk {
|
|
1003
|
+
budget;
|
|
1004
|
+
extents = [];
|
|
1005
|
+
/** The directory of the patch command reading the text being walked, if one does. */
|
|
1006
|
+
reader;
|
|
1007
|
+
steps = 0;
|
|
1008
|
+
constructor(budget) {
|
|
1009
|
+
this.budget = budget;
|
|
1010
|
+
}
|
|
1011
|
+
list(list, directory) {
|
|
1012
|
+
return this.run(list, directory).after;
|
|
1013
|
+
}
|
|
1014
|
+
/** A list, with the status its last and-or list exits with. */
|
|
1015
|
+
run(list, directory) {
|
|
1016
|
+
let outcome = { after: directory, status: "success", moved: false };
|
|
1017
|
+
for (const item of list.items) {
|
|
1018
|
+
const next = this.andOr(item, outcome.after);
|
|
1019
|
+
// A list sent to the background runs in a subshell, in bash and zsh
|
|
1020
|
+
// alike, and the shell goes on at once, with success.
|
|
1021
|
+
outcome = item.background ? { ...outcome, status: "success", moved: false } : next;
|
|
1022
|
+
}
|
|
1023
|
+
return outcome;
|
|
1024
|
+
}
|
|
1025
|
+
andOr(item, directory) {
|
|
1026
|
+
let outcome = this.pipeline(item.pipelines[0], directory);
|
|
1027
|
+
item.operators.forEach((operator, index) => {
|
|
1028
|
+
// `&&` runs the pipeline after it when what came before succeeded, `||` when it failed.
|
|
1029
|
+
const on = operator === "&&" ? "success" : "failure";
|
|
1030
|
+
const next = this.pipeline(item.pipelines[index + 1], branchStart(outcome, on));
|
|
1031
|
+
if (outcome.status === on) {
|
|
1032
|
+
outcome = { ...next, moved: outcome.moved || next.moved };
|
|
1033
|
+
}
|
|
1034
|
+
else if (next.moved) {
|
|
1035
|
+
// It runs off the followed path. When it does not move the shell
|
|
1036
|
+
// (`cd sub || exit 1`), the directory is the one the followed path
|
|
1037
|
+
// reached; when it does, either may be the one in effect after it.
|
|
1038
|
+
outcome = { ...outcome, after: new UnknownDirectory() };
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
return outcome;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Every member of a pipeline starts in the directory the pipeline starts
|
|
1045
|
+
* in. All but the last run in subshells; zsh runs the last in the current
|
|
1046
|
+
* shell and bash does not, so a directory change there is unknown after it.
|
|
1047
|
+
* When a member is a patch command, the other members feed it.
|
|
1048
|
+
*/
|
|
1049
|
+
pipeline(pipeline, directory) {
|
|
1050
|
+
const after = this.members(pipeline.commands, directory);
|
|
1051
|
+
return {
|
|
1052
|
+
after,
|
|
1053
|
+
status: pipeline.negated ? "failure" : "success",
|
|
1054
|
+
moved: after !== directory,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
members(commands, directory) {
|
|
1058
|
+
if (commands.length === 1) {
|
|
1059
|
+
return this.command(commands[0], directory);
|
|
1060
|
+
}
|
|
1061
|
+
const reader = this.reader;
|
|
1062
|
+
if (commands.some((command) => command.kind === "simple" && isPatchCommand(command.words))) {
|
|
1063
|
+
this.reader = directory;
|
|
1064
|
+
}
|
|
1065
|
+
let last = directory;
|
|
1066
|
+
for (const command of commands) {
|
|
1067
|
+
last = this.command(command, directory);
|
|
1068
|
+
}
|
|
1069
|
+
this.reader = reader;
|
|
1070
|
+
return last === directory ? directory : new UnknownDirectory();
|
|
1071
|
+
}
|
|
1072
|
+
command(command, directory) {
|
|
1073
|
+
switch (command.kind) {
|
|
1074
|
+
case "simple":
|
|
1075
|
+
return this.simple(command, directory);
|
|
1076
|
+
case "subshell":
|
|
1077
|
+
this.text(command.tail, directory, false);
|
|
1078
|
+
this.list(command.body, directory);
|
|
1079
|
+
return directory;
|
|
1080
|
+
case "group":
|
|
1081
|
+
this.text(command.tail, directory, false);
|
|
1082
|
+
return this.list(command.body, directory);
|
|
1083
|
+
case "if":
|
|
1084
|
+
return this.ifCommand(command, directory);
|
|
1085
|
+
case "case":
|
|
1086
|
+
return this.caseCommand(command, directory);
|
|
1087
|
+
case "loop":
|
|
1088
|
+
return this.loop(command, directory);
|
|
1089
|
+
case "function":
|
|
1090
|
+
// The body runs whenever the function is called, from wherever.
|
|
1091
|
+
this.command(command.body, new UnknownDirectory());
|
|
1092
|
+
return new UnknownDirectory();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** Each condition runs after the ones before it failed; one body, or none, runs. */
|
|
1096
|
+
ifCommand(command, directory) {
|
|
1097
|
+
this.text(command.tail, directory, false);
|
|
1098
|
+
return this.clauses(command.clauses, command.otherwise, directory);
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* The first clause, and the rest (the `elif` clauses after it and the `else`
|
|
1102
|
+
* body), which runs when the first clause's condition fails, as in `if C;
|
|
1103
|
+
* then BODY; else REST; fi`. When the condition does not move the shell,
|
|
1104
|
+
* both paths start where it ends. When it does, the one it takes off the
|
|
1105
|
+
* followed path starts from an unknown directory and, as after `||`, keeps
|
|
1106
|
+
* the directory the other reached unless it moves the shell itself.
|
|
1107
|
+
*/
|
|
1108
|
+
clauses(clauses, otherwise, directory) {
|
|
1109
|
+
const [clause, ...rest] = clauses;
|
|
1110
|
+
if (!clause) {
|
|
1111
|
+
return otherwise ? this.list(otherwise, directory) : directory;
|
|
1112
|
+
}
|
|
1113
|
+
const checked = this.run(clause.condition, directory);
|
|
1114
|
+
const body = (start) => this.list(clause.body, start);
|
|
1115
|
+
const remainder = (start) => this.clauses(rest, otherwise, start);
|
|
1116
|
+
if (!checked.moved) {
|
|
1117
|
+
return common([body(checked.after), remainder(checked.after)]);
|
|
1118
|
+
}
|
|
1119
|
+
const followed = checked.status === "success" ? body : remainder;
|
|
1120
|
+
const other = checked.status === "success" ? remainder : body;
|
|
1121
|
+
const end = followed(checked.after);
|
|
1122
|
+
const start = new UnknownDirectory();
|
|
1123
|
+
return other(start) === start ? end : new UnknownDirectory();
|
|
1124
|
+
}
|
|
1125
|
+
/** One branch runs, or none; a branch fallen into may start where the one before ended. */
|
|
1126
|
+
caseCommand(command, directory) {
|
|
1127
|
+
this.text(command.tail, directory, false);
|
|
1128
|
+
this.text(command.subject, directory, false);
|
|
1129
|
+
const ends = [directory];
|
|
1130
|
+
let fallenFrom = null;
|
|
1131
|
+
for (const branch of command.branches) {
|
|
1132
|
+
this.text(branch.patterns, directory, false);
|
|
1133
|
+
const start = fallenFrom === null ? directory : common([directory, fallenFrom]);
|
|
1134
|
+
const end = this.list(branch.body, start);
|
|
1135
|
+
ends.push(end);
|
|
1136
|
+
fallenFrom = branch.fallsThrough ? end : null;
|
|
1137
|
+
}
|
|
1138
|
+
return common(ends);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* A loop runs its body any number of times. When a pass ends where it
|
|
1142
|
+
* started, every pass does. Otherwise the passes after the first start
|
|
1143
|
+
* elsewhere, so the loop is walked again from an unknown directory, whose
|
|
1144
|
+
* placements then stand for every pass.
|
|
1145
|
+
*/
|
|
1146
|
+
loop(command, directory) {
|
|
1147
|
+
this.text(command.tail, directory, false);
|
|
1148
|
+
if (command.head) {
|
|
1149
|
+
this.text(command.head, directory, false);
|
|
1150
|
+
}
|
|
1151
|
+
const mark = this.extents.length;
|
|
1152
|
+
const first = this.pass(command, directory);
|
|
1153
|
+
if (first.next === directory) {
|
|
1154
|
+
return first.exit;
|
|
1155
|
+
}
|
|
1156
|
+
if (directory instanceof UnknownDirectory) {
|
|
1157
|
+
// From one unknown directory, the walk places text as it would from any other.
|
|
1158
|
+
return new UnknownDirectory();
|
|
1159
|
+
}
|
|
1160
|
+
this.extents.length = mark;
|
|
1161
|
+
const later = this.pass(command, new UnknownDirectory());
|
|
1162
|
+
return common([first.exit, later.exit]);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* One pass of a loop from `start`: where the next pass starts, and where
|
|
1166
|
+
* the loop ends if it ends instead, once a `for` loop's words run out, or
|
|
1167
|
+
* on the status of its condition that ends it: failure for `while`,
|
|
1168
|
+
* success for `until`.
|
|
1169
|
+
*/
|
|
1170
|
+
pass(command, start) {
|
|
1171
|
+
if (!command.condition) {
|
|
1172
|
+
return { next: this.list(command.body, start), exit: start };
|
|
1173
|
+
}
|
|
1174
|
+
const checked = this.run(command.condition, start);
|
|
1175
|
+
const [runs, ends] = command.until
|
|
1176
|
+
? ["failure", "success"]
|
|
1177
|
+
: ["success", "failure"];
|
|
1178
|
+
return {
|
|
1179
|
+
next: this.list(command.body, branchStart(checked, runs)),
|
|
1180
|
+
exit: branchStart(checked, ends),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
simple(command, directory) {
|
|
1184
|
+
this.text(command, directory, isPatchCommand(command.words));
|
|
1185
|
+
const effect = directoryEffect(command.words);
|
|
1186
|
+
switch (effect.kind) {
|
|
1187
|
+
case "none":
|
|
1188
|
+
return directory;
|
|
1189
|
+
case "unknown":
|
|
1190
|
+
return new UnknownDirectory();
|
|
1191
|
+
case "cd": {
|
|
1192
|
+
let moved;
|
|
1193
|
+
if (isAbsolute(effect.operand)) {
|
|
1194
|
+
moved = resolve(effect.operand);
|
|
1195
|
+
}
|
|
1196
|
+
else if (typeof directory === "string") {
|
|
1197
|
+
moved = resolve(directory, effect.operand);
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
return new UnknownDirectory();
|
|
1201
|
+
}
|
|
1202
|
+
return moved.length > PATH_MAX ? new UnknownDirectory() : moved;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Place a command's words and heredoc bodies, after walking the
|
|
1208
|
+
* substitutions in them, which run first and in the same directory. A patch
|
|
1209
|
+
* command reads its own text and what its substitutions print; any other
|
|
1210
|
+
* command's text is read by the patch command it feeds, if there is one.
|
|
1211
|
+
*/
|
|
1212
|
+
text(command, directory, patchCommand) {
|
|
1213
|
+
if (++this.steps > this.budget) {
|
|
1214
|
+
throw new Unparsed();
|
|
1215
|
+
}
|
|
1216
|
+
const reader = this.reader;
|
|
1217
|
+
if (patchCommand) {
|
|
1218
|
+
this.reader = directory;
|
|
1219
|
+
}
|
|
1220
|
+
for (const substitution of command.substitutions) {
|
|
1221
|
+
this.list(substitution, directory);
|
|
1222
|
+
}
|
|
1223
|
+
const read = this.reader !== undefined;
|
|
1224
|
+
const placed = typeof this.reader === "string" ? this.reader : null;
|
|
1225
|
+
this.reader = reader;
|
|
1226
|
+
if (command.end > command.start) {
|
|
1227
|
+
this.extents.push({ start: command.start, end: command.end, read, directory: placed });
|
|
1228
|
+
}
|
|
1229
|
+
for (const [start, end] of command.heredocs) {
|
|
1230
|
+
this.extents.push({ start, end, read, directory: placed });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Where a path taken when a list exits with `on` starts: where the list left
|
|
1236
|
+
* the shell when that is the followed path's status or the list never moved
|
|
1237
|
+
* the shell, and an unknown directory otherwise, since the list may have
|
|
1238
|
+
* failed anywhere after it moved.
|
|
1239
|
+
*/
|
|
1240
|
+
function branchStart(outcome, on) {
|
|
1241
|
+
return outcome.status === on || !outcome.moved ? outcome.after : new UnknownDirectory();
|
|
1242
|
+
}
|
|
1243
|
+
/** The directory every path leaves the shell in, or an unknown one when they differ. */
|
|
1244
|
+
function common(ends) {
|
|
1245
|
+
return ends.every((end) => end === ends[0]) ? ends[0] : new UnknownDirectory();
|
|
1246
|
+
}
|
|
1247
|
+
/** The index of the word naming the command run, past assignments and `builtin` or `command`. */
|
|
1248
|
+
function commandIndex(words) {
|
|
1249
|
+
let index = 0;
|
|
1250
|
+
while (words[index]?.assignment) {
|
|
1251
|
+
index++;
|
|
1252
|
+
}
|
|
1253
|
+
while (words[index] &&
|
|
1254
|
+
isReservedWord(words[index], new Set(["builtin", "command"])) &&
|
|
1255
|
+
!words[index + 1]?.value.startsWith("-")) {
|
|
1256
|
+
index++;
|
|
1257
|
+
}
|
|
1258
|
+
return index;
|
|
1259
|
+
}
|
|
1260
|
+
function isPatchCommand(words) {
|
|
1261
|
+
const name = words[commandIndex(words)];
|
|
1262
|
+
return name !== undefined && !name.expands && PATCH_COMMANDS.has(basename(name.value));
|
|
1263
|
+
}
|
|
1264
|
+
/** What running a simple command does to the shell's working directory. */
|
|
1265
|
+
function directoryEffect(words) {
|
|
1266
|
+
const index = commandIndex(words);
|
|
1267
|
+
const name = words[index];
|
|
1268
|
+
if (!name || name.expands) {
|
|
1269
|
+
return { kind: "none" };
|
|
1270
|
+
}
|
|
1271
|
+
if (name.value === "cd") {
|
|
1272
|
+
return cdEffect(words.slice(index + 1));
|
|
1273
|
+
}
|
|
1274
|
+
if (UNKNOWN_EFFECT_COMMANDS.has(name.value)) {
|
|
1275
|
+
return { kind: "unknown" };
|
|
1276
|
+
}
|
|
1277
|
+
return { kind: "none" };
|
|
1278
|
+
}
|
|
1279
|
+
function cdEffect(args) {
|
|
1280
|
+
let index = 0;
|
|
1281
|
+
while (args[index] && !args[index].expands && ["-L", "-P"].includes(args[index].value)) {
|
|
1282
|
+
index++;
|
|
1283
|
+
}
|
|
1284
|
+
let endOfOptions = false;
|
|
1285
|
+
if (args[index] && !args[index].expands && args[index].value === "--") {
|
|
1286
|
+
endOfOptions = true;
|
|
1287
|
+
index++;
|
|
1288
|
+
}
|
|
1289
|
+
const operands = args.slice(index);
|
|
1290
|
+
if (operands.length !== 1) {
|
|
1291
|
+
return { kind: "unknown" }; // `cd` alone goes home; zsh's `cd OLD NEW` substitutes
|
|
1292
|
+
}
|
|
1293
|
+
const operand = operands[0];
|
|
1294
|
+
if (operand.expands ||
|
|
1295
|
+
operand.pattern ||
|
|
1296
|
+
operand.expandsHome ||
|
|
1297
|
+
operand.value === "" ||
|
|
1298
|
+
(operand.value.startsWith("-") && !endOfOptions) ||
|
|
1299
|
+
/^\+\d+$/.test(operand.value)) {
|
|
1300
|
+
return { kind: "unknown" };
|
|
1301
|
+
}
|
|
1302
|
+
return { kind: "cd", operand: operand.value };
|
|
1303
|
+
}
|
|
1304
|
+
//# sourceMappingURL=shell-script.js.map
|