@brainervirus/workit-core 8.12.1 → 8.12.3
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/package.json +1 -1
- package/src/autonomy.ts +1 -1
- package/src/core/route-intent.ts +185 -45
- package/src/git/ops.ts +17 -6
- package/src/hooks/hosts/cursor.ts +3 -1
- package/src/hooks/policy.ts +11 -2
- package/src/hooks/raw-git.ts +1146 -142
- package/src/hooks/shell-words.ts +383 -28
package/src/hooks/shell-words.ts
CHANGED
|
@@ -1,20 +1,117 @@
|
|
|
1
1
|
// A small shell tokenizer shared by the hooks: quoted words, operators,
|
|
2
|
-
// redirects, heredoc bodies and `( … )` group markers. Pure string
|
|
3
|
-
// no I/O. Two dialects:
|
|
4
|
-
// - posix (Bash, sh, zsh): `\` escapes, '…' is literal, "…" honors `\"
|
|
2
|
+
// redirects, heredoc bodies, comments and `( … )` group markers. Pure string
|
|
3
|
+
// parsing, no I/O. Two dialects:
|
|
4
|
+
// - posix (Bash, sh, zsh): `\` escapes, '…' is literal, "…" honors `\"`,
|
|
5
|
+
// $'…' decodes C escapes, `\<newline>` continues the line, unquoted
|
|
6
|
+
// `{a,b}` expands, `$( … )`, backticks and `<( … )` are substitutions
|
|
7
|
+
// read apart, `(( … ))` is arithmetic, `#` starts a comment;
|
|
5
8
|
// - powershell (Claude Code's PowerShell tool): `\` is a path separator,
|
|
6
|
-
// the backtick escapes, '…' is literal with '' for
|
|
9
|
+
// the backtick escapes (and continues a line), '…' is literal with '' for
|
|
10
|
+
// a quote, "…" honors `", `{ … }` script blocks run their commands, `#`
|
|
11
|
+
// and `<# … #>` are comments.
|
|
7
12
|
export type Segment = {
|
|
8
13
|
words: string[];
|
|
9
14
|
redirects: string[];
|
|
10
15
|
/** A `(`/`$(` group starts or ends here; the segment carries no words. */
|
|
11
16
|
group?: "open" | "close";
|
|
17
|
+
/** Its standard output feeds the next segment (`a | b`). */
|
|
18
|
+
pipe?: true;
|
|
19
|
+
/** Text it reads on standard input: heredoc bodies and here-strings. */
|
|
20
|
+
stdin?: string[];
|
|
21
|
+
/** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
|
|
22
|
+
subs?: string[];
|
|
23
|
+
/** The command could not be analyzed (too deeply nested): why. The only segment then. */
|
|
24
|
+
unanalyzable?: string;
|
|
12
25
|
};
|
|
13
26
|
|
|
14
27
|
export type ShellDialect = "posix" | "powershell";
|
|
15
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A command the tokenizer cannot analyze within its budget (nesting too
|
|
31
|
+
* deep). The shell hook denies it with this reason instead of guessing.
|
|
32
|
+
*/
|
|
33
|
+
export class ShellAnalysisError extends Error {}
|
|
34
|
+
|
|
35
|
+
/** Substitutions and quotes nest at most this deep. */
|
|
36
|
+
const MAX_NESTING = 64;
|
|
37
|
+
|
|
38
|
+
// Unquoted brace characters, told apart from quoted ones until a word ends.
|
|
39
|
+
const OPEN = "\uE000";
|
|
40
|
+
const COMMA = "\uE001";
|
|
41
|
+
const CLOSE = "\uE002";
|
|
42
|
+
const MARKERS = /[\uE000-\uE002]/u;
|
|
43
|
+
const literal = (word: string) =>
|
|
44
|
+
word.replaceAll(OPEN, "{").replaceAll(COMMA, ",").replaceAll(CLOSE, "}");
|
|
45
|
+
/** At most this many words come out of one brace expansion. */
|
|
46
|
+
const BRACE_LIMIT = 64;
|
|
47
|
+
/** At most this many words wait to expand, and braces nest this deep, per word. */
|
|
48
|
+
const BRACE_PENDING = 512;
|
|
49
|
+
const BRACE_DEPTH = 16;
|
|
50
|
+
|
|
51
|
+
/** The first brace group with a comma at its top level: its bounds and commas. */
|
|
52
|
+
const braceGroup = (word: string) => {
|
|
53
|
+
for (let start = 0; start < word.length; start++) {
|
|
54
|
+
if (word[start] !== OPEN) continue;
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let end = start;
|
|
57
|
+
const commas: number[] = [];
|
|
58
|
+
for (; end < word.length; end++) {
|
|
59
|
+
if (word[end] === OPEN) depth++;
|
|
60
|
+
else if (word[end] === CLOSE && --depth === 0) break;
|
|
61
|
+
else if (word[end] === COMMA && depth === 1) commas.push(end);
|
|
62
|
+
}
|
|
63
|
+
if (end < word.length && commas.length) return { start, end, commas };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bash brace expansion of one word (`a{b,c}d` → `abd acd`); no sequences.
|
|
70
|
+
* Empty results are dropped (`{git,}` is `git`) and repeats collapse.
|
|
71
|
+
* Iterative and budgeted: a word nested too deep or expanding past the
|
|
72
|
+
* budget stays one literal word.
|
|
73
|
+
*/
|
|
74
|
+
const expandBraces = (word: string): string[] => {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
for (const char of word) {
|
|
77
|
+
if (char === OPEN && ++depth > BRACE_DEPTH) return [literal(word)];
|
|
78
|
+
if (char === CLOSE && depth > 0) depth--;
|
|
79
|
+
}
|
|
80
|
+
const done = new Set<string>();
|
|
81
|
+
const queue = [word];
|
|
82
|
+
const queued = new Set(queue);
|
|
83
|
+
for (let at = 0; at < queue.length; at++) {
|
|
84
|
+
const current = queue[at];
|
|
85
|
+
const group = braceGroup(current);
|
|
86
|
+
if (!group) {
|
|
87
|
+
const text = literal(current);
|
|
88
|
+
if (text) done.add(text);
|
|
89
|
+
if (done.size > BRACE_LIMIT) return [literal(word)];
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const pre = current.slice(0, group.start);
|
|
93
|
+
const post = current.slice(group.end + 1);
|
|
94
|
+
let from = group.start + 1;
|
|
95
|
+
for (const comma of [...group.commas, group.end]) {
|
|
96
|
+
const next = pre + current.slice(from, comma) + post;
|
|
97
|
+
from = comma + 1;
|
|
98
|
+
if (queued.has(next)) continue;
|
|
99
|
+
queued.add(next);
|
|
100
|
+
queue.push(next);
|
|
101
|
+
}
|
|
102
|
+
// A comma group leaves more words waiting than it takes: bounding them bounds the work.
|
|
103
|
+
if (queue.length - at > BRACE_PENDING) return [literal(word)];
|
|
104
|
+
}
|
|
105
|
+
return [...done];
|
|
106
|
+
};
|
|
107
|
+
|
|
16
108
|
/** The index of the quote closing the one at `start`, or -1. */
|
|
17
|
-
const closingQuote = (
|
|
109
|
+
const closingQuote = (
|
|
110
|
+
command: string,
|
|
111
|
+
start: number,
|
|
112
|
+
dialect: ShellDialect,
|
|
113
|
+
nesting = 0,
|
|
114
|
+
): number => {
|
|
18
115
|
const quote = command[start];
|
|
19
116
|
for (let index = start + 1; index < command.length; index++) {
|
|
20
117
|
const char = command[index];
|
|
@@ -31,72 +128,323 @@ const closingQuote = (command: string, start: number, dialect: ShellDialect): nu
|
|
|
31
128
|
index++;
|
|
32
129
|
continue;
|
|
33
130
|
}
|
|
131
|
+
// posix: a `$( … )` inside double quotes is parsed on its own (its own quotes and heredocs).
|
|
132
|
+
if (dialect === "posix" && char === "$" && command[index + 1] === "(") {
|
|
133
|
+
index = substitutionEnd(command, index + 1, nesting + 1);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (dialect === "posix" && char === "`") {
|
|
137
|
+
index = backtickEnd(command, index);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
34
140
|
if (char === '"') return index;
|
|
35
141
|
}
|
|
36
142
|
return -1;
|
|
37
143
|
};
|
|
38
144
|
|
|
145
|
+
/** The index of the backtick closing the one at `start`, or the end. */
|
|
146
|
+
const backtickEnd = (command: string, start: number): number => {
|
|
147
|
+
for (let index = start + 1; index < command.length; index++) {
|
|
148
|
+
if (command[index] === "\\") index++;
|
|
149
|
+
else if (command[index] === "`") return index;
|
|
150
|
+
}
|
|
151
|
+
return command.length;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** A heredoc operator's delimiter at `start` (just past `<<`): the word and where it ends. */
|
|
155
|
+
const heredocDelimiter = (command: string, start: number) => {
|
|
156
|
+
let index = start;
|
|
157
|
+
if (command[index] === "-") index++;
|
|
158
|
+
while (command[index] === " " || command[index] === "\t") index++;
|
|
159
|
+
let delimiter = "";
|
|
160
|
+
for (; index < command.length && !/[\s;&|<>()]/.test(command[index]); index++) {
|
|
161
|
+
const char = command[index];
|
|
162
|
+
if (char === "'" || char === '"') {
|
|
163
|
+
const end = command.indexOf(char, index + 1);
|
|
164
|
+
if (end < 0) break;
|
|
165
|
+
delimiter += command.slice(index + 1, end);
|
|
166
|
+
index = end;
|
|
167
|
+
} else if (char === "\\") delimiter += command[++index] ?? "";
|
|
168
|
+
else delimiter += char;
|
|
169
|
+
}
|
|
170
|
+
return { delimiter, end: index - 1 };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Past the heredoc bodies that start after the newline at `newline`: the index of the last line's end. */
|
|
174
|
+
const skipHeredocs = (command: string, newline: number, delimiters: string[]): number => {
|
|
175
|
+
let index = newline;
|
|
176
|
+
while (delimiters.length) {
|
|
177
|
+
const delimiter = delimiters.shift()!;
|
|
178
|
+
for (;;) {
|
|
179
|
+
const lineEnd = command.indexOf("\n", index + 1);
|
|
180
|
+
const line = command.slice(index + 1, lineEnd < 0 ? undefined : lineEnd);
|
|
181
|
+
index = lineEnd < 0 ? command.length : lineEnd;
|
|
182
|
+
if (line.trim() === delimiter || lineEnd < 0) break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return index;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The index of the `)` closing the `$(` whose `(` is at `start`, read as
|
|
190
|
+
* bash reads it: quotes, nested substitutions, backticks, comments and
|
|
191
|
+
* heredoc bodies (`$(cat <<'EOF' … 1) … EOF)`) do not count.
|
|
192
|
+
*/
|
|
193
|
+
const substitutionEnd = (command: string, start: number, nesting = 0): number => {
|
|
194
|
+
if (nesting > MAX_NESTING) throw new ShellAnalysisError("substitutions nest too deep");
|
|
195
|
+
let depth = 0;
|
|
196
|
+
let wordStart = true;
|
|
197
|
+
const heredocs: string[] = [];
|
|
198
|
+
for (let index = start; index < command.length; index++) {
|
|
199
|
+
const char = command[index];
|
|
200
|
+
const atWordStart = wordStart;
|
|
201
|
+
wordStart = /[\s;&|()]/.test(char);
|
|
202
|
+
if (char === "\\") index++;
|
|
203
|
+
else if (char === "\n" && heredocs.length) {
|
|
204
|
+
index = skipHeredocs(command, index, heredocs);
|
|
205
|
+
wordStart = true;
|
|
206
|
+
} else if (char === "'" || char === '"') {
|
|
207
|
+
const end = closingQuote(command, index, "posix", nesting);
|
|
208
|
+
if (end < 0) return command.length;
|
|
209
|
+
index = end;
|
|
210
|
+
} else if (char === "`") index = backtickEnd(command, index);
|
|
211
|
+
else if (char === "#" && atWordStart) {
|
|
212
|
+
const newline = command.indexOf("\n", index);
|
|
213
|
+
index = newline < 0 ? command.length : newline - 1;
|
|
214
|
+
} else if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<") {
|
|
215
|
+
const { delimiter, end } = heredocDelimiter(command, index + 2);
|
|
216
|
+
if (delimiter) heredocs.push(delimiter);
|
|
217
|
+
index = end;
|
|
218
|
+
} else if (char === "<" && command[index + 1] === "<") index += 2;
|
|
219
|
+
else if (char === "$" && command[index + 1] === "(")
|
|
220
|
+
index = substitutionEnd(command, index + 1, nesting + 1);
|
|
221
|
+
else if (char === "(") depth++;
|
|
222
|
+
else if (char === ")" && --depth === 0) return index;
|
|
223
|
+
}
|
|
224
|
+
return command.length;
|
|
225
|
+
};
|
|
226
|
+
|
|
39
227
|
/** The text of a quoted span without its quotes, escapes resolved. */
|
|
40
228
|
const unquote = (body: string, quote: string, dialect: ShellDialect): string => {
|
|
41
229
|
if (quote === "'") return dialect === "powershell" ? body.replaceAll("''", "'") : body;
|
|
42
|
-
return dialect === "posix"
|
|
230
|
+
return dialect === "posix"
|
|
231
|
+
? body.replace(/\\\n/g, "").replace(/\\(["\\$`])/g, "$1")
|
|
232
|
+
: body.replace(/`(.)/g, "$1");
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const C_ESCAPES: Record<string, string> = {
|
|
236
|
+
n: "\n",
|
|
237
|
+
t: "\t",
|
|
238
|
+
r: "\r",
|
|
239
|
+
a: "\x07",
|
|
240
|
+
b: "\b",
|
|
241
|
+
e: "\x1b",
|
|
242
|
+
E: "\x1b",
|
|
243
|
+
f: "\f",
|
|
244
|
+
v: "\v",
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** A posix `$'…'` span starting at its quote: the decoded text and the closing index. */
|
|
248
|
+
const ansiC = (command: string, start: number): { text: string; end: number } => {
|
|
249
|
+
let text = "";
|
|
250
|
+
let index = start + 1;
|
|
251
|
+
for (; index < command.length && command[index] !== "'"; index++) {
|
|
252
|
+
const char = command[index];
|
|
253
|
+
if (char !== "\\" || index + 1 >= command.length) {
|
|
254
|
+
text += char;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const rest = command.slice(++index);
|
|
258
|
+
const hex = /^x([0-9a-fA-F]{1,2})/.exec(rest);
|
|
259
|
+
const octal = /^[0-7]{1,3}/.exec(rest);
|
|
260
|
+
if (hex) {
|
|
261
|
+
text += String.fromCharCode(Number.parseInt(hex[1], 16));
|
|
262
|
+
index += hex[0].length - 1;
|
|
263
|
+
} else if (octal) {
|
|
264
|
+
text += String.fromCharCode(Number.parseInt(octal[0], 8));
|
|
265
|
+
index += octal[0].length - 1;
|
|
266
|
+
} else text += C_ESCAPES[rest[0]] ?? rest[0];
|
|
267
|
+
}
|
|
268
|
+
return { text, end: index };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The command substitutions (`$( … )`, `` `…` ``) inside a double-quoted body. */
|
|
272
|
+
const substitutions = (body: string): string[] => {
|
|
273
|
+
const found: string[] = [];
|
|
274
|
+
for (let index = 0; index < body.length; index++) {
|
|
275
|
+
const char = body[index];
|
|
276
|
+
if (char === "\\") index++;
|
|
277
|
+
else if (char === "`") {
|
|
278
|
+
const end = backtickEnd(body, index);
|
|
279
|
+
found.push(body.slice(index + 1, end).replace(/\\(.)/g, "$1"));
|
|
280
|
+
index = end;
|
|
281
|
+
} else if (char === "$" && body[index + 1] === "(") {
|
|
282
|
+
const end = substitutionEnd(body, index + 1);
|
|
283
|
+
found.push(body.slice(index + 2, end));
|
|
284
|
+
index = end;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return found;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Characters that are only ever part of a word, per dialect. */
|
|
291
|
+
const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
|
|
292
|
+
const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
|
|
293
|
+
|
|
294
|
+
/** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
|
|
295
|
+
const arithmeticEnd = (command: string, start: number): number => {
|
|
296
|
+
const end = substitutionEnd(command, start);
|
|
297
|
+
return command[end - 1] === ")" && end > start + 2 ? end : 0;
|
|
43
298
|
};
|
|
44
299
|
|
|
45
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* Tokenize `command` into simple-command segments. Never throws: a command
|
|
302
|
+
* it cannot analyze comes back as one segment that says why.
|
|
303
|
+
*/
|
|
46
304
|
export const segmentsOf = (command: string, dialect: ShellDialect = "posix"): Segment[] => {
|
|
305
|
+
try {
|
|
306
|
+
return tokenize(command, dialect);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const why = error instanceof ShellAnalysisError ? error.message : String(error);
|
|
309
|
+
return [{ words: [], redirects: [], unanalyzable: why }];
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
314
|
+
const posix = dialect === "posix";
|
|
47
315
|
const segments: Segment[] = [];
|
|
48
316
|
let current: Segment = { words: [], redirects: [] };
|
|
49
317
|
let word = "";
|
|
50
318
|
let inWord = false;
|
|
51
|
-
let pending: "redirect" | "input" | "heredoc" | null = null;
|
|
52
|
-
const heredocs: string
|
|
53
|
-
const escape =
|
|
319
|
+
let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
|
|
320
|
+
const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
|
|
321
|
+
const escape = posix ? "\\" : "`";
|
|
54
322
|
const flushWord = () => {
|
|
55
323
|
if (!inWord) return;
|
|
324
|
+
const text = MARKERS.test(word) ? literal(word) : word;
|
|
56
325
|
if (pending === "redirect") {
|
|
57
|
-
if (!
|
|
58
|
-
} else if (pending === "heredoc")
|
|
59
|
-
|
|
326
|
+
if (!text.startsWith("&")) current.redirects.push(text);
|
|
327
|
+
} else if (pending === "heredoc")
|
|
328
|
+
heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
|
|
329
|
+
else if (pending === "herestring") (current.stdin ??= []).push(text);
|
|
330
|
+
else if (pending !== "input")
|
|
331
|
+
current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
|
|
60
332
|
pending = null;
|
|
61
333
|
word = "";
|
|
62
334
|
inWord = false;
|
|
63
335
|
};
|
|
64
336
|
const flushSegment = () => {
|
|
65
337
|
flushWord();
|
|
66
|
-
if (current.words.length || current.redirects.length
|
|
338
|
+
if (current.words.length || current.redirects.length || current.stdin || current.subs)
|
|
339
|
+
segments.push(current);
|
|
67
340
|
current = { words: [], redirects: [] };
|
|
68
341
|
};
|
|
342
|
+
const group = (kind: "open" | "close") => {
|
|
343
|
+
flushSegment();
|
|
344
|
+
pending = null;
|
|
345
|
+
segments.push({ words: [], redirects: [], group: kind });
|
|
346
|
+
};
|
|
347
|
+
/** A substitution read apart: its command runs, its text stays in the word. */
|
|
348
|
+
const substitution = (inner: string, text: string) => {
|
|
349
|
+
(current.subs ??= []).push(inner);
|
|
350
|
+
word += text;
|
|
351
|
+
inWord = true;
|
|
352
|
+
};
|
|
353
|
+
const plain = posix ? PLAIN_POSIX : PLAIN_POWERSHELL;
|
|
69
354
|
for (let index = 0; index < command.length; index++) {
|
|
355
|
+
// A run of characters no rule treats specially joins the word at once.
|
|
356
|
+
plain.lastIndex = index;
|
|
357
|
+
const run = plain.exec(command);
|
|
358
|
+
if (run) {
|
|
359
|
+
word += run[0];
|
|
360
|
+
inWord = true;
|
|
361
|
+
index += run[0].length - 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
70
364
|
const char = command[index];
|
|
71
|
-
|
|
365
|
+
const next = command[index + 1];
|
|
366
|
+
if (char === escape && (next === "\n" || (next === "\r" && command[index + 2] === "\n")))
|
|
367
|
+
index += next === "\r" ? 2 : 1;
|
|
368
|
+
else if (char === "#" && !inWord) {
|
|
369
|
+
// A comment runs to the end of the line; its quotes are not quotes.
|
|
370
|
+
const newline = command.indexOf("\n", index);
|
|
371
|
+
index = (newline < 0 ? command.length : newline) - 1;
|
|
372
|
+
} else if (!posix && char === "<" && next === "#") {
|
|
373
|
+
const end = command.indexOf("#>", index + 2);
|
|
374
|
+
index = end < 0 ? command.length : end + 1;
|
|
375
|
+
} else if (posix && char === "$" && next === "'") {
|
|
376
|
+
const { text, end } = ansiC(command, index + 1);
|
|
377
|
+
word += text;
|
|
378
|
+
inWord = true;
|
|
379
|
+
index = end;
|
|
380
|
+
} else if (posix && char === "$" && next === '"') continue;
|
|
381
|
+
else if (char === "'" || char === '"') {
|
|
72
382
|
const end = closingQuote(command, index, dialect);
|
|
73
|
-
|
|
383
|
+
const body = command.slice(index + 1, end < 0 ? undefined : end);
|
|
384
|
+
if (posix && char === '"' && /\$\(|`/.test(body))
|
|
385
|
+
(current.subs ??= []).push(...substitutions(body));
|
|
386
|
+
word += unquote(body, char, dialect);
|
|
74
387
|
inWord = true;
|
|
75
388
|
index = end < 0 ? command.length : end;
|
|
389
|
+
} else if (posix && char === "`") {
|
|
390
|
+
const end = backtickEnd(command, index);
|
|
391
|
+
const inner = command.slice(index + 1, end);
|
|
392
|
+
substitution(inner.replace(/\\(.)/g, "$1"), `\`${inner}\``);
|
|
393
|
+
index = end;
|
|
76
394
|
} else if (char === escape && index + 1 < command.length) {
|
|
77
395
|
word += command[++index];
|
|
78
396
|
inWord = true;
|
|
79
397
|
} else if (char === "\n") {
|
|
80
398
|
flushSegment();
|
|
81
|
-
//
|
|
399
|
+
// Each heredoc body, up to its delimiter line, is its command's stdin.
|
|
82
400
|
while (heredocs.length) {
|
|
83
|
-
const delimiter = heredocs.shift()!;
|
|
401
|
+
const { delimiter, owner } = heredocs.shift()!;
|
|
84
402
|
const lines = command.slice(index + 1).split("\n");
|
|
85
403
|
const at = lines.findIndex((line) => line.trim() === delimiter);
|
|
86
|
-
|
|
87
|
-
index +=
|
|
404
|
+
(owner.stdin ??= []).push((at < 0 ? lines : lines.slice(0, at)).join("\n"));
|
|
405
|
+
index += (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
|
|
88
406
|
}
|
|
89
407
|
} else if (/\s/.test(char)) flushWord();
|
|
90
|
-
else if (char === "("
|
|
91
|
-
// `$(`
|
|
408
|
+
else if (posix && char === "(" && inWord && word.endsWith("$")) {
|
|
409
|
+
// `$( … )` stays part of its word (`"$(…)"`, `x$(…)y`); its command is read apart.
|
|
410
|
+
const end = substitutionEnd(command, index);
|
|
411
|
+
const inner = command.slice(index + 1, end);
|
|
412
|
+
substitution(inner, `(${inner})`);
|
|
413
|
+
index = end;
|
|
414
|
+
} else if (posix && char === "(" && (pending === "input" || pending === "redirect")) {
|
|
415
|
+
// `<( … )`, `>( … )`: a process substitution is an operand whose command runs.
|
|
416
|
+
const sign = pending === "input" ? "<" : ">";
|
|
417
|
+
pending = null;
|
|
418
|
+
const end = substitutionEnd(command, index);
|
|
419
|
+
const inner = command.slice(index + 1, end);
|
|
420
|
+
substitution(inner, `${sign}(${inner})`);
|
|
421
|
+
index = end;
|
|
422
|
+
} else if (posix && char === "(" && next === "(" && !inWord && arithmeticEnd(command, index)) {
|
|
423
|
+
// `(( … ))` is arithmetic: `<<` there is a shift, not a heredoc.
|
|
424
|
+
flushSegment();
|
|
425
|
+
index = arithmeticEnd(command, index);
|
|
426
|
+
} else if (char === "(" || char === ")") {
|
|
427
|
+
// PowerShell `$(`: the `$` is not a word.
|
|
92
428
|
if (char === "(" && inWord && word.endsWith("$")) {
|
|
93
429
|
word = word.slice(0, -1);
|
|
94
430
|
inWord = word !== "";
|
|
95
431
|
}
|
|
432
|
+
group(char === "(" ? "open" : "close");
|
|
433
|
+
} else if (!posix && (char === "{" || char === "}")) {
|
|
434
|
+
// A script block's commands run (`& { … }`, `Invoke-Command { … }`, `% { … }`).
|
|
435
|
+
if (char === "{" && inWord && word.endsWith("@")) {
|
|
436
|
+
word += char;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
96
439
|
flushSegment();
|
|
97
|
-
segments.push({ words: [], redirects: [], group: char === "(" ? "open" : "close" });
|
|
98
440
|
} else if (";&|".includes(char)) {
|
|
99
|
-
if (char === "&" &&
|
|
441
|
+
if (char === "&" && next === ">") continue;
|
|
442
|
+
if (char === "|" && next === "|") index++;
|
|
443
|
+
else if (char === "|") {
|
|
444
|
+
flushWord();
|
|
445
|
+
if (current.words.length) current.pipe = true;
|
|
446
|
+
if (next === "&") index++;
|
|
447
|
+
}
|
|
100
448
|
flushSegment();
|
|
101
449
|
} else if (char === ">") {
|
|
102
450
|
// `2>`, `&>`: the fd digit is not a word.
|
|
@@ -111,14 +459,21 @@ export const segmentsOf = (command: string, dialect: ShellDialect = "posix"): Se
|
|
|
111
459
|
inWord = true;
|
|
112
460
|
}
|
|
113
461
|
pending = "redirect";
|
|
114
|
-
} else if (char === "<" &&
|
|
462
|
+
} else if (char === "<" && posix) {
|
|
115
463
|
flushWord();
|
|
116
|
-
if (command[index +
|
|
464
|
+
if (next === "<" && command[index + 2] === "<") {
|
|
465
|
+
index += 2;
|
|
466
|
+
pending = "herestring";
|
|
467
|
+
} else if (next === "<") {
|
|
117
468
|
index++;
|
|
118
|
-
if (command[index + 1] === "<") index++;
|
|
119
469
|
pending = "heredoc";
|
|
120
470
|
} else pending = "input";
|
|
121
|
-
} else {
|
|
471
|
+
} else if (posix && char === "{" && !word.endsWith("$")) {
|
|
472
|
+
word += OPEN;
|
|
473
|
+
inWord = true;
|
|
474
|
+
} else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
|
|
475
|
+
else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
|
|
476
|
+
else {
|
|
122
477
|
word += char;
|
|
123
478
|
inWord = true;
|
|
124
479
|
}
|