use nu_engine::eval_call; use nu_parser::{flatten_expression, parse, trim_quotes, FlatShape}; use nu_protocol::{ ast::{Call, Expr}, engine::{EngineState, Stack, StateWorkingSet}, levenshtein_distance, PipelineData, Span, Value, CONFIG_VARIABLE_ID, }; use reedline::{Completer, Suggestion}; const SEP: char = std::path::MAIN_SEPARATOR; pub struct CompletionOptions { case_sensitive: bool, positional: bool, sort: bool, } impl Default for CompletionOptions { fn default() -> Self { Self { case_sensitive: true, positional: true, sort: true, } } } #[derive(Clone)] pub struct NuCompleter { engine_state: EngineState, stack: Stack, config: Option, } impl NuCompleter { pub fn new(engine_state: EngineState, stack: Stack, config: Option) -> Self { Self { engine_state, stack, config, } } fn external_command_completion(&self, prefix: &str) -> Vec { let mut executables = vec![]; let paths = self.engine_state.env_vars.get("PATH"); if let Some(paths) = paths { if let Ok(paths) = paths.as_list() { for path in paths { let path = path.as_string().unwrap_or_default(); if let Ok(mut contents) = std::fs::read_dir(path) { while let Some(Ok(item)) = contents.next() { if !executables.contains( &item .path() .file_name() .map(|x| x.to_string_lossy().to_string()) .unwrap_or_default(), ) && matches!( item.path() .file_name() .map(|x| x.to_string_lossy().starts_with(prefix)), Some(true) ) && is_executable::is_executable(&item.path()) { if let Ok(name) = item.file_name().into_string() { executables.push(name); } } } } } } } executables } fn complete_variables( &self, working_set: &StateWorkingSet, prefix: &[u8], span: Span, offset: usize, ) -> Vec { let mut output = vec![]; let builtins = ["$nu", "$in", "$config", "$env", "$nothing"]; for builtin in builtins { if builtin.as_bytes().starts_with(prefix) { output.push(Suggestion { value: builtin.to_string(), description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); } } for scope in &working_set.delta.scope { for v in &scope.vars { if v.0.starts_with(prefix) { output.push(Suggestion { value: String::from_utf8_lossy(v.0).to_string(), description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); } } } for scope in &self.engine_state.scope { for v in &scope.vars { if v.0.starts_with(prefix) { output.push(Suggestion { value: String::from_utf8_lossy(v.0).to_string(), description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); } } } output.dedup(); output } fn complete_commands( &self, working_set: &StateWorkingSet, span: Span, offset: usize, find_externals: bool, ) -> Vec { let prefix = working_set.get_span_contents(span); let results = working_set .find_commands_by_prefix(prefix) .into_iter() .map(move |x| Suggestion { value: String::from_utf8_lossy(&x).to_string(), description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); let results_aliases = working_set .find_aliases_by_prefix(prefix) .into_iter() .map(move |x| Suggestion { value: String::from_utf8_lossy(&x).to_string(), description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); let mut results = results.chain(results_aliases).collect::>(); let prefix = working_set.get_span_contents(span); let prefix = String::from_utf8_lossy(prefix).to_string(); let mut results = if find_externals { let results_external = self.external_command_completion(&prefix) .into_iter() .map(move |x| Suggestion { value: x, description: None, span: reedline::Span { start: span.start - offset, end: span.end - offset, }, }); for external in results_external { if results.contains(&external) { results.push(Suggestion { value: format!("^{}", external.value), description: None, span: external.span, }) } else { results.push(external) } } results } else { results }; results.sort_by(|a, b| { let a_distance = levenshtein_distance(&prefix, &a.value); let b_distance = levenshtein_distance(&prefix, &b.value); a_distance.cmp(&b_distance) }); results } fn completion_helper(&self, line: &str, pos: usize) -> Vec { let mut working_set = StateWorkingSet::new(&self.engine_state); let offset = working_set.next_span_start(); let mut line = line.to_string(); line.insert(pos, 'a'); let pos = offset + pos; let (output, _err) = parse( &mut working_set, Some("completer"), line.as_bytes(), false, &[], ); for pipeline in output.pipelines.into_iter() { for expr in pipeline.expressions { let flattened: Vec<_> = flatten_expression(&working_set, &expr); for (flat_idx, flat) in flattened.iter().enumerate() { if pos >= flat.0.start && pos < flat.0.end { let new_span = Span { start: flat.0.start, end: flat.0.end - 1, }; let mut prefix = working_set.get_span_contents(flat.0).to_vec(); prefix.remove(pos - flat.0.start); if prefix.starts_with(b"$") { let mut output = self.complete_variables(&working_set, &prefix, new_span, offset); output.sort_by(|a, b| a.value.cmp(&b.value)); return output; } if prefix.starts_with(b"-") { // this might be a flag, let's see if let Expr::Call(call) = &expr.expr { let decl = working_set.get_decl(call.decl_id); let sig = decl.signature(); let mut output = vec![]; for named in &sig.named { if let Some(short) = named.short { let mut named = vec![0; short.len_utf8()]; short.encode_utf8(&mut named); named.insert(0, b'-'); if named.starts_with(&prefix) { output.push(Suggestion { value: String::from_utf8_lossy(&named).to_string(), description: None, span: reedline::Span { start: new_span.start - offset, end: new_span.end - offset, }, }); } } if named.long.is_empty() { continue; } let mut named = named.long.as_bytes().to_vec(); named.insert(0, b'-'); named.insert(0, b'-'); if named.starts_with(&prefix) { output.push(Suggestion { value: String::from_utf8_lossy(&named).to_string(), description: None, span: reedline::Span { start: new_span.start - offset, end: new_span.end - offset, }, }); } } output.sort_by(|a, b| a.value.cmp(&b.value)); return output; } } match &flat.1 { FlatShape::Custom(decl_id) => { let mut stack = self.stack.clone(); // Set up our initial config to start from if let Some(conf) = &self.config { stack.vars.insert(CONFIG_VARIABLE_ID, conf.clone()); } else { stack.vars.insert( CONFIG_VARIABLE_ID, Value::Record { cols: vec![], vals: vec![], span: Span { start: 0, end: 0 }, }, ); } let result = eval_call( &self.engine_state, &mut stack, &Call { decl_id: *decl_id, head: new_span, positional: vec![], named: vec![], redirect_stdout: true, redirect_stderr: true, }, PipelineData::new(new_span), ); fn map_completions<'a>( list: impl Iterator, new_span: Span, offset: usize, ) -> Vec { list.filter_map(move |x| { let s = x.as_string(); match s { Ok(s) => Some(Suggestion { value: s, description: None, span: reedline::Span { start: new_span.start - offset, end: new_span.end - offset, }, }), Err(_) => None, } }) .collect() } let (completions, options) = match result { Ok(pd) => { let value = pd.into_value(new_span); match &value { Value::Record { .. } => { let completions = value .get_data_by_key("completions") .and_then(|val| { val.as_list().ok().map(|it| { map_completions( it.iter(), new_span, offset, ) }) }) .unwrap_or_default(); let options = value.get_data_by_key("options"); let options = if let Some(Value::Record { .. }) = &options { let options = options.unwrap_or_default(); CompletionOptions { case_sensitive: options .get_data_by_key("case_sensitive") .and_then(|val| val.as_bool().ok()) .unwrap_or(true), positional: options .get_data_by_key("positional") .and_then(|val| val.as_bool().ok()) .unwrap_or(true), sort: options .get_data_by_key("sort") .and_then(|val| val.as_bool().ok()) .unwrap_or(true), } } else { CompletionOptions::default() }; (completions, options) } Value::List { vals, .. } => { let completions = map_completions(vals.iter(), new_span, offset); (completions, CompletionOptions::default()) } _ => (vec![], CompletionOptions::default()), } } _ => (vec![], CompletionOptions::default()), }; let mut completions: Vec = completions .into_iter() .filter(|it| { // Minimise clones for new functionality match (options.case_sensitive, options.positional) { (true, true) => { it.value.as_bytes().starts_with(&prefix) } (true, false) => it.value.contains( std::str::from_utf8(&prefix).unwrap_or(""), ), (false, positional) => { let value = it.value.to_lowercase(); let prefix = std::str::from_utf8(&prefix) .unwrap_or("") .to_lowercase(); if positional { value.starts_with(&prefix) } else { value.contains(&prefix) } } } }) .collect(); if options.sort { completions.sort_by(|a, b| a.value.cmp(&b.value)); } return completions; } FlatShape::Filepath | FlatShape::GlobPattern => { let cwd = if let Some(d) = self.engine_state.env_vars.get("PWD") { match d.as_string() { Ok(s) => s, Err(_) => "".to_string(), } } else { "".to_string() }; let prefix = String::from_utf8_lossy(&prefix).to_string(); let mut output: Vec<_> = file_path_completion(new_span, &prefix, &cwd) .into_iter() .map(move |x| Suggestion { value: x.1, description: None, span: reedline::Span { start: x.0.start - offset, end: x.0.end - offset, }, }) .collect(); // output.sort_by(|a, b| a.value.cmp(&b.value)); output.sort_by(|a, b| { let a_distance = levenshtein_distance(&prefix, &a.value); let b_distance = levenshtein_distance(&prefix, &b.value); a_distance.cmp(&b_distance) }); return output; } flat_shape => { let last = flattened .iter() .rev() .skip_while(|x| x.0.end > pos) .take_while(|x| { matches!( x.1, FlatShape::InternalCall | FlatShape::External | FlatShape::ExternalArg | FlatShape::Literal | FlatShape::String ) }) .last(); // The last item here would be the earliest shape that could possible by part of this subcommand let subcommands = if let Some(last) = last { self.complete_commands( &working_set, Span { start: last.0.start, end: pos, }, offset, false, ) } else { vec![] }; if !subcommands.is_empty() { return subcommands; } let commands = if matches!(flat_shape, nu_parser::FlatShape::External) || matches!(flat_shape, nu_parser::FlatShape::InternalCall) || ((new_span.end - new_span.start) == 0) { // we're in a gap or at a command self.complete_commands(&working_set, new_span, offset, true) } else { vec![] }; let cwd = if let Some(d) = self.engine_state.env_vars.get("PWD") { match d.as_string() { Ok(s) => s, Err(_) => "".to_string(), } } else { "".to_string() }; let preceding_byte = if new_span.start > offset { working_set .get_span_contents(Span { start: new_span.start - 1, end: new_span.start, }) .to_vec() } else { vec![] }; // let prefix = working_set.get_span_contents(flat.0); let prefix = String::from_utf8_lossy(&prefix).to_string(); let mut output = file_path_completion(new_span, &prefix, &cwd) .into_iter() .map(move |x| { if flat_idx == 0 { // We're in the command position if x.1.starts_with('"') && !matches!(preceding_byte.get(0), Some(b'^')) { let trimmed = trim_quotes(x.1.as_bytes()); let trimmed = String::from_utf8_lossy(trimmed).to_string(); let expanded = nu_path::canonicalize_with(trimmed, &cwd); if let Ok(expanded) = expanded { if is_executable::is_executable(expanded) { (x.0, format!("^{}", x.1)) } else { (x.0, x.1) } } else { (x.0, x.1) } } else { (x.0, x.1) } } else { (x.0, x.1) } }) .map(move |x| Suggestion { value: x.1, description: None, span: reedline::Span { start: x.0.start - offset, end: x.0.end - offset, }, }) .chain(subcommands.into_iter()) .chain(commands.into_iter()) .collect::>(); //output.dedup_by(|a, b| a.1 == b.1); //output.sort_by(|a, b| a.value.cmp(&b.value)); output.sort_by(|a, b| { let a_distance = levenshtein_distance(&prefix, &a.value); let b_distance = levenshtein_distance(&prefix, &b.value); a_distance.cmp(&b_distance) }); return output; } } } } } } vec![] } } impl Completer for NuCompleter { fn complete(&self, line: &str, pos: usize) -> Vec { self.completion_helper(line, pos) } } fn file_path_completion( span: nu_protocol::Span, partial: &str, cwd: &str, ) -> Vec<(nu_protocol::Span, String)> { use std::path::{is_separator, Path}; let partial = partial.replace('\'', ""); let (base_dir_name, partial) = { // If partial is only a word we want to search in the current dir let (base, rest) = partial.rsplit_once(is_separator).unwrap_or((".", &partial)); // On windows, this standardizes paths to use \ let mut base = base.replace(is_separator, &SEP.to_string()); // rsplit_once removes the separator base.push(SEP); (base, rest) }; let base_dir = nu_path::expand_path_with(&base_dir_name, cwd); // This check is here as base_dir.read_dir() with base_dir == "" will open the current dir // which we don't want in this case (if we did, base_dir would already be ".") if base_dir == Path::new("") { return Vec::new(); } let mut results = if let Ok(result) = base_dir.read_dir() { result .filter_map(|entry| { entry.ok().and_then(|entry| { let mut file_name = entry.file_name().to_string_lossy().into_owned(); if matches(partial, &file_name) { let mut path = format!("{}{}", base_dir_name, file_name); if entry.path().is_dir() { path.push(SEP); file_name.push(SEP); } if path.contains(' ') { path = format!("\'{}\'", path); } Some((span, path)) } else { None } }) }) .collect() } else { Vec::new() }; results.sort_by(|a, b| { let a_distance = levenshtein_distance(partial, &a.1); let b_distance = levenshtein_distance(partial, &b.1); a_distance.cmp(&b_distance) }); results } fn matches(partial: &str, from: &str) -> bool { from.to_ascii_lowercase() .starts_with(&partial.to_ascii_lowercase()) }