fix(core/parse): treat jq filters as searches

This commit is contained in:
James Peter
2025-09-16 22:38:01 +10:00
parent 2233a347e4
commit ec1f12cb02
+179 -90
View File
@@ -176,6 +176,43 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn supports_rg_with_path_and_glob_filter() {
let inner = "rg \"codex-run-as-apply-patch\" -n codex-rs -g\"*.rs\"";
assert_parsed(
&vec_str(&["bash", "-lc", inner]),
vec![ParsedCommand::Search {
cmd: "rg codex-run-as-apply-patch -n codex-rs '-g*.rs'".to_string(),
query: Some("codex-run-as-apply-patch".to_string()),
path: Some("codex-rs".to_string()),
}],
);
}
#[test]
fn supports_jq_with_basic_filter() {
assert_parsed(
&vec_str(&["jq", ".name, .description", "package.json"]),
vec![ParsedCommand::Search {
cmd: "jq '.name, .description' package.json".to_string(),
query: Some(".name, .description".to_string()),
path: Some("package.json".to_string()),
}],
);
}
#[test]
fn supports_jq_with_directory_path() {
assert_parsed(
&vec_str(&["jq", "-r", ".name, .bin", "codex-cli/package.json"]),
vec![ParsedCommand::Search {
cmd: "jq -r '.name, .bin' codex-cli/package.json".to_string(),
query: Some(".name, .bin".to_string()),
path: Some("codex-cli/package.json".to_string()),
}],
);
}
#[test] #[test]
fn handles_complex_bash_command() { fn handles_complex_bash_command() {
let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200"; let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200";
@@ -1171,6 +1208,52 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
(query, path) (query, path)
} }
fn parse_jq_filter_and_path(args: &[String]) -> (Option<String>, Option<String>) {
let mut query: Option<String> = None;
let mut path: Option<String> = None;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--" {
// Treat everything after `--` as positional operands.
i += 1;
while i < args.len() {
let positional = &args[i];
if query.is_none() {
query = Some(positional.clone());
} else if path.is_none() {
path = Some(positional.clone());
break;
}
i += 1;
}
break;
}
if arg.starts_with('-') {
let flag = arg.as_str();
let mut skip = match flag {
"-f" | "--from-file" | "-L" | "--module-path" | "--indent" => 1,
"--arg" | "--argjson" | "--argfile" | "--rawfile" | "--slurpfile" => 2,
_ => 0,
};
i += 1;
while skip > 0 && i < args.len() {
i += 1;
skip -= 1;
}
continue;
}
if query.is_none() {
query = Some(arg.clone());
} else if path.is_none() {
path = Some(arg.clone());
break;
}
i += 1;
}
(query, path)
}
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> { fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
let [bash, flag, script] = original else { let [bash, flag, script] = original else {
return None; return None;
@@ -1180,111 +1263,108 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
} }
if let Some(tree) = try_parse_bash(script) { if let Some(tree) = try_parse_bash(script) {
if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) { if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
if !all_commands.is_empty() { if all_commands.is_empty() {
let script_tokens = shlex_split(script) return None;
.unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
// bias toward the primary command when pipelines are present.
// First, drop obvious small formatting helpers (e.g., wc/awk/etc).
let had_multiple_commands = all_commands.len() > 1;
// The bash AST walker yields commands in right-to-left order for
// connector/pipeline sequences. Reverse to reflect actual execution order.
let mut filtered_commands = drop_small_formatting_commands(all_commands);
filtered_commands.reverse();
if filtered_commands.is_empty() {
return Some(vec![ParsedCommand::Unknown {
cmd: script.clone(),
}]);
}
let mut commands: Vec<ParsedCommand> = filtered_commands
.into_iter()
.map(|tokens| summarize_main_tokens(&tokens))
.collect();
if commands.len() > 1 {
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
while let Some(next) = simplify_once(&commands) {
commands = next;
} }
} let script_tokens = shlex_split(script)
if commands.len() == 1 { .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
// If we reduced to a single command, attribute the full original script // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
// for clearer UX in file-reading and listing scenarios, or when there were // bias toward the primary command when pipelines are present.
// no connectors in the original script. For search commands that came from // First, drop obvious small formatting helpers (e.g., wc/awk/etc).
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command. let had_multiple_commands = all_commands.len() > 1;
let had_connectors = had_multiple_commands // The bash AST walker yields commands in right-to-left order for
|| script_tokens // connector/pipeline sequences. Reverse to reflect actual execution order.
.iter() let mut filtered_commands = drop_small_formatting_commands(all_commands);
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";"); filtered_commands.reverse();
commands = commands if filtered_commands.is_empty() {
return None;
}
let mut commands: Vec<ParsedCommand> = filtered_commands
.into_iter() .into_iter()
.map(|pc| match pc { .map(|tokens| summarize_main_tokens(&tokens))
ParsedCommand::Read { name, cmd, .. } => { .collect();
if had_connectors { if commands.len() > 1 {
let has_pipe = script_tokens.iter().any(|t| t == "|"); commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
let has_sed_n = script_tokens.windows(2).any(|w| { // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
w.first().map(|s| s.as_str()) == Some("sed") while let Some(next) = simplify_once(&commands) {
&& w.get(1).map(|s| s.as_str()) == Some("-n") commands = next;
}); }
if has_pipe && has_sed_n { }
ParsedCommand::Read { if commands.len() == 1 {
cmd: script.clone(), // If we reduced to a single command, attribute the full original script
name, // for clearer UX in file-reading and listing scenarios, or when there were
// no connectors in the original script. For search commands that came from
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
let had_connectors = had_multiple_commands
|| script_tokens
.iter()
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
commands = commands
.into_iter()
.map(|pc| match pc {
ParsedCommand::Read { name, cmd, .. } => {
if had_connectors {
let has_pipe = script_tokens.iter().any(|t| t == "|");
let has_sed_n = script_tokens.windows(2).any(|w| {
w.first().map(|s| s.as_str()) == Some("sed")
&& w.get(1).map(|s| s.as_str()) == Some("-n")
});
if has_pipe && has_sed_n {
ParsedCommand::Read {
cmd: script.clone(),
name,
}
} else {
ParsedCommand::Read {
cmd: cmd.clone(),
name,
}
} }
} else { } else {
ParsedCommand::Read { ParsedCommand::Read {
cmd: cmd.clone(), cmd: shlex_join(&script_tokens),
name, name,
} }
} }
} else { }
ParsedCommand::Read { ParsedCommand::ListFiles { path, cmd, .. } => {
cmd: shlex_join(&script_tokens), if had_connectors {
name, ParsedCommand::ListFiles {
cmd: cmd.clone(),
path,
}
} else {
ParsedCommand::ListFiles {
cmd: shlex_join(&script_tokens),
path,
}
} }
} }
} ParsedCommand::Search {
ParsedCommand::ListFiles { path, cmd, .. } => { query, path, cmd, ..
if had_connectors { } => {
ParsedCommand::ListFiles { if had_connectors {
cmd: cmd.clone(), ParsedCommand::Search {
path, cmd: cmd.clone(),
} query,
} else { path,
ParsedCommand::ListFiles { }
cmd: shlex_join(&script_tokens), } else {
path, ParsedCommand::Search {
cmd: shlex_join(&script_tokens),
query,
path,
}
} }
} }
} other => other,
ParsedCommand::Search { })
query, path, cmd, .. .collect();
} => {
if had_connectors {
ParsedCommand::Search {
cmd: cmd.clone(),
query,
path,
}
} else {
ParsedCommand::Search {
cmd: shlex_join(&script_tokens),
query,
path,
}
}
}
other => other,
})
.collect();
}
return Some(commands);
} }
return Some(commands);
} }
} }
Some(vec![ParsedCommand::Unknown { None
cmd: script.clone(),
}])
} }
/// Return true if this looks like a small formatting helper in a pipeline. /// Return true if this looks like a small formatting helper in a pipeline.
@@ -1406,6 +1486,15 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
path, path,
} }
} }
Some((head, tail)) if head == "jq" => {
let args_no_connector = trim_at_connector(tail);
let (query, path) = parse_jq_filter_and_path(&args_no_connector);
ParsedCommand::Search {
cmd: shlex_join(main_cmd),
query,
path,
}
}
Some((head, tail)) if head == "cat" => { Some((head, tail)) if head == "cat" => {
// Support both `cat <file>` and `cat -- <file>` forms. // Support both `cat <file>` and `cat -- <file>` forms.
let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") { let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") {