Quote the bash habits that misfire under zsh before the Bash tool runs them, and explain zsh errors after

Quotes the bash habits that misfire under zsh before the Bash tool runs them, and adds a note to the result after a zsh error. Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
hooks/hooks.json loads register.ts, the entry point.words.ts splits a command into words with their quoting and source spans.rewrite.ts applies the rewrite rules to unquoted characters only.explain.ts maps zsh error lines to notes.shell.ts probes the shell's options at session start.flag-glob quotes --name=*.ts values and find -name patterns.equals quotes words starting with =.url quotes URLs with ?, * or [.glob, equals, reserved, split, and bash-syntax. A repeat within a session gets the short form.CLAUDE_CODE_SHELL, then $SHELL, then zsh on PATH, and probes nomatch, nullglob, cshnullglob, and equals with a non-interactive zsh -c. The glob options gate flag-glob and url, and equals gates the equals rule. Under any other shell the mod is a no-op.session.start, rewrite, explain, and error go to mod-events.evals/bash-habits A/Bs the plugin against Bash habits that misfire under zsh. It runs locally only, from a zsh launcher with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.bun scripts/mod-test.ts zsh runs the mod's tests. Run it outside the sandbox, since it writes to a temp directory.
The mods query in observability:session reads these events from mod_events.
rewrite event's rules attribute for 30 days.explain events in 30 days.mod/register.ts 79 lines1import type { EngineInterface, On } from "claude-code";
2import { type Class, explain } from "./explain";
3import { rewrite } from "./rewrite";
4import { PROBE, type Shell, fallback, probed, resolve } from "./shell";
5
6const MOD = "zsh";
7
8interface State {
9 shell: Shell | undefined;
10 seen: Set<Class>;
11}
12
13function failed($: EngineInterface, hook: string, kind: string, message?: string): void {
14 $.modEvents
15 .emit({ mod: MOD, event: "error", ok: false, detail: { hook, kind, message: message ?? null } })
16 .catch(() => undefined);
17}
18
19async function detect($: EngineInterface): Promise<Shell> {
20 const path = resolve([await $.env.get("CLAUDE_CODE_SHELL"), await $.env.get("SHELL")]);
21 try {
22 return probed(path, await $.process.run([path, "-c", PROBE], { timeoutMs: 5000 }));
23 } catch (error) {
24 return fallback(path, error instanceof Error ? error.message : String(error));
25 }
26}
27
28export function register(on: On): void {
29 const state: State = { shell: undefined, seen: new Set() };
30
31 on("session.start", async ($, e, next) => {
32 const shell = await detect($);
33 state.shell = shell;
34 state.seen.clear();
35 void $.modEvents.emit({
36 mod: MOD,
37 event: "session.start",
38 detail: {
39 shell: shell.path,
40 version: shell.version,
41 active: shell.active,
42 ...Object.fromEntries(
43 Object.entries(shell.options).map(([name, set]) => [name, set ? "on" : "off"]),
44 ),
45 },
46 });
47 if (shell.error !== null) failed($, "session.start", "probe", shell.error);
48 return next(e);
49 }).catch(($, e, next) => {
50 failed($, "session.start", next.error.kind, next.error.message);
51 return next(e);
52 });
53
54 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
55 const shell = state.shell;
56 if (shell === undefined || !shell.active) return next(e);
57
58 const { command, rules } = rewrite(e.command, shell.gates);
59 if (rules.length > 0) void $.modEvents.emit({ mod: MOD, event: "rewrite", detail: { rules } });
60 const r = await next(rules.length > 0 ? { ...e, command } : e);
61 if (r.deny !== undefined) return r;
62
63 const notes = explain(r.text ?? "", state.seen);
64 for (const n of notes) {
65 void $.modEvents.emit({
66 mod: MOD,
67 event: "explain",
68 detail: { class: n.class, repeat: n.repeat },
69 });
70 }
71 if (notes.length === 0) return r;
72 return { ...r, context: [...(r.context ?? []), ...notes.map((n) => n.note)] };
73 // Fails open: a broken rewrite or note must never block the command.
74 }).catch(($, e, next) => {
75 failed($, "tool.call", next.error.kind, next.error.message);
76 return next(e);
77 });
78}
79mod/explain.ts 82 lines1import { URL } from "./rewrite";
2
3export type Class = "glob" | "equals" | "reserved" | "split" | "bash-syntax";
4
5export interface Note {
6 class: Class;
7 repeat: boolean;
8 note: string;
9}
10
11interface Hit {
12 class: Class;
13 full: string;
14 short: string;
15}
16
17const ERROR_LINE = /^(?:\(eval\)|zsh):\d+: (.*)$/gm;
18
19function classify(message: string): Hit | undefined {
20 const glob = /^no matches found: (.*)$/.exec(message);
21 if (glob) {
22 const pattern = glob[1] ?? "";
23 const hint = "Quote the argument";
24 return {
25 class: "glob",
26 full: URL.test(pattern)
27 ? `zsh rejected \`${pattern}\` because it contains glob characters. ${hint}.`
28 : `zsh skipped the command because \`${pattern}\` matched no files. That is not "no results", and \`2>/dev/null\` does not hide it. ${hint}, use \`fd\` or \`find\`, or prefix \`setopt nullglob;\`.`,
29 short: "zsh glob with no match. Quote the pattern or use `fd`.",
30 };
31 }
32
33 const equals = /^(\S+) not found$/.exec(message);
34 if (equals) {
35 return {
36 class: "equals",
37 full: `zsh expands a word starting with \`=\` as a command path, so \`=${equals[1]}\` failed. Quote words that start with \`=\`.`,
38 short: "zsh `=` expansion. Quote words that start with `=`.",
39 };
40 }
41
42 const reserved = /^read-only variable: (status|pipestatus)$/.exec(message);
43 if (reserved) {
44 return {
45 class: "reserved",
46 full: `\`${reserved[1]}\` is reserved in zsh. Rename the variable.`,
47 short: "Reserved zsh variable. Rename it.",
48 };
49 }
50
51 const split = /^command not found: (.*\s.*)$/.exec(message);
52 if (split) {
53 return {
54 class: "split",
55 full: `zsh does not word-split \`$var\`, so \`${split[1]}\` ran as one command name. Use an array, or \`\${=var}\`.`,
56 short: `zsh does not word-split \`$var\`. Use an array or \`\${=var}\`.`,
57 };
58 }
59
60 if (/^(bad substitution|bad math expression|invalid subscript)\b/.test(message)) {
61 return {
62 class: "bash-syntax",
63 full: `This ran in zsh, not bash. A bash-only construct such as \`\${!var}\`, \`\${var:off:len}\` on arrays, or 0-based array indexing is the likely cause.`,
64 short: "zsh, not bash. Check for a bash-only expansion.",
65 };
66 }
67
68 return undefined;
69}
70
71export function explain(text: string, seen: Set<Class>): Note[] {
72 const notes = new Map<Class, Note>();
73 for (const match of text.matchAll(ERROR_LINE)) {
74 const hit = classify(match[1] ?? "");
75 if (!hit || notes.has(hit.class)) continue;
76 const repeat = seen.has(hit.class);
77 notes.set(hit.class, { class: hit.class, repeat, note: repeat ? hit.short : hit.full });
78 }
79 for (const c of notes.keys()) seen.add(c);
80 return [...notes.values()];
81}
82mod/rewrite.ts 125 lines1import { type Word, words } from "./words";
2
3export type Rule = "flag-glob" | "equals" | "url";
4
5/** `glob` gates `flag-glob` and `url`. */
6export interface Gates {
7 glob: boolean;
8 equals: boolean;
9}
10
11interface Edit {
12 start: number;
13 end: number;
14 text: string;
15 rule: Rule;
16}
17
18const FLAG = /^--[A-Za-z0-9][\w-]*=/;
19export const URL = /^[A-Za-z][\w+.-]*:\/\//;
20const FIND = /^(?:.*\/)?find$/;
21const PREDICATES = new Set([
22 "-name",
23 "-iname",
24 "-path",
25 "-ipath",
26 "-wholename",
27 "-iwholename",
28 "-lname",
29 "-ilname",
30]);
31// Prefixes that run the next word as the command. Their own flags are skipped too.
32const ASSIGNMENT = /^[A-Za-z_]\w*=/;
33const WRAPPERS = new Set(["sudo", "env", "nice", "nohup"]);
34const GLOB = /[*?[]/;
35const BRACE = /([{},])/;
36
37/**
38 * Single-quotes `span`. When it holds a brace expansion, only the runs between
39 * `{`, `,` and `}` that contain a glob character are quoted, so the braces still expand.
40 */
41function quote(span: string): string | undefined {
42 if (!span.includes("{")) return `'${span}'`;
43 const parts = span.split(BRACE).map((part) => {
44 if (part === "{" || part === "}" || part === "," || !GLOB.test(part)) return part;
45 return part.startsWith("~") || part.startsWith("=") ? undefined : `'${part}'`;
46 });
47 return parts.includes(undefined) ? undefined : parts.join("");
48}
49
50function edit(w: Word, from: number, rule: Rule): Edit | undefined {
51 const span = w.text.slice(from);
52 if (span.startsWith("~") || !w.unquoted.slice(from).every(Boolean)) return undefined;
53 const text = quote(span);
54 if (text === undefined) return undefined;
55 return { start: w.start + from, end: w.end, text, rule };
56}
57
58function findValues(all: Word[]): Set<Word> {
59 const values = new Set<Word>();
60 const byCommand = new Map<number, Word[]>();
61 for (const w of all) {
62 const list = byCommand.get(w.command);
63 if (list === undefined) byCommand.set(w.command, [w]);
64 else list.push(w);
65 }
66 for (const list of byCommand.values()) {
67 const head = list.find(
68 (w) => !ASSIGNMENT.test(w.text) && !WRAPPERS.has(w.text) && !w.text.startsWith("-"),
69 );
70 if (head === undefined || !FIND.test(head.text) || !head.unquoted.every(Boolean)) continue;
71 for (const w of list) {
72 if (w.context !== "plain" || !PREDICATES.has(w.text)) continue;
73 const value = list.find((v) => v.index === w.index + 1);
74 if (value !== undefined) values.add(value);
75 }
76 }
77 return values;
78}
79
80function hasGlob(w: Word, from: number, chars: string): boolean {
81 for (let i = from; i < w.text.length; i++) {
82 if (w.unquoted[i] && chars.includes(w.text[i] ?? "")) return true;
83 }
84 return false;
85}
86
87function ruleFor(w: Word, findValue: boolean, gates: Gates): Edit | undefined {
88 if (gates.glob) {
89 if (findValue && hasGlob(w, 0, "*?[")) return edit(w, 0, "flag-glob");
90 const flag = FLAG.exec(w.text);
91 if (flag !== null && w.text.length > flag[0].length && hasGlob(w, flag[0].length, "*?[")) {
92 return edit(w, flag[0].length, "flag-glob");
93 }
94 if (URL.test(w.text) && hasGlob(w, 0, "?*[")) return edit(w, 0, "url");
95 }
96 if (gates.equals && w.text.length > 1 && w.text.startsWith("=")) {
97 if (w.text.includes("{")) return undefined;
98 return edit(w, 0, "equals");
99 }
100 return undefined;
101}
102
103/**
104 * Single-quotes the words zsh would glob or `=`-expand where bash would pass
105 * them through. Returns the command unchanged when no rule fires.
106 */
107export function rewrite(command: string, gates: Gates): { command: string; rules: Rule[] } {
108 if (!gates.glob && !gates.equals) return { command, rules: [] };
109 if (!/[*?[=]/.test(command)) return { command, rules: [] };
110 const all = words(command);
111 const values = findValues(all);
112 const edits: Edit[] = [];
113 for (const w of all) {
114 if (w.context !== "plain" || w.text.includes("'") || w.text.includes("$")) continue;
115 const e = ruleFor(w, values.has(w), gates);
116 if (e !== undefined) edits.push(e);
117 }
118 const rules = [...new Set(edits.map((e) => e.rule))];
119 let out = command;
120 for (const e of edits.toSorted((a, b) => b.start - a.start)) {
121 out = out.slice(0, e.start) + e.text + out.slice(e.end);
122 }
123 return { command: out, rules };
124}
125mod/shell.ts 74 lines1import type { ProcessRunResult } from "claude-code";
2
3export type Option = "nomatch" | "nullglob" | "cshnullglob" | "equals";
4
5export interface Shell {
6 path: string;
7 version: string | null;
8 active: boolean;
9 options: Record<Option, boolean>;
10 gates: { glob: boolean; equals: boolean };
11 error: string | null;
12}
13
14export const DEFAULTS: Record<Option, boolean> = {
15 nomatch: true,
16 nullglob: false,
17 cshnullglob: false,
18 equals: true,
19};
20
21const OPTIONS: Option[] = ["nomatch", "nullglob", "cshnullglob", "equals"];
22
23export const PROBE = `printf '%s\\n' "$ZSH_VERSION" ${OPTIONS.map((o) => `"\${options[${o}]}"`).join(" ")}`;
24
25function gatesOf(options: Record<Option, boolean>): Shell["gates"] {
26 return {
27 glob: options.nomatch && !options.nullglob && !options.cshnullglob,
28 equals: options.equals,
29 };
30}
31
32export function parse(path: string, stdout: string): Shell {
33 const [version = "", ...flags] = stdout.split("\n");
34 const options = { ...DEFAULTS };
35 for (const [i, option] of OPTIONS.entries()) {
36 const flag = flags[i]?.trim();
37 if (flag === "on" || flag === "off") options[option] = flag === "on";
38 }
39 return {
40 path,
41 version: version.trim() || null,
42 active: version.trim() !== "",
43 options,
44 gates: gatesOf(options),
45 error: null,
46 };
47}
48
49function isZsh(path: string): boolean {
50 return /(^|\/)zsh$/.test(path);
51}
52
53export function fallback(path: string, error: string): Shell {
54 return {
55 path,
56 version: null,
57 active: isZsh(path),
58 options: { ...DEFAULTS },
59 gates: gatesOf(DEFAULTS),
60 error,
61 };
62}
63
64// CLAUDE_CODE_SHELL, then $SHELL, each only when it names bash or zsh,
65// then the first zsh on PATH.
66export function resolve(candidates: (string | undefined)[]): string {
67 return candidates.find((c) => c !== undefined && /bash|zsh/.test(c)) ?? "zsh";
68}
69
70export function probed(path: string, result: ProcessRunResult): Shell {
71 if (result.exitCode !== 0) return fallback(path, `probe exited ${result.exitCode}`);
72 return parse(path, result.stdout);
73}
74mod/words.ts 397 lines1/**
2 * Where a word sits: `test` inside `[[ ]]`, `arith` for a `(( ))` command,
3 * `assign` for `NAME=...`. zsh doesn't glob any of those.
4 */
5export type Context = "plain" | "test" | "arith" | "assign" | "pattern";
6
7export interface Word {
8 text: string;
9 start: number;
10 end: number;
11 /** Per character of `text`, whether it is outside every quote, escape, and substitution. */
12 unquoted: boolean[];
13 context: Context;
14 /** The simple command the word belongs to, numbered across the whole input. */
15 command: number;
16 /** The word's position within its command, 0 for the first. */
17 index: number;
18}
19
20class UnbalancedError extends Error {
21 override name = "UnbalancedError";
22}
23
24const BLANK = new Set([" ", "\t"]);
25const BREAK = new Set([" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">"]);
26// After one of these, the next word is in command position again.
27const KEYWORDS = new Set([
28 "if",
29 "then",
30 "else",
31 "elif",
32 "while",
33 "until",
34 "do",
35 "{",
36 "!",
37 "time",
38 "nocorrect",
39 "noglob",
40 "builtin",
41 "command",
42 "exec",
43]);
44const CONTROL = new Set([";;", ";&", ";|", "&&", "||", "|&"]);
45const ARM_END = new Set([";;", ";&", ";|"]);
46const DECLARE = new Set(["typeset", "local", "export", "declare", "readonly", "integer", "float"]);
47const ASSIGNMENT = /^[A-Za-z_]\w*(\[[^\]]*\])?\+?=/;
48const REDIRECTS = ["<<<", "<<-", "<<", "<>", "<&", ">>|", ">>!", ">>", ">|", ">!", ">&", "<", ">"];
49
50interface Heredoc {
51 delimiter: string;
52 strip: boolean;
53}
54
55/** Saved per substitution level, so words inside `$(...)` do not disturb the command around it. */
56interface Level {
57 command: number;
58 index: number;
59 test: boolean;
60 named: boolean;
61 declaring: boolean;
62 header: boolean;
63}
64
65class Lexer {
66 pos = 0;
67 readonly found: Word[] = [];
68 private commands = 0;
69 private level: Level = {
70 command: 0,
71 index: 0,
72 test: false,
73 named: false,
74 declaring: false,
75 header: false,
76 };
77 private heredocs: Heredoc[] = [];
78 /** Inside a `case` pattern list, between `in` or `;;` and the closing `)`. */
79 private pattern = false;
80
81 constructor(private readonly src: string) {}
82
83 private at(offset = 0): string | undefined {
84 return this.src[this.pos + offset];
85 }
86
87 private newCommand(): void {
88 this.level = {
89 command: ++this.commands,
90 index: 0,
91 test: false,
92 named: false,
93 declaring: false,
94 header: false,
95 };
96 }
97
98 /** Lexes commands until `end` (unconsumed) or the end of input. */
99 list(end: ")" | "`" | undefined): void {
100 const outer = this.level;
101 const outerPattern = this.pattern;
102 this.pattern = false;
103 this.newCommand();
104 let parens = 0;
105 let cases = 0;
106 for (;;) {
107 const c = this.at();
108 if (c === undefined) {
109 if (end !== undefined) throw new UnbalancedError();
110 if (this.heredocs.length > 0) throw new UnbalancedError();
111 break;
112 }
113 if (c === end && (end !== ")" || parens === 0) && !(end === ")" && cases > 0)) break;
114 if (BLANK.has(c)) {
115 this.pos++;
116 } else if (c === "\\" && this.at(1) === "\n") {
117 this.pos += 2;
118 } else if (c === "\n") {
119 this.pos++;
120 this.bodies();
121 if (!this.level.test) this.newCommand();
122 } else if (c === "#") {
123 while (this.at() !== undefined && this.at() !== "\n") this.pos++;
124 } else if (this.level.test && "&|()<>!".includes(c)) {
125 this.pos++;
126 } else if (c === "&" && this.at(1) === ">") {
127 this.pos += this.at(2) === ">" ? 3 : 2;
128 } else if (c === ";" || c === "&" || c === "|") {
129 const two = c + (this.at(1) ?? "");
130 const op = CONTROL.has(two) ? two : c;
131 this.pos += op.length;
132 if (this.pattern && op === "|") continue;
133 if (cases > 0 && ARM_END.has(op)) this.pattern = true;
134 this.newCommand();
135 } else if (c === "(" && this.at(1) === "(" && this.level.index === 0) {
136 this.arith();
137 } else if (c === "(" && this.pattern) {
138 this.pos++;
139 } else if (c === "(") {
140 this.pos++;
141 parens++;
142 this.newCommand();
143 } else if (c === ")" && this.pattern) {
144 this.pos++;
145 this.pattern = false;
146 this.newCommand();
147 } else if (c === ")") {
148 this.pos++;
149 if (parens > 0) parens--;
150 else if (cases === 0) throw new UnbalancedError();
151 this.newCommand();
152 } else if ((c === "<" || c === ">") && this.at(1) !== "(") {
153 this.redirect();
154 } else {
155 const word = this.word(end);
156 if (word === "case" && this.level.index === 1) cases++;
157 else if (word === "in" && cases > 0 && this.level.index === 3) this.pattern = true;
158 else if (word === "esac" && cases > 0) {
159 cases--;
160 this.pattern = false;
161 }
162 }
163 }
164 this.level = outer;
165 this.pattern = outerPattern;
166 }
167
168 /** Skips each pending heredoc body, which starts at the current line. */
169 private bodies(): void {
170 for (const { delimiter, strip } of this.heredocs) {
171 for (;;) {
172 if (this.at() === undefined) throw new UnbalancedError();
173 const eol = this.src.indexOf("\n", this.pos);
174 const stop = eol === -1 ? this.src.length : eol;
175 let line = this.src.slice(this.pos, stop);
176 if (strip) line = line.replace(/^\t+/, "");
177 this.pos = eol === -1 ? stop : stop + 1;
178 if (line === delimiter) break;
179 }
180 }
181 this.heredocs = [];
182 }
183
184 private redirect(): void {
185 const op = REDIRECTS.find((r) => this.src.startsWith(r, this.pos)) ?? "";
186 this.pos += op.length;
187 if (op !== "<<" && op !== "<<-") return;
188 while (BLANK.has(this.at() ?? "")) this.pos++;
189 let delimiter = "";
190 for (;;) {
191 const c = this.at();
192 if (c === undefined || BREAK.has(c)) break;
193 if (c === "'" || c === '"') {
194 const close = this.src.indexOf(c, this.pos + 1);
195 if (close === -1) throw new UnbalancedError();
196 delimiter += this.src.slice(this.pos + 1, close);
197 this.pos = close + 1;
198 } else if (c === "\\") {
199 delimiter += this.at(1) ?? "";
200 this.pos += 2;
201 } else {
202 delimiter += c;
203 this.pos++;
204 }
205 }
206 if (delimiter === "") throw new UnbalancedError();
207 this.heredocs.push({ delimiter, strip: op === "<<-" });
208 }
209
210 /** A `(( ))` command, kept whole as one word. */
211 private arith(): void {
212 const start = this.pos;
213 this.pos += 2;
214 let depth = 0;
215 for (;;) {
216 const c = this.at();
217 if (c === undefined) throw new UnbalancedError();
218 this.pos++;
219 if (c === "(") depth++;
220 else if (c === ")" && depth > 0) depth--;
221 else if (c === ")" && this.at() === ")") {
222 this.pos++;
223 break;
224 }
225 }
226 this.emit(
227 start,
228 Array.from({ length: this.pos - start }, () => false),
229 "arith",
230 );
231 }
232
233 /** Reads one word, emits it, and returns its text. */
234 private word(end: string | undefined): string {
235 const start = this.pos;
236 const bare = new Set<number>();
237 for (;;) {
238 const c = this.at();
239 if (c === undefined || c === end) break;
240 const next = this.at(1);
241 if ((c === "<" || c === ">" || (c === "=" && this.pos === start)) && next === "(") {
242 this.pos += 2;
243 this.list(")");
244 this.pos++;
245 } else if (c === "(" && this.pos > start) {
246 this.group(bare);
247 } else if (BREAK.has(c)) {
248 break;
249 } else if (c === "'") {
250 const close = this.src.indexOf("'", this.pos + 1);
251 if (close === -1) throw new UnbalancedError();
252 this.pos = close + 1;
253 } else if (c === '"') {
254 this.pos++;
255 this.double();
256 this.pos++;
257 } else if (c === "\\") {
258 this.pos += next === undefined ? 1 : 2;
259 } else if (c === "`") {
260 this.pos++;
261 this.list("`");
262 this.pos++;
263 } else if (c === "$") {
264 this.dollar();
265 } else {
266 bare.add(this.pos++);
267 }
268 }
269 const text = this.src.slice(start, this.pos);
270 // An fd number before a redirect, as in `2>/dev/null`.
271 if (/^\d+$/.test(text) && (this.at() === "<" || this.at() === ">")) return text;
272 const level = this.level;
273 const flags = Array.from({ length: text.length }, (_, i) => bare.has(start + i));
274 let context: Context = level.test ? "test" : "plain";
275 if (this.pattern && text !== "esac") context = "pattern";
276 if (
277 !level.test &&
278 (!level.named || level.declaring) &&
279 ASSIGNMENT.test(text) &&
280 flags.slice(0, text.indexOf("=")).every(Boolean)
281 ) {
282 context = "assign";
283 }
284 const literal = flags.every(Boolean);
285 if (text === "[[" && literal && level.index === 0) {
286 level.test = true;
287 context = "test";
288 }
289 this.emit(start, flags, context);
290 if (text === "]]" && literal && level.test) level.test = false;
291 if (context === "plain" && !level.named) {
292 level.named = true;
293 if (KEYWORDS.has(text) && literal) this.newCommand();
294 else if (DECLARE.has(text) && literal) level.declaring = true;
295 else if (literal && (text === "function" || text.endsWith("()"))) level.header = true;
296 } else if (text === "{" && literal && level.header) {
297 this.newCommand();
298 }
299 return text;
300 }
301
302 /**
303 * A parenthesized group inside a word: a glob group such as `*.(ts|js)`, a
304 * qualifier such as `*(.)`, or an array after `NAME=`.
305 */
306 private group(bare: Set<number>): void {
307 let depth = 0;
308 for (;;) {
309 const c = this.at();
310 if (c === undefined || c === "\n") throw new UnbalancedError();
311 bare.add(this.pos++);
312 if (c === "(") depth++;
313 else if (c === ")" && --depth === 0) return;
314 }
315 }
316
317 /** The body of a double-quoted string, up to its closing quote. */
318 private double(): void {
319 for (;;) {
320 const c = this.at();
321 if (c === undefined) throw new UnbalancedError();
322 if (c === '"') return;
323 if (c === "\\") this.pos += 2;
324 else if (c === "`") {
325 this.pos++;
326 this.list("`");
327 this.pos++;
328 } else if (c === "$") {
329 this.dollar();
330 } else this.pos++;
331 }
332 }
333
334 private dollar(): void {
335 const next = this.at(1);
336 if (next === "'") {
337 let i = this.pos + 2;
338 for (; i < this.src.length && this.src[i] !== "'"; i++) if (this.src[i] === "\\") i++;
339 if (i >= this.src.length) throw new UnbalancedError();
340 this.pos = i + 1;
341 } else if (next === "(" && this.at(2) === "(") {
342 this.pos = this.balanced(this.pos + 1, "(", ")");
343 } else if (next === "(") {
344 this.pos += 2;
345 this.list(")");
346 this.pos++;
347 } else if (next === "{") {
348 this.pos = this.balanced(this.pos + 1, "{", "}");
349 } else {
350 this.pos++;
351 }
352 }
353
354 /** The index just past the bracket that closes the one at `from`. */
355 private balanced(from: number, open: string, close: string): number {
356 let depth = 0;
357 for (let i = from; i < this.src.length; i++) {
358 const c = this.src[i];
359 if (c === "\\") i++;
360 else if (c === "'" && open === "{") {
361 i = this.src.indexOf("'", i + 1);
362 if (i === -1) break;
363 } else if (c === open) depth++;
364 else if (c === close && --depth === 0) return i + 1;
365 }
366 throw new UnbalancedError();
367 }
368
369 private emit(start: number, flags: boolean[], context: Context): void {
370 this.found.push({
371 text: this.src.slice(start, this.pos),
372 start,
373 end: this.pos,
374 unquoted: flags,
375 context,
376 command: this.level.command,
377 index: this.level.index++,
378 });
379 }
380}
381
382/**
383 * Splits a shell command into words, including the words of every command
384 * substitution, in source order. Comments and heredoc bodies yield no words.
385 * Unbalanced input yields none at all, so a caller rewrites nothing.
386 */
387export function words(command: string): Word[] {
388 const lexer = new Lexer(command);
389 try {
390 lexer.list(undefined);
391 } catch (error) {
392 if (error instanceof UnbalancedError) return [];
393 throw error;
394 }
395 return lexer.found.toSorted((a, b) => a.start - b.start);
396}
397