diff --git a/src/terminal/completion.rs b/src/terminal/completion.rs index 519346dd..a8931993 100644 --- a/src/terminal/completion.rs +++ b/src/terminal/completion.rs @@ -69,11 +69,31 @@ const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"]; /// Where the pipeline segment holding the cursor begins. `foo | bar baz` is two /// segments, and each one starts a fresh command position. +/// +/// Quoting counts: in `git commit -m "fix bug; retry ` the `;` is part of the +/// message, not a new command, and treating it as one turned the file +/// completion the next word wants into a list of every binary on PATH. fn segment_start(prefix: &str) -> usize { - prefix - .rfind(['|', '&', ';', '\n', '(']) - .map(|i| i + 1) - .unwrap_or(0) + let mut start = 0; + let mut quote: Option = None; + let mut escaped = false; + for (i, c) in prefix.char_indices() { + if escaped { + escaped = false; + continue; + } + match (quote, c) { + // A backslash escapes the next character everywhere but inside + // single quotes, where the shell takes it literally. + (None, '\\') | (Some('"'), '\\') => escaped = true, + (None, '\'' | '"') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (Some(_), _) => {} + (None, '|' | '&' | ';' | '\n' | '(') => start = i + c.len_utf8(), + (None, _) => {} + } + } + start } /// True when the word being completed is the first word of its segment, so it @@ -770,6 +790,32 @@ mod tests { assert!(remote_path_request("ls | grep /et", 13, "/home/me").is_some()); } + #[test] + fn a_metacharacter_inside_quotes_does_not_start_a_new_command() { + // `;` in a commit message is text. Reading it as a pipeline separator + // put the next word in command position, so the menu filled with every + // binary on PATH instead of the files the argument wants. + let quoted = r#"git commit -m "fix bug; retry" ~/no"#; + assert!(!at_command_position( + "ed.chars().collect::>(), + quoted.chars().count() - 4 + )); + let unterminated = r#"git commit -m "fix bug; retry "#; + let chars: Vec = unterminated.chars().collect(); + assert!(!at_command_position(&chars, chars.len())); + // An escaped one outside quotes is text too. + let escaped = r"echo a\; "; + let chars: Vec = escaped.chars().collect(); + assert!(!at_command_position(&chars, chars.len())); + // A real separator still starts a command. + let piped = "ls | gre"; + assert!(at_command_position(&piped.chars().collect::>(), 5)); + // …including the remote path request, which returns nothing there. + assert!( + remote_path_request(unterminated, unterminated.chars().count(), "/home/me").is_some() + ); + } + #[test] fn signature_offers_subcommands_after_command() { let t = texts("git ");