SLOPSHOPPER

bash-guardrails

Tool-call guard rules (parser-based), written in CEL; add your own in ~/.claude/bash-guardrails.json or .claude/bash-guardrails.json. On by default: blocks…

newguardtoast
v0.2.2Unlicense AND ISC AND MITupdated 2026-10-09ruihe774/cc-bash-guardrails
A shopper browsing a rack in a slop shop
README

bash-guardrails

A Claude Mod that enforces guard rules on tool calls. Before Claude runs a Bash command (or calls the Monitor tool), the plugin checks it against a set of rules and, if one matches, blocks the call with a short explanation so Claude can correct course on its own. Each built-in rule is a toggle, and only a few are on by default. You can add your own rules, written in CEL, in the same format the built-in rules use.

Why not auto mode or a regex hook?

Auto mode. Auto mode's classifier decides whether an action is unsafe or unauthorized. The pitfalls here are different: commands that are allowed and not malicious but reliably waste time or cause trouble, such as find / crawling the whole filesystem, pgrep -f matching the harness's own bash -c wrapper, or an until grep loop that never exits when the writer dies. The auto mode classifier can reasonably let them through, since nothing about them is unsafe. These rules are deterministic, so the same command is always handled the same way, and each denial tells Claude what to do instead. They work alongside auto mode rather than replacing it.

Regex hooks. The usual way to guard Bash is a hook that matches the command text with a regular expression. That is brittle in both directions. echo "find / -name x" trips a regex for find /, while cd /x && find / -name y, bash -c "find / -type f", echo $(find /), \sudo ls or env X=1 timeout 5 find / slip past a simple one. This plugin parses the command with a real shell parser (a vendored copy of unbash) and checks the commands that would actually run, so quoting, escapes, pipelines, && chains, subshells, command substitutions, heredocs and bash -c strings are all understood. Commands behind wrappers such as env, timeout, nice, xargs, sudo and run0 are checked too.

What it does

RuleDefaultBlocks
monitor_no_commandonMonitor calls with neither a command string the Bash rules can check nor a ws source, rather than running them unchecked.
malformed_bashonBash commands that fail to parse. Claude gets the parse error and retries. The other Bash rules, including run0_confirm, rely on the parse: with this rule off, a command that doesn't parse may be only partly checked, so a run0 invocation in it can go unconfirmed. Keep it on if you use run0_confirm.
find_rootonfind rooted at / (including //, /* and find -- /), even with -xdev. It is independent of find_xdev.
find_xdevofffind without -xdev or -mount (or BSD/macOS -x), so searches never cross filesystem boundaries. find --help and --version are left alone.
pgrep_foffpgrep -f without -a, and any pkill -f. -f can match helper processes such as the harness's own bash -c wrapper, and pkill would kill them; pgrep -af shows each command line so matches can be verified before killing by PID.
pgrep_capturedoffpgrep output captured by a pipe or substitution (`pgrep x \wc -l, $(pgrep x), <(pgrep x)`).
until_grepoffuntil grep ... and while ! grep ... loops (also egrep, fgrep and rg), which never exit if the process writing the log dies. Claude is pointed to `tail -f --pid=<PID> <log> \grep -m1 <pattern>` instead.
monitor_disabledoffThe Monitor tool. Claude is pointed to a background Bash command (run_in_background) that blocks until the next event and exits.
sudooffsudo, including wrapped (env X=1 sudo ls) and nested (bash -c "sudo ls") forms. Claude is pointed to run0.
cat_readoffcat that only shows files: cat f, cat -n a b, cat < f, and cat f piped into nothing but line pickers (head, tail, sed -n 'X,Yp', awk 'NR...'). Claude is pointed to the Read tool. cat whose output goes into another command, a substitution or a file is left alone, as are cat -A/-v, files under /dev, /proc and /sys, and cat run through sudo, run0 or doas.
line_range_readoffhead f, sed -n 'X,Yp' f (also X,$p, X,+Np, sed 'X,Y!d' f and sed 'Nq' f) and awk 'NR>=X && NR<=Y' f showing lines of a file. Claude is pointed to Read with offset and limit. A standalone tail is left alone, since Read can't count back from the end of a file, as are sed -z (NUL-separated records) and commands run through sudo, run0 or doas.
heredoc_writeoffWriting a heredoc or here-string to a file: cat <<EOF > f, cat <<EOF >> f, `cat <<EOF \tee f, tee f <<EOF. Claude is pointed to the Write tool. A heredoc the shell expands (an unquoted delimiter with $ or a backquote in it) is left alone, as is a cat or tee run through sudo, run0 or doas (cat <<EOF \sudo tee /etc/f, sudo sh -c 'cat > /etc/f <<EOF'`).
sed_editoffsed -i on one file whose script is only s commands (on every line, or on a line or range, with or without g) and deletions of a line or range. Claude is pointed to the Edit tool, whose replace_all covers g. Several files, regex addresses, -n and -f scripts, and sed run through sudo, run0 or doas are left alone.
python_editoffA Python script, from python -c or a heredoc on python's stdin, that imports re or calls .replace(, opens a file (open( or pathlib), writes (.write(, .write_text(, .write_bytes( or .writelines() and has a multiline (triple-quoted) string literal. Claude is pointed to the Edit tool. Any subset of these alone is not enough, and python run through sudo, run0 or doas is left alone.
run0_confirmoffNot a block: before any run0 invocation, the user is asked to allow or deny it, with the exact wrapped command and the full Bash command shown. run0's own graphical prompt does not show the command.

Every deny rule is checked first, in the order above and then your custom rules, and the first match decides. Only then come the confirmations (run0_confirm and any custom ask rules), so you are never asked about a call that is blocked anyway. When a rule blocks a call, Claude sees the reason and the call does not run. Anything no rule matches runs as usual.

These rules are meant to catch common mistakes, not to be a security boundary. A command assembled at run time ($cmd, eval "$x") or run through a program that is not a recognized wrapper (ssh host find /, watch find /) is not seen.

When it runs

  • On every Bash and Monitor tool call, including calls made by subagents.
  • Other tools are never touched.
  • If the hook itself fails, the call is blocked rather than run unchecked.

Installation

Requires Claude Code v2.1.287 or later.

Install it from the official Anthropic plugin directory. In case you haven't added this marketplace yet, add it first, and refresh it to get the latest listing:

claude plugin marketplace add anthropic-plugin-directory
claude plugin marketplace update anthropic-plugin-directory
claude plugin install bash-guardrails@anthropic-plugin-directory

If you prefer using the TUI, inside a session, use /plugin marketplace add, /plugin marketplace update and /plugin install with the same arguments.

To update to a newer release later:

claude plugin marketplace update anthropic-plugin-directory
claude plugin update bash-guardrails@anthropic-plugin-directory

To try a local checkout while developing, load it directly instead:

claude --plugin-dir /path/to/cc-bash-guardrails

Configuration

Each built-in rule is a boolean userConfig option named as in the table above, for example sudo or find_xdev. Only monitor_no_command, malformed_bash and find_root default to true. Set the options in Claude Code's configuration (/config), or under pluginConfigs in your settings, keyed by the plugin's id (bash-guardrails@anthropic-plugin-directory). E.g., to enable more rules, in your ~/.claude/settings.json:

// ...
  "pluginConfigs": {
    "bash-guardrails@anthropic-plugin-directory": {
      "options": {
        "find_xdev": true,
        "pgrep_captured": true,
        "pgrep_f": true,
        "run0_confirm": true,
        "sudo": true,
        "until_grep": true
      }
    }
  },
// ...

Custom rules

Add your own rules in a JSON rule file. Two files are read, and their rules run after the built-in ones, in this order:

  • Your rules: ~/.claude/bash-guardrails.json (in $CLAUDE_CONFIG_DIR if you set it)
  • The project's rules: .claude/bash-guardrails.json under the project root

The easiest way is to ask Claude, in your own words: "stop Claude from force-pushing", "ask me before installing dependencies", "don't let it touch prod from this repo". The plugin ships a skill, custom-rules, that Claude loads for such requests (or run /bash-guardrails:custom-rules). It tells Claude how to write the rule, which file to put it in, and how to check it against sample commands before saving it. Claude points you to a built-in rule's toggle instead when one already does what you want.

A rule file looks like this:

{
  "defs": {
    "FETCHERS": ["curl", "wget"],
    "RM": { "short": "rRfiIdv", "long": { "recursive": "r", "force": "f" } }
  },
  "rules": [
    {
      "id": "no-fetch",
      "let": { "hits": "cmds.filter(c, c.name in FETCHERS).map(c, c.name)" },
      "when": "size(hits) > 0",
      "deny": "Do not use {{ hits[0] }}; use the WebFetch tool."
    },
    {
      "id": "confirm-rm-r",
      "tools": ["Bash"],
      "when": "cmds.exists(c, c.name == 'rm' && c.args.opts(RM).exists(o, o in ['r', 'R']))",
      "ask": { "header": "rm -r", "question": "Allow a recursive rm?\n\n{{ input.command }}" }
    }
  ]
}

Each rule has:

Field
idRequired. Letters, digits, - and _. It can't be a built-in rule's id.
whenRequired. A CEL expression that must be a bool. The rule fires when it is true.
denyThe reason Claude sees when the rule blocks the call.
askInstead of deny: ask the user first (see below).
letOptional. Named CEL values, in order, that when, the messages and later lets can use.
toolsOptional. ["Bash"], ["Monitor"] or both (the default).
enabledOptional. false turns the rule off.

deny, and the texts in ask, are templates: {{ expr }} inserts the value of a CEL expression. There is no allow action: a custom rule can only make the guard stricter, so a project's rule file can at worst block or ask about calls, and can't turn off a built-in rule or one of yours.

An ask has a question (a template), and optionally a header (12 characters at most), options (2 to 4 labels, Allow and Deny by default), allowIf (a CEL bool over answer, the label the user picked or the text they typed; by default the call is allowed only when the answer is the first option), declined (a template that can use answer) and dismissed (a template, for when the question is dismissed or no one can be asked).

What a rule sees

