fix(maestro): close command-resolution gaps from #199 review

Fixes six issues in the shared command resolver (Rust display.rs and
its TS mirror toolIcon.ts), all locked in by shared fixture cases:

- An unescaped newline now ends resolution like `;` does, instead of
  being folded in as whitespace; a backslash-newline is a line
  continuation, removed entirely rather than embedded or splitting
  the word.
- Assignment recognition (`FOO=bar`) now requires the `=` to come from
  plain, unquoted/unescaped source text. `"FOO=1"` and `FOO\=1` are
  executable words named `FOO=1`, not assignments — quote removal was
  losing that provenance.
- `command -v`/`-V` (lookup only, no execution), `env -S` (embeds a
  command string) and `npx -c`/`--call` (same) no longer resolve
  through to a program they don't actually run. Restricted bash
  `time`'s options to `-p`, the only flag the keyword itself parses
  (`-o` etc. belong to external GNU time) — this corrects a
  previously-wrong fixture case.
- `env`, `sudo` and `command`'s options may now bundle (`env -iv`,
  `sudo -nE`) or take an attached value (`sudo -uroot`), the way real
  getopt-based tools do, with missing-operand forms safely rejected.
- pnpm/yarn `dlx` and `bun x`/`bunx` now consume their own trailing
  options (`--package foo`/`--package=foo`, `--bun`) before the
  executable, matching each tool's documented CLI syntax.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-05 13:51:43 +01:00
