SLOPSHOPPER

zsh

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

newguardprocess
★ 18v?MITupdated 2026-10-14bendrucker/claude/plugins/zsh
A shopper browsing a rack in a slop shop
README

Zsh

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.

Contents

  • Mod: 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.
  • Rewrites:
  • flag-glob quotes --name=*.ts values and find -name patterns.
  • equals quotes words starting with =.
  • url quotes URLs with ?, * or [.
  • Notes: glob, equals, reserved, split, and bash-syntax. A repeat within a session gets the short form.
  • Shell detection: the mod picks the shell from 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.
  • Events: session.start, rewrite, explain, and error go to mod-events.
  • Evals: 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.

Tests

bun scripts/mod-test.ts zsh runs the mod's tests. Run it outside the sandbox, since it writes to a temp directory.

Removal

The mods query in observability:session reads these events from mod_events.

  • Retire a rewrite rule that appears in no rewrite event's rules attribute for 30 days.
  • Retire a note class with no explain events in 30 days.
  • Retire the plugin if the Bash tool stops running zsh or runs it under bash emulation.
Source 5 files
mod/register.ts 79 lines
1import 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}
79
mod/explain.ts 82 lines
1import { 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}
82
mod/rewrite.ts 125 lines
1import { 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}
125
mod/shell.ts 74 lines
1import 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}
74
mod/words.ts 397 lines
1/**
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