NameType
toolstringBash or Monitor
inputmap<string, dyn>The tool's input: command, run_in_background, timeout, ws and so on
cmdslist<Cmd>Every simple command the shell would run, including those in pipelines, && chains, subshells, substitutions, bash -c strings and behind wrappers such as env, timeout, xargs, sudo and run0
errorslist<string>Parse errors. When it isn't empty, cmds may be incomplete.
untilCondslist<list<Cmd>>For each until or while ! loop, the commands of its condition
pipelineslist<list<Cmd>>For each pipeline (`a \b \c), its stages in order. A stage that is not a simple command, such as { ...; }, has name ''`.
your defsConstants from the file's defs, and the built-in ones (FIND_LEAD, FIND_EXPR, PGREP, GREPS, CAT, HEAD_TAIL, SED, AWK, TEE, PYTHON and the others in hooks/builtin-rules.ts)

A Cmd has name (the program's basename), args (its arguments, with quotes removed), captured (its output goes into a pipe or a substitution), wrappers (the wrappers in front of it, outermost first, including those in front of a shell running it, as in sudo sh -c "...") and chain, a list of Links, one for each wrapper's invocation and a last one for the command itself. A Link has name (the program's basename) and argv (its invocation as written, the program's path included: /usr/bin/sudo ls has argv ["/usr/bin/sudo", "ls"]). Match programs on name and read argv for the rest. So env X=1 sudo -u root ls -l has name ls, args ["-l"], and a chain of env, sudo and ls whose argvs are ["env", "X=1", "sudo", "-u", "root", "ls", "-l"], ["sudo", "-u", "root", "ls", "-l"] and ["ls", "-l"].

A Cmd also has redirects, a list of Redirs: its own, then those of the compound commands around it ({ ...; } > f, for ...; done > f) and of a shell running it (bash -c "..." > f), innermost first. A Redir has op (>, >>, <, <<, <<-, <<<, >&, &> and so on), fd (the descriptor written before the operator, -1 if none, -2 for a {var} one), target (the file, descriptor or here-string word, or a heredoc's delimiter), body (the text a heredoc or here-string feeds in, or '') and quoted (a heredoc whose delimiter is quoted, so its body is not expanded). And stdout is where those redirects send its output: a file, &2 for another descriptor, or '' when it is not redirected.

On top of standard CEL, including macros such as exists, all, filter and map, cel.bind, and matches for regular expressions (JavaScript syntax), rules can use:

Function
args.opts(spec), args.operands(spec)Parse a list<string> of arguments by a spec and return the options seen, or the operands. A spec is a map with any of: short (getopt letters, a : after one that takes a value, :: after one whose value is optional and only in the same argument, as -i.bak), shortTakesValue (every letter in short takes a value, and only from the same argument), long (long option names mapped to the name to report, as {"full": "f"}; a name ending in :, as {"expression": "e:"}, takes a value from =value or else the next argument), words (find-style words, each mapped to how many arguments follow it, or to a list of terminators as {"-exec": [";", "+"]}), wordPatterns (the same, keyed by regex) and posix (stop at the first operand).
args.optValues(spec, name)The values given to option name (as reported by opts), in order: ['-e', 'a', '-eb'].optValues({"short": "e:"}, 'e') is ["a", "b"]
list.takeWhile(regex)The leading items that match the regex
list.take(n), list.drop(n)The first n items; all but the first n
shquote(argv)A list<string> as a shell-quoted command line
list.flatten(), list.distinct()Flatten a list of lists; drop repeated items

The built-in rules in hooks/builtin-rules.ts are written the same way, so they are worked examples.

When rules load, and when they fail

A rule file is read when the session starts and read again whenever it changes, so edits apply to the next call without a restart. Each rule is type-checked when it loads: a typo such as c.nmae, cmds.name == 'find' (a list has no name) or a when that isn't a bool is caught there. A rule, def or file that fails to load is skipped, and a line in the transcript names it and the error; the other rules keep working. A rule that fails while it runs, for example on a malformed argument spec, blocks the call.

What the hook does

The plugin registers two hooks. The main one is on the tool.call event, with the filter tool: ['Monitor', 'Bash']. For each call it runs the enabled deny rules in order and returns { deny: reason } for the first one that matches. Then, for each ask rule that matches (such as run0_confirm when the command contains a run0 invocation), it asks the user through $.ui.ask and denies the call unless the answer allows it. A dismissed prompt, or a session with no one to ask (such as -p), also denies. If nothing denies, it calls next with the call unchanged. The other hook, on session.start, only loads the custom rule files, so problems in them are reported right away. The plugin never modifies a tool call.

An error handler on the hook denies the call if the hook throws or times out before passing the call on.

What it runs, sends, and fetches

  • It runs a tool.call hook and a session.start hook, written in TypeScript (hooks/*.ts), inside Claude Code.
  • It makes no model calls, no network requests, and no shell commands. Commands are only parsed, never executed.
  • It reads only its two rule files, ~/.claude/bash-guardrails.json and .claude/bash-guardrails.json under the project root ($.fs.stat and $.fs.read), and writes no files. To find the first, it reads the environment variables CLAUDE_CONFIG_DIR, HOME and USERPROFILE. It uses no credentials or MCP servers.
  • The custom-rules skill includes a checker script, skills/custom-rules/check.ts, which Claude runs with node (22.18 or later) or bun when you ask it for a rule. The checker reads the rule file it is given and parses the sample commands; it executes nothing and writes nothing.
  • It does not collect, store, or transmit any data. The only things it shows are confirmation dialogs (run0_confirm and your ask rules) and a transcript line for each rule that fails to load.
  • Its dependencies are vendored and have no install step: the unbash parser (ISC license, in hooks/vendor/unbash/) and the cel-js CEL interpreter (MIT license, bundled into hooks/vendor/cel/).

Development

The rule logic is pure and kept free of the mods API: the CEL engine (compileFile and decide) in hooks/engine.ts, the argument parser in hooks/argv.ts, and the parser wrapper analyze in hooks/shell.ts. The built-in rules are data in hooks/builtin-rules.ts, compiled in hooks/rules.ts. hooks/register.ts loads the rule files and calls the engine. The custom-rules skill (skills/custom-rules/) is the guide Claude follows to write rules, and its check.ts compiles a rule file with the same engine; the hooks code must stay loadable by plain type stripping (no parameter properties or enums) so node check.ts works. The unit tests are in tests/: rules.test.ts for the built-in rules, engine.test.ts for custom rules and the engine, and register.test.ts for the hooks.

claude plugin validate .
claude plugin test

License

The plugin's own code is released into the public domain under the Unlicense. The vendored code is not public domain: the unbash parser in hooks/vendor/unbash/ is Copyright (c) Lars Kappert and licensed under the ISC License, and the cel-js interpreter in hooks/vendor/cel/ is Copyright (c) 2025 Marc Bachmann and licensed under the MIT License, so the combined work is Unlicense AND ISC AND MIT. Their copyright and permission notices must be preserved in all copies; see THIRD_PARTY_NOTICES.md, hooks/vendor/unbash/LICENSE and hooks/vendor/cel/LICENSE.

Source 17 files
hooks/register.ts 78 lines
1import { compileText, decide, type CompiledFile, type CompiledRule } from './engine.ts'
2import { builtin, builtinIds, ruleEnabled } from './rules.ts'
3
4// Custom rule files, in the order their rules run: the user's, then the project's
5export const USER_FILE = 'bash-guardrails.json' // in the Claude config dir (~/.claude)
6export const PROJECT_FILE = '.claude/bash-guardrails.json' // under the project root
7
8interface Cached {
9  mtimeMs: number
10  size: number
11  compiled: CompiledFile
12}
13const cache = new Map<string, Cached>()
14
15const join = (dir: string, file: string) => `${dir.replace(/[\\/]+$/, '')}/${file}`
16
17// One rule file, compiled again only when it changed. Problems are logged once per change.
18async function loadFile($: any, path: string): Promise<CompiledRule[]> {
19  const stat = await $.fs.stat(path).catch(() => undefined)
20  if (!stat || stat.kind !== 'file') {
21    cache.delete(path)
22    return []
23  }
24  const hit = cache.get(path)
25  if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.compiled.rules
26  let compiled: CompiledFile
27  try {
28    const text = await $.fs.read(path)
29    // Custom rules build on the built-in defs, and may not reuse a built-in id
30    compiled = compileText(text, path, { env: builtin.env, reservedIds: builtinIds })
31  } catch (err) {
32    compiled = { rules: [], problems: [{ source: path, message: `cannot read: ${(err as Error)?.message ?? err}` }], env: builtin.env }
33  }
34  cache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, compiled })
35  for (const p of compiled.problems)
36    $.ui.log(`bash-guardrails: ${p.source}: ${p.id === undefined ? '' : `rule ${p.id}: `}${p.message} (skipped)`)
37  return compiled.rules
38}
39
40// The custom rules from both files. A file that can't be found contributes nothing.
41async function customRules($: any): Promise<CompiledRule[]> {
42  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || join((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '', '.claude')
43  const userPath = join(configDir, USER_FILE)
44  const projectPath = join(await $.session.root(), PROJECT_FILE)
45  const user = await loadFile($, userPath)
46  // Opened from the config dir's parent, the project file is the user file
47  return projectPath === userPath ? user : [...user, ...(await loadFile($, projectPath))]
48}
49
50export function register(on: any, options?: Record<string, unknown>) {
51  // Report problems in the rule files when the session starts, not at the first Bash call
52  on('session.start', async ($: any, e: any, next: any) => {
53    await customRules($).catch(() => [])
54    return next(e)
55  })
56
57  on('tool.call', { tool: ['Monitor', 'Bash'] }, async ($: any, e: any, next: any) => {
58    const custom = await customRules($)
59    const { tool, ...input } = e
60    const reason = await decide(
61      [...builtin.rules, ...custom],
62      tool,
63      input,
64      ruleEnabled(options),
65      (question, opts) => $.ui.ask(question, opts),
66      // Name the rule only: the command is in the verbose transcript (Ctrl+O)
67      (rule) => {
68        try { $.ui.toast(`Rule '${rule.id}' denied a command`) } catch {}
69      },
70    )
71    return reason ? { deny: reason } : next(e)
72  }).catch(async ($: any, e: any, next: any) => {
73    // Without this a failed hook is skipped and the call runs unguarded
74    if (next.called) return next(e)
75    return { deny: `bash-guardrails failed (${next.error.message}); refusing the call rather than running it unchecked.` }
76  })
77}
78
hooks/engine.ts 427 lines
1// The rule engine: compiles rule files (CEL expressions, type-checked when they
2// load) and runs them against a tool call. Pure and free of the mods API, so it
3// can be unit tested; register.ts does the I/O.
4import { Environment } from './vendor/cel/cel.js'
5import { parseArgs, shellQuote, toSpec } from './argv.ts'
6import { analyze, type Redirection, type SimpleCommand } from './shell.ts'
7
8// ---- The rule-file format ----
9
10export interface AskDef {
11  question: string // a template
12  header?: string // a short chip beside the question, 12 characters at most
13  options?: string[] // 2-4 labels; default Allow, Deny
14  allowIf?: string // CEL bool over `answer`; default: answer is the first option
15  declined?: string // a template that can use `answer`
16  dismissed?: string // a template
17}
18
19export interface RuleDef {
20  id: string
21  tools?: string[] // default: both
22  enabled?: boolean // custom rules only; built-ins are toggled by userConfig
23  let?: Record<string, string> // named CEL values, in order, shared by `when` and the messages
24  when: string // CEL, must type-check to bool
25  deny?: string // a template
26  ask?: AskDef
27}
28
29export interface RuleFile {
30  defs?: Record<string, unknown>
31  rules: RuleDef[]
32}
33
34export const TOOLS = ['Bash', 'Monitor'] as const
35
36// ---- Compiled form ----
37
38type Fn = (ctx: Record<string, unknown>) => unknown
39type Template = (ctx: Record<string, unknown>) => string
40
41export interface CompiledRule {
42  id: string
43  source: string // 'built-in' or the file it came from
44  isBuiltin: boolean
45  enabled: boolean
46  tools: ReadonlySet<string>
47  lets: readonly (readonly [string, Fn])[]
48  when: Fn
49  deny?: Template
50  ask?: {
51    question: Template
52    header?: string
53    options: string[]
54    allowIf?: Fn
55    declined: Template
56    dismissed: Template
57  }
58}
59
60export interface Problem {
61  source: string
62  id?: string
63  message: string
64}
65
66export interface CompiledFile {
67  rules: CompiledRule[]
68  problems: Problem[]
69  env: Environment // with this file's defs, for files that build on it
70}
71
72// ---- What a rule sees of a call ----
73
74// Classes, not plain objects: cel-js checks a registered type's values by constructor
75// (Fields spelled out rather than as parameter properties, so plain type stripping can load this file.)
76export class Link {
77  readonly name: string // the program's basename: match on this
78  readonly argv: string[] // verbatim, program path included
79  constructor(name: string, argv: string[]) {
80    this.name = name
81    this.argv = argv
82  }
83}
84export class Redir {
85  readonly op: string
86  readonly fd: bigint
87  readonly target: string
88  readonly body: string
89  readonly quoted: boolean
90  constructor(op: string, fd: bigint, target: string, body: string, quoted: boolean) {
91    this.op = op
92    this.fd = fd
93    this.target = target
94    this.body = body
95    this.quoted = quoted
96  }
97}
98export class Cmd {
99  readonly name: string
100  readonly args: string[]
101  readonly captured: boolean
102  readonly wrappers: string[]
103  readonly chain: Link[] // each wrapper's invocation, then the command itself
104  readonly redirects: Redir[]
105  readonly stdout: string
106  constructor(name: string, args: string[], captured: boolean, wrappers: string[], chain: Link[], redirects: Redir[], stdout: string) {
107    this.name = name
108    this.args = args
109    this.captured = captured
110    this.wrappers = wrappers
111    this.chain = chain
112    this.redirects = redirects
113    this.stdout = stdout
114  }
115}
116
117const toRedir = (r: Redirection) => new Redir(r.op, BigInt(r.fd), r.target, r.body, r.quoted)
118
119// Each SimpleCommand becomes one Cmd, so a command reads the same in `cmds` and `pipelines`
120const cmdOf = (memo: Map<SimpleCommand, Cmd>) => (c: SimpleCommand): Cmd => {
121  let cmd = memo.get(c)
122  if (!cmd) {
123    const chain = [...c.wrappers.map((w, i) => new Link(w, c.wrapped[i]!)), new Link(c.name, [c.prog, ...c.args])]
124    memo.set(c, (cmd = new Cmd(c.name, c.args, c.captured, c.wrappers, chain, c.redirects.map(toRedir), c.stdout)))
125  }
126  return cmd
127}
128
129// ---- The environment every rule sees ----
130
131export const baseEnv = new Environment({ homogeneousAggregateLiterals: false })
132  .registerType('Link', { ctor: Link, fields: { name: 'string', argv: 'list<string>' } })
133  .registerType('Redir', { ctor: Redir, fields: { op: 'string', fd: 'int', target: 'string', body: 'string', quoted: 'bool' } })
134  .registerType('Cmd', {
135    ctor: Cmd,
136    fields: {
137      name: 'string',
138      args: 'list<string>',
139      captured: 'bool',
140      wrappers: 'list<string>',
141      chain: 'list<Link>',
142      redirects: 'list<Redir>',
143      stdout: 'string',
144    },
145  })
146  .registerVariable('tool', 'string')
147  .registerVariable('input', 'map<string, dyn>')
148  .registerVariable('cmds', 'list<Cmd>')
149  .registerVariable('errors', 'list<string>')
150  .registerVariable('untilConds', 'list<list<Cmd>>')
151  .registerVariable('pipelines', 'list<list<Cmd>>')
152  .registerFunction('list<string>.opts(map<string, dyn>): list<string>', (a: string[], s: unknown) => parseArgs(a, toSpec(s)).opts)
153  .registerFunction('list<string>.operands(map<string, dyn>): list<string>', (a: string[], s: unknown) => parseArgs(a, toSpec(s)).operands)
154  .registerFunction('list<string>.optValues(map<string, dyn>, string): list<string>', (a: string[], s: unknown, name: string) =>
155    parseArgs(a, toSpec(s)).values.filter(([n]) => n === name).map(([, v]) => v),
156  )
157  .registerFunction('list<A>.take(int): list<A>', (l: unknown[], n: bigint) => l.slice(0, Math.max(0, Number(n))))
158  .registerFunction('list<A>.drop(int): list<A>', (l: unknown[], n: bigint) => l.slice(Math.max(0, Number(n))))
159  .registerFunction(
160    'list<string>.takeWhile(string): list<string>',
161    (a: string[], re: string) => {
162      const r = new RegExp(re)
163      const out: string[] = []
164      for (const x of a) {
165        if (!r.test(x)) break
166        out.push(x)
167      }
168      return out
169    },
170  )
171  .registerFunction('shquote(list<string>): string', (argv: string[]) => argv.map(shellQuote).join(' '))
172  .registerFunction('list<list<dyn>>.flatten(): list<dyn>', (l: unknown[][]) => l.flat())
173  .registerFunction('list<dyn>.distinct(): list<dyn>', (l: unknown[]) => [...new Set(l)])
174
175/** The variables a rule's expressions run against. `input` is the tool input
176 *  without `tool`; with no command string there are no commands to see. */
177export function context(tool: string, input: Record<string, unknown>): Record<string, unknown> {
178  const a = typeof input.command === 'string' ? analyze(input.command) : { commands: [], errors: [], untilClauses: [], pipelines: [] }
179  const toCmd = cmdOf(new Map())
180  return {
181    tool,
182    input,
183    cmds: a.commands.map(toCmd),
184    errors: a.errors,
185    untilConds: a.untilClauses.map((u) => u.map(toCmd)),
186    pipelines: a.pipelines.map((p) => p.map(toCmd)),
187  }
188}
189
190// ---- Compiling ----
191
192const RULE_KEYS = new Set(['id', 'tools', 'enabled', 'let', 'when', 'deny', 'ask'])
193const ASK_KEYS = new Set(['question', 'header', 'options', 'allowIf', 'declined', 'dismissed'])
194const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
195const RULE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
196
197const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
198const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string')
199const firstLine = (e: unknown) => String((e as Error)?.message ?? e).split('\n')[0]!
200
201// JSON numbers that are whole become CEL ints (bigint); the rest stays as it is
202function celValue(v: unknown): unknown {
203  if (typeof v === 'number') return Number.isInteger(v) ? BigInt(v) : v
204  if (Array.isArray(v)) return v.map(celValue)
205  if (isRecord(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, celValue(x)]))
206  return v
207}
208
209function celType(v: unknown): string {
210  if (typeof v === 'string') return 'string'
211  if (typeof v === 'boolean') return 'bool'
212  if (typeof v === 'number') return Number.isInteger(v) ? 'int' : 'double'
213  if (isStringList(v)) return 'list<string>'
214  if (Array.isArray(v)) return 'list<dyn>'
215  if (isRecord(v)) return 'map<string, dyn>'
216  throw new Error('must be a string, number, bool, list or map')
217}
218
219class RuleError extends Error {}
220
221function compileRule(raw: unknown, env: Environment, source: string, isBuiltin: boolean): CompiledRule {
222  if (!isRecord(raw)) throw new RuleError('a rule must be an object')
223  const r = raw as Partial<RuleDef> & Record<string, unknown>
224  for (const k of Object.keys(r)) if (!RULE_KEYS.has(k)) throw new RuleError(`unknown key "${k}"`)
225  if (r.tools !== undefined && !(isStringList(r.tools) && r.tools.length && r.tools.every((t) => (TOOLS as readonly string[]).includes(t))))
226    throw new RuleError(`tools must be a non-empty list of ${TOOLS.join(', ')}`)
227  if (r.enabled !== undefined && typeof r.enabled !== 'boolean') throw new RuleError('enabled must be true or false')
228  if (typeof r.when !== 'string') throw new RuleError('when must be a CEL expression string')
229  if ((r.deny === undefined) === (r.ask === undefined)) throw new RuleError('a rule needs exactly one action: deny or ask')
230
231  // Each rule gets its own child env, so its `let` names are declared for it alone
232  const ruleEnv = env.clone()
233  const check = (where: string, e: Environment, src: unknown, want?: string): { fn: Fn; type: string } => {
234    if (typeof src !== 'string') throw new RuleError(`${where} must be a CEL expression string`)
235    const res = e.check(src)
236    if (!res.valid) throw new RuleError(`${where}: ${firstLine(res.error)}`)
237    const type = String(res.type)
238    if (want && type !== want) throw new RuleError(`${where} must be ${want}, but is ${type}`)
239    return { fn: e.parse(src) as unknown as Fn, type }
240  }
241  const template = (where: string, e: Environment, src: unknown): Template => {
242    if (typeof src !== 'string') throw new RuleError(`${where} must be a string`)
243    const parts = src.split(/\{\{(.*?)\}\}/s)
244    const fns = parts.map((p, i) => (i % 2 ? check(`${where} {{${p}}}`, e, p).fn : null))
245    return (ctx) => parts.map((p, i) => (i % 2 ? display(fns[i]!(ctx)) : p)).join('')
246  }
247
248  const lets: (readonly [string, Fn])[] = []
249  if (r.let !== undefined) {
250    if (!isRecord(r.let)) throw new RuleError('let must map names to CEL expressions')
251    for (const [name, src] of Object.entries(r.let)) {
252      if (!IDENT.test(name) || ruleEnv.hasVariable(name)) throw new RuleError(`let: "${name}" is not a free name`)
253      const { fn, type } = check(`let ${name}`, ruleEnv, src)
254      ruleEnv.registerVariable(name, type)
255      lets.push([name, fn])
256    }
257  }
258
259  const rule: CompiledRule = {
260    id: r.id!,
261    source,
262    isBuiltin,
263    enabled: r.enabled ?? true,
264    tools: new Set(r.tools ?? TOOLS),
265    lets,
266    when: check('when', ruleEnv, r.when, 'bool').fn,
267  }
268  if (r.deny !== undefined) rule.deny = template('deny', ruleEnv, r.deny)
269  else {
270    const a = r.ask as unknown
271    if (!isRecord(a)) throw new RuleError('ask must be an object')
272    for (const k of Object.keys(a)) if (!ASK_KEYS.has(k)) throw new RuleError(`ask: unknown key "${k}"`)
273    const options = a.options ?? ['Allow', 'Deny']
274    if (!isStringList(options) || options.length < 2 || options.length > 4 || options.some((o) => !o))
275      throw new RuleError('ask.options must be 2-4 non-empty labels')
276    if (a.header !== undefined && (typeof a.header !== 'string' || a.header.length > 12))
277      throw new RuleError('ask.header must be a string of at most 12 characters')
278    const answerEnv = ruleEnv.clone().registerVariable('answer', 'string')
279    const id = rule.id
280    rule.ask = {
281      question: template('ask.question', ruleEnv, a.question),
282      options,
283      allowIf: a.allowIf === undefined ? undefined : check('ask.allowIf', answerEnv, a.allowIf, 'bool').fn,
284      declined: template(
285        'ask.declined',
286        answerEnv,
287        a.declined ?? `The user did not approve this call (bash-guardrails rule ${id}): {{ answer }}. Do not retry without asking.`,
288      ),
289      dismissed: template(
290        'ask.dismissed',
291        ruleEnv,
292        a.dismissed ?? `Not approved (bash-guardrails rule ${id}): the confirmation was dismissed or no one could be asked.`,
293      ),
294    }
295    if (typeof a.header === 'string') rule.ask.header = a.header
296  }
297  return rule
298}
299
300/** Compiles one rule file on top of `env` (the base, or a file whose defs it
301 *  may use). A rule or def that fails to compile is left out and reported. */
302export function compileFile(file: unknown, source: string, opts: { env?: Environment; isBuiltin?: boolean; reservedIds?: Iterable<string> } = {}): CompiledFile {
303  const problems: Problem[] = []
304  const rules: CompiledRule[] = []
305  const env = (opts.env ?? baseEnv).clone()
306  if (!isRecord(file) || !Array.isArray(file.rules)) {
307    problems.push({ source, message: 'a rule file must be an object with a "rules" list' })
308    return { rules, problems, env }
309  }
310  for (const k of Object.keys(file)) if (k !== 'defs' && k !== 'rules') problems.push({ source, message: `unknown top-level key "${k}"` })
311  if (file.defs !== undefined && !isRecord(file.defs)) problems.push({ source, message: '"defs" must be an object' })
312  else
313    for (const [name, value] of Object.entries(file.defs ?? {})) {
314      try {
315        if (!IDENT.test(name) || env.hasVariable(name)) throw new Error('is not a free name')
316        env.registerConstant(name, celType(value), celValue(value))
317      } catch (e) {
318        problems.push({ source, message: `def ${name}: ${firstLine(e)}` })
319      }
320    }
321  const seen = new Set(opts.reservedIds ?? [])
322  for (const raw of file.rules) {
323    const id = isRecord(raw) && typeof raw.id === 'string' ? raw.id : undefined
324    try {
325      if (id === undefined || !RULE_ID.test(id)) throw new RuleError('a rule needs an "id" of letters, digits, - and _')
326      if (seen.has(id)) throw new RuleError('duplicate id')
327      seen.add(id)
328      rules.push(compileRule(raw, env, source, opts.isBuiltin ?? false))
329    } catch (e) {
330      problems.push({ source, id, message: firstLine(e) })
331    }
332  }
333  return { rules, problems, env }
334}
335
336/** Parses and compiles the text of a rule file. */
337export function compileText(text: string, source: string, opts: Parameters<typeof compileFile>[2] = {}): CompiledFile {
338  let file: unknown
339  try {
340    file = JSON.parse(text)
341  } catch (e) {
342    return { rules: [], problems: [{ source, message: `not valid JSON: ${firstLine(e)}` }], env: (opts.env ?? baseEnv).clone() }
343  }
344  return compileFile(file, source, opts)
345}
346
347// ---- Running ----
348
349function display(v: unknown): string {
350  if (typeof v === 'string') return v
351  if (v instanceof Map) v = Object.fromEntries(v)
352  if (v && typeof v === 'object') return JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? Number(x) : x))
353  return String(v)
354}
355
356export type Ask = (question: string, options: { options: string[]; header?: string }) => Promise<string>
357
358/** Runs `rules` against one call and resolves to the deny reason, or null to
359 *  let it run. Every deny rule runs before any ask, so the user is never asked
360 *  about a call that is denied anyway; an ask the user allows moves on to the
361 *  next. A rule that fails while it runs denies the call. `onDeny` is told which
362 *  rule denied it. */
363export async function decide(
364  rules: readonly CompiledRule[],
365  tool: string,
366  input: Record<string, unknown>,
367  isEnabled: (r: CompiledRule) => boolean,
368  ask: Ask,
369  onDeny?: (rule: CompiledRule) => void,
370): Promise<string | null> {
371  const active = rules.filter((r) => r.tools.has(tool) && isEnabled(r))
372  if (!active.length) return null
373  const ctx = context(tool, input)
374  const scope = (r: CompiledRule) => {
375    const vars = { ...ctx }
376    for (const [name, f] of r.lets) vars[name] = f(vars)
377    return vars
378  }
379  const denied = (r: CompiledRule, reason: string) => {
380    onDeny?.(r)
381    return reason
382  }
383  const failed = (r: CompiledRule, e: unknown) =>
384    denied(r, `bash-guardrails rule ${r.id} (${r.source}) failed (${firstLine(e)}); refusing the call rather than running it unchecked.`)
385
386  for (const r of active) {
387    if (!r.deny) continue
388    try {
389      const vars = scope(r)
390      if (r.when(vars) === true) return denied(r, r.deny(vars))
391    } catch (e) {
392      return failed(r, e)
393    }
394  }
395  for (const r of active) {
396    const a = r.ask
397    if (!a) continue
398    let vars: Record<string, unknown>
399    let question: string
400    try {
401      vars = scope(r)
402      if (r.when(vars) !== true) continue
403      question = a.question(vars)
404    } catch (e) {
405      return failed(r, e)
406    }
407    let answer: string
408    try {
409      answer = await ask(question, a.header === undefined ? { options: a.options } : { options: a.options, header: a.header })
410    } catch {
411      try {
412        return denied(r, a.dismissed(vars))
413      } catch (e) {
414        return failed(r, e)
415      }
416    }
417    try {
418      const withAnswer = { ...vars, answer }
419      const allowed = a.allowIf ? a.allowIf(withAnswer) === true : answer === a.options[0]
420      if (!allowed) return denied(r, a.declined(withAnswer))
421    } catch (e) {
422      return failed(r, e)
423    }
424  }
425  return null
426}
427
hooks/rules.ts 20 lines
1// The built-in rules, compiled once, and the userConfig toggles that switch them.
2// Kept free of the mods API so it can be unit tested.
3import { builtinRules } from './builtin-rules.ts'
4import { compileFile, type CompiledRule } from './engine.ts'
5
6export const builtin = compileFile(builtinRules, 'built-in', { isBuiltin: true })
7// The shipped rules are part of the code: a broken one is a bug, not a user's typo
8if (builtin.problems.length)
9  throw new Error(`bash-guardrails: built-in rules failed to compile: ${builtin.problems.map((p) => `${p.id}: ${p.message}`).join('; ')}`)
10
11export const builtinIds = builtin.rules.map((r) => r.id)
12
13// The userConfig key that toggles a built-in rule: its id with underscores
14export const ruleKey = (id: string) => id.replace(/-/g, '_')
15
16// Defaults live in the manifest's userConfig, which Claude Code fills into
17// options; a built-in whose option is missing is off. Custom rules carry their own `enabled`.
18export const ruleEnabled = (options: Record<string, unknown> | undefined) => (r: CompiledRule) =>
19  r.isBuiltin ? options?.[ruleKey(r.id)] === true : r.enabled
20
hooks/vendor/cel/cel.js 4552 lines
1// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/errors.js
2class CelError extends Error {
3  #node;
4  #code;
5  #range;
6  #summary;
7  constructor({ name, code, message, node, cause, range }) {
8    super(message, cause ? { cause } : undefined);
9    this.name = name;
10    this.#code = code;
11    this.#summary = message;
12    this.#node = node;
13    this.#range = range && normalizeRange(range) || normalizeRange(node);
14    if (!node?.input)
15      return;
16    this.message = formatErrorWithHighlight(this.#summary, node, this.#range);
17  }
18  get node() {
19    return this.#node;
20  }
21  get code() {
22    return this.#code;
23  }
24  get range() {
25    return this.#range;
26  }
27  get summary() {
28    return this.#summary;
29  }
30  withAst(node) {
31    if (this.#node || !node?.input)
32      return this;
33    this.#node = node;
34    this.#range ??= normalizeRange(node);
35    this.message = formatErrorWithHighlight(this.#summary, node, this.#range);
36    return this;
37  }
38}
39function normalizeArgs(name, defaultCode, message, node, cause) {
40  if (typeof message === "string")
41    return { name, code: defaultCode, message, node, cause };
42  const opts = message;
43  if (typeof opts !== "object")
44    throw new Error("First param to error must be a string or object");
45  return {
46    name,
47    code: opts.code || defaultCode,
48    message: opts.message,
49    node: opts.node,
50    cause: opts.cause,
51    range: opts.range
52  };
53}
54
55class ParseError extends CelError {
56  constructor(message, node, cause) {
57    super(normalizeArgs("ParseError", "parse_error", message, node, cause));
58  }
59}
60
61class EvaluationError extends CelError {
62  constructor(message, node, cause) {
63    super(normalizeArgs("EvaluationError", "evaluation_error", message, node, cause));
64  }
65}
66
67class TypeError2 extends CelError {
68  constructor(message, node, cause) {
69    super(normalizeArgs("TypeError", "type_error", message, node, cause));
70  }
71}
72function parseError(code, message, node) {
73  if (typeof code === "object")
74    return new ParseError(code);
75  return new ParseError({ code, message, node });
76}
77function evaluationError(code, message, node) {
78  if (typeof code === "object")
79    return new EvaluationError(code);
80  return new EvaluationError({ code, message, node });
81}
82function typeError(code, message, node) {
83  if (typeof code === "object")
84    return new TypeError2(code);
85  return new TypeError2({ code, message, node });
86}
87function normalizeRange(node) {
88  const start = node?.pos ?? node?.start;
89  if (typeof start !== "number")
90    return;
91  const end = typeof node.end === "number" ? node.end : start;
92  return { start, end };
93}
94function formatErrorWithHighlight(message, node, range) {
95  const pos = node.pos ?? range?.start;
96  if (typeof pos !== "number")
97    return message;
98  const input = node.input;
99  let lineNum = 1;
100  let currentPos = 0;
101  let columnNum = 0;
102  while (currentPos < pos) {
103    if (input[currentPos] === `
104`) {
105      lineNum++;
106      columnNum = 0;
107    } else {
108      columnNum++;
109    }
110    currentPos++;
111  }
112  let contextStart = pos;
113  let contextEnd = pos;
114  while (contextStart > 0 && input[contextStart - 1] !== `
115`)
116    contextStart--;
117  while (contextEnd < input.length && input[contextEnd] !== `
118`)
119    contextEnd++;
120  const line = input.slice(contextStart, contextEnd);
121  const highlight = `> ${`${lineNum}`.padStart(4, " ")} | ${line}
122${" ".repeat(9 + columnNum)}^`;
123  return `${message}
124
125${highlight}`;
126}
127function attachErrorAst(error, node) {
128  if (error instanceof CelError)
129    return error.withAst(node);
130  return error;
131}
132
133// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/optional.js
134class Optional {
135  #value;
136  constructor(value) {
137    this.#value = value;
138  }
139  static of(value) {
140    if (value === undefined)
141      return OPTIONAL_NONE;
142    return new Optional(value);
143  }
144  static none() {
145    return OPTIONAL_NONE;
146  }
147  hasValue() {
148    return this.#value !== undefined;
149  }
150  value() {
151    if (this.#value === undefined) {
152      throw evaluationError("optional_value_missing", "Optional value is not present");
153    }
154    return this.#value;
155  }
156  or(optional) {
157    if (this.#value !== undefined)
158      return this;
159    if (optional instanceof Optional)
160      return optional;
161    throw evaluationError("invalid_optional_argument", "Optional.or must be called with an Optional argument");
162  }
163  orValue(defaultValue) {
164    return this.#value === undefined ? defaultValue : this.#value;
165  }
166  get [Symbol.toStringTag]() {
167    return "optional";
168  }
169  [Symbol.for("nodejs.util.inspect.custom")]() {
170    return this.#value === undefined ? `Optional { none }` : `Optional { value: ${JSON.stringify(this.#value)} }`;
171  }
172}
173var OPTIONAL_NONE = Object.freeze(new Optional);
174
175class OptionalNamespace {
176}
177var optionalNamespace = new OptionalNamespace;
178function toggleOptionalTypes(registry, enable) {
179  const optionalConstant = enable ? optionalNamespace : undefined;
180  registry.deleteVariable("optional");
181  registry.registerConstant("optional", "OptionalNamespace", optionalConstant);
182}
183function register(registry) {
184  const sync = { async: false };
185  const functionOverload = (sig, handler) => registry.registerFunctionOverload(sig, handler, sync);
186  const optionalConstant = registry.enableOptionalTypes ? optionalNamespace : undefined;
187  registry.registerType("OptionalNamespace", OptionalNamespace);
188  registry.registerConstant("optional", "OptionalNamespace", optionalConstant);
189  functionOverload("optional.hasValue(): bool", (v) => v.hasValue());
190  functionOverload("optional<A>.value(): A", (v) => v.value());
191  registry.registerFunctionOverload("OptionalNamespace.none(): optional<T>", () => Optional.none());
192  functionOverload("OptionalNamespace.of(A): optional<A>", (_, value) => Optional.of(value));
193  function ensureOptional(value, ast, description) {
194    if (value instanceof Optional)
195      return value;
196    throw evaluationError("optional_expected", `${description} must be optional`, ast);
197  }
198  function evaluateOptional(ev, macro, ctx) {
199    const v = ev.run(macro.receiver, ctx);
200    if (v instanceof Promise)
201      return v.then((_v) => handleOptionalResolved(_v, ev, macro, ctx));
202    return handleOptionalResolved(v, ev, macro, ctx);
203  }
204  function handleOptionalResolved(value, ev, macro, ctx) {
205    const optional = ensureOptional(value, macro.receiver, `${macro.functionDesc} receiver`);
206    if (optional.hasValue())
207      return macro.onHasValue(optional);
208    return macro.onEmpty(ev, macro, ctx);
209  }
210  function ensureOptionalType(checker, node, ctx, description) {
211    const type = checker.check(node, ctx);
212    if (type.kind === "optional")
213      return type;
214    if (type.kind === "dyn")
215      return checker.getType("optional");
216    throw checker.createError("optional_expected", `${description} must be optional, got '${type}'`, node);
217  }
218  function createOptionalMacro({ functionDesc, evaluate, typeCheck, onHasValue, onEmpty }) {
219    return ({ ast, args, receiver }) => ({
220      ast,
221      functionDesc,
222      receiver,
223      arg: args[0],
224      evaluate,
225      typeCheck,
226      onHasValue,
227      onEmpty
228    });
229  }
230  const invalidOrValueReceiver = "optional.orValue() receiver";
231  const invalidOrReceiver = "optional.or(optional) receiver";
232  const invalidOrArg = "optional.or(optional) argument";
233  registry.registerFunctionOverload("optional.or(ast): optional<dyn>", createOptionalMacro({
234    functionDesc: "optional.or(optional)",
235    evaluate: evaluateOptional,
236    typeCheck(check, macro, ctx) {
237      const l = ensureOptionalType(check, macro.receiver, ctx, invalidOrReceiver);
238      const r = ensureOptionalType(check, macro.arg, ctx, invalidOrArg);
239      if (!(macro.receiver.maybeAsync || macro.arg.maybeAsync))
240        macro.ast.setMeta("async", false);
241      const unified = l.unify(check.registry, r);
242      if (unified)
243        return unified;
244      throw check.createError("incompatible_argument_type", `${macro.functionDesc} argument must be compatible type, got '${l}' and '${r}'`, macro.arg);
245    },
246    onHasValue: (optional) => optional,
247    onEmpty(ev, macro, ctx) {
248      const ast = macro.arg;
249      const v = ev.run(ast, ctx);
250      if (v instanceof Promise)
251        return v.then((_v) => ensureOptional(_v, ast, invalidOrArg));
252      return ensureOptional(v, ast, invalidOrArg);
253    }
254  }));
255  registry.registerFunctionOverload("optional.orValue(ast): dyn", createOptionalMacro({
256    functionDesc: "optional.orValue(value)",
257    onHasValue: (optionalValue) => optionalValue.value(),
258    onEmpty(ev, macro, ctx) {
259      return ev.run(macro.arg, ctx);
260    },
261    evaluate: evaluateOptional,
262    typeCheck(check, macro, ctx) {
263      const l = ensureOptionalType(check, macro.receiver, ctx, invalidOrValueReceiver).valueType;
264      const r = check.check(macro.arg, ctx);
265      if (!(macro.receiver.maybeAsync || macro.arg.maybeAsync))
266        macro.ast.setMeta("async", false);
267      const unified = l.unify(check.registry, r);
268      if (unified)
269        return unified;
270      throw check.createError("incompatible_argument_type", `${macro.functionDesc} argument must be compatible type, got '${l}' and '${r}'`, macro.arg);
271    }
272  }));
273}
274
275// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/globals.js
276var hasOwn = Object.hasOwn;
277var objKeys = Object.keys;
278var objFreeze = Object.freeze;
279var objEntries = Object.entries;
280var isArray = Array.isArray;
281var arrayFrom = Array.from;
282var MIN_UINT = 0n;
283var MAX_UINT = 18446744073709551615n;
284var MAX_INT = 9223372036854775807n;
285var MIN_INT = -9223372036854775808n;
286function isAsync(fn, fallback) {
287  if (fn?.[Symbol.toStringTag] === "AsyncFunction")
288    return true;
289  return typeof fallback === "boolean" ? fallback : true;
290}
291var RESERVED = new Set([
292  "as",
293  "break",
294  "const",
295  "continue",
296  "else",
297  "for",
298  "function",
299  "if",
300  "import",
301  "let",
302  "loop",
303  "package",
304  "namespace",
305  "return",
306  "var",
307  "void",
308  "while",
309  "__proto__",
310  "prototype"
311]);
312
313// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/functions.js
314class UnsignedInt {
315  #value;
316  constructor(value) {
317    this.verify(typeof value === "bigint" ? value : BigInt(value));
318  }
319  get value() {
320    return this.#value;
321  }
322  valueOf() {
323    return this.#value;
324  }
325  toString() {
326    return `${this.#value}`;
327  }
328  verify(v) {
329    if (v < MIN_UINT || v > MAX_UINT) {
330      throw evaluationError("numeric_overflow", "Unsigned integer overflow");
331    }
332    this.#value = v;
333  }
334  get [Symbol.toStringTag]() {
335    return `value = ${this.#value}`;
336  }
337  [Symbol.for("nodejs.util.inspect.custom")]() {
338    return `UnsignedInteger { value: ${this.#value} }`;
339  }
340}
341var billion = 1e9;
342var billionBigInt = 1000000000n;
343var UNIT_NANOSECONDS = {
344  h: 3600000000000n,
345  m: 60000000000n,
346  s: billionBigInt,
347  ms: 1000000n,
348  us: 1000n,
349  µs: 1000n,
350  ns: 1n
351};
352
353class Duration {
354  #seconds;
355  #nanos;
356  constructor(seconds, nanos = 0) {
357    this.#seconds = BigInt(seconds);
358    this.#nanos = nanos;
359  }
360  get seconds() {
361    return this.#seconds;
362  }
363  get nanos() {
364    return this.#nanos;
365  }
366  valueOf() {
367    return Number(this.#seconds) * 1000 + this.#nanos / 1e6;
368  }
369  static fromMilliseconds(ms) {
370    const totalNanos = BigInt(Math.trunc(ms * 1e6));
371    const seconds = totalNanos / billionBigInt;
372    const nanos = Number(totalNanos % billionBigInt);
373    return new Duration(seconds, nanos);
374  }
375  addDuration(other) {
376    const nanos = this.#nanos + other.nanos;
377    return new Duration(this.#seconds + other.seconds + BigInt(Math.floor(nanos / billion)), nanos % billion);
378  }
379  subtractDuration(other) {
380    const nanos = this.#nanos - other.nanos;
381    return new Duration(this.#seconds - other.seconds + BigInt(Math.floor(nanos / billion)), (nanos + billion) % billion);
382  }
383  extendTimestamp(ts) {
384    return new Date(ts.getTime() + Number(this.#seconds) * 1000 + Math.floor(this.#nanos / 1e6));
385  }
386  subtractTimestamp(ts) {
387    return new Date(ts.getTime() - Number(this.#seconds) * 1000 - Math.floor(this.#nanos / 1e6));
388  }
389  toString() {
390    const nanos = this.#nanos ? (this.#nanos / billion).toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 9 }).slice(1) : "";
391    return `${this.#seconds}${nanos}s`;
392  }
393  getHours() {
394    return this.#seconds / 3600n;
395  }
396  getMinutes() {
397    return this.#seconds / 60n;
398  }
399  getSeconds() {
400    return this.#seconds;
401  }
402  getMilliseconds() {
403    return this.#seconds * 1000n + BigInt(Math.floor(this.#nanos / 1e6));
404  }
405  get [Symbol.toStringTag]() {
406    return "google.protobuf.Duration";
407  }
408  [Symbol.for("nodejs.util.inspect.custom")]() {
409    return `google.protobuf.Duration { seconds: ${this.#seconds}, nanos: ${this.#nanos} }`;
410  }
411}
412function registerFunctions(registry) {
413  const sync = { async: false };
414  const functionOverload = (sig, handler) => registry.registerFunctionOverload(sig, handler, sync);
415  const identity = (v) => v;
416  functionOverload("dyn(dyn): dyn", identity);
417  for (const _t in TYPES) {
418    const type = TYPES[_t];
419    if (!(type instanceof Type))
420      continue;
421    functionOverload(`type(${type.name}): type`, () => type);
422  }
423  functionOverload("bool(bool): bool", identity);
424  functionOverload("bool(string): bool", (v) => {
425    switch (v) {
426      case "1":
427      case "t":
428      case "true":
429      case "TRUE":
430      case "True":
431        return true;
432      case "0":
433      case "f":
434      case "false":
435      case "FALSE":
436      case "False":
437        return false;
438      default:
439        throw evaluationError("bool_conversion_error", `bool() conversion error: invalid string value "${v}"`);
440    }
441  });
442  functionOverload("size(string): int", (v) => BigInt(stringSize(v)));
443  functionOverload("size(bytes): int", (v) => BigInt(v.length));
444  functionOverload("size(list): int", (v) => BigInt(v.length ?? v.size));
445  functionOverload("size(map): int", (v) => BigInt(v instanceof Map ? v.size : objKeys(v).length));
446  functionOverload("string.size(): int", (v) => BigInt(stringSize(v)));
447  functionOverload("bytes.size(): int", (v) => BigInt(v.length));
448  functionOverload("list.size(): int", (v) => BigInt(v.length ?? v.size));
449  functionOverload("map.size(): int", (v) => BigInt(v instanceof Map ? v.size : objKeys(v).length));
450  functionOverload("bytes(string): bytes", (v) => ByteOpts.fromString(v));
451  functionOverload("bytes(bytes): bytes", identity);
452  functionOverload("double(double): double", identity);
453  functionOverload("double(int): double", (v) => Number(v));
454  functionOverload("double(uint): double", (v) => Number(v));
455  functionOverload("double(string): double", (v) => {
456    if (!v || v !== v.trim())
457      throw evaluationError("double_conversion_error", "double() type error: cannot convert to double");
458    const s = v.toLowerCase();
459    switch (s) {
460      case "inf":
461      case "+inf":
462      case "infinity":
463      case "+infinity":
464        return Number.POSITIVE_INFINITY;
465      case "-inf":
466      case "-infinity":
467        return Number.NEGATIVE_INFINITY;
468      case "nan":
469        return Number.NaN;
470      default: {
471        const parsed = Number(v);
472        if (!Number.isNaN(parsed))
473          return parsed;
474        throw evaluationError("double_conversion_error", "double() type error: cannot convert to double");
475      }
476    }
477  });
478  functionOverload("int(int): int", identity);
479  functionOverload("int(double): int", (v) => {
480    if (Number.isFinite(v))
481      return BigInt(Math.trunc(v));
482    throw evaluationError("numeric_overflow", "int() type error: integer overflow");
483  });
484  functionOverload("int(string): int", (v) => {
485    if (v !== v.trim() || v.length > 20 || v.includes("0x")) {
486      throw evaluationError("int_conversion_error", "int() type error: cannot convert to int");
487    }
488    try {
489      const num = BigInt(v);
490      if (num <= MAX_INT && num >= MIN_INT)
491        return num;
492    } catch (_e) {}
493    throw evaluationError("int_conversion_error", "int() type error: cannot convert to int");
494  });
495  functionOverload("uint(uint): uint", identity);
496  functionOverload("uint(int): uint", (v) => {
497    try {
498      return new UnsignedInt(v);
499    } catch (e) {
500      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
501    }
502  });
503  functionOverload("uint(double): uint", (v) => {
504    try {
505      return new UnsignedInt(Math.trunc(v));
506    } catch (e) {
507      throw evaluationError("numeric_overflow", "uint() type error: unsigned integer overflow");
508    }
509  });
510  functionOverload("uint(string): uint", (v) => {
511    if (v !== v.trim() || v.length > 20 || v.includes("0x")) {
512      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
513    }
514    try {
515      return new UnsignedInt(v);
516    } catch (e) {
517      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
518    }
519  });
520  functionOverload("string(string): string", identity);
521  functionOverload("string(bool): string", (v) => `${v}`);
522  functionOverload("string(int): string", (v) => `${v}`);
523  functionOverload("string(uint): string", (v) => `${v}`);
524  functionOverload("string(bytes): string", (v) => ByteOpts.toUtf8(v));
525  functionOverload("string(double): string", (v) => {
526    if (v === Infinity)
527      return "+Inf";
528    if (v === -Infinity)
529      return "-Inf";
530    return `${v}`;
531  });
532  functionOverload("string.startsWith(string): bool", (a, b) => a.startsWith(b));
533  functionOverload("string.endsWith(string): bool", (a, b) => a.endsWith(b));
534  functionOverload("string.contains(string): bool", (a, b) => a.includes(b));
535  functionOverload("string.lowerAscii(): string", (a) => a.toLowerCase());
536  functionOverload("string.upperAscii(): string", (a) => a.toUpperCase());
537  functionOverload("string.trim(): string", (a) => a.trim());
538  functionOverload("string.indexOf(string): int", (string, search) => BigInt(string.indexOf(search)));
539  functionOverload("string.indexOf(string, int): int", (string, search, fromIndex) => {
540    if (search === "")
541      return fromIndex;
542    fromIndex = Number(fromIndex);
543    if (fromIndex < 0 || fromIndex >= string.length) {
544      throw evaluationError("index_out_of_range", "string.indexOf(search, fromIndex): fromIndex out of range");
545    }
546    return BigInt(string.indexOf(search, fromIndex));
547  });
548  functionOverload("string.lastIndexOf(string): int", (string, search) => BigInt(string.lastIndexOf(search)));
549  functionOverload("string.lastIndexOf(string, int): int", (string, search, fromIndex) => {
550    if (search === "")
551      return fromIndex;
552    fromIndex = Number(fromIndex);
553    if (fromIndex < 0 || fromIndex >= string.length) {
554      throw evaluationError("index_out_of_range", "string.lastIndexOf(search, fromIndex): fromIndex out of range");
555    }
556    return BigInt(string.lastIndexOf(search, fromIndex));
557  });
558  functionOverload("string.substring(int): string", (string, start) => {
559    start = Number(start);
560    if (start < 0 || start > string.length) {
561      throw evaluationError("index_out_of_range", "string.substring(start, end): start index out of range");
562    }
563    return string.substring(start);
564  });
565  functionOverload("string.substring(int, int): string", (string, start, end) => {
566    start = Number(start);
567    if (start < 0 || start > string.length) {
568      throw evaluationError("index_out_of_range", "string.substring(start, end): start index out of range");
569    }
570    end = Number(end);
571    if (end < start || end > string.length) {
572      throw evaluationError("index_out_of_range", "string.substring(start, end): end index out of range");
573    }
574    return string.substring(start, end);
575  });
576  functionOverload("string.matches(string): bool", (a, b) => {
577    try {
578      return new RegExp(b).test(a);
579    } catch (_err) {
580      throw evaluationError("invalid_regular_expression", `Invalid regular expression: ${b}`);
581    }
582  });
583  functionOverload("string.split(string): list<string>", (s, sep) => s.split(sep));
584  functionOverload("string.split(string, int): list<string>", (s, sep, l) => {
585    l = Number(l);
586    if (l === 0)
587      return [];
588    const parts = s.split(sep);
589    if (l < 0 || parts.length <= l)
590      return parts;
591    const limited = parts.slice(0, l - 1);
592    limited.push(parts.slice(l - 1).join(sep));
593    return limited;
594  });
595  functionOverload("list<string>.join(): string", (v) => {
596    for (let i = 0;i < v.length; i++) {
597      if (typeof v[i] !== "string") {
598        throw evaluationError("invalid_list_element_type", "string.join(): list must contain only strings");
599      }
600    }
601    return v.join("");
602  });
603  functionOverload("list<string>.join(string): string", (v, sep) => {
604    for (let i = 0;i < v.length; i++) {
605      if (typeof v[i] !== "string") {
606        throw evaluationError("invalid_list_element_type", "string.join(separator): list must contain only strings");
607      }
608    }
609    return v.join(sep);
610  });
611  const textEncoder = new TextEncoder("utf8");
612  const textDecoder = new TextDecoder("utf8");
613  const ByteOpts = typeof Buffer !== "undefined" ? {
614    byteLength: (v) => Buffer.byteLength(v),
615    fromString: (str) => Buffer.from(str, "utf8"),
616    toHex: (b) => Buffer.prototype.hexSlice.call(b, 0, b.length),
617    toBase64: (b) => Buffer.prototype.base64Slice.call(b, 0, b.length),
618    toUtf8: (b) => Buffer.prototype.utf8Slice.call(b, 0, b.length),
619    jsonParse: (b) => JSON.parse(b)
620  } : {
621    textEncoder: new TextEncoder("utf8"),
622    byteLength: (v) => textEncoder.encode(v).length,
623    fromString: (str) => textEncoder.encode(str),
624    toHex: Uint8Array.prototype.toHex ? (b) => b.toHex() : (b) => arrayFrom(b, (i) => i.toString(16).padStart(2, "0")).join(""),
625    toBase64: Uint8Array.prototype.toBase64 ? (b) => b.toBase64() : (b) => btoa(arrayFrom(b, (i) => String.fromCodePoint(i)).join("")),
626    toUtf8: (b) => textDecoder.decode(b),
627    jsonParse: (b) => JSON.parse(textEncoder.decode(b))
628  };
629  functionOverload("bytes.json(): map", ByteOpts.jsonParse);
630  functionOverload("bytes.hex(): string", ByteOpts.toHex);
631  functionOverload("bytes.string(): string", ByteOpts.toUtf8);
632  functionOverload("bytes.base64(): string", ByteOpts.toBase64);
633  functionOverload("bytes.at(int): int", (b, index) => {
634    if (index < 0 || index >= b.length) {
635      throw evaluationError("index_out_of_range", "Bytes index out of range");
636    }
637    return BigInt(b[index]);
638  });
639  const TS = "google.protobuf.Timestamp";
640  const GPD = "google.protobuf.Duration";
641  const TimestampType = registry.registerType(TS, Date).typeType;
642  const DurationType = registry.registerType(GPD, Duration).typeType;
643  registry.registerConstant("google", "map<string, map<string, type>>", {
644    protobuf: { Duration: DurationType, Timestamp: TimestampType }
645  });
646  function tzDate(d, timeZone) {
647    return new Date(d.toLocaleString("en-US", { timeZone }));
648  }
649  function getDayOfYear(d, tz) {
650    const workingDate = tz ? tzDate(d, tz) : new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
651    const start = new Date(workingDate.getFullYear(), 0, 0);
652    return BigInt(Math.floor((workingDate - start) / 86400000) - 1);
653  }
654  functionOverload(`timestamp(string): ${TS}`, (v) => {
655    if (v.length < 20 || v.length > 30) {
656      throw evaluationError("invalid_timestamp", "timestamp() requires a string in ISO 8601 format");
657    }
658    const d = new Date(v);
659    if (d <= 253402300799999 && d >= -62135596800000)
660      return d;
661    throw evaluationError("invalid_timestamp", "timestamp() requires a string in ISO 8601 format");
662  });
663  functionOverload(`timestamp(int): ${TS}`, (i) => {
664    i = Number(i) * 1000;
665    if (i <= 253402300799999 && i >= -62135596800000)
666      return new Date(i);
667    throw evaluationError("invalid_timestamp", "timestamp() requires a valid integer unix timestamp");
668  });
669  functionOverload(`${TS}.getDate(): int`, (d) => BigInt(d.getUTCDate()));
670  functionOverload(`${TS}.getDate(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDate()));
671  functionOverload(`${TS}.getDayOfMonth(): int`, (d) => BigInt(d.getUTCDate() - 1));
672  functionOverload(`${TS}.getDayOfMonth(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDate() - 1));
673  functionOverload(`${TS}.getDayOfWeek(): int`, (d) => BigInt(d.getUTCDay()));
674  functionOverload(`${TS}.getDayOfWeek(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDay()));
675  functionOverload(`${TS}.getDayOfYear(): int`, getDayOfYear);
676  functionOverload(`${TS}.getDayOfYear(string): int`, getDayOfYear);
677  functionOverload(`${TS}.getFullYear(): int`, (d) => BigInt(d.getUTCFullYear()));
678  functionOverload(`${TS}.getFullYear(string): int`, (d, tz) => BigInt(tzDate(d, tz).getFullYear()));
679  functionOverload(`${TS}.getHours(): int`, (d) => BigInt(d.getUTCHours()));
680  functionOverload(`${TS}.getHours(string): int`, (d, tz) => BigInt(tzDate(d, tz).getHours()));
681  functionOverload(`${TS}.getMilliseconds(): int`, (d) => BigInt(d.getUTCMilliseconds()));
682  functionOverload(`${TS}.getMilliseconds(string): int`, (d) => BigInt(d.getUTCMilliseconds()));
683  functionOverload(`${TS}.getMinutes(): int`, (d) => BigInt(d.getUTCMinutes()));
684  functionOverload(`${TS}.getMinutes(string): int`, (d, tz) => BigInt(tzDate(d, tz).getMinutes()));
685  functionOverload(`${TS}.getMonth(): int`, (d) => BigInt(d.getUTCMonth()));
686  functionOverload(`${TS}.getMonth(string): int`, (d, tz) => BigInt(tzDate(d, tz).getMonth()));
687  functionOverload(`${TS}.getSeconds(): int`, (d) => BigInt(d.getUTCSeconds()));
688  functionOverload(`${TS}.getSeconds(string): int`, (d, tz) => BigInt(tzDate(d, tz).getSeconds()));
689  const parseDurationPattern = /(\d*\.?\d*)(ns|us|µs|ms|s|m|h)/;
690  function parseDuration(string) {
691    if (!string)
692      throw evaluationError("invalid_duration", `Invalid duration string: ''`);
693    const isNegative = string[0] === "-";
694    if (string[0] === "-" || string[0] === "+")
695      string = string.slice(1);
696    let nanoseconds = BigInt(0);
697    while (true) {
698      const match = parseDurationPattern.exec(string);
699      if (!match)
700        throw evaluationError("invalid_duration", `Invalid duration string: ${string}`);
701      if (match.index !== 0)
702        throw evaluationError("invalid_duration", `Invalid duration string: ${string}`);
703      string = string.slice(match[0].length);
704      const unitNanos = UNIT_NANOSECONDS[match[2]];
705      const [intPart = "0", fracPart = ""] = match[1].split(".");
706      const intVal = BigInt(intPart) * unitNanos;
707      const fracNanos = fracPart ? BigInt(fracPart.slice(0, 13).padEnd(13, "0")) * unitNanos / 10000000000000n : 0n;
708      nanoseconds += intVal + fracNanos;
709      if (string === "")
710        break;
711    }
712    const seconds = nanoseconds >= billionBigInt ? nanoseconds / billionBigInt : 0n;
713    const nanos = Number(nanoseconds % billionBigInt);
714    if (isNegative)
715      return new Duration(-seconds, -nanos);
716    return new Duration(seconds, nanos);
717  }
718  functionOverload(`duration(string): google.protobuf.Duration`, (s) => parseDuration(s));
719  functionOverload(`google.protobuf.Duration.getHours(): int`, (d) => d.getHours());
720  functionOverload(`google.protobuf.Duration.getMinutes(): int`, (d) => d.getMinutes());
721  functionOverload(`google.protobuf.Duration.getSeconds(): int`, (d) => d.getSeconds());
722  functionOverload(`google.protobuf.Duration.getMilliseconds(): int`, (d) => d.getMilliseconds());
723  register(registry);
724}
725function stringSize(str) {
726  let count = 0;
727  for (const c of str)
728    count++;
729  return count;
730}
731
732// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/registry.js
733class Type {
734  #name;
735  constructor(name) {
736    this.#name = name;
737    objFreeze(this);
738  }
739  get name() {
740    return this.#name;
741  }
742  get [Symbol.toStringTag]() {
743    return `Type<${this.#name}>`;
744  }
745  toString() {
746    return `Type<${this.#name}>`;
747  }
748}
749var TYPES = {
750  string: new Type("string"),
751  bool: new Type("bool"),
752  int: new Type("int"),
753  uint: new Type("uint"),
754  double: new Type("double"),
755  map: new Type("map"),
756  list: new Type("list"),
757  bytes: new Type("bytes"),
758  null_type: new Type("null"),
759  type: new Type("type")
760};
761var optionalType = new Type("optional");
762var valueTypeMatchers = {
763  dyn(v, ev) {
764    switch (typeof v) {
765      case "string":
766      case "bigint":
767      case "number":
768      case "boolean":
769        return true;
770      case "object":
771        switch (v ? v.constructor : v) {
772          case null:
773          case undefined:
774          case Object:
775          case Map:
776          case Array:
777          case Set:
778            return true;
779          default:
780            if (ev.objectTypesByConstructor.get(v.constructor))
781              return true;
782        }
783    }
784    return !!ev.debugType(v);
785  },
786  string(v) {
787    return typeof v === "string";
788  },
789  int(v) {
790    return typeof v === "bigint";
791  },
792  double(v) {
793    return typeof v === "number";
794  },
795  bool(v) {
796    return typeof v === "boolean";
797  },
798  null(v) {
799    return v === null;
800  },
801  bytes(v) {
802    return v instanceof Uint8Array;
803  },
804  uint(v) {
805    return v instanceof UnsignedInt;
806  },
807  type(v) {
808    return v instanceof Type;
809  },
810  list(v) {
811    switch (v?.constructor) {
812      case Array:
813      case Set:
814        return true;
815      default:
816        return false;
817    }
818  },
819  map(v) {
820    switch (typeof v === "object" && v ? v.constructor : null) {
821      case undefined:
822      case Object:
823      case Map:
824        return true;
825      default:
826        return false;
827    }
828  },
829  optional(v) {
830    return v instanceof Optional;
831  },
832  message(v, ev) {
833    return this === ev.debugType(v);
834  }
835};
836valueTypeMatchers.param = valueTypeMatchers.dyn;
837
838class TypeDeclaration {
839  #matchesCache = new WeakMap;
840  constructor({ kind, type, name, keyType, valueType }) {
841    this.kind = kind;
842    this.type = type;
843    this.name = name;
844    this.keyType = keyType;
845    this.valueType = valueType;
846    this.unwrappedType = kind === "dyn" && valueType ? valueType.unwrappedType : this;
847    this.wrappedType = kind === "dyn" ? this : _createDynType(this.unwrappedType);
848    this.hasDynType = this.kind === "dyn" || this.valueType?.hasDynType || this.keyType?.hasDynType || false;
849    this.hasPlaceholderType = this.kind === "param" || this.keyType?.hasPlaceholderType || this.valueType?.hasPlaceholderType || false;
850    if (kind === "list")
851      this.fieldLazy = this.#getListField;
852    else if (kind === "map")
853      this.fieldLazy = this.#getMapField;
854    else if (kind === "message")
855      this.fieldLazy = this.#getMessageField;
856    else if (kind === "optional")
857      this.fieldLazy = this.#getOptionalField;
858    this.matchesValueType = valueTypeMatchers[name] || valueTypeMatchers[kind];
859    objFreeze(this);
860  }
861  isDynOrBool() {
862    return this.type === "bool" || this.kind === "dyn";
863  }
864  isEmpty() {
865    return this.valueType && this.valueType.kind === "param";
866  }
867  unify(r, t2) {
868    const t1 = this;
869    if (t1 === t2 || t1.kind === "dyn" || t2.kind === "param")
870      return t1;
871    if (t2.kind === "dyn" || t1.kind === "param")
872      return t2;
873    if (t1.kind !== t2.kind)
874      return null;
875    if (!(t1.hasPlaceholderType || t2.hasPlaceholderType || t1.hasDynType || t2.hasDynType))
876      return null;
877    const valueType = t1.valueType.unify(r, t2.valueType);
878    if (!valueType)
879      return null;
880    switch (t1.kind) {
881      case "optional":
882        return r.getOptionalType(valueType);
883      case "list":
884        return r.getListType(valueType);
885      case "map":
886        const keyType = t1.keyType.unify(r, t2.keyType);
887        return keyType ? r.getMapType(keyType, valueType) : null;
888    }
889  }
890  templated(r, bind) {
891    if (!this.hasPlaceholderType)
892      return this;
893    switch (this.kind) {
894      case "dyn":
895        return this.valueType.templated(r, bind);
896      case "param":
897        return bind?.get(this.name) || this;
898      case "map":
899        return r.getMapType(this.keyType.templated(r, bind), this.valueType.templated(r, bind));
900      case "list":
901        return r.getListType(this.valueType.templated(r, bind));
902      case "optional":
903        return r.getOptionalType(this.valueType.templated(r, bind));
904      default:
905        return this;
906    }
907  }
908  toString() {
909    return this.name;
910  }
911  #getOptionalField(obj, key, ast, ev) {
912    obj = obj instanceof Optional ? obj.orValue() : obj;
913    if (obj === undefined)
914      return OPTIONAL_NONE;
915    const type = ev.debugType(obj);
916    try {
917      return Optional.of(type.fieldLazy(obj, key, ast, ev));
918    } catch (e) {
919      if (e instanceof EvaluationError)
920        return OPTIONAL_NONE;
921      throw e;
922    }
923  }
924  #getMessageField(obj, key, ast, ev) {
925    const message = obj ? ev.objectTypesByConstructor.get(obj.constructor) : undefined;
926    if (!message)
927      return;
928    const type = message.fields ? message.fields[key] : dynType;
929    if (!type)
930      return;
931    const value = obj instanceof Map ? obj.get(key) : obj[key];
932    if (value === undefined)
933      return;
934    if (type.matchesValueType(value, ev))
935      return value;
936    throw evaluationError("field_type_mismatch", `Field '${key}' is not of type '${type}', got '${ev.debugType(value)}'`, ast);
937  }
938  #getMapField(obj, key, ast, ev) {
939    const value = obj instanceof Map ? obj.get(key) : obj && hasOwn(obj, key) ? obj[key] : undefined;
940    if (value === undefined)
941      return;
942    if (this.valueType.matchesValueType(value, ev))
943      return value;
944    throw evaluationError("field_type_mismatch", `Field '${key}' is not of type '${this.valueType}', got '${ev.debugType(value)}'`, ast);
945  }
946  #getListElementAtIndex(list, pos) {
947    switch (list?.constructor) {
948      case Array:
949        return list[pos];
950      case Set: {
951        let i = 0;
952        for (const item of list) {
953          if (i++ !== pos)
954            continue;
955          return item;
956        }
957      }
958    }
959  }
960  #getListField(obj, key, ast, ev) {
961    if (typeof key === "bigint")
962      key = Number(key);
963    else if (typeof key !== "number")
964      return;
965    const value = this.#getListElementAtIndex(obj, key);
966    if (value === undefined) {
967      if (!obj)
968        return;
969      throw evaluationError("index_out_of_bounds", `No such key: index out of bounds, index ${key} ${key < 0 ? "< 0" : `>= size ${obj.length || obj.size}`}`, ast);
970    }
971    if (this.valueType.matchesValueType(value, ev))
972      return value;
973    throw evaluationError("list_item_type_mismatch", `List item with index '${key}' is not of type '${this.valueType}', got '${ev.debugType(value)}'`, ast);
974  }
975  fieldLazy() {}
976  field(obj, key, ast, ev) {
977    const v = this.fieldLazy(obj, key, ast, ev);
978    if (v !== undefined)
979      return v;
980    throw evaluationError("no_such_key", `No such key: ${key}`, ast);
981  }
982  matchesBoth(other) {
983    return this.matches(other) && other.matches(this);
984  }
985  matches(o) {
986    const s = this.unwrappedType;
987    o = o.unwrappedType;
988    if (s === o || s.kind === "dyn" || o.kind === "dyn" || o.kind === "param")
989      return true;
990    return this.#matchesCache.get(o) ?? this.#matchesCache.set(o, this.#matches(s, o)).get(o);
991  }
992  #matches(s, o) {
993    switch (s.kind) {
994      case "dyn":
995      case "param":
996        return true;
997      case "list":
998        return o.kind === "list" && s.valueType.matches(o.valueType);
999      case "map":
1000        return o.kind === "map" && s.keyType.matches(o.keyType) && s.valueType.matches(o.valueType);
1001      case "optional":
1002        return o.kind === "optional" && s.valueType.matches(o.valueType);
1003      default:
1004        return s.name === o.name;
1005    }
1006  }
1007}
1008var macroEvaluateErr = `have a .callAst property or .evaluate(checker, macro, ctx) method.`;
1009var macroTypeCheckErr = `have a .callAst property or .typeCheck(checker, macro, ctx) method.`;
1010function wrapMacroExpander(name, handler) {
1011  const p = `Macro '${name}' must`;
1012  return function macroExpander(opts) {
1013    const macro = handler(opts);
1014    if (!macro || typeof macro !== "object")
1015      throw new Error(`${p} return an object.`);
1016    if (macro.callAst)
1017      return macro;
1018    if (!macro.evaluate)
1019      throw new Error(`${p} ${macroEvaluateErr}`);
1020    if (!macro.typeCheck)
1021      throw new Error(`${p} ${macroTypeCheckErr}`);
1022    return macro;
1023  };
1024}
1025
1026class VariableDeclaration {
1027  constructor(name, type, description, value) {
1028    this.name = name;
1029    this.type = type;
1030    this.description = description ?? null;
1031    this.constant = value !== undefined;
1032    this.value = value;
1033    objFreeze(this);
1034  }
1035}
1036
1037class FunctionDeclaration {
1038  constructor({ name, receiverType, returnType, handler, description, params, async }) {
1039    if (typeof name !== "string")
1040      throw new Error("name must be a string");
1041    if (typeof handler !== "function")
1042      throw new Error("handler must be a function");
1043    this.name = name;
1044    this.async = isAsync(handler, async);
1045    this.receiverType = receiverType ?? null;
1046    this.returnType = returnType;
1047    this.description = description ?? null;
1048    this.params = params;
1049    this.argTypes = params.map((p) => p.type);
1050    this.macro = this.argTypes.includes(astType);
1051    const receiverString = receiverType ? `${receiverType}.` : "";
1052    this.signature = `${receiverString}${name}(${this.argTypes.join(", ")}): ${returnType}`;
1053    this.handler = this.macro ? wrapMacroExpander(this.signature, handler) : handler;
1054    this.partitionKey = `${receiverType ? "rcall" : "call"}:${name}:${params.length}`;
1055    this.hasPlaceholderType = this.returnType.hasPlaceholderType || this.receiverType?.hasPlaceholderType || this.argTypes.some((t) => t.hasPlaceholderType) || false;
1056    objFreeze(this);
1057  }
1058  matchesArgs(argTypes) {
1059    return argTypes.length === this.argTypes.length && this.argTypes.every((t, i) => t.matches(argTypes[i])) ? this : null;
1060  }
1061}
1062
1063class OperatorDeclaration {
1064  constructor({ op, leftType, rightType, handler, returnType, async }) {
1065    this.operator = op;
1066    this.leftType = leftType;
1067    this.rightType = rightType || null;
1068    this.handler = handler;
1069    this.async = isAsync(handler, async);
1070    this.returnType = returnType;
1071    if (rightType)
1072      this.signature = `${leftType} ${op} ${rightType}: ${returnType}`;
1073    else
1074      this.signature = `${op}${leftType}: ${returnType}`;
1075    this.hasPlaceholderType = this.leftType.hasPlaceholderType || this.rightType?.hasPlaceholderType || false;
1076    objFreeze(this);
1077  }
1078  equals(other) {
1079    return this.operator === other.operator && this.leftType === other.leftType && this.rightType === other.rightType;
1080  }
1081}
1082function _createListType(valueType) {
1083  return new TypeDeclaration({
1084    kind: "list",
1085    name: `list<${valueType}>`,
1086    type: "list",
1087    valueType
1088  });
1089}
1090function _createPrimitiveType(name) {
1091  return new TypeDeclaration({ kind: "primitive", name, type: name });
1092}
1093function _createMessageType(name) {
1094  return new TypeDeclaration({ kind: "message", name, type: name });
1095}
1096function _createDynType(valueType) {
1097  const name = valueType ? `dyn<${valueType}>` : "dyn";
1098  return new TypeDeclaration({ kind: "dyn", name, type: name, valueType });
1099}
1100function _createOptionalType(valueType) {
1101  const name = `optional<${valueType}>`;
1102  return new TypeDeclaration({ kind: "optional", name, type: "optional", valueType });
1103}
1104function _createMapType(keyType, valueType) {
1105  return new TypeDeclaration({
1106    kind: "map",
1107    name: `map<${keyType}, ${valueType}>`,
1108    type: "map",
1109    keyType,
1110    valueType
1111  });
1112}
1113function _createPlaceholderType(name) {
1114  return new TypeDeclaration({ kind: "param", name, type: name });
1115}
1116var dynType = _createDynType();
1117var astType = _createPrimitiveType("ast");
1118var listType = _createListType(dynType);
1119var mapType = _createMapType(dynType, dynType);
1120var celTypes = {
1121  string: _createPrimitiveType("string"),
1122  bool: _createPrimitiveType("bool"),
1123  int: _createPrimitiveType("int"),
1124  uint: _createPrimitiveType("uint"),
1125  double: _createPrimitiveType("double"),
1126  bytes: _createPrimitiveType("bytes"),
1127  dyn: dynType,
1128  null: _createPrimitiveType("null"),
1129  type: _createPrimitiveType("type"),
1130  optional: _createOptionalType(dynType),
1131  list: listType,
1132  "list<dyn>": listType,
1133  map: mapType,
1134  "map<dyn, dyn>": mapType
1135};
1136for (const t of [celTypes.string, celTypes.double, celTypes.int]) {
1137  const list = _createListType(t);
1138  const map = _createMapType(celTypes.string, t);
1139  celTypes[list.name] = list;
1140  celTypes[map.name] = map;
1141}
1142Object.freeze(celTypes);
1143
1144class Candidates {
1145  returnType = null;
1146  async = false;
1147  macro = false;
1148  #matchCache = null;
1149  #checkCache = null;
1150  declarations = [];
1151  constructor(registry) {
1152    this.registry = registry;
1153  }
1154  [Symbol.iterator]() {
1155    return this.declarations[Symbol.iterator]();
1156  }
1157  add(decl) {
1158    this.returnType = (this.returnType || decl.returnType).unify(this.registry, decl.returnType) || dynType;
1159    if (decl.macro)
1160      this.macro = decl;
1161    if (decl.async && !this.async)
1162      this.async = true;
1163    this.declarations.push(decl);
1164    this.#matchCache?.clear();
1165    this.#checkCache?.clear();
1166  }
1167  findFunction(argTypes, receiverType = null) {
1168    for (let i = 0;i < this.declarations.length; i++) {
1169      const match = this.#matchesFunction(this.declarations[i], argTypes, receiverType);
1170      if (match)
1171        return match;
1172    }
1173    return null;
1174  }
1175  findUnaryOverload(left) {
1176    const cached = (this.#matchCache ??= new Map).get(left);
1177    if (cached !== undefined)
1178      return cached;
1179    let value = false;
1180    for (const decl of this.declarations) {
1181      if (decl.leftType !== left)
1182        continue;
1183      value = decl;
1184      break;
1185    }
1186    this.#matchCache.set(left, value);
1187    return value;
1188  }
1189  findBinaryOverload(left, right) {
1190    if (left.kind === "dyn" && left.valueType)
1191      right = right.wrappedType;
1192    else if (right.kind === "dyn" && right.valueType)
1193      left = left.wrappedType;
1194    return (this.#matchCache ??= new Map).get(left)?.get(right) ?? this.#cacheBinary(this.#matchCache, left, right, this.#findBinaryUncached(left, right));
1195  }
1196  checkBinaryOverload(left, right) {
1197    return (this.#checkCache ??= new Map).get(left)?.get(right) ?? this.#cacheBinary(this.#checkCache, left, right, this.#checkBinaryUncached(left, right));
1198  }
1199  #cacheBinary(c, l, r, v) {
1200    return (c.get(l) || c.set(l, new Map).get(l)).set(r, v), v;
hooks/argv.ts 132 lines
1// A spec-driven argument parser, the one stateful scan the CEL rules can't express
2// themselves. The domain knowledge (which options take a value) lives in the
3// rule file's defs; this only knows the common syntaxes.
4
5export interface ArgSpec {
6  // getopt-style short options. Without shortTakesValue, a letter followed by
7  // `:` takes a value, from the rest of the argument or else the next one; one
8  // followed by `::` takes an optional value, only from the rest of the argument
9  short?: string
10  // Every letter in `short` takes a value, and only from the rest of the same
11  // argument, never the next one (pgrep's `-u -f` keeps -f an option)
12  shortTakesValue?: boolean
13  // --name and --name=value; maps a long name to the name reported for it (`full` -> `f`).
14  // A reported name ending in `:` takes a value, from `=value` or else the next argument
15  long?: Record<string, string>
16  // find-style single-dash words. A number is how many arguments follow; a list
17  // of strings ends the word at the first argument that equals one (`-exec ... ;`)
18  words?: Record<string, number | string[]>
19  // Like `words`, keyed by a regex the whole word must match
20  wordPatterns?: Record<string, number>
21  // Stop at the first operand: everything after it is an operand too
22  posix?: boolean
23}
24
25export interface ParsedArgs {
26  opts: string[]
27  operands: string[]
28  values: [string, string][] // each option given a value, with that value, in order
29}
30
31const SPEC_KEYS = new Set(['short', 'shortTakesValue', 'long', 'words', 'wordPatterns', 'posix'])
32
33// CEL hands over maps (possibly as Map) and ints as bigint; turn them into plain JS
34function plain(v: unknown): unknown {
35  if (v instanceof Map) return Object.fromEntries([...v].map(([k, x]) => [k, plain(x)]))
36  if (Array.isArray(v)) return v.map(plain)
37  if (typeof v === 'bigint') return Number(v)
38  if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)]))
39  return v
40}
41
42const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
43
44/** Checks a spec's shape, so a typo in a rule file fails loudly instead of parsing wrong. */
45export function toSpec(raw: unknown): ArgSpec {
46  const s = plain(raw)
47  if (!isRecord(s)) throw new Error('argument spec must be a map')
48  for (const k of Object.keys(s)) if (!SPEC_KEYS.has(k)) throw new Error(`argument spec: unknown key "${k}"`)
49  if (s.short !== undefined && typeof s.short !== 'string') throw new Error('argument spec: short must be a string')
50  for (const k of ['shortTakesValue', 'posix'])
51    if (s[k] !== undefined && typeof s[k] !== 'boolean') throw new Error(`argument spec: ${k} must be a bool`)
52  if (s.long !== undefined && !(isRecord(s.long) && Object.values(s.long).every((v) => typeof v === 'string')))
53    throw new Error('argument spec: long must map names to strings')
54  if (
55    s.words !== undefined &&
56    !(isRecord(s.words) &&
57      Object.values(s.words).every(
58        (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0) || (Array.isArray(v) && v.every((x) => typeof x === 'string')),
59      ))
60  )
61    throw new Error('argument spec: words must map words to a count or a list of terminators')
62  if (s.wordPatterns !== undefined) {
63    if (!(isRecord(s.wordPatterns) && Object.values(s.wordPatterns).every((v) => typeof v === 'number' && Number.isInteger(v) && v >= 0)))
64      throw new Error('argument spec: wordPatterns must map regexes to a count')
65    for (const re of Object.keys(s.wordPatterns)) new RegExp(re)
66  }
67  return s as ArgSpec
68}
69
70export function parseArgs(args: readonly string[], spec: ArgSpec): ParsedArgs {
71  const opts: string[] = []
72  const operands: string[] = []
73  const values: [string, string][] = []
74  const short = spec.short ?? ''
75  // 0: no value, 1: a value, 2: an optional value from the same argument
76  const valueKind = (ch: string) => {
77    const i = short.indexOf(ch)
78    if (i < 0 || ch === ':') return 0
79    if (spec.shortTakesValue) return 1
80    return short[i + 1] !== ':' ? 0 : short[i + 2] === ':' ? 2 : 1
81  }
82  for (let i = 0; i < args.length; i++) {
83    const a = args[i]!
84    if (a === '--' && !spec.words) {
85      operands.push(...args.slice(i + 1))
86      break
87    }
88    if (spec.words && a.startsWith('-') && a.length > 1) {
89      let arity = spec.words[a]
90      if (arity === undefined)
91        for (const [re, n] of Object.entries(spec.wordPatterns ?? {})) if (new RegExp(re).test(a)) arity = n
92      opts.push(a)
93      if (Array.isArray(arity)) {
94        // The embedded command runs up to a terminator
95        while (i + 1 < args.length && !arity.includes(args[i]!)) i++
96      } else i += arity ?? 0
97      continue
98    }
99    if (a.startsWith('--') && a.length > 2 && spec.long) {
100      const eq = a.indexOf('=')
101      const name = eq < 0 ? a.slice(2) : a.slice(2, eq)
102      const mapped = spec.long[name] ?? name
103      const reported = mapped.endsWith(':') ? mapped.slice(0, -1) : mapped
104      opts.push(reported)
105      if (eq >= 0) values.push([reported, a.slice(eq + 1)])
106      else if (mapped.endsWith(':') && i + 1 < args.length) values.push([reported, args[++i]!])
107      continue
108    }
109    if (/^-[^-]/.test(a) && !spec.words) {
110      for (let j = 1; j < a.length; j++) {
111        const ch = a[j]!
112        opts.push(ch)
113        const n = valueKind(ch)
114        if (!n) continue
115        if (j < a.length - 1) values.push([ch, a.slice(j + 1)])
116        else if (n === 1 && !spec.shortTakesValue && i + 1 < args.length) values.push([ch, args[++i]!])
117        break
118      }
119      continue
120    }
121    operands.push(a)
122    if (spec.posix) {
123      operands.push(...args.slice(i + 1))
124      break
125    }
126  }
127  return { opts, operands, values }
128}
129
130// Quote a word the way a shell would need it, so a displayed command is unambiguous
131export const shellQuote = (w: string) => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`)
132
hooks/shell.ts 241 lines
1// Bash command analysis on top of a real parser (vendored unbash), so rules see
2// commands, not substrings: quoting, pipelines, subshells, $(...) and `...` are
3// all handled by the parser.
4import { parse } from './vendor/unbash/parser.js'
5
6export interface SimpleCommand {
7  name: string // basename of the program, wrappers (sudo, env, ...) removed
8  prog: string // the program as written, path included (`/bin/ls`)
9  args: string[] // its arguments, quotes removed
10  wrappers: string[] // wrappers removed from in front of it (and of a shell running it), outermost first
11  wrapped: string[][] // per wrapper, parallel to `wrappers`: that wrapper's whole invocation, options and wrapped command included
12  captured: boolean // its stdout is consumed: left of a pipe, or inside $(...), `...` or <(...)
13  redirects: Redirection[] // its own, then those of the compound commands and shells (`bash -c`) around it, innermost first
14  stdout: string // where those redirects send its stdout: a file, `&N` for another descriptor, or '' if they don't
15}
16
17export interface Redirection {
18  op: string // > >> >| < <> <& >& &> &>> << <<- <<<
19  fd: number // the descriptor written before the operator, -1 if none, -2 for a {var} one
20  target: string // the file, descriptor or here-string word; a heredoc's delimiter
21  body: string // what a heredoc or here-string feeds to stdin; '' for the others
22  quoted: boolean // a heredoc whose delimiter is quoted, so its body is not expanded
23}
24
25// Wrappers that run another command given as their first non-option argument.
26// argOpts: short options that consume a following argument (`sudo -u root`).
27// positionals: leading positionals owned by the wrapper (`timeout 5 cmd`).
28const WRAPPERS: Record<string, { argOpts?: string; positionals?: number }> = {
29  sudo: { argOpts: 'ugCDhprtUR' },
30  doas: { argOpts: 'uC' },
31  run0: { argOpts: 'ugD' },
32  env: { argOpts: 'uCS' },
33  command: {},
34  builtin: {},
35  exec: { argOpts: 'a' },
36  nohup: {},
37  time: { argOpts: 'fo' },
38  nice: { argOpts: 'n' },
39  ionice: { argOpts: 'cnp' },
40  setsid: {},
41  stdbuf: { argOpts: 'ioe' },
42  timeout: { argOpts: 'ks', positionals: 1 },
43  xargs: { argOpts: 'aEeIiLnPsd' },
44}
45
46// Shells whose `-c` argument is itself a script
47const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
48
49const MAX_DEPTH = 8
50
51const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1)
52
53const argText = (a: any): string => (a.type === 'Assignment' ? a.text : a.value)
54
55function redirection(r: any): Redirection {
56  const fd = typeof r.descriptor?.value === 'number' ? r.descriptor.value : r.descriptor ? -2 : -1
57  if (r.type === 'HereDoc')
58    return { op: r.operator, fd, target: r.delimiter?.value ?? '', body: r.body?.text ?? '', quoted: !!r.delimiter?.quoted }
59  const target = r.target?.value ?? ''
60  return { op: r.operator, fd, target, body: r.type === 'HereString' ? target + '\n' : '', quoted: false }
61}
62
63// Where one command's (or compound's) own redirects send stdout: the last that touches it wins
64function stdoutOf(redirects: readonly Redirection[]): string {
65  let out = ''
66  for (const r of redirects) {
67    if (r.op === '&>' || r.op === '&>>') out = r.target
68    else if (r.fd !== -1 && r.fd !== 1) continue
69    else if (r.op === '>' || r.op === '>>' || r.op === '>|') out = r.target
70    // >&2 duplicates a descriptor; >&file is &>file
71    else if (r.op === '>&') out = /^(\d+|-)$/.test(r.target) ? `&${r.target}` : r.target
72  }
73  return out
74}
75
76function unwrap(words: string[]): { words: string[]; wrappers: string[]; wrapped: string[][] } {
77  const wrappers: string[] = []
78  const wrapped: string[][] = []
79  for (let guard = 0; words.length && guard < 16; guard++) {
80    const prog = basename(words[0]!)
81    const w = WRAPPERS[prog]
82    if (!w) break
83    wrappers.push(prog)
84    wrapped.push(words)
85    let i = 1
86    let skip = w.positionals ?? 0
87    for (; i < words.length; i++) {
88      const t = words[i]!
89      if (t === '--') {
90        i++
91        break
92      }
93      if (t.startsWith('-') && t.length > 1) {
94        if (!t.startsWith('--') && t.length === 2 && w.argOpts?.includes(t[1]!)) i++
95        continue
96      }
97      if (prog === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) continue
98      if (skip > 0) {
99        skip--
100        continue
101      }
102      break
103    }
104    words = words.slice(i)
105  }
106  return { words, wrappers, wrapped }
107}
108
109// Every child of a node. The parser's lazy fields (parts, elements, index,
110// expression, ...) are not own properties, but its toJSON() lists them all.
111function children(node: any): any[] {
112  return Object.values(typeof node.toJSON === 'function' ? node.toJSON() : node)
113}
114
115type Visit = (n: any, captured: boolean, outer: readonly Redirection[][]) => void
116
117// `outer`: the redirects of each compound command around the node, innermost first
118function walk(node: any, visit: Visit, captured = false, outer: readonly Redirection[][] = [], seen = new Set<any>()): void {
119  if (!node || typeof node !== 'object' || seen.has(node)) return
120  seen.add(node)
121  if (Array.isArray(node)) {
122    for (const n of node) walk(n, visit, captured, outer, seen)
123    return
124  }
125  if (typeof node.type === 'string') visit(node, captured, outer)
126  if (node.type === 'Pipeline') {
127    // Every stage but the last feeds the next one, so its stdout is captured
128    const last = node.commands.length - 1
129    node.commands.forEach((c: any, i: number) => walk(c, visit, captured || i < last, outer, seen))
130    return
131  }
132  // A substitution's output goes to the command around it, not to that command's redirects
133  const expansion = node.type === 'CommandExpansion' || node.type === 'ProcessSubstitution'
134  // $(...), `...` and <(...) hand the output to the surrounding command
135  const inner = captured || node.type === 'CommandExpansion' || (node.type === 'ProcessSubstitution' && node.operator === '<')
136  const redirs = expansion ? [] : node.type === 'Redirected' ? [node.redirects.map(redirection), ...outer] : outer
137  for (const v of children(node)) walk(v, visit, inner, redirs, seen)
138}
139
140export interface Analysis {
141  commands: SimpleCommand[]
142  errors: string[] // parse errors, including those in nested scripts; empty if well-formed
143  untilClauses: SimpleCommand[][] // per `until` / `while !` loop, the commands of its condition
144  pipelines: SimpleCommand[][] // per pipeline, its stages in order; a stage that is not a simple command has name ''
145}
146
147/** Every simple command in `source`, including those nested in subshells,
148 *  substitutions, control structures and `sh -c '...'` / `eval` strings, plus
149 *  any parse errors. A non-empty `errors` means `commands` may be incomplete. */
150export function analyze(source: string, depth = 0): Analysis {
151  const out: Analysis = { commands: [], errors: [], untilClauses: [], pipelines: [] }
152  if (depth > MAX_DEPTH) {
153    out.errors.push('nesting too deep')
154    return out
155  }
156  let ast: any
157  try {
158    ast = parse(source)
159  } catch (err) {
160    out.errors.push(String((err as Error)?.message ?? err))
161    return out
162  }
163  // A nested script's commands inherit the shell's wrappers (`sudo sh -c ...`), capture and redirects
164  const nested = (script: string, shell: SimpleCommand) => {
165    const sub = analyze(script, depth + 1)
166    const lift = new Map<SimpleCommand, SimpleCommand>()
167    for (const c of sub.commands) {
168      const redirects = [...c.redirects, ...shell.redirects]
169      lift.set(c, {
170        ...c,
171        wrappers: [...shell.wrappers, ...c.wrappers],
172        wrapped: [...shell.wrapped, ...c.wrapped],
173        captured: c.captured || shell.captured,
174        redirects,
175        stdout: c.stdout || shell.stdout,
176      })
177    }
178    const lifted = (c: SimpleCommand) => lift.get(c) ?? c
179    out.commands.push(...sub.commands.map(lifted))
180    out.errors.push(...sub.errors)
181    out.untilClauses.push(...sub.untilClauses.map((u) => u.map(lifted)))
182    out.pipelines.push(...sub.pipelines.map((p) => p.map(lifted)))
183  }
184  const byNode = new Map<any, SimpleCommand>()
185  const pipelines: any[] = []
186  try {
187    walk(ast, (n, captured, outer) => {
188      if (n.type === 'Pipeline') pipelines.push({ node: n, captured })
189      for (const e of n.errors ?? []) out.errors.push(`${e.message} at ${e.pos}`)
190      if (n.type === 'While') {
191        const clause = source.slice(n.clause.pos, n.clause.end)
192        // `while ! cond` waits for cond to succeed, exactly like `until cond`
193        if (n.kind === 'until' || clause.trimStart().startsWith('!')) {
194          const sub = analyze(clause, depth + 1)
195          out.untilClauses.push(sub.commands)
196          out.errors.push(...sub.errors)
197        }
198      }
199      if (n.type !== 'Command' || !n.name) return
200      let { words, wrappers, wrapped } = unwrap([n.name.value, ...n.args.map(argText)])
201      // A wrapper with nothing to run (`sudo -v`) is itself the command
202      if (!words.length) {
203        const last = wrappers.pop()
204        const call = wrapped.pop()
205        if (!last || !call) return
206        words = call
207      }
208      const prog = words[0]!
209      const name = basename(prog)
210      const args = words.slice(1)
211      const own: Redirection[] = n.redirects.map(redirection)
212      const stdout = [own, ...outer].map(stdoutOf).find((s) => s) ?? ''
213      const cmd: SimpleCommand = { name, prog, args, wrappers, wrapped, captured, redirects: [...own, ...outer.flat()], stdout }
214      out.commands.push(cmd)
215      byNode.set(n, cmd)
216
217      // Re-parse script strings handed to another shell
218      if (SHELLS.has(name)) {
219        const c = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
220        const script = args[c + 1]
221        if (c >= 0 && script !== undefined) nested(script, cmd)
222      } else if (name === 'eval') {
223        nested(args.join(' '), cmd)
224      }
225    })
226    for (const { node, captured } of pipelines)
227      out.pipelines.push(
228        node.commands.map(
229          (c: any, i: number): SimpleCommand =>
230            byNode.get(c) ?? { name: '', prog: '', args: [], wrappers: [], wrapped: [], captured: captured || i < node.commands.length - 1, redirects: [], stdout: '' },
231        ),
232      )
233  } catch (err) {
234    // A walk that dies midway has not seen every command
235    out.errors.push(`analysis failed: ${(err as Error)?.message ?? err}`)
236  }
237  return out
238}
239
240export const simpleCommands = (source: string): SimpleCommand[] => analyze(source).commands
241
hooks/builtin-rules.ts 222 lines
1// The built-in rules, written in the same rule-file format as custom rules (see
2// README.md) and compiled by the same engine. Each is toggled by the userConfig
3// option named after its id (find-xdev -> find_xdev). This is a .ts module only
4// because a hooks module can import nothing but code files.
5import type { RuleFile } from './engine.ts'
6
7// CEL fragments shared by the rules that point to the Read, Write and Edit tools.
8// Each takes the CEL expression for a Cmd (or for a list of file operands).
9
10// Its output reaches the tool result: not piped, substituted or redirected
11const toTerminal = (c: string) => `!${c}.captured && ${c}.stdout == ''`
12// It runs as the user, not behind sudo, run0 or doas (directly or through `sudo sh -c`),
13// so the file tools can reach the files it does
14const unelevated = (c: string) => `!${c}.wrappers.exists(w, w in ELEVATE)`
15// Operands that are regular files the file tools can open
16const someFiles = (f: string) => `size(${f}) > 0 && ${f}.all(x, x != '-' && !x.matches(SPECIAL_FILE))`
17const noFiles = (f: string) => `size(${f}) == 0`
18// cat that only shows files (or the one its stdin is redirected from), at most numbering lines
19const catShows = (c: string) =>
20  `${c}.name == 'cat' && ${unelevated(c)} && ${c}.args.opts(CAT).all(o, o in ['b', 'n', 's', 'u']) && cel.bind(f, ${c}.args.operands(CAT), size(f) > 0 ? ${someFiles('f')} : ${c}.redirects.exists(r, r.op == '<' && r.fd <= 0 && !r.target.matches(SPECIAL_FILE)))`
21// head or tail picking lines (not bytes, not following)
22const headPicks = (c: string, names: string, files: (f: string) => string) =>
23  `${c}.name in ${names} && ${c}.args.opts(HEAD_TAIL).all(o, o in ['n', 'q', 'v'] || o.matches('^[0-9]$')) && cel.bind(f, ${c}.args.operands(HEAD_TAIL), ${files('f')})`
24// sed -n 'X,Yp', sed 'X,Y!d' or sed 'Nq': the script from -e, or else the first operand.
25// Not with -z, whose "lines" are NUL-separated records
26const sedPicks = (c: string, files: (f: string) => string) =>
27  `${c}.name == 'sed' && cel.bind(o, ${c}.args.opts(SED), !('i' in o) && !('f' in o) && !('z' in o) && cel.bind(e, ${c}.args.optValues(SED, 'e'), cel.bind(ops, ${c}.args.operands(SED), cel.bind(scripts, size(e) > 0 ? e : ops.take(1), size(scripts) > 0 && scripts.all(s, s.matches('n' in o ? SED_PRINT : SED_KEEP)) && cel.bind(f, size(e) > 0 ? ops : ops.drop(1), ${files('f')})))))`
28// awk 'NR>=X && NR<=Y': the program is the first operand; var=value operands are not files
29const awkPicks = (c: string, files: (f: string) => string) =>
30  `${c}.name in AWKS && cel.bind(o, ${c}.args.opts(AWK), !('f' in o) && !('e' in o) && cel.bind(ops, ${c}.args.operands(AWK), size(ops) > 0 && ops[0].matches(AWK_LINES) && cel.bind(f, ops.drop(1).filter(x, !x.matches('^[A-Za-z_][A-Za-z0-9_]*=')), ${files('f')})))`
31// Its stdin is a heredoc or here-string of fixed text (nothing for the shell to expand)
32const literalStdin = (c: string) =>
33  `${c}.redirects.exists(r, r.op in ['<<', '<<-', '<<<'] && r.fd <= 0 && (r.quoted || !r.body.matches(EXPANSION)))`
34const catFromLiteral = (c: string) => `${c}.name == 'cat' && ${c}.args.operands(CAT).all(x, x == '-') && ${literalStdin(c)}`
35const teeFiles = (c: string) => `${c}.name == 'tee' && ${unelevated(c)} && cel.bind(f, ${c}.args.operands(TEE), size(f) > 0 && f.all(x, !x.matches(SPECIAL_FILE)))`
36// A Python script, from -c or from a heredoc on stdin, that matches every regex in PY_EDIT
37const pyEdits = (s: string) => `PY_EDIT.all(re, ${s}.matches(re))`
38const pythonEdits = (c: string) =>
39  `${c}.name.matches(PYTHON_NAME) && ${unelevated(c)} && cel.bind(o, ${c}.args.opts(PYTHON), ${c}.args.optValues(PYTHON, 'c').exists(s, ${pyEdits('s')}) || !('c' in o) && !('m' in o) && cel.bind(ops, ${c}.args.operands(PYTHON), size(ops) == 0 || ops[0] == '-') && ${c}.redirects.exists(r, r.op in ['<<', '<<-', '<<<'] && r.fd <= 0 && ${pyEdits('r.body')}))`
40
41export const builtinRules: RuleFile = {
42  defs: {
43    // find's leading options (-H -L -P, -D <debugopts>, -O<level>); its start points follow
44    FIND_LEAD: { short: 'HLPD:O:', posix: true },
45    // find's expression: primaries that consume a value, which may itself look like an option
46    FIND_EXPR: {
47      words: {
48        '-D': 1,
49        '-name': 1, '-iname': 1, '-path': 1, '-ipath': 1, '-wholename': 1, '-iwholename': 1, '-regex': 1, '-iregex': 1,
50        '-lname': 1, '-ilname': 1, '-type': 1, '-xtype': 1, '-user': 1, '-group': 1, '-perm': 1, '-size': 1,
51        '-newer': 1, '-anewer': 1, '-cnewer': 1, '-samefile': 1, '-mtime': 1, '-atime': 1, '-ctime': 1, '-mmin': 1,
52        '-amin': 1, '-cmin': 1, '-links': 1, '-inum': 1, '-uid': 1, '-gid': 1, '-used': 1, '-fstype': 1, '-printf': 1,
53        '-fprintf': 1, '-fprint': 1, '-fprint0': 1, '-fls': 1, '-context': 1, '-maxdepth': 1, '-mindepth': 1, '-newerXY': 1,
54        '-exec': [';', '+'], '-execdir': [';', '+'], '-ok': [';', '+'], '-okdir': [';', '+'],
55      },
56      wordPatterns: { '^-newer[aBcmt][aBcmt]$': 1 },
57    },
58    // BSD/macOS find's leading flags (-E -H -L -P -X -d -s -x) with -x, its spelling of -xdev
59    FIND_BSD_XDEV: '^-[EHLPXdsx]*x[EHLPXdsx]*$',
60    // GNU find's informational primaries: find prints and exits, searching nothing
61    FIND_INFO: ['-help', '--help', '-version', '--version'],
62    // pgrep/pkill options that take a value, so in `-uf` the f is a user name, not -f
63    PGREP: { short: 'dFgGJOPrstTuU', shortTakesValue: true, long: { full: 'f', 'list-full': 'a' } },
64    GREPS: ['grep', 'egrep', 'fgrep', 'rg'],
65    // Files the Read, Write and Edit tools are not for
66    SPECIAL_FILE: '^/(dev|proc|sys)/',
67    // Wrappers that run a command as another user, whose files the file tools may not reach
68    ELEVATE: ['sudo', 'run0', 'doas'],
69    // Text the shell would expand in an unquoted heredoc
70    EXPANSION: '[$`]',
71    // cat's options; with nothing but -b -n -s -u it only shows the file
72    CAT: {
73      short: 'AbeEnstTuv',
74      long: { 'show-all': 'A', 'number-nonblank': 'b', 'show-ends': 'E', number: 'n', 'squeeze-blank': 's', 'show-tabs': 'T', 'show-nonprinting': 'v' },
75    },
76    HEAD_TAIL: {
77      short: 'c:n:s:fFqvz',
78      long: {
79        bytes: 'c:', lines: 'n:', 'sleep-interval': 's:', pid: 'pid:', 'max-unchanged-stats': 'max-unchanged-stats:',
80        follow: 'f', quiet: 'q', silent: 'q', verbose: 'v', 'zero-terminated': 'z',
81      },
82    },
83    // GNU sed; -i takes an optional suffix in the same argument (-i.bak)
84    SED: {
85      short: 'nrEsuzi::e:f:l:',
86      long: {
87        quiet: 'n', silent: 'n', 'in-place': 'i', expression: 'e:', file: 'f:', 'line-length': 'l:',
88        'regexp-extended': 'E', separate: 's', unbuffered: 'u', 'null-data': 'z',
89      },
90    },
91    // A sed -n script that prints a line or a range of lines (X,Y X,$ X,+N), maybe quitting after
92    SED_PRINT: String.raw`^\s*(\d+|\$)(\s*,\s*(\d+|\$|\+\d+))?\s*p\s*(;\s*\d+\s*q\s*)?;?\s*$`,
93    // A sed script (without -n) that keeps only a range of lines: X,Y!d or Nq
94    SED_KEEP: String.raw`^\s*((\d+|\$)(\s*,\s*(\d+|\$|\+\d+))?\s*!\s*d|\d+\s*q)\s*;?\s*$`,
95    // A sed script of nothing but s commands (on every line, or on a line or range; g included,
96    // since Edit's replace_all does the same) and deletions of a line or range, separated by ; or newlines
97    SED_EDIT: String.raw`^[\s;]*(?:(?:(?:(?:\d+|\$)(?:[ \t]*,[ \t]*(?:\d+|\$))?[ \t]*)?s([^\\\n\sA-Za-z0-9])(?:\\.|(?!\1)[^\\\n])*\1(?:\\.|(?!\1)[^\\\n])*\1[0-9gpIiMm]*|(?:\d+|\$)(?:[ \t]*,[ \t]*(?:\d+|\$))?[ \t]*d)[ \t]*(?:[;\n][\s;]*|$))+$`,
98    AWKS: ['awk', 'gawk', 'mawk', 'nawk'],
99    AWK: { short: 'F:v:f:e:', long: { 'field-separator': 'F:', assign: 'v:', file: 'f:', source: 'e:' }, posix: true },
100    // An awk program that prints a line or a range of lines by NR (or FNR), maybe exiting after
101    AWK_LINES: String.raw`^\s*F?NR\s*(==|>=|<=|>|<)\s*\d+\s*((&&|,)\s*F?NR\s*(==|>=|<=|>|<)\s*\d+\s*)?(\{\s*(print(\s+\$0)?\s*;?\s*)?(exit\s*;?\s*)?\}\s*)?$`,
102    TEE: { short: 'aip', long: { append: 'a', 'ignore-interrupts': 'i' } },
103    PYTHON_NAME: String.raw`^python(3(\.\d+)?)?$`,
104    // Options stop at the first operand: the script file, or - for stdin
105    PYTHON: { short: 'bBdEhiIOPqsSuvVxc:m:W:X:', posix: true },
106    // A Python script that edits a file: it imports re or calls .replace(), opens a file
107    // (open() or pathlib), writes (.write(), .write_text(), .write_bytes() or .writelines()) and has a
108    // multiline (triple-quoted) string literal. It must match all four.
109    PY_EDIT: [
110      String.raw`(^|[\n;])[ \t]*(import[ \t]+([\w.]+([ \t]+as[ \t]+\w+)?[ \t]*,[ \t]*)*re(?![\w.])|from[ \t]+re[ \t]+import\b)|\.replace\s*\(`,
111      String.raw`\bopen\s*\(|\bpathlib\b`,
112      String.raw`\.write(_text|_bytes|lines)?\s*\(`,
113      `"{3}|'{3}`,
114    ],
115  },
116  rules: [
117    {
118      id: 'monitor-disabled',
119      tools: ['Monitor'],
120      when: 'true',
121      deny: 'Monitor is disabled. Use Bash with run_in_background to spawn a blocking waiter that exits on the next event.',
122    },
123    {
124      // A ws source streams a WebSocket and runs no shell, so there is nothing to check.
125      // With neither field the shell rules would be checking an empty string: fail closed.
126      id: 'monitor-no-command',
127      tools: ['Monitor'],
128      when: '!(has(input.command) && type(input.command) == string) && !has(input.ws)',
129      deny: 'Monitor call should have either a command or a ws source.',
130    },
131    {
132      // First among the shell rules: if the command can't be parsed, the rules below can't be trusted to see all of it
133      id: 'malformed-bash',
134      when: 'size(errors) > 0',
135      deny: 'Malformed bash command ({{ errors[0] }}). Fix the syntax and retry.',
136    },
137    {
138      // `/*` expands to every top-level entry, which scans the whole filesystem too
139      id: 'find-root',
140      when: "cmds.exists(c, c.name == 'find' && c.args.operands(FIND_LEAD).takeWhile(r'^(?!-|[(!]$)').exists(p, p.matches(r'^/+(\\./?)*\\*?$')))",
141      deny: 'find rooted at / is disabled. Scan a specific directory instead.',
142    },
143    {
144      // -mount is the traditional spelling of -xdev, -x the BSD one; --help and --version search nothing
145      id: 'find-xdev',
146      when: "cmds.exists(c, c.name == 'find' && !c.args.opts(FIND_EXPR).exists(o, o in ['-xdev', '-mount'] || o.matches(FIND_BSD_XDEV) || o in FIND_INFO))",
147      deny: 'find must specify -xdev so it does not cross filesystem boundaries. To search several filesystems, issue a separate find -xdev per filesystem.',
148    },
149    {
150      // pkill has no list-full option, so -a does not help it
151      id: 'pgrep-f',
152      when: "cmds.exists(c, cel.bind(o, c.args.opts(PGREP), 'f' in o && (c.name == 'pkill' || c.name == 'pgrep' && !('a' in o))))",
153      deny: 'pgrep -f and pkill -f can match helper processes spawned by the harness, and pkill would kill them. Use pgrep -af and confirm each match cmdline by yourself.',
154    },
155    {
156      id: 'pgrep-captured',
157      when: "cmds.exists(c, c.name == 'pgrep' && c.captured)",
158      deny: 'Do not capture pgrep output in a pipe or substitution ($(...), `...`, <(...)). Let pgrep print straight to stdout and read the result.',
159    },
160    {
161      id: 'until-grep',
162      when: 'untilConds.exists(cond, cond.exists(c, c.name in GREPS))',
163      deny: 'An "until grep ..." (or "while ! grep ...") loop never exits if the process writing the log silently dies. Watch the process instead: tail -f --pid=<PID> <log> | grep -m1 <pattern> (tail stops when the process exits, ending the pipeline either way).',
164    },
165    {
166      id: 'sudo',
167      when: "cmds.exists(c, c.chain.exists(l, l.name == 'sudo'))",
168      deny: 'sudo is disabled: passwordless sudo is insecure, and a password prompt needs a TTY, which Bash processes spawned by Claude Code do not have. Use run0 instead, which shows the user a graphical auth prompt every time and does not cache authentication.',
169    },
170    {
171      // cat whose output reaches the tool result, alone or through line pickers (cat f | head)
172      id: 'cat-read',
173      tools: ['Bash'],
174      when: `cmds.exists(c, ${toTerminal('c')} && ${catShows('c')}) || pipelines.exists(p, size(p) >= 2 && ${catShows('p[0]')} && p.all(st, st.stdout == '') && !p[size(p) - 1].captured && p.drop(1).all(st, ${headPicks('st', "['head', 'tail']", noFiles)} || ${sedPicks('st', noFiles)} || ${awkPicks('st', noFiles)}))`,
175      deny: 'Use the Read tool to view a file, not cat (alone or piped into head, tail, sed or awk). Read numbers the lines, and its offset and limit select a range of them. cat is still fine when its output feeds another command or a file.',
176    },
177    {
178      // A standalone tail is left alone: Read can't count lines back from the end
179      id: 'line-range-read',
180      tools: ['Bash'],
181      when: `cmds.exists(c, ${toTerminal('c')} && ${unelevated('c')} && (${headPicks('c', "['head']", someFiles)} || ${sedPicks('c', someFiles)} || ${awkPicks('c', someFiles)}))`,
182      deny: "Use the Read tool with offset and limit to view lines of a file, not head, sed -n 'X,Yp' or awk 'NR>=X && NR<=Y'.",
183    },
184    {
185      // cat <<EOF > file, cat <<EOF | tee file, tee file <<EOF. A heredoc the shell expands
186      // ($var, $(cmd)) has content only the shell knows, so it is left alone.
187      id: 'heredoc-write',
188      tools: ['Bash'],
189      when: `cmds.exists(c, ${catFromLiteral('c')} && ${unelevated('c')} && c.stdout != '' && !c.stdout.startsWith('&') && !c.stdout.matches(SPECIAL_FILE) || ${teeFiles('c')} && ${literalStdin('c')}) || pipelines.exists(p, size(p) == 2 && ${catFromLiteral('p[0]')} && ${teeFiles('p[1]')})`,
190      deny: 'Use the Write tool to create or overwrite a file (or Edit to add to one), not a heredoc through cat or tee.',
191    },
192    {
193      // A substitution over several files is a batch job and stays allowed
194      id: 'sed-edit',
195      tools: ['Bash'],
196      when: "cmds.exists(c, c.name == 'sed' && !c.wrappers.exists(w, w in ELEVATE) && cel.bind(o, c.args.opts(SED), 'i' in o && !('n' in o) && !('f' in o) && cel.bind(e, c.args.optValues(SED, 'e'), cel.bind(ops, c.args.operands(SED).filter(x, x != ''), cel.bind(scripts, size(e) > 0 ? e : ops.take(1), size(scripts) > 0 && scripts.all(s, s.matches(SED_EDIT)) && size(size(e) > 0 ? ops : ops.drop(1)) == 1)))))",
197      deny: 'Use the Edit tool to change a file, not sed -i: Edit shows the exact change and fails if the text is not there.',
198    },
199    {
200      id: 'python-edit',
201      tools: ['Bash'],
202      when: `cmds.exists(c, ${pythonEdits('c')})`,
203      deny: 'Use the Edit tool (with replace_all to change every occurrence) to change a file, not a Python script that rewrites it with re or .replace() and a multiline string.',
204    },
205    {
206      // run0's graphical prompt does not show the wrapped command, so show it here first.
207      // Each run0 in a chain (`env X=1 run0 ls`) reports its own tail.
208      id: 'run0-confirm',
209      let: { items: "cmds.map(c, c.chain.filter(l, l.name == 'run0').map(l, shquote(l.argv))).flatten().distinct()" },
210      when: 'size(items) > 0',
211      ask: {
212        header: 'run0',
213        question: "Allow Claude to run with elevated privileges via run0?\n\n{{ items.map(i, '  ' + i).join('\\n') }}\n\nFull {{ tool }} command:\n{{ input.command }}\n\nAllow?",
214        options: ['Allow', 'Deny'],
215        allowIf: "answer == 'Allow'",
216        declined: "The user declined run0{{ answer == 'Deny' ? '' : ': ' + answer }}. Do not retry without asking.",
217        dismissed: 'run0 was not approved: the confirmation was dismissed or no one could be asked.',
218      },
219    },
220  ],
221}
222
hooks/vendor/unbash/parser.js 1296 lines
1import { hasEmbeddedWordStructure, LexContext, MAX_SYNTAX_NESTING, Token, Lexer, TokenValue } from "./lexer.js";
2import { parseArithmeticExpression } from "./arithmetic.js";
3import { computeWordParts, computeEmbeddedWordParts, computeHereDocBodyParts } from "./parts.js";
4import { WordImpl } from "./word.js";
5import { AssignmentImpl } from "./assignment.js";
6import { CommandImpl } from "./command.js";
7import { HereDocBodyImpl } from "./heredoc.js";
8WordImpl._resolveWord = computeWordParts;
9HereDocBodyImpl._resolveParts = computeHereDocBodyParts;
10function isDeclarationCommand(name) {
11    switch (name.length) {
12        case 5:
13            return name === "local" || name === "alias";
14        case 6:
15            return name === "export";
16        case 7:
17            return name === "declare" || name === "typeset";
18        case 8:
19            return name === "readonly";
20        default:
21            return false;
22    }
23}
24class ArithmeticCommandImpl {
25    type = "ArithmeticCommand";
26    pos;
27    end;
28    body;
29    #source;
30    #depth;
31    #expression = null;
32    constructor(pos, end, body, source, depth) {
33        this.pos = pos;
34        this.end = end;
35        this.body = body;
36        this.#source = source;
37        this.#depth = depth;
38    }
39    get expression() {
40        if (this.#expression === null) {
41            this.#expression = parseArithmeticWithParts(this.body, this.pos + 2, this.#source, this.#depth);
42        }
43        return this.#expression;
44    }
45    set expression(v) {
46        this.#expression = v ?? undefined;
47    }
48    toJSON() {
49        return {
50            type: this.type,
51            pos: this.pos,
52            end: this.end,
53            expression: this.expression,
54            body: this.body,
55        };
56    }
57}
58class ArithmeticForImpl {
59    type = "ArithmeticFor";
60    pos;
61    end;
62    body;
63    #initStr;
64    #testStr;
65    #updateStr;
66    #initPos;
67    #testPos;
68    #updatePos;
69    #source;
70    #depth;
71    #initialize = null;
72    #test = null;
73    #update = null;
74    constructor(pos, end, body, initStr, testStr, updateStr, initPos, testPos, updatePos, source, depth) {
75        this.pos = pos;
76        this.end = end;
77        this.body = body;
78        this.#initStr = initStr;
79        this.#testStr = testStr;
80        this.#updateStr = updateStr;
81        this.#initPos = initPos;
82        this.#testPos = testPos;
83        this.#updatePos = updatePos;
84        this.#source = source;
85        this.#depth = depth;
86    }
87    get initialize() {
88        if (this.#initialize === null) {
89            if (this.#initStr) {
90                this.#initialize = parseArithmeticWithParts(this.#initStr, this.#initPos, this.#source, this.#depth);
91            }
92            else {
93                this.#initialize = undefined;
94            }
95        }
96        return this.#initialize;
97    }
98    set initialize(v) {
99        this.#initialize = v ?? undefined;
100    }
101    get test() {
102        if (this.#test === null) {
103            if (this.#testStr) {
104                this.#test = parseArithmeticWithParts(this.#testStr, this.#testPos, this.#source, this.#depth);
105            }
106            else {
107                this.#test = undefined;
108            }
109        }
110        return this.#test;
111    }
112    set test(v) {
113        this.#test = v ?? undefined;
114    }
115    get update() {
116        if (this.#update === null) {
117            if (this.#updateStr) {
118                this.#update = parseArithmeticWithParts(this.#updateStr, this.#updatePos, this.#source, this.#depth);
119            }
120            else {
121                this.#update = undefined;
122            }
123        }
124        return this.#update;
125    }
126    set update(v) {
127        this.#update = v ?? undefined;
128    }
129    toJSON() {
130        return {
131            type: this.type,
132            pos: this.pos,
133            end: this.end,
134            initialize: this.initialize,
135            test: this.test,
136            update: this.update,
137            body: this.body,
138        };
139    }
140}
141const CASE_TERMINATORS = {
142    [Token.DoubleSemi]: ";;",
143    [Token.SemiAmp]: ";&",
144    [Token.DoubleSemiAmp]: ";;&",
145};
146const REDIRECT_OPS = {
147    ">": ">",
148    ">>": ">>",
149    "<": "<",
150    "<>": "<>",
151    "<&": "<&",
152    ">&": ">&",
153    ">|": ">|",
154    "&>": "&>",
155    "&>>": "&>>",
156};
157function parseArithmeticWithParts(body, offset, source, depth = 0) {
158    if (!hasEmbeddedWordStructure(source, offset, offset + body.length)) {
159        return parseArithmeticExpression(body, offset) ?? undefined;
160    }
161    const commandExpansions = [];
162    const embeddedWords = [];
163    const lexer = new Lexer(source);
164    const expression = parseArithmeticExpression(body, offset, {
165        commandExpansions,
166        embeddedWords,
167        findClosingBracket: (start, end) => lexer.findClosingBracket(start, end),
168        findClosingBrace: (start, end) => lexer.findClosingBrace(start, end),
169        findClosingParenthesis: (start, end) => lexer.findClosingParenthesis(start, end),
170        findArithmeticExpansionEnd: (start, end) => lexer.findArithmeticExpansionEnd(start, end),
171        findArithmeticWordEnd: (start, end) => lexer.findArithmeticWordEnd(start, end),
172    }) ?? undefined;
173    for (const node of commandExpansions) {
174        if (depth <= MAX_SYNTAX_NESTING) {
175            node.script = parseRegion(source, node.pos + 2, node.end - 1, depth + 1, true);
176        }
177    }
178    for (const node of embeddedWords)
179        node.parts = computeEmbeddedWordParts(source, node, depth);
180    return expression;
181}
182// Lookup tables for O(1) token classification (replaces sequential comparisons)
183const listTerminators = new Uint8Array(37);
184listTerminators[Token.EOF] = 1;
185listTerminators[Token.RParen] = 1;
186listTerminators[Token.RBrace] = 1;
187listTerminators[Token.Then] = 1;
188listTerminators[Token.Else] = 1;
189listTerminators[Token.Elif] = 1;
190listTerminators[Token.Fi] = 1;
191listTerminators[Token.Do] = 1;
192listTerminators[Token.Done] = 1;
193listTerminators[Token.Esac] = 1;
194listTerminators[Token.DoubleSemi] = 1;
195listTerminators[Token.SemiAmp] = 1;
196listTerminators[Token.DoubleSemiAmp] = 1;
197// After one of these Bash is at a command-start position, the only place a reserved-word
198// terminator may follow with no separator.
199const compoundClosers = new Uint8Array(37);
200compoundClosers[Token.RParen] = 1;
201compoundClosers[Token.RBrace] = 1;
202compoundClosers[Token.DblRBracket] = 1;
203compoundClosers[Token.Fi] = 1;
204compoundClosers[Token.Done] = 1;
205compoundClosers[Token.Esac] = 1;
206compoundClosers[Token.ArithCmd] = 1;
207// Inside `[[ ]]` only an unquoted `!` negates; `'!'` and `\!` are ordinary operands.
208function isTestNegation(t) {
209    return t.token === Token.Word && t.keywordEligible && t.value === "!";
210}
211const commandStarts = new Uint8Array(37);
212commandStarts[Token.Word] = 1;
213commandStarts[Token.Assignment] = 1;
214commandStarts[Token.Bang] = 1;
215commandStarts[Token.LParen] = 1;
216commandStarts[Token.LBrace] = 1;
217commandStarts[Token.DblLBracket] = 1;
218commandStarts[Token.If] = 1;
219commandStarts[Token.For] = 1;
220commandStarts[Token.While] = 1;
221commandStarts[Token.Until] = 1;
222commandStarts[Token.Case] = 1;
223commandStarts[Token.Function] = 1;
224commandStarts[Token.Select] = 1;
225commandStarts[Token.ArithCmd] = 1;
226commandStarts[Token.Coproc] = 1;
227commandStarts[Token.Redirect] = 1;
228const UNARY_TEST_OPS = {
229    "-a": 1,
230    "-b": 1,
231    "-c": 1,
232    "-d": 1,
233    "-e": 1,
234    "-f": 1,
235    "-g": 1,
236    "-h": 1,
237    "-k": 1,
238    "-p": 1,
239    "-r": 1,
240    "-s": 1,
241    "-t": 1,
242    "-u": 1,
243    "-v": 1,
244    "-w": 1,
245    "-x": 1,
246    "-z": 1,
247    "-n": 1,
248    "-o": 1,
249    "-N": 1,
250    "-S": 1,
251    "-L": 1,
252    "-G": 1,
253    "-O": 1,
254    "-R": 1,
255};
256const BINARY_TEST_OPS = {
257    "==": 1,
258    "!=": 1,
259    "=~": 1,
260    "=": 1,
261    "-eq": 1,
262    "-ne": 1,
263    "-lt": 1,
264    "-le": 1,
265    "-gt": 1,
266    "-ge": 1,
267    "-nt": 1,
268    "-ot": 1,
269    "-ef": 1,
270    "<": 1,
271    ">": 1,
272};
273const EMPTY_REDIRECTS = [];
274export function parse(source) {
275    return new Parser(source, 0, source.length).run();
276}
277// Parse a [start, end) window of `source` in place, so the resulting nodes index the original
278// source directly. Used to resolve substitution scripts with absolute offsets; not public API.
279export function parseRegion(source, start, end, depth = 0, parenBoundary = false) {
280    return new Parser(source, start, end, depth, parenBoundary).run();
281}
282class Parser {
283    tok;
284    source;
285    start;
286    end;
287    depth;
288    errors = null;
289    syntaxDepth = 0;
290    // `depth` counts the substitution scripts (and sub-fields) enclosing this region; it
291    // shares the MAX_SYNTAX_NESTING budget with the lexer's lazy word-part materialization.
292    constructor(source, start, end, depth = 0, parenBoundary = false) {
293        this.tok = new Lexer(source, start, end, parenBoundary);
294        this.tok._nestingDepth = depth;
295        this.source = source;
296        this.start = start;
297        this.end = end;
298        this.depth = depth;
299    }
300    run() {
301        const start = this.start;
302        // The boundary script one level past the budget still parses (one level is cheap and
303        // iterative) but is flagged: everything below it stays unresolved.
304        if (this.depth > MAX_SYNTAX_NESTING)
305            this.error("maximum substitution nesting depth exceeded", start);
306        let shebang;
307        if (start === 0 && this.source.charCodeAt(0) === 35 && this.source.charCodeAt(1) === 33) {
308            const nl = this.source.indexOf("\n");
309            shebang = nl === -1 ? this.source : this.source.slice(0, nl);
310        }
311        const commands = this.list();
312        for (;;) {
313            const unexpected = this.tok.peek(LexContext.CommandStart);
314            if (unexpected.token === Token.EOF)
315                break;
316            this.error(`unexpected token '${unexpected.value}'`, unexpected.pos);
317            // `In` cannot join `listTerminators`: `list()` shares it, and `in` must not terminate a
318            // list inside `for`/`case`.
319            if (!listTerminators[unexpected.token] && unexpected.token !== Token.In)
320                break;
321            this.tok.next(LexContext.CommandStart);
322            let separator = this.tok.peek(LexContext.CommandStart).token;
323            if (separator !== Token.Semi && separator !== Token.Newline && separator !== Token.Amp)
324                break;
325            while (separator === Token.Semi || separator === Token.Newline || separator === Token.Amp) {
326                this.tok.next(LexContext.CommandStart);
327                separator = this.tok.peek(LexContext.CommandStart).token;
328            }
329            const recovered = this.list();
330            for (let i = 0; i < recovered.length; i++)
331                commands.push(recovered[i]);
332        }
333        const lexerErrors = this.tok._errors;
334        if (lexerErrors !== null && lexerErrors.length > 0) {
335            const errors = this.errors ?? (this.errors = []);
336            for (let i = 0; i < lexerErrors.length; i++)
337                errors.push(lexerErrors[i]);
338        }
339        if (this.errors !== null && this.errors.length > 1)
340            this.errors.sort((a, b) => a.pos - b.pos);
341        return {
342            type: "Script",
343            pos: start,
344            end: this.end,
345            shebang,
346            commands,
347            errors: this.errors ?? undefined,
348        };
349    }
350    error(message, pos) {
351        (this.errors ?? (this.errors = [])).push({ message, pos });
352    }
353    skipSemi() {
354        if (this.tok.peek(LexContext.Normal).token === Token.Semi)
355            this.tok.next(LexContext.Normal);
356    }
357    accept(token, ctx = LexContext.Normal) {
358        if (this.tok.peek(ctx).token === token)
359            return this.tok.next(ctx);
360        return null;
361    }
362    acceptEnd(token, ctx = LexContext.Normal) {
363        if (this.tok.peek(ctx).token === token)
364            return this.tok.next(ctx).end;
365        return -1;
366    }
367    skipNewlines(ctx = LexContext.Normal) {
368        while (this.tok.peek(ctx).token === Token.Newline)
369            this.tok.next(ctx);
370    }
371    makeStatement(command) {
372        return {
373            type: "Statement",
374            pos: command.pos,
375            end: command.end,
376            command,
377            background: undefined,
378        };
379    }
380    // list := and_or ((';' | '&' | NEWLINE) and_or)* [';' | '&' | NEWLINE]
381    list() {
382        const commands = [];
383        this.skipNewlines(LexContext.CommandStart);
384        let t = this.tok.peek(LexContext.CommandStart).token;
385        if (listTerminators[t] || !commandStarts[t])
386            return commands;
387        const first = this.andOr();
388        if (first)
389            commands.push(this.makeStatement(first));
390        for (;;) {
391            t = this.tok.peekFollow(compoundClosers).token;
392            if (t !== Token.Semi && t !== Token.Newline && t !== Token.Amp)
393                break;
394            const isBackground = t === Token.Amp;
395            const sepEnd = this.tok.next(LexContext.Normal).end;
396            if (isBackground) {
397                const stmt = commands[commands.length - 1];
398                stmt.background = true;
399                stmt.end = sepEnd;
400            }
401            this.skipNewlines(LexContext.CommandStart);
402            t = this.tok.peek(LexContext.CommandStart).token;
403            if (listTerminators[t] || !commandStarts[t])
404                break;
405            const node = this.andOr();
406            if (node)
407                commands.push(this.makeStatement(node));
408        }
409        return commands;
410    }
411    // and_or := pipeline (('&&' | '||') newlines pipeline)*
412    andOr() {
413        const first = this.pipeline();
414        if (!first)
415            return null;
416        let t = this.tok.peek(LexContext.Normal).token;
417        if (t !== Token.And && t !== Token.Or)
418            return first;
419        const commands = [first];
420        const operators = [];
421        do {
422            const operatorToken = this.tok.next(LexContext.Normal);
423            const operator = operatorToken.token === Token.And ? "&&" : "||";
424            this.skipNewlines(LexContext.CommandStart);
425            const next = this.pipeline();
426            if (!next) {
427                this.error(`expected command after '${operator}'`, operatorToken.end);
428                break;
429            }
430            operators.push(operator);
431            commands.push(next);
432            t = this.tok.peek(LexContext.Normal).token;
433        } while (t === Token.And || t === Token.Or);
434        return {
435            type: "AndOr",
436            pos: first.pos,
437            end: commands[commands.length - 1].end,
438            commands,
439            operators,
440        };
441    }
442    withRedirects(command) {
443        const redirects = this.collectTrailingRedirects();
444        if (redirects.length === 0)
445            return command;
446        return { type: "Redirected", pos: command.pos, end: redirects[redirects.length - 1].end, command, redirects };
447    }
448    pipeline() {
449        let prefixes;
450        let exceeded = false;
451        for (;;) {
452            const token = this.tok.peek(LexContext.CommandStart);
453            const isTime = token.token === Token.Word && token.keywordEligible && token.value === "time";
454            if (token.token !== Token.Bang && !isTime)
455                break;
456            const prefix = this.tok.next(LexContext.CommandStart);
457            const pos = prefix.pos;
458            const keywordEnd = prefix.end;
459            let posix;
460            let endOfOptions;
461            if (isTime) {
462                let flag = this.tok.peek(LexContext.CommandStart);
463                if (flag.token === Token.Word && flag.keywordEligible && flag.value === "-p") {
464                    this.tok.next(LexContext.CommandStart);
465                    posix = { pos: flag.pos, end: flag.end };
466                    flag = this.tok.peek(LexContext.CommandStart);
467                }
468                if (flag.token === Token.Word && flag.keywordEligible && flag.value === "--") {
469                    this.tok.next(LexContext.CommandStart);
470                    endOfOptions = { pos: flag.pos, end: flag.end };
471                }
472            }
473            if (prefixes === undefined)
474                prefixes = [];
475            if (prefixes.length + this.syntaxDepth === MAX_SYNTAX_NESTING) {
476                if (!exceeded)
477                    this.error("maximum pipeline prefix nesting depth exceeded", pos);
478                exceeded = true;
479                continue;
480            }
481            prefixes.push(isTime
482                ? {
483                    type: "Time",
484                    pos,
485                    end: endOfOptions?.end ?? posix?.end ?? keywordEnd,
486                    keywordEnd,
487                    posix,
488                    endOfOptions,
489                    command: undefined,
490                }
491                : { type: "Negation", pos, end: keywordEnd, keywordEnd, command: undefined });
492        }
493        if (!prefixes)
494            return this.pipelineCommands();
495        this.syntaxDepth += prefixes.length;
496        let command = this.pipelineCommands();
497        this.syntaxDepth -= prefixes.length;
498        for (let i = prefixes.length - 1; i >= 0; i--) {
499            const prefix = prefixes[i];
500            prefix.command = command ?? undefined;
501            if (command)
502                prefix.end = command.end;
503            command = prefix;
504        }
505        return command;
506    }
507    pipelineCommands() {
508        const first = this.command();
509        if (!first)
510            return null;
511        if (this.tok.peek(LexContext.Normal).token !== Token.Pipe)
512            return first;
513        const commands = [first];
514        const operators = [];
515        while (this.tok.peek(LexContext.Normal).token === Token.Pipe) {
516            const pipeToken = this.tok.next(LexContext.Normal);
517            const operator = pipeToken.value === "|&" ? "|&" : "|";
518            this.skipNewlines(LexContext.CommandStart);
519            const cmd = this.command();
520            if (!cmd) {
521                this.error(`expected command after '${operator}'`, pipeToken.end);
522                break;
523            }
524            operators.push(operator);
525            commands.push(cmd);
526        }
527        if (commands.length === 1) {
528            return commands[0];
529        }
530        const pipeline = {
531            type: "Pipeline",
532            pos: first.pos,
533            end: commands[commands.length - 1].end,
534            commands,
535            operators,
536        };
537        return pipeline;
538    }
539    // command := compound_command | function_def | simple_command
540    command() {
541        let compound;
542        switch (this.tok.peek(LexContext.CommandStart).token) {
543            case Token.LParen:
544                compound = this.subshell();
545                break;
546            case Token.LBrace:
547                compound = this.braceGroup();
548                break;
549            case Token.If:
550                compound = this.ifClause();
551                break;
552            case Token.For:
553                compound = this.forClause();
554                break;
555            case Token.While:
556                compound = this.whileClause();
557                break;
558            case Token.Until:
559                compound = this.untilClause();
560                break;
561            case Token.Case:
562                compound = this.caseClause();
563                break;
564            case Token.Function:
565                return this.functionDef();
566            case Token.Select:
567                compound = this.selectClause();
568                break;
569            case Token.DblLBracket:
570                compound = this.testCommand();
571                break;
572            case Token.ArithCmd:
573                compound = this.arithCommand();
574                break;
575            case Token.Coproc:
576                return this.coprocCommand();
577            case Token.Word:
578            case Token.Assignment:
579            case Token.Redirect:
580                return this.simpleCommandOrFunction();
581            default:
582                return null;
583        }
584        return this.withRedirects(compound);
585    }
586    collectTrailingRedirects() {
587        let redirects = EMPTY_REDIRECTS;
588        while (this.tok.peekFollow(compoundClosers).token === Token.Redirect) {
589            if (redirects === EMPTY_REDIRECTS)
590                redirects = [];
591            redirects.push(this.readRedirect(LexContext.Normal));
592        }
593        return redirects;
594    }
595    // arith_command := (( expr ))
596    arithCommand() {
597        const tok = this.tok.next(LexContext.CommandStart);
598        return new ArithmeticCommandImpl(tok.pos, tok.end, tok.value, this.source, this.depth);
599    }
600    // coproc := COPROC ([name] compound_command [redirections] | simple_command)
601    coprocCommand() {
602        const start = this.tok.next(LexContext.CommandStart);
603        const pos = start.pos;
604        const first = this.tok.peek(LexContext.CommandStart);
605        let name;
606        // Only a compound command can follow a coprocess name. Decide before parsing
607        // a simple command so its assignments and reserved words use the actual name.
608        if (first.token === Token.Word) {
609            const lookahead = new Lexer(this.source, first.end, this.end);
610            lookahead._nestingDepth = this.depth;
611            switch (lookahead.peek(LexContext.CommandStart).token) {
612                case Token.LParen:
613                    // `name ( )` opens a function definition, not a subshell body after a name.
614                    lookahead.next(LexContext.CommandStart);
615                    if (lookahead.peek(LexContext.Normal).token !== Token.RParen)
616                        name = this.readWord(LexContext.CommandStart);
617                    break;
618                case Token.LBrace:
619                case Token.If:
620                case Token.For:
621                case Token.While:
622                case Token.Until:
623                case Token.Case:
624                case Token.Select:
625                case Token.DblLBracket:
626                case Token.ArithCmd:
627                    name = this.readWord(LexContext.CommandStart);
628            }
629        }
630        const cmd = this.command();
631        if (cmd && cmd.type !== "Function" && cmd.type !== "Coproc") {
632            return { type: "Coproc", pos, end: cmd.end, name, body: cmd };
633        }
634        this.error("expected command after 'coproc'", cmd?.pos ?? start.end);
635        const body = cmd
636            ? this.makeCompoundList([this.makeStatement(cmd)])
637            : { type: "CompoundList", pos: start.end, end: start.end, commands: [] };
638        return { type: "Coproc", pos, end: body.end, name, body };
639    }
640    // subshell := '(' list ')'
641    subshell() {
642        return this.subshellBody(this.tok.next(LexContext.CommandStart).pos);
643    }
644    // Continues a subshell whose '(' the caller already consumed.
645    subshellBody(pos) {
646        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
647            this.error("maximum subshell nesting depth exceeded", pos);
648            const closeEnd = this.tok.skipSubshellBody();
649            if (closeEnd < 0)
650                this.error("expected ')' to close subshell", this.tok.getPos());
651            const end = closeEnd >= 0 ? closeEnd : pos;
652            return { type: "Subshell", pos, end, body: this.makeCompoundList([]) };
653        }
654        this.syntaxDepth++;
655        const commands = this.list();
656        this.syntaxDepth--;
657        if (commands.length === 0)
658            this.error("expected command in subshell", pos);
659        const closeEnd = this.acceptEnd(Token.RParen, LexContext.Normal);
660        if (closeEnd < 0)
661            this.error("expected ')' to close subshell", this.tok.getPos());
662        const end = closeEnd >= 0 ? closeEnd : pos;
663        return { type: "Subshell", pos, end, body: this.makeCompoundList(commands) };
664    }
665    // brace_group := '{' list '}'
666    braceGroup() {
667        const pos = this.tok.next(LexContext.CommandStart).pos;
668        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
669            this.error("maximum brace group nesting depth exceeded", pos);
670            const closeEnd = this.tok.skipCompoundBody(Token.RBrace);
671            if (closeEnd < 0)
672                this.error("expected '}' to close brace group", this.tok.getPos());
673            const end = closeEnd >= 0 ? closeEnd : pos;
674            return { type: "BraceGroup", pos, end, body: this.makeCompoundList([]) };
675        }
676        this.syntaxDepth++;
677        const commands = this.list();
678        this.syntaxDepth--;
679        if (commands.length === 0)
680            this.error("expected command in brace group", pos);
681        const closeEnd = this.acceptEnd(Token.RBrace, LexContext.Normal);
682        if (closeEnd < 0)
683            this.error("expected '}' to close brace group", this.tok.getPos());
684        const end = closeEnd >= 0 ? closeEnd : pos;
685        return { type: "BraceGroup", pos, end, body: this.makeCompoundList(commands) };
686    }
687    // if_clause := IF list THEN list (ELIF list THEN list)* [ELSE list] FI
688    ifClause() {
689        const pos = this.tok.next(LexContext.CommandStart).pos;
690        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
691            this.error("maximum if nesting depth exceeded", pos);
692            const closeEnd = this.tok.skipCompoundBody(Token.Fi);
693            if (closeEnd < 0)
694                this.error("expected 'fi' to close 'if'", this.tok.getPos());
695            const end = closeEnd >= 0 ? closeEnd : pos;
696            return {
697                type: "If",
698                pos,
699                end,
700                clause: this.makeCompoundList([]),
701                then: this.makeCompoundList([]),
702                else: undefined,
703            };
704        }
705        this.syntaxDepth++;
706        let firstBranch;
707        let lastBranch;
708        let branchPos = pos;
709        let clause;
710        let then_;
711        for (;;) {
712            clause = this.makeCompoundList(this.list());
713            this.skipSemi();
714            const thenToken = this.accept(Token.Then, LexContext.CommandStart);
715            if (!thenToken)
716                this.error("expected 'then'", this.tok.getPos());
717            const thenCommands = this.list();
718            if (thenToken && thenCommands.length === 0)
719                this.error("expected command after 'then'", this.tok.peek(LexContext.CommandStart).pos);
720            then_ = this.makeCompoundList(thenCommands);
721            this.skipSemi();
722            const elif = this.accept(Token.Elif, LexContext.CommandStart);
723            if (!elif)
724                break;
725            const branch = {
726                type: "If",
727                pos: branchPos,
728                end: branchPos,
729                clause,
730                then: then_,
731                else: undefined,
732            };
733            if (lastBranch)
734                lastBranch.else = branch;
735            else
736                firstBranch = branch;
737            lastBranch = branch;
738            branchPos = elif.pos;
739        }
740        let else_;
741        let end;
742        if (this.accept(Token.Else, LexContext.CommandStart)) {
743            else_ = this.makeCompoundList(this.list());
744            this.skipSemi();
745            const closeEnd = this.acceptEnd(Token.Fi, LexContext.CommandStart);
746            if (closeEnd < 0)
747                this.error("expected 'fi' to close 'if'", this.tok.getPos());
748            end = closeEnd >= 0 ? closeEnd : branchPos;
749        }
750        else {
751            const closeEnd = this.acceptEnd(Token.Fi, LexContext.CommandStart);
752            if (closeEnd < 0)
753                this.error("expected 'fi' to close 'if'", this.tok.getPos());
754            end = closeEnd >= 0 ? closeEnd : branchPos;
755        }
756        this.syntaxDepth--;
757        const finalBranch = { type: "If", pos: branchPos, end, clause, then: then_, else: else_ };
758        if (!firstBranch)
759            return finalBranch;
760        lastBranch.else = finalBranch;
761        let branch = firstBranch;
762        while (branch?.type === "If") {
763            branch.end = end;
764            branch = branch.else;
765        }
766        return firstBranch;
767    }
768    // for_clause := FOR word [IN word* (';'|NL)] DO list DONE
769    //            | FOR '((' expr '))' [';'|NL] DO list DONE
770    forClause() {
771        const pos = this.tok.next(LexContext.CommandStart).pos;
772        if (this.tok.peek(LexContext.Normal).token === Token.LParen) {
773            return this.cStyleFor(pos);
774        }
775        const name = this.readWord(LexContext.Normal);
776        let wordlist;
777        this.skipNewlines(LexContext.CommandStart);
778        if (this.tok.peek(LexContext.CommandStart).token === Token.In) {
779            this.tok.next(LexContext.CommandStart);
780            wordlist = [];
781            while (this.tok.peek(LexContext.Normal).token === Token.Word) {
782                wordlist.push(this.readWord(LexContext.Normal));
783            }
784        }
785        this.skipSemi();
786        this.skipNewlines(LexContext.CommandStart);
787        if (this.tok.peek(LexContext.CommandStart).token === Token.LBrace) {
788            const bg = this.braceGroup();
789            return { type: "For", pos, end: bg.end, name, wordlist, body: bg.body };
790        }
791        if (!this.accept(Token.Do, LexContext.CommandStart))
792            this.error("expected 'do'", this.tok.getPos());
793        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
794            this.error("maximum for nesting depth exceeded", pos);
795            const closeEnd = this.tok.skipCompoundBody(Token.Done);
796            if (closeEnd < 0)
797                this.error("expected 'done' to close 'for'", this.tok.getPos());
798            const end = closeEnd >= 0 ? closeEnd : pos;
799            return { type: "For", pos, end, name, wordlist, body: this.makeCompoundList([]) };
800        }
801        this.syntaxDepth++;
802        const body = this.list();
803        this.syntaxDepth--;
804        this.skipSemi();
805        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
806        if (closeEnd < 0)
807            this.error("expected 'done' to close 'for'", this.tok.getPos());
808        const end = closeEnd >= 0 ? closeEnd : pos;
809        return { type: "For", pos, end, name, wordlist, body: this.makeCompoundList(body) };
810    }
811    // C-style for: (( expr; expr; expr )) [;|NL] do list done | { list }
812    cStyleFor(pos) {
813        const [initStr, testStr, updateStr, initPos, testPos, updatePos] = this.tok.readCStyleForExprs();
814        if (this.tok.peek(LexContext.CommandStart).token === Token.Semi)
815            this.tok.next(LexContext.CommandStart);
816        this.skipNewlines(LexContext.CommandStart);
817        if (this.tok.peek(LexContext.CommandStart).token === Token.LBrace) {
818            const bg = this.braceGroup();
819            return new ArithmeticForImpl(pos, bg.end, bg.body, initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth);
820        }
821        if (!this.accept(Token.Do, LexContext.CommandStart))
822            this.error("expected 'do'", this.tok.getPos());
823        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
824            this.error("maximum for nesting depth exceeded", pos);
825            const closeEnd = this.tok.skipCompoundBody(Token.Done);
826            if (closeEnd < 0)
827                this.error("expected 'done' to close 'for'", this.tok.getPos());
828            const end = closeEnd >= 0 ? closeEnd : pos;
829            return new ArithmeticForImpl(pos, end, this.makeCompoundList([]), initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth);
830        }
831        this.syntaxDepth++;
832        const body = this.list();
833        this.syntaxDepth--;
834        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
835        if (closeEnd < 0)
836            this.error("expected 'done' to close 'for'", this.tok.getPos());
837        const end = closeEnd >= 0 ? closeEnd : pos;
838        return new ArithmeticForImpl(pos, end, this.makeCompoundList(body), initStr, testStr, updateStr, initPos, testPos, updatePos, this.source, this.depth);
839    }
840    whileClause() {
841        return this.whileOrUntil("while");
842    }
843    untilClause() {
844        return this.whileOrUntil("until");
845    }
846    whileOrUntil(kind) {
847        const pos = this.tok.next(LexContext.CommandStart).pos;
848        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
849            this.error(`maximum ${kind} nesting depth exceeded`, pos);
850            const closeEnd = this.tok.skipCompoundBody(Token.Done);
851            if (closeEnd < 0)
852                this.error(`expected 'done' to close '${kind}'`, this.tok.getPos());
853            const end = closeEnd >= 0 ? closeEnd : pos;
854            return {
855                type: "While",
856                pos,
857                end,
858                kind,
859                clause: this.makeCompoundList([]),
860                body: this.makeCompoundList([]),
861            };
862        }
863        this.syntaxDepth++;
864        const clause = this.makeCompoundList(this.list());
865        this.skipSemi();
866        if (!this.accept(Token.Do, LexContext.CommandStart))
867            this.error("expected 'do'", this.tok.getPos());
868        const body = this.list();
869        this.skipSemi();
870        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
871        if (closeEnd < 0)
872            this.error(`expected 'done' to close '${kind}'`, this.tok.getPos());
873        const end = closeEnd >= 0 ? closeEnd : pos;
874        this.syntaxDepth--;
875        return { type: "While", pos, end, kind, clause, body: this.makeCompoundList(body) };
876    }
877    // case_clause := CASE word IN (pattern) list (;; | ;& | ;;&) ... ESAC
878    caseClause() {
879        const pos = this.tok.next(LexContext.CommandStart).pos;
880        const word = this.readWord(LexContext.Normal);
881        this.skipNewlines(LexContext.CommandStart);
882        if (!this.accept(Token.In, LexContext.CommandStart))
883            this.error("expected 'in' after 'case' word", this.tok.getPos());
884        this.skipNewlines(LexContext.CommandStart);
885        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
886            this.error("maximum case nesting depth exceeded", pos);
887            const closeEnd = this.tok.skipCompoundBody(Token.Esac);
888            if (closeEnd < 0)
889                this.error("expected 'esac' to close 'case'", this.tok.getPos());
890            const end = closeEnd >= 0 ? closeEnd : pos;
891            return { type: "Case", pos, end, word, items: [] };
892        }
893        this.syntaxDepth++;
894        const items = [];
895        let t = this.tok.peek(LexContext.CommandStart).token;
896        while (t !== Token.Esac && t !== Token.EOF) {
897            const itemPos = this.tok.peek(LexContext.Normal).pos;
898            this.accept(Token.LParen, LexContext.Normal);
899            const pattern = [];
900            t = this.tok.peek(LexContext.Normal).token;
901            while (t !== Token.RParen && t !== Token.EOF) {
902                if (t !== Token.Pipe)
903                    pattern.push(this.toWord(this.tok.next(LexContext.Normal)));
904                else
905                    this.tok.next(LexContext.Normal);
906                t = this.tok.peek(LexContext.Normal).token;
907            }
908            const rparenEnd = this.acceptEnd(Token.RParen, LexContext.Normal);
909            const cmds = this.list();
910            // An empty body belongs where commands would start, not wherever the lexer stopped.
911            const bodyPos = rparenEnd >= 0 ? rparenEnd : pattern.length > 0 ? pattern[pattern.length - 1].end : itemPos;
912            const itemEnd = cmds.length > 0 ? cmds[cmds.length - 1].end : bodyPos;
913            const item = {
914                type: "CaseItem",
915                pos: itemPos,
916                end: itemEnd,
917                pattern,
918                body: cmds.length > 0
919                    ? this.makeCompoundList(cmds)
920                    : { type: "CompoundList", pos: bodyPos, end: bodyPos, commands: [] },
921                terminator: undefined,
922            };
923            t = this.tok.peek(LexContext.CommandStart).token;
924            if (t === Token.DoubleSemi || t === Token.SemiAmp || t === Token.DoubleSemiAmp) {
925                const termTok = this.tok.next(LexContext.CommandStart);
926                item.terminator = CASE_TERMINATORS[termTok.token];
927                item.end = termTok.end;
928            }
929            items.push(item);
930            this.skipNewlines(LexContext.CommandStart);
931            t = this.tok.peek(LexContext.CommandStart).token;
932        }
933        const closeEnd = this.acceptEnd(Token.Esac, LexContext.CommandStart);
934        if (closeEnd < 0)
935            this.error("expected 'esac' to close 'case'", this.tok.getPos());
936        const end = closeEnd >= 0 ? closeEnd : pos;
937        this.syntaxDepth--;
938        return { type: "Case", pos, end, word, items };
939    }
940    // select_clause := SELECT word [IN word* (';'|NL)] DO list DONE
941    selectClause() {
942        const pos = this.tok.next(LexContext.CommandStart).pos;
943        const name = this.readWord(LexContext.Normal);
944        let wordlist;
945        this.skipNewlines(LexContext.CommandStart);
946        if (this.tok.peek(LexContext.CommandStart).token === Token.In) {
947            this.tok.next(LexContext.CommandStart);
948            wordlist = [];
949            while (this.tok.peek(LexContext.Normal).token === Token.Word) {
950                wordlist.push(this.readWord(LexContext.Normal));
951            }
952        }
953        this.skipSemi();
954        this.skipNewlines(LexContext.CommandStart);
955        if (this.tok.peek(LexContext.CommandStart).token === Token.LBrace) {
956            const bg = this.braceGroup();
957            return { type: "Select", pos, end: bg.end, name, wordlist, body: bg.body };
958        }
959        if (!this.accept(Token.Do, LexContext.CommandStart))
960            this.error("expected 'do'", this.tok.getPos());
961        if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
962            this.error("maximum select nesting depth exceeded", pos);
963            const closeEnd = this.tok.skipCompoundBody(Token.Done);
964            if (closeEnd < 0)
965                this.error("expected 'done' to close 'select'", this.tok.getPos());
966            const end = closeEnd >= 0 ? closeEnd : pos;
967            return { type: "Select", pos, end, name, wordlist, body: this.makeCompoundList([]) };
968        }
969        this.syntaxDepth++;
970        const body = this.list();
971        this.syntaxDepth--;
972        this.skipSemi();
973        const closeEnd = this.acceptEnd(Token.Done, LexContext.CommandStart);
974        if (closeEnd < 0)
975            this.error("expected 'done' to close 'select'", this.tok.getPos());
976        const end = closeEnd >= 0 ? closeEnd : pos;
977        return { type: "Select", pos, end, name, wordlist, body: this.makeCompoundList(body) };
978    }
979    // test_command := [[ test_expr ]]
980    testCommand() {
981        const pos = this.tok.next(LexContext.CommandStart).pos; // consume [[
982        const expr = this.parseTestOr();
983        const closeEnd = this.acceptEnd(Token.DblRBracket, LexContext.TestMode);
984        if (closeEnd < 0)
985            this.error("expected ']]' to close '[['", this.tok.getPos());
986        const end = closeEnd >= 0 ? closeEnd : pos;
987        return { type: "TestCommand", pos, end, expression: expr };
988    }
989    // test_or := test_and ('||' test_and)*
990    parseTestOr() {
991        let left = this.parseTestAnd();
992        while (this.tok.peek(LexContext.TestMode).token === Token.Or) {
993            this.tok.next(LexContext.TestMode);
994            const right = this.parseTestAnd();
995            left = {
996                type: "TestLogical",
997                pos: left.pos,
998                end: right.end,
999                operator: "||",
1000                left,
1001                right,
1002            };
1003        }
1004        return left;
1005    }
1006    // test_and := test_not ('&&' test_not)*
1007    parseTestAnd() {
1008        let left = this.parseTestNot();
1009        while (this.tok.peek(LexContext.TestMode).token === Token.And) {
1010            this.tok.next(LexContext.TestMode);
1011            const right = this.parseTestNot();
1012            left = {
1013                type: "TestLogical",
1014                pos: left.pos,
1015                end: right.end,
1016                operator: "&&",
1017                left,
1018                right,
1019            };
1020        }
1021        return left;
1022    }
1023    // test_not := '!' test_not | test_primary
1024    parseTestNot() {
1025        let t = this.tok.peek(LexContext.TestMode);
1026        if (!isTestNegation(t))
1027            return this.parseTestPrimary();
1028        const firstPos = this.tok.next(LexContext.TestMode).pos;
1029        t = this.tok.peek(LexContext.TestMode);
1030        if (!isTestNegation(t)) {
1031            const operand = this.parseTestPrimary();
1032            return { type: "TestNot", pos: firstPos, end: operand.end, operand };
1033        }
1034        const positions = [firstPos];
1035        while (isTestNegation(t)) {
1036            positions.push(this.tok.next(LexContext.TestMode).pos);
1037            t = this.tok.peek(LexContext.TestMode);
1038        }
1039        let expression = this.parseTestPrimary();
1040        for (let i = positions.length - 1; i >= 0; i--) {
1041            expression = {
1042                type: "TestNot",
1043                pos: positions[i],
1044                end: expression.end,
1045                operand: expression,
1046            };
1047        }
1048        return expression;
1049    }
1050    // test_primary := '(' test_or ')' | unary_op word | word binary_op word | word
1051    parseTestPrimary() {
1052        // Grouped: ( expr )
1053        if (this.tok.peek(LexContext.TestMode).token === Token.LParen) {
1054            const openPos = this.tok.next(LexContext.TestMode).pos;
1055            if (this.syntaxDepth === MAX_SYNTAX_NESTING) {
1056                this.error("maximum test group nesting depth exceeded", openPos);
1057                const closeEnd = this.tok.skipTestGroup();
1058                if (closeEnd < 0)
1059                    this.error("expected ')' to close test group", this.tok.getPos());
1060                const end = closeEnd >= 0 ? closeEnd : openPos;
1061                const operand = new WordImpl("", openPos, openPos, this.source, undefined, this.depth);
1062                const expression = {
1063                    type: "TestUnary",
1064                    pos: openPos,
1065                    end: openPos,
1066                    operator: "-n",
1067                    operand,
1068                };
1069                return { type: "TestGroup", pos: openPos, end, expression };
1070            }
1071            this.syntaxDepth++;
1072            const expr = this.parseTestOr();
1073            this.syntaxDepth--;
1074            const closeEnd = this.acceptEnd(Token.RParen, LexContext.TestMode);
1075            if (closeEnd < 0)
1076                this.error("expected ')' to close test group", this.tok.getPos());
1077            const end = closeEnd >= 0 ? closeEnd : openPos;
1078            return { type: "TestGroup", pos: openPos, end, expression: expr };
1079        }
1080        const first = this.tok.next(LexContext.TestMode);
1081        const val = first.value;
1082        const firstPos = first.pos;
1083        const firstEnd = first.end;
1084        // Unary test: -op word, recognized only as written. Bash rejects the operator without an
1085        // operand, so keep the operator and report the missing word instead of demoting it to a string.
1086        if (first.keywordEligible && UNARY_TEST_OPS[val] === 1) {
1087            if (this.tok.peek(LexContext.TestMode).token === Token.Word) {
1088                const operand = this.readWord(LexContext.TestMode);
1089                return {
1090                    type: "TestUnary",
1091                    pos: firstPos,
1092                    end: operand.end,
1093                    operator: val,
1094                    operand,
1095                };
1096            }
1097            this.error("expected operand after unary test operator", firstEnd);
1098            const operand = new WordImpl("", firstEnd, firstEnd, this.source, undefined, this.depth);
1099            return { type: "TestUnary", pos: firstPos, end: firstEnd, operator: val, operand };
1100        }
1101        // Check for binary op
1102        const nt = this.tok.peek(LexContext.TestMode);
1103        if (nt.token === Token.Word && nt.keywordEligible && BINARY_TEST_OPS[nt.value] === 1) {
1104            const op = this.tok.next(LexContext.TestMode).value;
1105            let right;
1106            if (op === "=~") {
1107                const token = this.tok.readTestRegexWord();
1108                right = new WordImpl(this.source.slice(token.pos, token.end), token.pos, token.end, this.source, computeEmbeddedWordParts, this.depth);
1109            }
1110            else {
1111                right = this.readWord(LexContext.TestMode);
1112            }
1113            const left = this.toWordFromPosEnd(first, firstPos, firstEnd);
1114            return {
1115                type: "TestBinary",
1116                pos: firstPos,
1117                end: right.end,
1118                operator: op,
1119                left,
1120                right,
1121            };
1122        }
1123        // Standalone word (implicit -n test)
1124        const w = this.toWordFromPosEnd(first, firstPos, firstEnd);
1125        return { type: "TestUnary", pos: firstPos, end: w.end, operator: "-n", operand: w };
1126    }
1127    // function_def with 'function' keyword
1128    functionDef() {
1129        const pos = this.tok.next(LexContext.CommandStart).pos;
1130        const name = this.readWord(LexContext.Normal);
1131        let body;
1132        if (this.tok.peek(LexContext.CommandStart).token === Token.LParen) {
1133            const openPos = this.tok.next(LexContext.CommandStart).pos;
1134            // `(` is the optional empty parameter list only when `)` follows immediately;
1135            // anything else opens a subshell body, as in `f() ( ... )`.
1136            if (this.tok.peek(LexContext.CommandStart).token === Token.RParen) {
1137                this.tok.next(LexContext.CommandStart);
1138                this.skipNewlines(LexContext.CommandStart);
1139                body = this.commandAsBody();
1140            }
1141            else {
1142                body = this.withRedirects(this.subshellBody(openPos));
1143            }
1144        }
1145        else {
1146            this.skipNewlines(LexContext.CommandStart);
1147            body = this.commandAsBody();
1148        }
1149        return { type: "Function", pos, end: body.end, name, body };
1150    }
1151    // simple_command or function_def (word '(' ')' body)
1152    simpleCommandOrFunction() {
1153        const prefix = [];
1154        const cmdPos = this.tok.peek(LexContext.CommandStart).pos;
1155        let lastEnd = cmdPos;
1156        // Assignments and redirects interleave freely; after the first element CommandPrefix
1157        // keeps the following command name from being read as a reserved word.
1158        let ctx = LexContext.CommandStart;
1159        for (;;) {
1160            const t = this.tok.peek(ctx).token;
1161            if (t === Token.Assignment) {
1162                const assignment = this.tok.next(ctx);
1163                lastEnd = assignment.end;
1164                prefix.push(this.parseAssignment(assignment));
1165            }
1166            else if (t === Token.Redirect) {
1167                const redirect = this.readRedirect(ctx);
1168                prefix.push(redirect);
1169                lastEnd = redirect.end;
1170            }
1171            else {
1172                break;
1173            }
1174            ctx = LexContext.CommandPrefix;
1175        }
1176        if (this.tok.peek(LexContext.Normal).token !== Token.Word) {
1177            return new CommandImpl(cmdPos, lastEnd, undefined, prefix, []);
1178        }
1179        const nameToken = this.tok.next(LexContext.Normal);
1180        const declaration = nameToken.keywordEligible && isDeclarationCommand(nameToken.value);
1181        const name = this.toWord(nameToken);
1182        ctx = declaration ? LexContext.Declaration : LexContext.Normal;
1183        lastEnd = name.end;
1184        // Check for function definition: word '(' ')' body
1185        if (this.tok.peek(ctx).token === Token.LParen) {
1186            this.tok.next(LexContext.Normal);
1187            if (this.tok.peek(LexContext.Normal).token === Token.RParen) {
1188                this.tok.next(LexContext.Normal);
1189                this.skipNewlines(LexContext.CommandStart);
1190                const body = this.commandAsBody();
1191                return {
1192                    type: "Function",
1193                    pos: name.pos,
1194                    end: body.end,
1195                    name,
1196                    body,
1197                };
1198            }
1199        }
1200        const suffix = [];
hooks/vendor/unbash/lexer.js 3931 lines
1import { decodeAnsiCQuoted } from "./ansi-c.js";
2import { parseArithmeticExpression } from "./arithmetic.js";
3import { WordImpl } from "./word.js";
4import { CH_TAB, CH_NL, CH_SPACE, CH_BANG, CH_DQUOTE, CH_HASH, CH_DOLLAR, CH_PERCENT, CH_AMP, CH_SQUOTE, CH_LPAREN, CH_RPAREN, CH_STAR, CH_PLUS, CH_COMMA, CH_DASH, CH_SLASH, CH_0, CH_9, CH_COLON, CH_SEMI, CH_LT, CH_EQ, CH_GT, CH_QUESTION, CH_AT, CH_A, CH_Z, CH_LBRACKET, CH_BACKSLASH, CH_RBRACKET, CH_CARET, CH_UNDERSCORE, CH_BACKTICK, CH_a, CH_z, CH_LBRACE, CH_PIPE, CH_RBRACE, } from "./chars.js";
5// Shared nesting budget for compound syntax and for structure materialized on demand
6// (sub-field words, nested substitution scripts). 256 nested levels stay lossless; past
7// that the lexer stops descending instead of overflowing the stack: deeper sub-fields
8// keep their raw text without parts, and substitution scripts stay unresolved past one
9// boundary script flagged with "maximum substitution nesting depth exceeded". The
10// iterative scanners additionally report the depth error where their counters can see
11// it (nested `${`, `$((`, and `$(`); cut-offs those counters cannot see (e.g. chains
12// interleaved with double quotes) degrade to plain text without an error.
13export const MAX_SYNTAX_NESTING = 256;
14export const Token = {
15    Word: 0,
16    Assignment: 1,
17    Semi: 2,
18    Newline: 3,
19    Pipe: 4,
20    And: 5,
21    Or: 6,
22    Amp: 7,
23    LParen: 8,
24    RParen: 9,
25    LBrace: 10,
26    RBrace: 11,
27    Bang: 12,
28    If: 13,
29    Then: 14,
30    Else: 15,
31    Elif: 16,
32    Fi: 17,
33    Do: 18,
34    Done: 19,
35    For: 20,
36    While: 21,
37    Until: 22,
38    In: 23,
39    Case: 24,
40    Esac: 25,
41    Function: 26,
42    DoubleSemi: 27,
43    SemiAmp: 28,
44    DoubleSemiAmp: 29,
45    Select: 30,
46    DblLBracket: 31,
47    DblRBracket: 32,
48    EOF: 33,
49    ArithCmd: 34,
50    Coproc: 35,
51    Redirect: 36,
52};
53export class TokenValue {
54    token = Token.EOF;
55    // Materialized token value, or null for word tokens whose value has not been
56    // requested yet (computed from [pos, end) on demand via the owning lexer).
57    _value = "";
58    _owner;
59    pos = 0;
60    end = 0;
61    fileDescriptor = undefined;
62    variableName = undefined;
63    descriptorEnd = 0;
64    delimiterQuoted = false;
65    content = undefined;
66    targetPos = 0;
67    targetEnd = 0;
68    assignmentOperatorPos = -1;
69    // True when `value` is exactly the raw source span [pos, end) — lets consumers
70    // reuse the string instead of slicing the source again.
71    raw = false;
72    // True when a word contains no quoting, escaped characters, or expansions.
73    // Backslash-newline continuations preserve keyword eligibility.
74    keywordEligible = false;
75    constructor(owner = null) {
76        this._owner = owner;
77    }
78    get value() {
79        return (this._value ?? (this._value = this._owner === null ? "" : this._owner._tokenValue(this.pos, this.end, this.raw)));
80    }
81    set value(v) {
82        this._value = v;
83    }
84    reset() {
85        this.token = Token.EOF;
86        this._value = "";
87        this.pos = 0;
88        this.end = 0;
89        this.fileDescriptor = undefined;
90        this.variableName = undefined;
91        this.descriptorEnd = 0;
92        this.delimiterQuoted = false;
93        this.content = undefined;
94        this.targetPos = 0;
95        this.targetEnd = 0;
96        this.assignmentOperatorPos = -1;
97        this.raw = false;
98        this.keywordEligible = false;
99    }
100    copyFrom(other) {
101        this.token = other.token;
102        this._value = other._value;
103        this.pos = other.pos;
104        this.end = other.end;
105        this.fileDescriptor = other.fileDescriptor;
106        this.variableName = other.variableName;
107        this.descriptorEnd = other.descriptorEnd;
108        this.delimiterQuoted = other.delimiterQuoted;
109        this.content = other.content;
110        this.targetPos = other.targetPos;
111        this.targetEnd = other.targetEnd;
112        this.assignmentOperatorPos = other.assignmentOperatorPos;
113        this.raw = other.raw;
114        this.keywordEligible = other.keywordEligible;
115    }
116}
117const RESERVED_WORDS = new Map([
118    ["if", Token.If],
119    ["then", Token.Then],
120    ["else", Token.Else],
121    ["elif", Token.Elif],
122    ["fi", Token.Fi],
123    ["do", Token.Do],
124    ["done", Token.Done],
125    ["for", Token.For],
126    ["while", Token.While],
127    ["until", Token.Until],
128    ["in", Token.In],
129    ["case", Token.Case],
130    ["esac", Token.Esac],
131    ["function", Token.Function],
132    ["select", Token.Select],
133    ["coproc", Token.Coproc],
134    ["!", Token.Bang],
135    ["{", Token.LBrace],
136    ["}", Token.RBrace],
137]);
138// `name()` is a syntax error for these; `time`, `[[` and `]]` are matched positionally
139// rather than through RESERVED_WORDS, and a name spelled like an assignment reads as a prefix.
140export function requiresFunctionKeyword(name) {
141    return RESERVED_WORDS.has(name) || name === "time" || name === "[[" || name === "]]" || name.includes("=");
142}
143// Combined character type table — bit 0: metachar, bit 1: word-special
144const charType = new Uint8Array(128);
145charType[CH_PIPE] = 1;
146charType[CH_AMP] = 1;
147charType[CH_SEMI] = 1;
148charType[CH_LPAREN] = 1;
149charType[CH_RPAREN] = 1;
150charType[CH_LT] = 1;
151charType[CH_GT] = 1;
152charType[CH_SPACE] = 1;
153charType[CH_TAB] = 1;
154charType[CH_NL] = 1;
155charType[CH_BACKSLASH] = 2;
156charType[CH_SQUOTE] = 2;
157charType[CH_DQUOTE] = 2;
158charType[CH_DOLLAR] = 2;
159charType[CH_BACKTICK] = 2;
160charType[CH_LBRACE] = 2;
161export function skipLineContinuations(source, pos, end) {
162    while (pos + 1 < end && source.charCodeAt(pos) === CH_BACKSLASH && source.charCodeAt(pos + 1) === CH_NL)
163        pos += 2;
164    return pos;
165}
166const arithmeticWordDelimiter = new Uint8Array(128);
167for (const ch of [
168    CH_TAB,
169    CH_NL,
170    CH_SPACE,
171    CH_BANG,
172    CH_PERCENT,
173    CH_AMP,
174    CH_LPAREN,
175    CH_RPAREN,
176    CH_STAR,
177    CH_PLUS,
178    CH_COMMA,
179    CH_DASH,
180    CH_SLASH,
181    CH_COLON,
182    CH_LT,
183    CH_EQ,
184    CH_GT,
185    CH_QUESTION,
186    CH_CARET,
187    CH_PIPE,
188]) {
189    arithmeticWordDelimiter[ch] = 1;
190}
191export function hasEmbeddedWordStructure(source, start, end) {
192    for (let pos = start; pos < end; pos++) {
193        const ch = source.charCodeAt(pos);
194        if (ch === CH_BACKSLASH ||
195            ch === CH_SQUOTE ||
196            ch === CH_DQUOTE ||
197            ch === CH_DOLLAR ||
198            ch === CH_BACKTICK ||
199            ((ch === CH_LT || ch === CH_GT) && pos + 1 < end && source.charCodeAt(pos + 1) === CH_LPAREN)) {
200            return true;
201        }
202    }
203    return false;
204}
205function findUnnested(s, target, pairTernaries = false, findNestedEnd) {
206    let depth = 0;
207    let ternaryDepth = 0;
208    for (let i = 0; i < s.length; i++) {
209        const c = s.charCodeAt(i);
210        if (c === CH_BACKSLASH) {
211            i++;
212            continue;
213        }
214        if (c === CH_LBRACE) {
215            depth++;
216            continue;
217        }
218        if (c === CH_RBRACE) {
219            if (depth > 0)
220                depth--;
221            continue;
222        }
223        if (c === CH_SQUOTE) {
224            i++;
225            while (i < s.length && s.charCodeAt(i) !== CH_SQUOTE)
226                i++;
227            continue;
228        }
229        if (c === CH_DQUOTE) {
230            i++;
231            while (i < s.length) {
232                const quoted = s.charCodeAt(i);
233                if (quoted === CH_DQUOTE)
234                    break;
235                if (quoted === CH_BACKSLASH) {
236                    i += 2;
237                    continue;
238                }
239                const nestedEnd = findNestedEnd?.(i, true) ?? i;
240                if (nestedEnd > i) {
241                    i = nestedEnd;
242                    continue;
243                }
244                i++;
245            }
246            continue;
247        }
248        const nestedEnd = findNestedEnd?.(i, false) ?? i;
249        if (nestedEnd > i) {
250            i = nestedEnd - 1;
251            continue;
252        }
253        if (pairTernaries && depth === 0) {
254            if (c === CH_QUESTION) {
255                ternaryDepth++;
256                continue;
257            }
258            if (c === CH_COLON && ternaryDepth > 0) {
259                ternaryDepth--;
260                continue;
261            }
262        }
263        if (c === target && depth === 0)
264            return i;
265    }
266    return -1;
267}
268// Lookup: identifier chars (a-z, A-Z, 0-9, _) — bit 0: start, bit 1: continue
269const isIdChar = new Uint8Array(128);
270for (let i = CH_a; i <= CH_z; i++)
271    isIdChar[i] = 3;
272for (let i = CH_A; i <= CH_Z; i++)
273    isIdChar[i] = 3;
274for (let i = CH_0; i <= CH_9; i++)
275    isIdChar[i] = 2;
276isIdChar[CH_UNDERSCORE] = 3;
277const extglobPrefix = new Uint8Array(128);
278extglobPrefix[CH_QUESTION] = 1;
279extglobPrefix[CH_AT] = 1;
280extglobPrefix[CH_STAR] = 1;
281extglobPrefix[CH_PLUS] = 1;
282extglobPrefix[CH_BANG] = 1;
283extglobPrefix[CH_EQ] = 1;
284const extglobOp = {
285    [CH_QUESTION]: "?",
286    [CH_AT]: "@",
287    [CH_STAR]: "*",
288    [CH_PLUS]: "+",
289    [CH_BANG]: "!",
290};
291function isDQChild(p) {
292    const t = p.type;
293    return (t === "Literal" ||
294        t === "SimpleExpansion" ||
295        t === "ParameterExpansion" ||
296        t === "CommandExpansion" ||
297        t === "ArithmeticExpansion");
298}
299function isAllDigitsRange(src, start, end) {
300    for (let i = start; i < end; i++) {
301        const c = src.charCodeAt(i);
302        if (c < CH_0 || c > CH_9)
303            return false;
304    }
305    return end > start;
306}
307const ASSIGNMENT_INVALID = -1;
308const ASSIGNMENT_NAME_START = 0;
309const ASSIGNMENT_NAME = 1;
310const ASSIGNMENT_AFTER_INDEX = 2;
311const ASSIGNMENT_AFTER_PLUS = 3;
312const ASSIGNMENT_INDEX_BASE = 4;
313function isMatchedAssignment(state) {
314    return state < ASSIGNMENT_INVALID;
315}
316function assignmentOperatorPos(state) {
317    return -state - 2;
318}
319function scanAssignmentPrefix(src, start, end, initialState) {
320    let state = initialState;
321    for (let i = start; i < end && state >= 0; i++) {
322        const c = src.charCodeAt(i);
323        if (state >= ASSIGNMENT_INDEX_BASE) {
324            if (c === CH_LBRACKET)
325                state++;
326            else if (c === CH_RBRACKET && --state === ASSIGNMENT_INDEX_BASE)
327                state = ASSIGNMENT_AFTER_INDEX;
328        }
329        else if (state === ASSIGNMENT_NAME_START) {
330            state = c < 128 && isIdChar[c] & 1 ? ASSIGNMENT_NAME : ASSIGNMENT_INVALID;
331        }
332        else if (state === ASSIGNMENT_NAME) {
333            if (c < 128 && isIdChar[c] & 2)
334                continue;
335            if (c === CH_LBRACKET)
336                state = ASSIGNMENT_INDEX_BASE + 1;
337            else if (c === CH_PLUS)
338                state = ASSIGNMENT_AFTER_PLUS;
339            else
340                state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
341        }
342        else if (state === ASSIGNMENT_AFTER_INDEX) {
343            if (c === CH_PLUS)
344                state = ASSIGNMENT_AFTER_PLUS;
345            else
346                state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
347        }
348        else {
349            state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
350        }
351    }
352    return state;
353}
354const NO_EXPANSIONS = [];
355function setToken(out, token, value, pos = 0, end = 0) {
356    out.token = token;
357    out._value = value;
358    out.pos = pos;
359    out.end = end;
360    out.fileDescriptor = undefined;
361    out.variableName = undefined;
362    out.descriptorEnd = 0;
363    out.delimiterQuoted = false;
364    out.content = undefined;
365    out.assignmentOperatorPos = -1;
366    out.raw = false;
367    out.keywordEligible = false;
368}
369// Word token over [pos, end) whose value materializes on first access.
370function setSpanToken(out, token, pos, end, raw) {
371    out.token = token;
372    out._value = null;
373    out.pos = pos;
374    out.end = end;
375    out.fileDescriptor = undefined;
376    out.variableName = undefined;
377    out.descriptorEnd = 0;
378    out.delimiterQuoted = false;
379    out.content = undefined;
380    out.assignmentOperatorPos = -1;
381    out.raw = raw;
382    out.keywordEligible = false;
383}
384// Positional reserved-word match over src[start, start+len) — no slice. Mirrors
385// RESERVED_WORDS; first-char dispatch plus length checks reject fast.
386function matchReservedWord(src, start, len) {
387    switch (src.charCodeAt(start)) {
388        case CH_BANG:
389            return len === 1 ? Token.Bang : undefined;
390        case CH_LBRACE:
391            return len === 1 ? Token.LBrace : undefined;
392        case CH_RBRACE:
393            return len === 1 ? Token.RBrace : undefined;
394        case 0x69 /* i */: {
395            if (len !== 2)
396                return undefined;
397            const c = src.charCodeAt(start + 1);
398            return c === 0x66 /* f */ ? Token.If : c === 0x6e /* n */ ? Token.In : undefined;
399        }
400        case 0x66 /* f */:
401            if (len === 2)
402                return src.charCodeAt(start + 1) === 0x69 /* i */ ? Token.Fi : undefined;
403            if (len === 3)
404                return src.startsWith("for", start) ? Token.For : undefined;
405            if (len === 8)
406                return src.startsWith("function", start) ? Token.Function : undefined;
407            return undefined;
408        case 0x74 /* t */:
409            return len === 4 && src.startsWith("then", start) ? Token.Then : undefined;
410        case 0x65 /* e */:
411            if (len !== 4)
412                return undefined;
413            if (src.startsWith("else", start))
414                return Token.Else;
415            if (src.startsWith("elif", start))
416                return Token.Elif;
417            if (src.startsWith("esac", start))
418                return Token.Esac;
419            return undefined;
420        case 0x64 /* d */:
421            if (len === 2)
422                return src.charCodeAt(start + 1) === 0x6f /* o */ ? Token.Do : undefined;
423            if (len === 4)
424                return src.startsWith("done", start) ? Token.Done : undefined;
425            return undefined;
426        case 0x63 /* c */:
427            if (len === 4)
428                return src.startsWith("case", start) ? Token.Case : undefined;
429            if (len === 6)
430                return src.startsWith("coproc", start) ? Token.Coproc : undefined;
431            return undefined;
432        case 0x77 /* w */:
433            return len === 5 && src.startsWith("while", start) ? Token.While : undefined;
434        case 0x75 /* u */:
435            return len === 5 && src.startsWith("until", start) ? Token.Until : undefined;
436        case 0x73 /* s */:
437            return len === 6 && src.startsWith("select", start) ? Token.Select : undefined;
438        default:
439            return undefined;
440    }
441}
442export const LexContext = {
443    Normal: 0,
444    CommandStart: 1,
445    TestMode: 2,
446    // After a prefix element: assignments still recognized, reserved words not.
447    CommandPrefix: 3,
448    // Assignment arguments retain ordinary word boundaries inside subscripts.
449    Declaration: 4,
450    ArrayElement: 5,
451};
452function scanBraceExpansion(src, pos, len) {
453    const nextCh = pos + 1 < len ? src.charCodeAt(pos + 1) : 0;
454    if (nextCh <= CH_SPACE || nextCh === CH_RBRACE)
455        return -1;
456    let depth = 1;
457    let hasSep = false;
458    let scanPos = pos + 1;
459    while (scanPos < len && depth > 0) {
460        const bc = src.charCodeAt(scanPos);
461        if (bc === CH_LBRACE)
462            depth++;
463        else if (bc === CH_RBRACE) {
464            if (--depth === 0)
465                break;
466        }
467        else if (bc <= CH_SPACE || bc === CH_SEMI || bc === CH_PIPE || bc === CH_AMP)
468            return -1;
469        else if (depth === 1 &&
470            (bc === 0x2c /* , */ || (bc === 0x2e /* . */ && scanPos + 1 < len && src.charCodeAt(scanPos + 1) === 0x2e)))
471            hasSep = true;
472        if (bc === CH_BACKSLASH)
473            scanPos++;
474        scanPos++;
475    }
476    if (depth === 0 && hasSep)
477        return scanPos + 1;
478    return -1;
479}
480export class Lexer {
481    src;
482    srcEnd;
483    parenBoundary;
484    pos;
485    current;
486    nextState;
487    hasPeek;
488    pendingHereDocs;
489    collectedExpansions;
490    _errors = null;
491    _buildParts = false;
492    // Build processed text while scanning. Off on the normal token path (values
493    // materialize lazily); on for redirect targets, heredoc delimiters, arithmetic
494    // command bodies, and bounded value re-lexes. _buildParts implies it.
495    _buildValue = false;
496    // Nesting depth of the window being lexed (enclosing sub-fields plus substitution
497    // scripts), sharing the MAX_SYNTAX_NESTING budget across lazily created lexers.
498    _nestingDepth = 0;
499    // `start`/`end` bound the lexer to a window of `src` so substitution scripts can be
500    // parsed in place against the original source — every position is then absolute, with
501    // no slicing or re-basing. Defaults cover the whole string (the common top-level parse).
502    constructor(src, start = 0, end = src.length, parenBoundary = false) {
503        this.src = src;
504        this.srcEnd = end;
505        this.parenBoundary = parenBoundary;
506        this.pos = start;
507        this.current = new TokenValue(this);
508        this.nextState = new TokenValue(this);
509        this.hasPeek = false;
510        this.pendingHereDocs = null;
511        this.collectedExpansions = null;
512        if (start === 0 && src.charCodeAt(0) === CH_HASH && src.charCodeAt(1) === CH_BANG) {
513            const nl = src.indexOf("\n");
514            this.pos = nl === -1 ? this.srcEnd : nl + 1;
515        }
516    }
517    getSource() {
518        return this.src;
519    }
520    get errors() {
521        return this._errors ?? (this._errors = []);
522    }
523    getCollectedExpansions() {
524        return this.collectedExpansions ?? NO_EXPANSIONS;
525    }
526    // Collected expansions resolve after the enclosing scan unwinds, so each records the
527    // depth it was found at; resolveCollected charges that depth against the shared budget.
528    collect(part, inner, innerStart, parenBoundary = false) {
529        (this.collectedExpansions ?? (this.collectedExpansions = [])).push([
530            part,
531            this._nestingDepth,
532            inner,
533            innerStart,
534            parenBoundary,
535        ]);
536    }
537    getPos() {
538        return this.pos;
539    }
540    /** Materialize a word token's value: raw spans slice directly, others re-lex the span. */
541    _tokenValue(pos, end, raw) {
542        return raw ? this.src.slice(pos, end) : this.wordValueOf(pos, end);
543    }
544    // Re-lex [start, end) in value mode to produce the processed word text. Bounded
545    // to the span, so every expansion the original scan consumed closes within it.
546    // Errors were already reported by the original scan; suppress duplicates.
547    wordValueOf(start, end) {
548        const savedPos = this.pos;
549        const savedEnd = this.srcEnd;
550        const savedBuildValue = this._buildValue;
551        const savedUnbalanced = this._unbalanced;
552        const errorCount = this._errors === null ? 0 : this._errors.length;
553        this.pos = start;
554        this.srcEnd = end;
555        this._buildValue = true;
556        this.readWordText();
557        const value = this._wordText;
558        this.pos = savedPos;
559        this.srcEnd = savedEnd;
560        this._buildValue = savedBuildValue;
561        this._unbalanced = savedUnbalanced;
562        if (this._errors !== null)
563            this._errors.length = errorCount;
564        return value;
565    }
566    /** Find the closing bracket for a shell subscript, ignoring brackets inside nested shell syntax. */
567    findClosingBracket(start, end = this.srcEnd) {
568        return this.findClosingShellDelimiter(start, end, CH_RBRACKET);
569    }
570    /** Find the closing bracket of `$[ … ]`, which unlike a subscript does not recurse into `${ }`. */
571    findClosingArithmeticBracket(start, end = this.srcEnd) {
572        return this.findClosingShellDelimiter(start, end, CH_RBRACKET, false, false);
573    }
574    /** Find the closing brace for a parameter expansion, ignoring braces inside nested shell syntax. */
575    findClosingBrace(start, end = this.srcEnd) {
576        return this.findClosingShellDelimiter(start, end, CH_RBRACE);
577    }
578    /** Find the closing parenthesis for a shell substitution using the command-aware scanner. */
579    findClosingParenthesis(start, end = this.srcEnd) {
580        const savedPos = this.pos;
581        const savedEnd = this.srcEnd;
582        const savedUnbalanced = this._unbalanced;
583        this.pos = start;
584        this.srcEnd = Math.min(end, this.srcEnd);
585        this.extractBalanced();
586        const close = this._unbalanced ? -1 : this.pos - 1;
587        this.pos = savedPos;
588        this.srcEnd = savedEnd;
589        this._unbalanced = savedUnbalanced;
590        return close;
591    }
592    /** Find the end of one arithmetic expansion using the canonical lexer scanner. */
593    findArithmeticExpansionEnd(start, end = this.srcEnd) {
594        const scanner = new Lexer(this.src, start, end);
595        scanner.pos = start + 1;
596        scanner.scanArithmeticBody();
597        return scanner.pos;
598    }
599    /** Find the end of one shell-expanded arithmetic word using the canonical lexer scanners. */
600    findArithmeticWordEnd(start, end = this.srcEnd) {
601        const scanner = new Lexer(this.src, start, end);
602        scanner.pos = start;
603        return scanner.scanArithmeticWordEnd();
604    }
605    scanArithmeticWordEnd() {
606        while (this.pos < this.srcEnd) {
607            const ch = this.src.charCodeAt(this.pos);
608            if (ch === CH_DOLLAR) {
609                this.readDollar();
610                continue;
611            }
612            if (ch === CH_BACKTICK) {
613                this.readBacktickExpansion();
614                continue;
615            }
616            if (ch === CH_SQUOTE) {
617                this.pos++;
618                this.skipSQ();
619                continue;
620            }
621            if (ch === CH_DQUOTE) {
622                this.pos++;
623                this.skipDQ();
624                continue;
625            }
626            if (ch === CH_BACKSLASH) {
627                this.pos += 2;
628                continue;
629            }
630            if (ch === CH_LBRACKET) {
631                const close = this.findClosingBracket(this.pos + 1);
632                if (close !== -1) {
633                    this.pos = close + 1;
634                    continue;
635                }
636            }
637            if ((ch === CH_LT || ch === CH_GT) && this.src.charCodeAt(this.pos + 1) === CH_LPAREN) {
638                this.pos += 2;
639                this.extractBalanced();
640                continue;
641            }
642            if (ch < 128 && arithmeticWordDelimiter[ch])
643                break;
644            this.pos++;
645        }
646        return this.pos;
647    }
648    // Only array assignment bodies take comments; extglob shares this scanner, and `#` is
649    // pattern data there.
650    findClosingShellDelimiter(start, end, closing, comments = false, braces = true) {
651        const savedPos = this.pos;
652        const savedEnd = this.srcEnd;
653        const savedUnbalanced = this._unbalanced;
654        this.srcEnd = Math.min(end, this.srcEnd);
655        const delimiters = [closing];
656        let pos = start;
657        let wordStart = true;
658        while (pos < this.srcEnd) {
659            const ch = this.src.charCodeAt(pos);
660            if (ch === CH_BACKSLASH) {
661                if (pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) !== CH_NL)
662                    wordStart = false;
663                pos += 2;
664                continue;
665            }
666            if (ch === CH_HASH && comments && delimiters.length === 1 && wordStart) {
667                while (pos < this.srcEnd && this.src.charCodeAt(pos) !== CH_NL)
668                    pos++;
669                continue;
670            }
671            if (ch === CH_SQUOTE) {
672                this.pos = pos + 1;
673                this.skipSQ();
674                pos = this.pos;
675                wordStart = false;
676                continue;
677            }
678            if (ch === CH_DQUOTE) {
679                this.pos = pos + 1;
680                this.skipDQ();
681                pos = this.pos;
682                wordStart = false;
683                continue;
684            }
685            if (ch === CH_BACKTICK) {
686                pos++;
687                while (pos < this.srcEnd && this.src.charCodeAt(pos) !== CH_BACKTICK) {
688                    if (this.src.charCodeAt(pos) === CH_BACKSLASH)
689                        pos++;
690                    pos++;
691                }
692                if (pos < this.srcEnd)
693                    pos++;
694                wordStart = false;
695                continue;
696            }
697            if ((ch === CH_DOLLAR && pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) === CH_LPAREN) ||
698                ((ch === CH_LT || ch === CH_GT) && pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) === CH_LPAREN)) {
699                this.pos = pos + 2;
700                this.extractBalanced();
701                pos = this.pos;
702                wordStart = false;
703                continue;
704            }
705            const expected = delimiters[delimiters.length - 1];
706            if (ch === CH_DOLLAR && pos + 1 < this.srcEnd) {
707                const after = this.src.charCodeAt(pos + 1);
708                if (after === CH_DOLLAR) {
709                    pos += 2; // `$$` consumes its second `$`, so a `{` after it opens nothing
710                    wordStart = false;
711                    continue;
712                }
713                if (after === CH_LBRACE && braces) {
714                    delimiters.push(CH_RBRACE);
715                    pos += 2;
716                    wordStart = false;
717                    continue;
718                }
719            }
720            if (expected === CH_RBRACKET && ch === CH_LBRACKET) {
721                delimiters.push(CH_RBRACKET);
722            }
723            else if (expected === CH_RPAREN && ch === CH_LPAREN) {
724                delimiters.push(CH_RPAREN);
725            }
726            else if (ch === expected) {
727                delimiters.pop();
728                if (delimiters.length === 0) {
729                    this.pos = savedPos;
730                    this.srcEnd = savedEnd;
731                    this._unbalanced = savedUnbalanced;
732                    return pos;
733                }
734                wordStart = false;
735                pos++;
736                continue;
737            }
738            wordStart = ch < 128 && (charType[ch] & 1) !== 0;
739            pos++;
740        }
741        this.pos = savedPos;
742        this.srcEnd = savedEnd;
743        this._unbalanced = savedUnbalanced;
744        return -1;
745    }
746    skipSubshellBody() {
747        this.extractBalanced();
748        return this._unbalanced ? -1 : this.pos;
749    }
750    skipCompoundBody(closeToken) {
751        const frames = [
752            { close: closeToken, phase: closeToken === Token.Esac ? "case-pattern" : "commands" },
753        ];
754        let commandStart = true;
755        for (;;) {
756            const value = this.next(commandStart ? LexContext.CommandStart : LexContext.Normal);
757            const token = value.token;
758            if (token === Token.EOF)
759                return -1;
760            const last = frames.length - 1;
761            const frame = frames[last];
762            if (frame.phase === "function-name") {
763                if (token === Token.Newline)
764                    continue;
765                frame.phase = "function-body";
766                commandStart = true;
767                continue;
768            }
769            else if (frame.phase === "function-body") {
770                if (token === Token.Newline)
771                    continue;
772                frame.phase = "commands";
773                commandStart = true;
774                if (token === Token.LParen && this.peek(LexContext.Normal).token === Token.RParen) {
775                    this.next(LexContext.Normal);
776                    frame.phase = "function-body";
777                    continue;
778                }
779            }
780            else if (frame.phase === "coproc-command") {
781                if (token === Token.Newline)
782                    continue;
783                if (token === Token.Word) {
784                    frame.phase = "coproc-body";
785                    commandStart = true;
786                    continue;
787                }
788                frame.phase = "commands";
789                commandStart = true;
790            }
791            else if (frame.phase === "coproc-body") {
792                if (token === Token.Newline)
793                    continue;
794                frame.phase =
795                    token === Token.Word && value.keywordEligible && value.value === "time" ? "time-command" : "commands";
796                commandStart = true;
797                if (frame.phase === "time-command")
798                    continue;
799            }
800            else if (frame.phase === "time-command") {
801                if (token === Token.Word && value.keywordEligible && value.value === "-p") {
802                    frame.phase = "time-command-after-p";
803                    continue;
804                }
805                if (token === Token.Word && value.keywordEligible && value.value === "--") {
806                    frame.phase = "commands";
807                    continue;
808                }
809                frame.phase = "commands";
810                commandStart = true;
811            }
812            else if (frame.phase === "time-command-after-p") {
813                if (token === Token.Word && value.keywordEligible && value.value === "--") {
814                    frame.phase = "commands";
815                    continue;
816                }
817                frame.phase = "commands";
818                commandStart = true;
819            }
820            else if (frame.phase === "for-header") {
821                if (token === Token.ArithCmd || token === Token.Semi || token === Token.Newline) {
822                    commandStart = true;
823                    continue;
824                }
825                if (token === Token.Do || token === Token.LBrace) {
826                    frame.close = token === Token.Do ? Token.Done : Token.RBrace;
827                    frame.phase = "commands";
828                    commandStart = true;
829                    continue;
830                }
831            }
832            else if (frame.phase === "case-word") {
833                if (token === Token.Newline)
834                    continue;
835                frame.phase = "case-in";
836                commandStart = false;
837                continue;
838            }
839            else if (frame.phase === "case-in") {
840                if (token === Token.Newline) {
841                    commandStart = true;
842                    continue;
843                }
844                frame.phase = "case-pattern";
845                commandStart = true;
846                continue;
847            }
848            else if (frame.phase === "case-pattern") {
849                if (token === Token.Esac && commandStart) {
850                    frames.pop();
851                    if (frames.length === 0)
852                        return value.end;
853                    commandStart = false;
854                    continue;
855                }
856                if (token === Token.RParen) {
857                    frame.phase = "commands";
858                    commandStart = true;
859                }
860                else {
861                    commandStart = token === Token.Newline;
862                }
863                continue;
864            }
865            if (token === frame.close) {
866                frames.pop();
867                if (frames.length === 0)
868                    return value.end;
869                commandStart = false;
870                continue;
871            }
872            if (commandStart) {
873                switch (token) {
874                    case Token.LParen:
875                        frames.push({ close: Token.RParen, phase: "commands" });
876                        break;
877                    case Token.LBrace:
878                        frames.push({ close: Token.RBrace, phase: "commands" });
879                        break;
880                    case Token.If:
881                        frames.push({ close: Token.Fi, phase: "commands" });
882                        break;
883                    case Token.For:
884                        frames.push({ close: Token.Done, phase: "for-header" });
885                        break;
886                    case Token.While:
887                    case Token.Until:
888                    case Token.Select:
889                        frames.push({ close: Token.Done, phase: "commands" });
890                        break;
891                    case Token.Case:
892                        frames.push({ close: Token.Esac, phase: "case-word" });
893                        break;
894                    case Token.DblLBracket:
895                        if (!this.skipTestCommandBody())
896                            return -1;
897                        commandStart = false;
898                        continue;
899                    case Token.Assignment:
900                    case Token.Redirect:
901                    case Token.Bang:
902                    case Token.Then:
903                    case Token.Else:
904                    case Token.Elif:
905                    case Token.Do:
906                    case Token.In:
907                        break;
908                    case Token.Semi:
909                    case Token.Newline:
910                    case Token.Pipe:
911                    case Token.And:
912                    case Token.Or:
913                    case Token.Amp:
914                    case Token.DoubleSemi:
915                    case Token.SemiAmp:
916                    case Token.DoubleSemiAmp:
917                        break;
918                    case Token.Function:
919                        frame.phase = "function-name";
920                        break;
921                    case Token.Coproc:
922                        frame.phase = "coproc-command";
923                        break;
924                    default:
925                        if (token === Token.Word && value.keywordEligible && value.value === "time") {
926                            frame.phase = "time-command";
927                            commandStart = true;
928                        }
929                        else {
930                            commandStart = false;
931                        }
932                        continue;
933                }
934            }
935            switch (token) {
936                case Token.Semi:
937                case Token.Newline:
938                case Token.Pipe:
939                case Token.And:
940                case Token.Or:
941                case Token.Amp:
942                    commandStart = true;
943                    break;
944                case Token.DoubleSemi:
945                case Token.SemiAmp:
946                case Token.DoubleSemiAmp:
947                    if (frame.close === Token.Esac)
948                        frame.phase = "case-pattern";
949                    commandStart = true;
950                    break;
951                case Token.RParen:
952                    commandStart = true;
953                    break;
954            }
955        }
956    }
957    skipTestGroup() {
958        let depth = 1;
959        for (;;) {
960            const value = this.next(LexContext.TestMode);
961            if (value.token === Token.EOF)
962                return -1;
963            if (value.token === Token.DblRBracket) {
964                this.unshift(value);
965                return -1;
966            }
967            if (value.token === Token.LParen)
968                depth++;
969            else if (value.token === Token.RParen && --depth === 0)
970                return value.end;
971        }
972    }
973    skipTestCommandBody() {
974        for (;;) {
975            const token = this.next(LexContext.TestMode).token;
976            if (token === Token.DblRBracket)
977                return true;
978            if (token === Token.EOF)
979                return false;
980        }
981    }
982    /** Set position and scan a word, building parts. Used by computeWordParts. */
983    buildWordParts(startPos) {
984        this._buildParts = true;
985        this.pos = startPos;
986        // Handle process substitution words <(...) and >(...)
987        const ch = this.src.charCodeAt(startPos);
988        if ((ch === 0x3c /* < */ || ch === 0x3e) /* > */ &&
989            startPos + 1 < this.srcEnd &&
990            this.src.charCodeAt(startPos + 1) === 0x28 /* ( */) {
991            this.pos = startPos + 2;
992            const inner = this.extractBalanced();
993            if (this._unbalanced)
994                this.errors.push({ message: "unterminated process substitution", pos: startPos });
995            const text = this.src.slice(startPos, this.pos);
996            const part = {
997                type: "ProcessSubstitution",
998                pos: startPos,
999                end: this.pos,
1000                text,
1001                operator: ch === 0x3c ? "<" : ">",
1002                script: undefined,
1003            };
1004            this.collect(part, inner, startPos + 2, !this._unbalanced);
1005            // Continue reading any trailing word text (e.g., suffix after proc sub)
1006            if (this.pos < this.srcEnd) {
1007                this.readWordText();
1008                if (this._wordParts) {
1009                    this._wordParts.unshift(part);
1010                }
1011                else {
1012                    this._wordParts = [part];
1013                }
1014            }
1015            else {
1016                this._wordParts = [part];
1017            }
1018        }
1019        else {
1020            this.readWordText();
1021        }
1022        return this._wordParts;
1023    }
1024    /** Scan a bounded word-like span without treating shell operators or whitespace as terminators. */
1025    buildEmbeddedWordParts(startPos) {
1026        this._buildParts = true;
1027        this.pos = startPos;
1028        this.readInnerWordText();
1029        return this._wordParts;
1030    }
1031    buildArrayElementParts(startPos) {
1032        const close = this.findClosingBracket(startPos + 1);
1033        if (close === -1)
1034            return this.buildWordParts(startPos);
1035        this._buildParts = true;
1036        const prefix = hasEmbeddedWordStructure(this.src, startPos, close + 1)
1037            ? this.parseSubFieldWord(startPos, close + 1)
1038            : undefined;
1039        this.pos = close + 1;
1040        this.readWordText();
1041        const parts = this._wordParts;
1042        const prefixParts = prefix?.parts;
1043        if (prefixParts) {
1044            if (parts)
1045                return prefixParts.concat(parts);
1046            if (this.pos > close + 1) {
1047                prefixParts.push({
1048                    type: "Literal",
1049                    pos: close + 1,
1050                    end: this.pos,
1051                    text: this.src.slice(close + 1, this.pos),
1052                    value: this._wordText,
1053                });
1054            }
1055            return prefixParts;
1056        }
1057        if (parts) {
1058            const text = this.src.slice(startPos, close + 1);
1059            parts.unshift({ type: "Literal", pos: startPos, end: close + 1, text, value: prefix?.value ?? text });
1060        }
1061        return parts;
1062    }
1063    /** Scan a heredoc body for expansions, building parts. Spaces/newlines are literal. */
1064    buildHereDocParts(bodyPos, bodyEnd) {
1065        this._buildParts = true;
1066        const src = this.src;
1067        const parts = [];
1068        let hasExpansion = false;
1069        let litBuf = "";
1070        let litStart = bodyPos;
1071        let i = bodyPos;
1072        const flushLit = () => {
1073            if (i > litStart) {
1074                parts.push({ type: "Literal", pos: litStart, end: i, value: litBuf, text: src.slice(litStart, i) });
1075                litBuf = "";
1076            }
1077        };
1078        while (i < bodyEnd) {
1079            const ch = src.charCodeAt(i);
1080            if (ch === 0x5c /* \\ */) {
1081                // Backslash escape — in unquoted heredoc, \\$, \\`, \\\\ are special
1082                if (i + 1 < bodyEnd) {
1083                    const nc = src.charCodeAt(i + 1);
1084                    if (nc === CH_NL) {
1085                        i += 2;
1086                        continue;
1087                    }
1088                    if (nc === 0x24 /* $ */ || nc === 0x60 /* ` */ || nc === 0x5c /* \\ */) {
1089                        litBuf += String.fromCharCode(nc);
1090                        i += 2;
1091                        continue;
1092                    }
1093                }
1094                litBuf += "\\";
1095                i++;
1096                continue;
1097            }
1098            if (ch === 0x24 /* $ */) {
1099                flushLit();
1100                litStart = i;
1101                this.pos = i;
1102                this.readDollar();
1103                if (this._resultPart) {
1104                    hasExpansion = true;
1105                    parts.push(this._resultPart);
1106                    litStart = this.pos;
1107                }
1108                else {
1109                    litBuf += src.slice(i, this.pos);
1110                }
1111                i = this.pos;
1112                continue;
1113            }
1114            if (ch === 0x60 /* ` */) {
1115                flushLit();
1116                litStart = i;
1117                this.pos = i;
1118                this.readBacktickExpansion();
1119                if (this._resultPart) {
1120                    hasExpansion = true;
1121                    parts.push(this._resultPart);
1122                    litStart = this.pos;
1123                }
1124                else {
1125                    litBuf += src.slice(i, this.pos);
1126                }
1127                i = this.pos;
1128                continue;
1129            }
1130            litBuf += src[i];
1131            i++;
1132        }
1133        flushLit();
1134        return hasExpansion ? parts : null;
1135    }
1136    registerHereDocTarget(target) {
1137        if (this.pendingHereDocs === null)
1138            return;
1139        for (const hd of this.pendingHereDocs) {
1140            if (!hd.target) {
1141                hd.target = target;
1142                return;
1143            }
1144        }
1145    }
1146    // Read the right-hand operand of =~ in [[ ]]. Bash ends the operand at
1147    // depth-zero whitespace, `)`, `;`, `&`, `<`, or `>` (but `<(`/`>(` open a
1148    // process substitution and `|` never delimits). An unquoted `(` opens a
1149    // group that consumes everything — `]]`, newlines, and metacharacters
1150    // included — until its matching `)`; inside a group only quotes stay opaque
1151    // and expansion parens count naively, matching bash. Quotes and expansions
1152    // at depth zero skip via the same readers normal words use, so their errors
1153    // and spans stay identical. The token is the raw source span; value and
1154    // parts resolve lazily like every other word.
1155    readTestRegexWord() {
1156        this.hasPeek = false;
1157        this.skipSpacesAndTabs();
1158        const src = this.src;
1159        const len = this.srcEnd;
1160        const start = this.pos;
1161        let depth = 0;
1162        while (this.pos < len) {
1163            const ch = src.charCodeAt(this.pos);
1164            if (ch === CH_LPAREN) {
1165                depth++;
1166                this.pos++;
1167                continue;
1168            }
1169            if (ch === CH_BACKSLASH) {
1170                this.pos += this.pos + 1 < len ? 2 : 1;
1171                continue;
1172            }
1173            if (ch === CH_SQUOTE) {
1174                const quotePos = this.pos++;
1175                const ansiC = quotePos > start && src.charCodeAt(quotePos - 1) === CH_DOLLAR;
1176                while (this.pos < len && src.charCodeAt(this.pos) !== CH_SQUOTE) {
1177                    if (ansiC && src.charCodeAt(this.pos) === CH_BACKSLASH && this.pos + 1 < len)
1178                        this.pos++;
1179                    this.pos++;
1180                }
1181                if (this.pos < len)
1182                    this.pos++;
1183                else
1184                    this.errors.push({
1185                        message: ansiC ? "unterminated ANSI-C quote" : "unterminated single quote",
1186                        pos: quotePos,
1187                    });
1188                continue;
1189            }
1190            if (ch === CH_DQUOTE) {
1191                this.pos++;
1192                this.readDoubleQuoted();
1193                continue;
1194            }
1195            if (ch === CH_BACKTICK) {
1196                this.readBacktickExpansion();
1197                continue;
1198            }
1199            if (depth > 0) {
1200                if (ch === CH_RPAREN)
hooks/vendor/unbash/arithmetic.js 531 lines
1import { CH_TAB, CH_NL, CH_SPACE, CH_BANG, CH_DOLLAR, CH_PERCENT, CH_AMP, CH_LPAREN, CH_RPAREN, CH_STAR, CH_PLUS, CH_COMMA, CH_DASH, CH_SLASH, CH_0, CH_9, CH_COLON, CH_LT, CH_EQ, CH_GT, CH_QUESTION, CH_A, CH_Z, CH_LBRACKET, CH_RBRACKET, CH_CARET, CH_UNDERSCORE, CH_a, CH_z, CH_LBRACE, CH_RBRACE, CH_PIPE, CH_TILDE, } from "./chars.js";
2function opPrec(op) {
3    switch (op) {
4        case ",":
5            return 1;
6        case "=":
7        case "+=":
8        case "-=":
9        case "*=":
10        case "/=":
11        case "%=":
12        case "<<=":
13        case ">>=":
14        case "&=":
15        case "|=":
16        case "^=":
17            return 2;
18        case "||":
19            return 4;
20        case "&&":
21            return 5;
22        case "|":
23            return 6;
24        case "^":
25            return 7;
26        case "&":
27            return 8;
28        case "==":
29        case "!=":
30            return 9;
31        case "<":
32        case "<=":
33        case ">":
34        case ">=":
35            return 10;
36        case "<<":
37        case ">>":
38            return 11;
39        case "+":
40        case "-":
41            return 12;
42        case "*":
43        case "/":
44        case "%":
45            return 13;
46        case "**":
47            return 14;
48        default:
49            return -1;
50    }
51}
52function opRightAssoc(op) {
53    switch (op) {
54        case "=":
55        case "+=":
56        case "-=":
57        case "*=":
58        case "/=":
59        case "%=":
60        case "<<=":
61        case ">>=":
62        case "&=":
63        case "|=":
64        case "^=":
65        case "**":
66            return true;
67        default:
68            return false;
69    }
70}
71export function parseArithmeticExpression(src, offset = 0, collector) {
72    let pos = 0;
73    const len = src.length;
74    const initialCommandCount = collector?.commandExpansions.length ?? 0;
75    const initialWordCount = collector?.embeddedWords.length ?? 0;
76    function makeWord(start, end, embedded = false) {
77        const node = {
78            type: "ArithmeticWord",
79            pos: start + offset,
80            end: end + offset,
81            value: src.slice(start, end),
82            parts: undefined,
83        };
84        if (embedded)
85            collector?.embeddedWords.push(node);
86        return node;
87    }
88    function skipWS() {
89        while (pos < len) {
90            const c = src.charCodeAt(pos);
91            if (c === CH_SPACE || c === CH_TAB || c === CH_NL)
92                pos++;
93            else
94                break;
95        }
96    }
97    function tryReadBinOp() {
98        if (pos >= len)
99            return null;
100        const c = src.charCodeAt(pos);
101        const nc = pos + 1 < len ? src.charCodeAt(pos + 1) : 0;
102        const nnc = pos + 2 < len ? src.charCodeAt(pos + 2) : 0;
103        switch (c) {
104            case CH_COMMA:
105                pos++;
106                return ",";
107            case CH_EQ:
108                if (nc === CH_EQ) {
109                    pos += 2;
110                    return "==";
111                }
112                pos++;
113                return "=";
114            case CH_BANG:
115                if (nc === CH_EQ) {
116                    pos += 2;
117                    return "!=";
118                }
119                return null; // unary
120            case CH_LT:
121                if (nc === CH_LT) {
122                    if (nnc === CH_EQ) {
123                        pos += 3;
124                        return "<<=";
125                    }
126                    pos += 2;
127                    return "<<";
128                }
129                if (nc === CH_EQ) {
130                    pos += 2;
131                    return "<=";
132                }
133                pos++;
134                return "<";
135            case CH_GT:
136                if (nc === CH_GT) {
137                    if (nnc === CH_EQ) {
138                        pos += 3;
139                        return ">>=";
140                    }
141                    pos += 2;
142                    return ">>";
143                }
144                if (nc === CH_EQ) {
145                    pos += 2;
146                    return ">=";
147                }
148                pos++;
149                return ">";
150            case CH_PLUS:
151                if (nc === CH_EQ) {
152                    pos += 2;
153                    return "+=";
154                }
155                if (nc === CH_PLUS)
156                    return null; // postfix/prefix, not binary
157                pos++;
158                return "+";
159            case CH_DASH:
160                if (nc === CH_EQ) {
161                    pos += 2;
162                    return "-=";
163                }
164                if (nc === CH_DASH)
165                    return null; // postfix/prefix, not binary
166                pos++;
167                return "-";
168            case CH_STAR:
169                if (nc === CH_STAR) {
170                    pos += 2;
171                    return "**";
172                }
173                if (nc === CH_EQ) {
174                    pos += 2;
175                    return "*=";
176                }
177                pos++;
178                return "*";
179            case CH_SLASH:
180                if (nc === CH_EQ) {
181                    pos += 2;
182                    return "/=";
183                }
184                pos++;
185                return "/";
186            case CH_PERCENT:
187                if (nc === CH_EQ) {
188                    pos += 2;
189                    return "%=";
190                }
191                pos++;
192                return "%";
193            case CH_PIPE:
194                if (nc === CH_PIPE) {
195                    pos += 2;
196                    return "||";
197                }
198                if (nc === CH_EQ) {
199                    pos += 2;
200                    return "|=";
201                }
202                pos++;
203                return "|";
204            case CH_AMP:
205                if (nc === CH_AMP) {
206                    pos += 2;
207                    return "&&";
208                }
209                if (nc === CH_EQ) {
210                    pos += 2;
211                    return "&=";
212                }
213                pos++;
214                return "&";
215            case CH_CARET:
216                if (nc === CH_EQ) {
217                    pos += 2;
218                    return "^=";
219                }
220                pos++;
221                return "^";
222            case CH_QUESTION:
223                pos++;
224                return "?";
225            default:
226                return null;
227        }
228    }
229    function parseBinExpr(minPrec) {
230        let left = parseUnaryExpr();
231        while (true) {
232            skipWS();
233            if (pos >= len)
234                break;
235            const saved = pos;
236            const op = tryReadBinOp();
237            if (!op)
238                break;
239            // Ternary
240            if (op === "?") {
241                if (3 < minPrec) {
242                    pos = saved;
243                    break;
244                }
245                const consequent = parseBinExpr(1);
246                skipWS();
247                if (pos < len && src.charCodeAt(pos) === CH_COLON)
248                    pos++;
249                const alternate = parseBinExpr(3);
250                left = { type: "ArithmeticTernary", pos: left.pos, end: alternate.end, test: left, consequent, alternate };
251                continue;
252            }
253            const prec = opPrec(op);
254            if (prec < minPrec) {
255                pos = saved;
256                break;
257            }
258            const nextPrec = opRightAssoc(op) ? prec : prec + 1;
259            const right = parseBinExpr(nextPrec);
260            left = { type: "ArithmeticBinary", pos: left.pos, end: right.end, operator: op, left, right };
261        }
262        return left;
263    }
264    function parseUnaryExpr() {
265        skipWS();
266        if (pos >= len)
267            return makeWord(pos, pos);
268        const start = pos;
269        const c = src.charCodeAt(pos);
270        const nc = pos + 1 < len ? src.charCodeAt(pos + 1) : 0;
271        // Prefix ++ --
272        if (c === CH_PLUS && nc === CH_PLUS) {
273            pos += 2;
274            const operand = parseUnaryExpr();
275            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "++", operand, prefix: true };
276        }
277        if (c === CH_DASH && nc === CH_DASH) {
278            pos += 2;
279            const operand = parseUnaryExpr();
280            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "--", operand, prefix: true };
281        }
282        // Unary ! ~ + -
283        if (c === CH_BANG) {
284            pos++;
285            const operand = parseUnaryExpr();
286            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "!", operand, prefix: true };
287        }
288        if (c === CH_TILDE) {
289            pos++;
290            const operand = parseUnaryExpr();
291            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "~", operand, prefix: true };
292        }
293        if (c === CH_PLUS && nc !== CH_PLUS && nc !== CH_EQ) {
294            pos++;
295            const operand = parseUnaryExpr();
296            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "+", operand, prefix: true };
297        }
298        if (c === CH_DASH && nc !== CH_DASH && nc !== CH_EQ) {
299            pos++;
300            const operand = parseUnaryExpr();
301            return { type: "ArithmeticUnary", pos: start + offset, end: operand.end, operator: "-", operand, prefix: true };
302        }
303        return parsePostfixExpr();
304    }
305    function parsePostfixExpr() {
306        const operand = parseAtom();
307        skipWS();
308        if (pos + 1 < len) {
309            const c = src.charCodeAt(pos);
310            const nc = src.charCodeAt(pos + 1);
311            if (c === CH_PLUS && nc === CH_PLUS) {
312                pos += 2;
313                return { type: "ArithmeticUnary", pos: operand.pos, end: pos + offset, operator: "++", operand, prefix: false };
314            }
315            if (c === CH_DASH && nc === CH_DASH) {
316                pos += 2;
317                return { type: "ArithmeticUnary", pos: operand.pos, end: pos + offset, operator: "--", operand, prefix: false };
318            }
319        }
320        return operand;
321    }
322    function parseAtom() {
323        skipWS();
324        if (pos >= len)
325            return makeWord(pos, pos);
326        const c = src.charCodeAt(pos);
327        // Parenthesized expression
328        if (c === CH_LPAREN) {
329            const start = pos;
330            pos++;
331            const expr = parseBinExpr(0);
332            skipWS();
333            if (pos < len && src.charCodeAt(pos) === CH_RPAREN)
334                pos++;
335            return { type: "ArithmeticGroup", pos: start + offset, end: pos + offset, expression: expr };
336        }
337        // Dollar expansion
338        if (c === CH_DOLLAR) {
339            const start = pos;
340            const commandCount = collector?.commandExpansions.length ?? 0;
341            const wordCount = collector?.embeddedWords.length ?? 0;
342            const atom = readDollarAtom();
343            const wordEnd = collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? pos + offset;
344            if (wordEnd > pos + offset) {
345                if (collector) {
346                    collector.commandExpansions.length = commandCount;
347                    collector.embeddedWords.length = wordCount;
348                }
349                pos = wordEnd - offset;
350                return makeWord(start, pos, true);
351            }
352            return atom;
353        }
354        if (c === 0x60 /* ` */ || c === 0x22 /* " */ || c === 0x27 /* ' */) {
355            const start = pos;
356            pos = (collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? start + offset + 1) - offset;
357            return makeWord(start, pos, true);
358        }
359        // Number or variable name
360        const start = pos;
361        const wordCount = collector?.embeddedWords.length ?? 0;
362        const atom = readWordAtom();
363        const wordEnd = collector?.findArithmeticWordEnd?.(start + offset, offset + len) ?? pos + offset;
364        if (wordEnd > pos + offset) {
365            if (collector)
366                collector.embeddedWords.length = wordCount;
367            pos = wordEnd - offset;
368            return makeWord(start, pos, true);
369        }
370        return atom;
371    }
372    function readDollarAtom() {
373        const start = pos;
374        pos++; // skip $
375        if (pos >= len)
376            return makeWord(start, pos);
377        const c = src.charCodeAt(pos);
378        if (c === CH_LPAREN) {
379            if (pos + 1 < len && src.charCodeAt(pos + 1) === CH_LPAREN) {
380                // $(( nested arithmetic ))
381                const expansionEnd = collector?.findArithmeticExpansionEnd(start + offset, offset + len) ?? -1;
382                if (expansionEnd !== -1) {
383                    pos = expansionEnd - offset;
384                }
385                else {
386                    pos += 2;
387                    let depth = 1;
388                    while (pos < len && depth > 0) {
389                        if (src.charCodeAt(pos) === CH_LPAREN && src.charCodeAt(pos + 1) === CH_LPAREN) {
390                            depth++;
391                            pos += 2;
392                        }
393                        else if (src.charCodeAt(pos) === CH_RPAREN && src.charCodeAt(pos + 1) === CH_RPAREN) {
394                            depth--;
395                            pos += 2;
396                        }
397                        else {
398                            pos++;
399                        }
400                    }
401                }
402            }
403            else {
404                // $( command substitution )
405                pos++; // skip (
406                const close = collector?.findClosingParenthesis(pos + offset, offset + len) ?? -1;
407                if (close !== -1) {
408                    pos = close - offset + 1;
409                }
410                else {
411                    let depth = 1;
412                    while (pos < len && depth > 0) {
413                        const ch = src.charCodeAt(pos++);
414                        if (ch === CH_LPAREN)
415                            depth++;
416                        else if (ch === CH_RPAREN)
417                            depth--;
418                    }
419                }
420                const text = src.slice(start, pos);
421                const node = {
422                    type: "ArithmeticCommandExpansion",
423                    pos: start + offset,
424                    end: pos + offset,
425                    text,
426                    script: undefined,
427                };
428                collector?.commandExpansions.push(node);
429                return node;
430            }
431        }
432        else if (c === CH_LBRACE) {
433            // ${ parameter expansion }
434            const close = collector?.findClosingBrace(pos + offset + 1, offset + len) ?? -1;
435            if (close !== -1) {
436                pos = close - offset + 1;
437            }
438            else {
439                pos++;
440                let depth = 1;
441                while (pos < len && depth > 0) {
442                    const ch = src.charCodeAt(pos++);
443                    if (ch === CH_LBRACE)
444                        depth++;
445                    else if (ch === CH_RBRACE)
446                        depth--;
447                }
448            }
449        }
450        else {
451            // $var
452            while (pos < len) {
453                const ch = src.charCodeAt(pos);
454                if ((ch >= CH_a && ch <= CH_z) ||
455                    (ch >= CH_A && ch <= CH_Z) ||
456                    (ch >= CH_0 && ch <= CH_9) ||
457                    ch === CH_UNDERSCORE)
458                    pos++;
459                else
460                    break;
461            }
462        }
463        return makeWord(start, pos, c === CH_LPAREN || c === CH_LBRACE);
464    }
465    function readWordAtom() {
466        const start = pos;
467        while (pos < len) {
468            const c = src.charCodeAt(pos);
469            if ((c >= CH_0 && c <= CH_9) ||
470                (c >= CH_A && c <= CH_Z) ||
471                (c >= CH_a && c <= CH_z) ||
472                c === CH_UNDERSCORE ||
473                c === 35 // # for base-N literals like 2#101
474            ) {
475                pos++;
476            }
477            else
478                break;
479        }
480        // Array subscript: var[expr]
481        if (pos > start && pos < len && src.charCodeAt(pos) === CH_LBRACKET) {
482            const close = collector?.findClosingBracket?.(pos + offset + 1, offset + len) ?? -1;
483            if (close !== -1) {
484                pos = close - offset + 1;
485            }
486            else {
487                pos++;
488                let depth = 1;
489                while (pos < len && depth > 0) {
490                    const c = src.charCodeAt(pos);
491                    if (c === CH_LBRACKET)
492                        depth++;
493                    else if (c === CH_RBRACKET)
494                        depth--;
495                    pos++;
496                }
497            }
498            return makeWord(start, pos, true);
499        }
500        if (pos === start) {
501            // Unknown character — advance to prevent infinite loop
502            pos++;
503            return makeWord(start, pos);
504        }
505        return makeWord(start, pos);
506    }
507    skipWS();
508    if (pos >= len)
509        return null;
510    const start = pos;
511    const result = parseBinExpr(0);
512    skipWS();
513    if (pos < len) {
514        // Unparsed tokens remain: a partial tree would silently drop them, so keep the whole body
515        // as one word. With embedded structure its parts still expose nested substitutions.
516        if (collector) {
517            collector.commandExpansions.length = initialCommandCount;
518            collector.embeddedWords.length = initialWordCount;
519        }
520        let end = len;
521        while (end > start) {
522            const c = src.charCodeAt(end - 1);
523            if (c !== CH_SPACE && c !== CH_TAB && c !== CH_NL)
524                break;
525            end--;
526        }
527        return makeWord(start, end, collector !== undefined);
528    }
529    return result;
530}
531
hooks/vendor/unbash/parts.js 83 lines
1import { hasEmbeddedWordStructure, Lexer, MAX_SYNTAX_NESTING } from "./lexer.js";
2import { parseRegion } from "./parser.js";
3/**
4 * Compute the structural parts of a word by re-scanning the source.
5 * This is the "cold path" — only called when consumers actually need parts.
6 *
7 * Returns undefined for simple words (no quotes, expansions, or special structure).
8 */
9export function computeWordParts(source, word, depth = 0) {
10    // Bound the re-lex to the word's span. A word inside a substitution script carries the
11    // whole original as its source, so an unbounded scan would overrun the word into an
12    // adjacent delimiter (e.g. a backtick or `)` immediately after it). For top-level words
13    // word.end is the natural boundary, so the bound is a no-op.
14    const lexer = new Lexer(source, word.pos, word.end);
15    lexer._nestingDepth = depth;
16    const parts = lexer.buildWordParts(word.pos);
17    if (!parts)
18        return undefined;
19    resolveCollected(lexer);
20    return parts;
21}
22/** Compute structural parts for a word-like span that may contain shell operators or whitespace. */
23export function computeEmbeddedWordParts(source, word, depth = 0) {
24    if (!hasEmbeddedWordStructure(source, word.pos, word.end))
25        return undefined;
26    const lexer = new Lexer(source, word.pos, word.end);
27    lexer._nestingDepth = depth;
28    const parts = lexer.buildEmbeddedWordParts(word.pos);
29    if (!parts)
30        return undefined;
31    resolveCollected(lexer);
32    return parts;
33}
34export function computeArrayElementParts(source, word, depth = 0) {
35    const lexer = new Lexer(source, word.pos, word.end);
36    lexer._nestingDepth = depth;
37    const parts = lexer.buildArrayElementParts(word.pos);
38    if (!parts)
39        return undefined;
40    resolveCollected(lexer);
41    return parts;
42}
43/**
44 * Compute parts for an unquoted heredoc body.
45 * Heredoc bodies use different scanning rules than shell words: newlines are
46 * literal and single/double quotes have no special meaning.
47 */
48export function computeHereDocBodyParts(source, word, depth = 0) {
49    const lexer = new Lexer(source, word.pos, word.end);
50    lexer._nestingDepth = depth;
51    const parts = lexer.buildHereDocParts(word.pos, word.end);
52    if (!parts)
53        return undefined;
54    resolveCollected(lexer);
55    return parts;
56}
57/**
58 * Resolve each collected substitution's inner script. The script is parsed *in place*
59 * against the original source over the window [innerStart, innerStart + inner.length),
60 * so every node is born with absolute pos/end — no slicing, no re-basing — and nested
61 * substitutions compose because deeper words re-lex the original on demand.
62 *
63 * Escaped backticks rebuild `inner` with the escapes removed, so it is no longer a verbatim
64 * substring of the source and carries no innerStart; those parse the rebuilt slice and stay
65 * relative to it — the single exception to absolute offsets. Only these scripts carry a
66 * `source` property holding the decoded string their positions index.
67 */
68function resolveCollected(lexer) {
69    const source = lexer.getSource();
70    for (const [part, innerDepth, inner, innerStart, parenBoundary] of lexer.getCollectedExpansions()) {
71        const depth = innerDepth + 1;
72        if (depth > MAX_SYNTAX_NESTING + 1)
73            continue;
74        if (innerStart !== undefined) {
75            part.script = parseRegion(source, innerStart, innerStart + inner.length, depth, parenBoundary);
76        }
77        else {
78            part.script = parseRegion(inner, 0, inner.length, depth);
79            part.script.source = inner;
80        }
81    }
82}
83
hooks/vendor/unbash/word.js 103 lines
1function dequoteValue(parts) {
2    let s = "";
3    for (const c of parts)
4        s += c.type === "Literal" ? c.value : c.text;
5    return s;
6}
7function unescapeBareValue(text) {
8    const first = text.indexOf("\\");
9    if (first === -1)
10        return text;
11    let s = "";
12    let start = 0;
13    for (let i = first; i < text.length; i++) {
14        if (text.charCodeAt(i) !== 92)
15            continue;
16        s += text.slice(start, i);
17        i++;
18        if (i >= text.length) {
19            s += "\\";
20            start = i;
21            break;
22        }
23        if (text.charCodeAt(i) !== 10)
24            s += text[i];
25        start = i + 1;
26    }
27    return s + text.slice(start);
28}
29function continuedExpansionValue(text) {
30    let pos = 1;
31    while (text[pos] === "\\" && text[pos + 1] === "\n")
32        pos += 2;
33    return pos === 1 || text[pos] !== "(" ? text : text[0] + text.slice(pos);
34}
35export class WordImpl {
36    static _resolveWord;
37    type = "Word";
38    text;
39    pos;
40    end;
41    #source;
42    #resolver;
43    #depth;
44    #parts;
45    #value = null;
46    constructor(text, pos, end, source, resolver, depth = 0) {
47        this.text = text;
48        this.pos = pos;
49        this.end = end;
50        this.#source = source;
51        this.#resolver = resolver ?? WordImpl._resolveWord;
52        this.#depth = depth;
53        this.#parts = source !== undefined ? null : undefined;
54    }
55    get value() {
56        if (this.#value === null) {
57            const parts = this.parts;
58            if (!parts) {
59                this.#value = unescapeBareValue(this.text);
60            }
61            else {
62                let s = "";
63                for (const p of parts) {
64                    switch (p.type) {
65                        case "Literal":
66                        case "SingleQuoted":
67                        case "AnsiCQuoted":
68                            s += p.value;
69                            break;
70                        case "DoubleQuoted":
71                        case "LocaleString":
72                            s += dequoteValue(p.parts);
73                            break;
74                        case "CommandExpansion":
75                            s += p.text[0] === "$" ? continuedExpansionValue(p.text) : p.text;
76                            break;
77                        case "ExtendedGlob":
78                            s += continuedExpansionValue(p.text);
79                            break;
80                        default:
81                            s += p.text;
82                            break;
83                    }
84                }
85                this.#value = s;
86            }
87        }
88        return this.#value;
89    }
90    get parts() {
91        if (this.#parts === null) {
92            this.#parts = this.#resolver(this.#source ?? "", this, this.#depth) ?? undefined;
93        }
94        return this.#parts;
95    }
96    set parts(v) {
97        this.#parts = v ?? undefined;
98    }
99    toJSON() {
100        return { type: this.type, text: this.text, pos: this.pos, end: this.end, parts: this.parts, value: this.value };
101    }
102}
103