diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 015d4fb..3c40b2d 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -274,14 +274,11 @@ 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. +/// One token from [`tokenize`]: its dequoted text, and — when both the name +/// and `=` were entirely unquoted/unescaped in the source — the byte length +/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`) +/// makes the word an executable named `FOO=1`; quoting/escaping only the value +/// preserves the assignment, matching Bash's assignment-word grammar (#199). #[derive(Debug, Clone, PartialEq, Eq)] struct Word { text: String, @@ -303,6 +300,7 @@ fn tokenize(command: &str) -> Option> { let mut current = String::new(); let mut has_current = false; let mut plain_eq: Option = None; + let mut assignment_name_is_plain = true; let mut chars = command.chars().peekable(); while let Some(c) = chars.next() { @@ -314,6 +312,7 @@ fn tokenize(command: &str) -> Option> { plain_eq: plain_eq.take(), }); has_current = false; + assignment_name_is_plain = true; } } '\n' => return None, @@ -321,6 +320,9 @@ fn tokenize(command: &str) -> Option> { '$' if chars.peek() == Some(&'(') => return None, '\'' => { has_current = true; + if plain_eq.is_none() { + assignment_name_is_plain = false; + } loop { match chars.next() { Some('\'') => break, @@ -331,6 +333,9 @@ fn tokenize(command: &str) -> Option> { } '"' => { has_current = true; + if plain_eq.is_none() { + assignment_name_is_plain = false; + } loop { match chars.next() { Some('"') => break, @@ -352,13 +357,16 @@ fn tokenize(command: &str) -> Option> { Some('\n') => {} // line continuation, removed entirely Some(c2) => { has_current = true; + if plain_eq.is_none() { + assignment_name_is_plain = false; + } current.push(c2); } None => return None, // trailing backslash }, other => { has_current = true; - if other == '=' && plain_eq.is_none() { + if other == '=' && plain_eq.is_none() && assignment_name_is_plain { plain_eq = Some(current.len()); } current.push(other); @@ -406,8 +414,8 @@ impl OptionTable { if word == "-" || !word.starts_with('-') { return Some(i); } - if let Some((flag, _)) = word.split_once('=') { - return if self.valued.contains(&flag) { + if let Some((flag, value)) = word.split_once('=') { + return if self.valued.contains(&flag) && !value.is_empty() { i += 1; continue; } else { @@ -419,7 +427,10 @@ impl OptionTable { continue; } if self.valued.contains(&word) { - words.get(i + 1)?; + let value = words.get(i + 1)?; + if value.is_empty() || value.starts_with('-') { + return None; + } i += 2; continue; } @@ -523,11 +534,19 @@ const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable { /// `/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"], - valued: &[], - bundles_short_options: true, -}; +fn resolve_time(words: &[&str], i: usize) -> Option { + let mut next = i + 1; + if words.get(next) == Some(&"-p") { + next += 1; + } + if words.get(next) == Some(&"--") { + next += 1; + } + match words.get(next) { + Some(word) if word.starts_with('-') => None, + _ => Some(next), + } +} /// `-c`/`--call` embeds a whole shell command string rather than naming the /// program positionally — resolving past it would mean guessing at a @@ -549,7 +568,7 @@ const PNPM_OPTS: OptionTable = OptionTable { ], // `--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`). + // `dlx` itself also accepts (`PNPM_DLX_OPTS`). valued: &["-C", "--dir", "--filter", "--package"], bundles_short_options: false, }; @@ -560,20 +579,21 @@ const YARN_OPTS: OptionTable = OptionTable { 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: &[], +const PNPM_DLX_OPTS: OptionTable = OptionTable { + flags: &["-s", "--silent"], 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 YARN_DLX_OPTS: OptionTable = OptionTable { + flags: &["-q", "--quiet"], + valued: &["-p", "--package"], + bundles_short_options: false, +}; + const BUN_X_OPTS: OptionTable = OptionTable { - flags: &["--bun"], - valued: &[], + flags: &["--bun", "--no-install", "--verbose", "--silent"], + valued: &["-p", "--package"], bundles_short_options: false, }; @@ -589,10 +609,10 @@ fn wrapper_resolver(name: &str) -> Option { "sudo" => Some(|words, i| SUDO_OPTS.consume(words, i + 1)), "command" => Some(|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1)), "nohup" => Some(|words, i| NO_OPTIONS.consume(words, i + 1)), - "time" => Some(|words, i| TIME_OPTS.consume(words, i + 1)), + "time" => Some(resolve_time), "npx" => Some(|words, i| NPX_OPTS.consume(words, i + 1)), - "pnpm" => Some(|words, i| resolve_dlx(&PNPM_OPTS, words, i)), - "yarn" => Some(|words, i| resolve_dlx(&YARN_OPTS, words, i)), + "pnpm" => Some(|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i)), + "yarn" => Some(|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i)), "bun" => Some(resolve_bun), "bunx" => Some(|words, i| BUN_X_OPTS.consume(words, i + 1)), "uvx" => Some(|_words, i| Some(i + 1)), @@ -603,14 +623,18 @@ fn wrapper_resolver(name: &str) -> Option { /// `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`). `dlx` -/// itself takes options too (`DLX_TRAILING_OPTS`), consumed before the -/// executable. -fn resolve_dlx(opts: &OptionTable, words: &[&str], i: usize) -> Option { +/// itself takes launcher-specific options too, consumed before the executable. +fn resolve_dlx( + opts: &OptionTable, + dlx_opts: &OptionTable, + words: &[&str], + i: usize, +) -> Option { let j = opts.consume(words, i + 1)?; if words.get(j) != Some(&"dlx") { return None; } - DLX_TRAILING_OPTS.consume(words, j + 1) + dlx_opts.consume(words, j + 1) } /// `bun x ` is bun's `dlx`/`npx` equivalent; `bun run