co-authored by Claude Sonnet 5
parent 370edfa763
commit 0af91296df
3 changed files with 391 additions and 76 deletions
+219 -49
View File
@@ -239,14 +239,14 @@ struct Resolution {
/// when [`tokenize`] can't safely make sense of the line at all.
fn resolve(command: &str) -> Option<Resolution> {
let words = tokenize(command)?;
let refs: Vec<&str> = words.iter().map(String::as_str).collect();
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
let mut i = 0;
while let Some(&word) = refs.get(i) {
while let Some(word) = words.get(i) {
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
let wrapper = basename(&word.text).to_lowercase();
let Some(resolver) = wrapper_resolver(&wrapper) else {
break;
};
@@ -259,7 +259,7 @@ fn resolve(command: &str) -> Option<Resolution> {
}
}
Some(Resolution {
words,
words: words.into_iter().map(|w| w.text).collect(),
program_index: i,
})
}
@@ -274,26 +274,49 @@ fn legacy_first_word(command: &str) -> String {
}
}
/// One token from [`tokenize`]: its dequoted text, and — when a literal,
/// unquoted/unescaped `=` occurred in the source before any quoting or
/// escaping ever touched this word — the byte length of the name preceding
/// it. Only a *plain* `=` counts as an assignment's: quoting or escaping it
/// (`"FOO=1"`, `FOO\=1`) makes the word an executable command named
/// `FOO=1`, not an assignment (#199) — quoting elsewhere in the word (a
/// value, or even part of the name, as in `FO"O"=1`) doesn't disqualify it,
/// matching a real shell's own assignment-word grammar.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Word {
text: String,
plain_eq: Option<usize>,
}
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
/// `None` the moment it needs real shell grammar this resolver doesn't
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
/// substitution (`(`, `)`, `` ` ``, `$(`), or an unterminated quote/escape.
/// Never expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal.
fn tokenize(command: &str) -> Option<Vec<String>> {
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
/// separator, like `;` — #199), or an unterminated quote/escape. Never
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
/// backslash immediately before a newline, quoted or not, is a line
/// continuation: removed entirely rather than embedding the newline or
/// ending the word (#199).
fn tokenize(command: &str) -> Option<Vec<Word>> {
let mut words = Vec::new();
let mut current = String::new();
let mut has_current = false;
let mut plain_eq: Option<usize> = None;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\n' | '\r' => {
' ' | '\t' | '\r' => {
if has_current {
words.push(std::mem::take(&mut current));
words.push(Word {
text: std::mem::take(&mut current),
plain_eq: plain_eq.take(),
});
has_current = false;
}
}
'\n' => return None,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
'$' if chars.peek() == Some(&'(') => return None,
'\'' => {
@@ -313,6 +336,7 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
current.push('\\');
current.push(c2);
@@ -324,18 +348,28 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
}
}
}
'\\' => {
has_current = true;
current.push(chars.next()?); // None: trailing backslash
}
'\\' => match chars.next() {
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
has_current = true;
current.push(c2);
}
None => return None, // trailing backslash
},
other => {
has_current = true;
if other == '=' && plain_eq.is_none() {
plain_eq = Some(current.len());
}
current.push(other);
}
}
}
if has_current {
words.push(current);
words.push(Word {
text: current,
plain_eq,
});
}
Some(words)
}
@@ -348,6 +382,12 @@ fn tokenize(command: &str) -> Option<Vec<String>> {
struct OptionTable {
flags: &'static [&'static str],
valued: &'static [&'static str],
/// Whether this wrapper's short options may be combined (`-iv`) or take
/// an attached value (`-uroot`), the way real getopt-based tools do
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
/// `dlx`) don't get this — guessing at bundling they don't actually
/// support would resolve through options they'd reject outright.
bundles_short_options: bool,
}
impl OptionTable {
@@ -383,19 +423,64 @@ impl OptionTable {
i += 2;
continue;
}
if self.bundles_short_options
&& !word.starts_with("--")
&& word.len() > 2
&& self.matches_short_cluster(word)
{
i += 1;
continue;
}
return None;
}
}
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
/// option with its value attached) — getopt's traditional short-option
/// forms. `false` the moment a character isn't a short option this
/// table knows, or a valued one ends the word with no attached value
/// left to be its operand (#199: rejected rather than guessed to come
/// from the next word instead).
fn matches_short_cluster(&self, word: &str) -> bool {
if !word.is_ascii() {
return false;
}
let bytes = word.as_bytes();
let mut buf = [b'-', 0];
let mut idx = 1;
while idx < bytes.len() {
buf[1] = bytes[idx];
let short = std::str::from_utf8(&buf).expect("ascii buffer");
if self.flags.contains(&short) {
idx += 1;
continue;
}
if self.valued.contains(&short) {
return idx + 1 < bytes.len();
}
return false;
}
true
}
}
const NO_OPTIONS: OptionTable = OptionTable {
flags: &[],
valued: &[],
bundles_short_options: false,
};
const ENV_OPTS: OptionTable = OptionTable {
flags: &["-i", "--ignore-environment", "-0", "--null"],
valued: &["-u", "--unset", "-C", "--chdir", "-S", "--split-string"],
flags: &[
"-i",
"--ignore-environment",
"-0",
"--null",
"-v",
"--debug",
],
valued: &["-u", "--unset", "-C", "--chdir"],
bundles_short_options: true,
};
const SUDO_OPTS: OptionTable = OptionTable {
@@ -420,30 +505,38 @@ const SUDO_OPTS: OptionTable = OptionTable {
"--stdin",
],
valued: &["-u", "--user", "-g", "--group"],
bundles_short_options: true,
};
/// `-v`/`-V` only print what would run — they never execute it — so
/// resolving through them would claim a program runs that `command` never
/// launches (#199). Only `-p`, which still executes normally, is safe to
/// look through.
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
flags: &["-p", "-v", "-V"],
flags: &["-p"],
valued: &[],
bundles_short_options: true,
};
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
/// `/usr/bin/time`, a different program a bare `time` word doesn't
/// necessarily name. Assuming them here would be guessing which `time`
/// resolves (#199).
const TIME_OPTS: OptionTable = OptionTable {
flags: &[
"-p",
"--portability",
"-v",
"--verbose",
"-a",
"--append",
"-q",
"--quiet",
],
valued: &["-o", "--output", "-f", "--format"],
flags: &["-p"],
valued: &[],
bundles_short_options: true,
};
/// `-c`/`--call` embeds a whole shell command string rather than naming the
/// program positionally — resolving past it would mean guessing at a
/// program buried inside that string, so it's left unrecognised and
/// resolution stops at `npx` (#199).
const NPX_OPTS: OptionTable = OptionTable {
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
valued: &["-p", "--package", "-c", "--call", "--shell"],
valued: &["-p", "--package", "--shell"],
bundles_short_options: false,
};
const PNPM_OPTS: OptionTable = OptionTable {
@@ -454,12 +547,34 @@ const PNPM_OPTS: OptionTable = OptionTable {
"--aggregate-output",
"--no-progress",
],
valued: &["-C", "--dir", "--filter"],
// `--package`/`--package=value` is documented in this pre-`dlx`
// position (`pnpm --package=x dlx y`), alongside the identical flag
// `dlx` itself also accepts (`DLX_TRAILING_OPTS`).
valued: &["-C", "--dir", "--filter", "--package"],
bundles_short_options: false,
};
const YARN_OPTS: OptionTable = OptionTable {
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
valued: &["--cwd"],
bundles_short_options: false,
};
/// `--package`/`--package=value` right after `dlx`: both pnpm's and yarn's
/// `dlx` document it there, to run a binary whose name differs from the
/// package that provides it (#199).
const DLX_TRAILING_OPTS: OptionTable = OptionTable {
flags: &[],
valued: &["--package"],
bundles_short_options: false,
};
/// `--bun`, forcing bun's own runtime instead of Node — bun's docs place it
/// right after the `x`/`bunx` trigger, before the executable (#199).
const BUN_X_OPTS: OptionTable = OptionTable {
flags: &["--bun"],
valued: &[],
bundles_short_options: false,
};
/// A resolver for one wrapper form: given the tokenized command and the
@@ -479,38 +594,51 @@ fn wrapper_resolver(name: &str) -> Option<WrapperResolver> {
"pnpm" => Some(|words, i| resolve_dlx(&PNPM_OPTS, words, i)),
"yarn" => Some(|words, i| resolve_dlx(&YARN_OPTS, words, i)),
"bun" => Some(resolve_bun),
"bunx" | "uvx" => Some(|_words, i| Some(i + 1)),
"bunx" => Some(|words, i| BUN_X_OPTS.consume(words, i + 1)),
"uvx" => Some(|_words, i| Some(i + 1)),
_ => None,
}
}
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
/// — without it, the next word is a `package.json` script name, not a
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`).
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
/// itself takes options too (`DLX_TRAILING_OPTS`), consumed before the
/// executable.
fn resolve_dlx(opts: &OptionTable, words: &[&str], i: usize) -> Option<usize> {
let j = opts.consume(words, i + 1)?;
(words.get(j) == Some(&"dlx")).then_some(j + 1)
if words.get(j) != Some(&"dlx") {
return None;
}
DLX_TRAILING_OPTS.consume(words, j + 1)
}
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
/// subcommand is looked through.
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
/// the executable.
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
(words.get(i + 1) == Some(&"x")).then_some(i + 2)
if words.get(i + 1) != Some(&"x") {
return None;
}
BUN_X_OPTS.consume(words, i + 2)
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
/// `FOO=bar`, the shell's own prefix for one command's environment —
/// recognised only when the `=` came from plain, unquoted/unescaped source
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
/// an assignment.
fn is_env_assignment(word: &Word) -> bool {
let Some(eq) = word.plain_eq else {
return false;
};
let name = &word.text[..eq];
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn basename(word: &str) -> &str {
@@ -795,32 +923,52 @@ mod tests {
}
}
fn word(text: &str) -> Word {
Word {
text: text.into(),
plain_eq: None,
}
}
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
#[test]
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
assert_eq!(
tokenize("claude --resume"),
Some(vec!["claude".into(), "--resume".into()])
Some(vec![word("claude"), word("--resume")])
);
assert_eq!(
tokenize("\"/opt/my agent/claude\" --resume"),
Some(vec!["/opt/my agent/claude".into(), "--resume".into()])
Some(vec![word("/opt/my agent/claude"), word("--resume")])
);
assert_eq!(
tokenize("FOO=\"bar baz\" claude"),
Some(vec!["FOO=bar baz".into(), "claude".into()])
Some(vec![
Word {
text: "FOO=bar baz".into(),
plain_eq: Some(3),
},
word("claude"),
])
);
assert_eq!(
tokenize("claude --name=$HOME"),
Some(vec!["claude".into(), "--name=$HOME".into()]),
Some(vec![
word("claude"),
Word {
text: "--name=$HOME".into(),
plain_eq: Some(6),
},
]),
"a bare $ is kept literal, never expanded"
);
}
/// Real shell grammar this resolver doesn't attempt — pipelines, list
/// operators, subshells/substitution, an unterminated quote — is
/// rejected outright rather than guessed through (#199).
/// operators, subshells/substitution, an unescaped newline, an
/// unterminated quote — is rejected outright rather than guessed
/// through (#199).
#[test]
fn tokenize_rejects_ambiguous_shell_grammar() {
for command in [
@@ -831,8 +979,30 @@ mod tests {
"claude \"unterminated",
"claude 'unterminated",
"claude \\",
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
] {
assert_eq!(tokenize(command), None, "{command:?}");
}
}
/// A backslash immediately before a newline is a line continuation:
/// removed entirely, joining what it split rather than embedding a
/// literal newline or ending the command (#199).
#[test]
fn tokenize_treats_backslash_newline_as_a_continuation() {
assert_eq!(
tokenize("clau\\\nde --resume"),
Some(vec![word("claude"), word("--resume")])
);
}
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
/// quoting or escaping the `=` itself disqualifies the word, even
/// though the dequoted text looks identical (#199).
#[test]
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
}
}
+25 -1
View File
@@ -17,8 +17,11 @@
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
{ "command": "time claude", "program": "claude", "tool": "claude" },
{ "command": "time -o /tmp/t.log claude", "program": "claude", "tool": "claude" },
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
{ "command": "command claude", "program": "claude", "tool": "claude" },
{ "command": "command -v claude", "program": "command", "tool": null },
{ "command": "command -V claude", "program": "command", "tool": null },
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
@@ -28,8 +31,29 @@
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u", "program": "sudo", "tool": null },
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
+147 -26
View File
@@ -74,7 +74,7 @@ export function splitCommand(command: string): CommandParts {
}
const i = programIndex(words);
if (i >= words.length) return { tool: null, program: command.trim() };
const program = basename(words[i]);
const program = basename(words[i].text);
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
}
@@ -86,20 +86,41 @@ function legacyFirstWord(command: string): string {
return first === undefined ? command.trim() : basename(first);
}
/** One token from {@link tokenize}: its dequoted text, and — when a
* literal, unquoted/unescaped `=` occurred in the source before any
* quoting or escaping ever touched this word — the index of the name
* preceding it. Only a *plain* `=` counts as an assignment's: quoting or
* escaping it (`"FOO=1"`, `FOO\=1`) makes the word an executable command
* named `FOO=1`, not an assignment (#199) — quoting elsewhere in the word
* (a value, or even part of the name, as in `FO"O"=1`) doesn't disqualify
* it, matching a real shell's own assignment-word grammar. */
interface Word {
text: string;
plainEq: number | null;
}
/** `FOO=bar`, the shell's own prefix for one command's environment —
* recognised only when the `=` came from plain, unquoted/unescaped source
* text (#199). */
function isEnvAssignment(word: Word): boolean {
if (word.plainEq === null) return false;
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq));
}
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
* (interleaved in any order/count) to the index of the actual program. */
function programIndex(words: string[]): number {
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
if (isEnvAssignment(words[i])) {
i += 1;
continue;
}
const wrapper = basename(word).toLowerCase();
const wrapper = basename(texts[i]).toLowerCase();
const resolver = WRAPPER_RESOLVERS[wrapper];
if (!resolver) break;
const next = resolver(words, i);
const next = resolver(texts, i);
// Not a form of the wrapper we recognise (an unknown option, or — for
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
// word itself is the program, not a launcher.
@@ -118,24 +139,32 @@ function basename(word: string): string {
* `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
* `null` the moment it needs real shell grammar this resolver doesn't
* attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
* substitution (`(`, `)`, `` ` ``, `$(`), or an unterminated quote/escape.
* Never expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. */
function tokenize(command: string): string[] | null {
const words: string[] = [];
* substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
* separator, like `;` — #199), or an unterminated quote/escape. Never
* expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
* backslash immediately before a newline, quoted or not, is a line
* continuation: removed entirely rather than embedding the newline or
* ending the word (#199). */
function tokenize(command: string): Word[] | null {
const words: Word[] = [];
let current = "";
let hasCurrent = false;
let plainEq: number | null = null;
let i = 0;
const n = command.length;
while (i < n) {
const c = command[i];
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
if (c === " " || c === "\t" || c === "\r") {
if (hasCurrent) {
words.push(current);
words.push({ text: current, plainEq });
current = "";
hasCurrent = false;
plainEq = null;
}
i += 1;
} else if (c === "\n") {
return null;
} else if ("|&;<>()`".includes(c)) {
return null;
} else if (c === "$" && command[i + 1] === "(") {
@@ -160,6 +189,10 @@ function tokenize(command: string): string[] | null {
if (c2 === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped;
i += 2;
} else {
@@ -170,16 +203,23 @@ function tokenize(command: string): string[] | null {
} else if (c === "\\") {
const escaped = command[i + 1];
if (escaped === undefined) return null; // trailing backslash
if (escaped === "\n") {
i += 2; // line continuation, removed entirely
continue;
}
hasCurrent = true;
current += escaped;
i += 2;
} else {
hasCurrent = true;
if (c === "=" && plainEq === null) {
plainEq = current.length;
}
current += c;
i += 1;
}
}
if (hasCurrent) words.push(current);
if (hasCurrent) words.push({ text: current, plainEq });
return words;
}
@@ -191,6 +231,33 @@ function tokenize(command: string): string[] | null {
interface OptionTable {
flags: Set<string>;
valued: Set<string>;
/** Whether this wrapper's short options may be combined (`-iv`) or take
* an attached value (`-uroot`), the way real getopt-based tools do
* (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`)
* don't get this — guessing at bundling they don't actually support
* would resolve through options they'd reject outright. */
bundlesShortOptions: boolean;
}
/** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option
* with its value attached) — getopt's traditional short-option forms.
* `false` the moment a character isn't a short option this table knows, or
* a valued one ends the word with no attached value left to be its operand
* (#199: rejected rather than guessed to come from the next word instead). */
function matchesShortCluster(opts: OptionTable, word: string): boolean {
let idx = 1;
while (idx < word.length) {
const short = "-" + word[idx];
if (opts.flags.has(short)) {
idx += 1;
continue;
}
if (opts.valued.has(short)) {
return idx + 1 < word.length;
}
return false;
}
return true;
}
/** Consume `opts`'s options starting at `words[i]`, returning the index just
@@ -220,15 +287,25 @@ function consumeOptions(opts: OptionTable, words: string[], i: number): number |
i += 2;
continue;
}
if (
opts.bundlesShortOptions &&
!word.startsWith("--") &&
word.length > 2 &&
matchesShortCluster(opts, word)
) {
i += 1;
continue;
}
return null;
}
}
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set() };
const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false };
const ENV_OPTS: OptionTable = {
flags: new Set(["-i", "--ignore-environment", "-0", "--null"]),
valued: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]),
flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]),
valued: new Set(["-u", "--unset", "-C", "--chdir"]),
bundlesShortOptions: true,
};
const SUDO_OPTS: OptionTable = {
@@ -253,47 +330,91 @@ const SUDO_OPTS: OptionTable = {
"--stdin",
]),
valued: new Set(["-u", "--user", "-g", "--group"]),
bundlesShortOptions: true,
};
/** `-v`/`-V` only print what would run — they never execute it — so
* resolving through them would claim a program runs that `command` never
* launches (#199). Only `-p`, which still executes normally, is safe to
* look through. */
const COMMAND_BUILTIN_OPTS: OptionTable = {
flags: new Set(["-p", "-v", "-V"]),
flags: new Set(["-p"]),
valued: new Set(),
bundlesShortOptions: true,
};
/** The bash *keyword* `time` only ever parses `-p` (POSIX output format);
* `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
* `/usr/bin/time`, a different program a bare `time` word doesn't
* necessarily name. Assuming them here would be guessing which `time`
* resolves (#199). */
const TIME_OPTS: OptionTable = {
flags: new Set(["-p", "--portability", "-v", "--verbose", "-a", "--append", "-q", "--quiet"]),
valued: new Set(["-o", "--output", "-f", "--format"]),
flags: new Set(["-p"]),
valued: new Set(),
bundlesShortOptions: true,
};
/** `-c`/`--call` embeds a whole shell command string rather than naming the
* program positionally — resolving past it would mean guessing at a
* program buried inside that string, so it's left unrecognised and
* resolution stops at `npx` (#199). */
const NPX_OPTS: OptionTable = {
flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]),
valued: new Set(["-p", "--package", "-c", "--call", "--shell"]),
valued: new Set(["-p", "--package", "--shell"]),
bundlesShortOptions: false,
};
const PNPM_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
valued: new Set(["-C", "--dir", "--filter"]),
// `--package`/`--package=value` is documented in this pre-`dlx` position
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
// also accepts (`DLX_TRAILING_OPTS`).
valued: new Set(["-C", "--dir", "--filter", "--package"]),
bundlesShortOptions: false,
};
const YARN_OPTS: OptionTable = {
flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]),
valued: new Set(["--cwd"]),
bundlesShortOptions: false,
};
/** `--package`/`--package=value` right after `dlx`: both pnpm's and yarn's
* `dlx` document it there, to run a binary whose name differs from the
* package that provides it (#199). */
const DLX_TRAILING_OPTS: OptionTable = {
flags: new Set(),
valued: new Set(["--package"]),
bundlesShortOptions: false,
};
/** `--bun`, forcing bun's own runtime instead of Node — bun's docs place it
* right after the `x`/`bunx` trigger, before the executable (#199). */
const BUN_X_OPTS: OptionTable = {
flags: new Set(["--bun"]),
valued: new Set(),
bundlesShortOptions: false,
};
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
* — without it, the next word is a `package.json` script name, not a
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). */
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
* itself takes options too (`DLX_TRAILING_OPTS`), consumed before the
* executable. */
function resolveDlx(opts: OptionTable, words: string[], i: number): number | null {
const j = consumeOptions(opts, words, i + 1);
if (j === null) return null;
return words[j] === "dlx" ? j + 1 : null;
if (words[j] !== "dlx") return null;
return consumeOptions(DLX_TRAILING_OPTS, words, j + 1);
}
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
* subcommand is looked through. */
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
* the executable. */
function resolveBun(words: string[], i: number): number | null {
return words[i + 1] === "x" ? i + 2 : null;
if (words[i + 1] !== "x") return null;
return consumeOptions(BUN_X_OPTS, words, i + 2);
}
/** A resolver for one wrapper form: given the tokenized command and the
@@ -312,7 +433,7 @@ const WRAPPER_RESOLVERS: Record<string, WrapperResolver> = {
pnpm: (words, i) => resolveDlx(PNPM_OPTS, words, i),
yarn: (words, i) => resolveDlx(YARN_OPTS, words, i),
bun: resolveBun,
bunx: (_words, i) => i + 1,
bunx: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
uvx: (_words, i) => i + 1,
};