From 7c285750c79045a098a031d53939382e68c44452 Mon Sep 17 00:00:00 2001 From: alesito85 Date: Sat, 25 Feb 2023 00:05:36 +0100 Subject: [PATCH] Fixes autocomplete when using sudo (#8094) # Description This PR addresses issue #2047 in order to enable autocomplete functionality when using sudo for executing commands. I'e done a couple of auxiliary checks such as ignoring whitespace and the last pipe in order to determine the last command. # User-Facing Changes The only user facing change should be the autocomplete working. # Tests + Formatting All tests and formatting pass. # Screenshots image # Suggestions welcome I still don't know the in's and out's if nushell very well, any suggestions for improvements are welcome. --- .../src/completions/command_completions.rs | 102 ++++++++++++++++++ crates/nu-cli/src/completions/completer.rs | 68 +++++++++++- crates/nu-protocol/src/engine/engine_state.rs | 8 ++ 3 files changed, 175 insertions(+), 3 deletions(-) diff --git a/crates/nu-cli/src/completions/command_completions.rs b/crates/nu-cli/src/completions/command_completions.rs index b84c925aaa..2fa7b1fa43 100644 --- a/crates/nu-cli/src/completions/command_completions.rs +++ b/crates/nu-cli/src/completions/command_completions.rs @@ -199,6 +199,7 @@ impl Completer for CommandCompletion { let commands = if matches!(self.flat_shape, nu_parser::FlatShape::External) || matches!(self.flat_shape, nu_parser::FlatShape::InternalCall) || ((span.end - span.start) == 0) + || is_passthrough_command(working_set.delta.get_file_contents()) { // we're in a gap or at a command if working_set.get_span_contents(span).is_empty() && !self.force_completion_after_space @@ -226,3 +227,104 @@ impl Completer for CommandCompletion { SortBy::LevenshteinDistance } } + +pub fn find_non_whitespace_index(contents: &[u8], start: usize) -> usize { + match contents.get(start..) { + Some(contents) => { + contents + .iter() + .take_while(|x| x.is_ascii_whitespace()) + .count() + + start + } + None => start, + } +} + +pub fn is_passthrough_command(working_set_file_contents: &[(Vec, usize, usize)]) -> bool { + for (contents, _, _) in working_set_file_contents { + let last_pipe_pos_rev = contents.iter().rev().position(|x| x == &b'|'); + let last_pipe_pos = last_pipe_pos_rev.map(|x| contents.len() - x).unwrap_or(0); + + let cur_pos = find_non_whitespace_index(contents, last_pipe_pos); + + let result = match contents.get(cur_pos..) { + Some(contents) => contents.starts_with(b"sudo "), + None => false, + }; + if result { + return true; + } + } + false +} + +#[cfg(test)] +mod command_completions_tests { + use super::*; + + #[test] + fn test_find_non_whitespace_index() { + let commands = vec![ + (" hello", 4), + ("sudo ", 0), + (" sudo ", 2), + (" sudo ", 2), + (" hello ", 1), + (" hello ", 3), + (" hello | sudo ", 4), + (" sudo|sudo", 5), + ("sudo | sudo ", 0), + (" hello sud", 1), + ]; + for (idx, ele) in commands.iter().enumerate() { + let index = find_non_whitespace_index(&Vec::from(ele.0.as_bytes()), 0); + assert_eq!(index, ele.1, "Failed on index {}", idx); + } + } + + #[test] + fn test_is_last_command_passthrough() { + let commands = vec![ + (" hello", false), + (" sudo ", true), + ("sudo ", true), + (" hello", false), + (" sudo", false), + (" sudo ", true), + (" sudo ", true), + (" sudo ", true), + (" hello ", false), + (" hello | sudo ", true), + (" sudo|sudo", false), + ("sudo | sudo ", true), + (" hello sud", false), + (" sudo | sud ", false), + (" sudo|sudo ", true), + (" sudo | sudo ls | sudo ", true), + ]; + for (idx, ele) in commands.iter().enumerate() { + let input = ele.0.as_bytes(); + + let mut engine_state = EngineState::new(); + engine_state.add_file("test.nu".into(), vec![]); + + let delta = { + let mut working_set = StateWorkingSet::new(&engine_state); + working_set.add_file("child.nu".into(), input); + working_set.render() + }; + + if let Err(err) = engine_state.merge_delta(delta) { + assert!(false, "Merge delta has failed: {}", err); + } + + let is_passthrough_command = is_passthrough_command(engine_state.get_file_contents()); + assert_eq!( + is_passthrough_command, ele.1, + "index for '{}': {}", + ele.0, idx + ); + } + } +} diff --git a/crates/nu-cli/src/completions/completer.rs b/crates/nu-cli/src/completions/completer.rs index eddda79fa1..524b23e027 100644 --- a/crates/nu-cli/src/completions/completer.rs +++ b/crates/nu-cli/src/completions/completer.rs @@ -135,6 +135,10 @@ impl NuCompleter { let mut spans: Vec = vec![]; for (flat_idx, flat) in flattened.iter().enumerate() { + let is_passthrough_command = spans + .first() + .filter(|content| *content == &String::from("sudo")) + .is_some(); // Read the current spam to string let current_span = working_set.get_span_contents(flat.0).to_vec(); let current_span_str = String::from_utf8_lossy(¤t_span); @@ -217,8 +221,9 @@ impl NuCompleter { } // specially check if it is currently empty - always complete commands - if flat_idx == 0 - && working_set.get_span_contents(new_span).is_empty() + if (is_passthrough_command && flat_idx == 1) + || (flat_idx == 0 + && working_set.get_span_contents(new_span).is_empty()) { let mut completer = CommandCompletion::new( self.engine_state.clone(), @@ -239,7 +244,7 @@ impl NuCompleter { } // Completions that depends on the previous expression (e.g: use, source-env) - if flat_idx > 0 { + if (is_passthrough_command && flat_idx > 1) || flat_idx > 0 { if let Some(previous_expr) = flattened.get(flat_idx - 1) { // Read the content for the previous expression let prev_expr_str = @@ -576,3 +581,60 @@ pub fn map_value_completions<'a>( }) .collect() } + +#[cfg(test)] +mod completer_tests { + use super::*; + + #[test] + fn test_completion_helper() { + let mut engine_state = nu_command::create_default_context(); + + // Custom additions + let delta = { + let working_set = nu_protocol::engine::StateWorkingSet::new(&engine_state); + working_set.render() + }; + + if let Err(err) = engine_state.merge_delta(delta) { + assert!(false, "Error merging delta: {:?}", err); + } + + let mut completer = NuCompleter::new(engine_state.into(), Stack::new()); + let dataset = vec![ + ("sudo", false, "", Vec::new()), + ("sudo l", true, "l", vec!["ls", "let", "lines", "loop"]), + (" sudo", false, "", Vec::new()), + (" sudo le", true, "le", vec!["let", "length"]), + ( + "ls | c", + true, + "c", + vec!["cd", "config", "const", "cp", "cal"], + ), + ("ls | sudo m", true, "m", vec!["mv", "mut", "move"]), + ]; + for (line, has_result, begins_with, expected_values) in dataset { + let result = completer.completion_helper(line, line.len()); + // Test whether the result is empty or not + assert_eq!(result.len() > 0, has_result, "line: {}", line); + + // Test whether the result begins with the expected value + result + .iter() + .for_each(|x| assert!(x.value.starts_with(begins_with))); + + // Test whether the result contains all the expected values + assert_eq!( + result + .iter() + .map(|x| expected_values.contains(&x.value.as_str())) + .filter(|x| *x == true) + .count(), + expected_values.len(), + "line: {}", + line + ); + } + } +} diff --git a/crates/nu-protocol/src/engine/engine_state.rs b/crates/nu-protocol/src/engine/engine_state.rs index e41e44a926..298ea3b998 100644 --- a/crates/nu-protocol/src/engine/engine_state.rs +++ b/crates/nu-protocol/src/engine/engine_state.rs @@ -1007,6 +1007,10 @@ impl EngineState { .map(|d| d.as_string().unwrap_or_default()) .unwrap_or_default() } + + pub fn get_file_contents(&self) -> &Vec<(Vec, usize, usize)> { + &self.file_contents + } } /// A temporary extension to the global state. This handles bridging between the global state and the @@ -1191,6 +1195,10 @@ impl StateDelta { pub fn exit_scope(&mut self) { self.scope.pop(); } + + pub fn get_file_contents(&self) -> &Vec<(Vec, usize, usize)> { + &self.file_contents + } } impl<'a> StateWorkingSet<'a> {