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…

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.
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.
| Rule | Default | Blocks | ||
|---|---|---|---|---|
monitor_no_command | on | Monitor calls with neither a command string the Bash rules can check nor a ws source, rather than running them unchecked. | ||
malformed_bash | on | Bash 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_root | on | find rooted at / (including //, /* and find -- /), even with -xdev. It is independent of find_xdev. | ||
find_xdev | off | find without -xdev or -mount (or BSD/macOS -x), so searches never cross filesystem boundaries. find --help and --version are left alone. | ||
pgrep_f | off | pgrep -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_captured | off | pgrep output captured by a pipe or substitution (`pgrep x \ | wc -l, $(pgrep x), <(pgrep x)`). | |
until_grep | off | until 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_disabled | off | The Monitor tool. Claude is pointed to a background Bash command (run_in_background) that blocks until the next event and exits. | ||
sudo | off | sudo, including wrapped (env X=1 sudo ls) and nested (bash -c "sudo ls") forms. Claude is pointed to run0. | ||
cat_read | off | cat 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_read | off | head 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_write | off | Writing 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_edit | off | sed -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_edit | off | A 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_confirm | off | Not 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.
Bash and Monitor tool call, including calls made by subagents.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
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
}
}
},
// ...
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:
~/.claude/bash-guardrails.json (in $CLAUDE_CONFIG_DIR if you set it).claude/bash-guardrails.json under the project rootThe 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 | |
|---|---|
id | Required. Letters, digits, - and _. It can't be a built-in rule's id. |
when | Required. A CEL expression that must be a bool. The rule fires when it is true. |
deny | The reason Claude sees when the rule blocks the call. |
ask | Instead of deny: ask the user first (see below). |
let | Optional. Named CEL values, in order, that when, the messages and later lets can use. |
tools | Optional. ["Bash"], ["Monitor"] or both (the default). |
enabled | Optional. 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).
| Name | Type | |||
|---|---|---|---|---|
tool | string | Bash or Monitor | ||
input | map<string, dyn> | The tool's input: command, run_in_background, timeout, ws and so on | ||
cmds | list<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 | ||
errors | list<string> | Parse errors. When it isn't empty, cmds may be incomplete. | ||
untilConds | list<list<Cmd>> | For each until or while ! loop, the commands of its condition | ||
pipelines | list<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 defs | Constants 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.
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.
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.
tool.call hook and a session.start hook, written in TypeScript (hooks/*.ts), inside Claude Code.~/.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.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.run0_confirm and your ask rules) and a transcript line for each rule that fails to load.hooks/vendor/unbash/) and the cel-js CEL interpreter (MIT license, bundled into hooks/vendor/cel/).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
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.
hooks/register.ts 78 lines1import { 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}
78hooks/engine.ts 427 lines1// 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}
427hooks/rules.ts 20 lines1// 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
20hooks/vendor/cel/cel.js 4552 lines1// 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 lines1// 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, `'\\''`)}'`)
132hooks/shell.ts 241 lines1// 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
241hooks/builtin-rules.ts 222 lines1// 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}
222hooks/vendor/unbash/parser.js 1296 lines1import { 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 lines1import { 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 lines1import { 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}
531hooks/vendor/unbash/parts.js 83 lines1import { 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}
83hooks/vendor/unbash/word.js 103 lines1function 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