diff --git a/crates/nu-cli/src/commands/commandline/complete.rs b/crates/nu-cli/src/commands/commandline/complete.rs index f4f2042004..a53066f833 100644 --- a/crates/nu-cli/src/commands/commandline/complete.rs +++ b/crates/nu-cli/src/commands/commandline/complete.rs @@ -1,18 +1,38 @@ -use std::{borrow::Cow, sync::Arc}; +use std::borrow::Cow; use nu_engine::command_prelude::*; -use nu_protocol::FromValue; +use nu_protocol::{FromValue, shell_error::generic::GenericError}; use crate::completions::{ - Completer, CompletionEngine, DirectoryCompletion, FileCompletion, SemanticSuggestion, + Buffer, CommandCompletion, CommandScope, Completer, CompletionEngine, DeclaredInputs, + DirectoryCompletion, EnvVarCompletion, FileCompletion, SemanticSuggestion, VariableCompletion, + flush_completion_warnings, }; #[derive(Debug, Clone, FromValue)] -#[nu_value(rename_all = "kebab-case", type_name = "directory | path | glob")] +#[nu_value( + rename_all = "kebab-case", + type_name = "directory | path | glob | command | variable | env-var" +)] enum CompletionType { Directory, Path, Glob, + Command, + Variable, + EnvVar, +} + +impl CompletionType { + /// The names a `--type` value may take, for the flag's own completions and its error. + const NAMES: [&'static str; 6] = [ + "directory", + "path", + "glob", + "command", + "variable", + "env-var", + ]; } #[derive(Clone)] @@ -31,26 +51,42 @@ impl Command for CommandlineComplete { Signature::build("commandline complete") .input_output_type( Type::Nothing, - Type::one_of([Type::list(Type::String), Type::list(Type::record())]), + Type::one_of([ + Type::list(Type::String), + Type::list(Type::record()), + Type::record(), + ]), ) .input_output_type( Type::String, - Type::one_of([Type::list(Type::String), Type::list(Type::record())]), + Type::one_of([ + Type::list(Type::String), + Type::list(Type::record()), + Type::record(), + ]), ) .switch( "detailed", "Output completions as records, in the format expected from custom completers.", Some('d'), ) + .switch( + "input", + "Output the record a completer would receive here (`{token, place, buffer}`), \ + instead of completions.", + Some('i'), + ) .param( Flag::new("type") .arg(SyntaxShape::String) - .desc("The type of values to allow as completions.") - .completion(Completion::List(nu_utils::NuCow::Borrowed(&[ - "directory", - "glob", - "path", - ]))), + .desc( + "Restrict completions to one built-in source (directory, path, glob, \ + command, variable, or env-var), so a completer can compose the \ + engine's own sources with its results.", + ) + .completion(Completion::List(nu_utils::NuCow::Borrowed( + &CompletionType::NAMES, + ))), ) .category(Category::Core) } @@ -59,7 +95,10 @@ impl Command for CommandlineComplete { "This command can be used to obtain the completions that Nushell would normally provide for the given commandline contents. Completions will be provided as if the cursor is placed at the end of the given string. -If no input is provided, the current commandline contents will be used instead." +If no input is provided, the current commandline contents will be used instead. + +With --input, the record a completer would receive at that position is returned instead of +completions, which is the supported way to develop and test a completer from inside Nushell." } fn search_terms(&self) -> Vec<&str> { @@ -81,6 +120,26 @@ If no input is provided, the current commandline contents will be used instead." extract_input_buffer(&input, engine_state, head_span, source_span)?; let is_detailed = call.has_flag(engine_state, stack, "detailed")?; + + // --input returns the completer's input record, not completions; reject the flags that + // shape output. + if call.has_flag(engine_state, stack, "input")? { + for conflicting in ["detailed", "type"] { + if let Some(span) = call.get_flag_span(stack, conflicting) { + return Err(ShellError::IncompatibleParameters { + left_message: "cannot be used with --input".into(), + left_span: span, + right_message: "--input returns the completer's input record".into(), + right_span: call.get_flag_span(stack, "input").unwrap_or(head_span), + }); + } + } + + return Ok(CompletionEngine::new(engine_state, stack) + .completer_input_at(&buffer, cursor_position, DeclaredInputs::all()) + .into_pipeline_data()); + } + let completion_type = match call.get_flag::(engine_state, stack, "type")? { Some(v) => { let type_str = v @@ -90,7 +149,7 @@ If no input is provided, the current commandline contents will be used instead." Some(CompletionType::from_value(v.clone()).map_err(|_| { ShellError::InvalidValue { - valid: r#"type "directory", "path", or "glob""#.into(), + valid: format!("one of {}", CompletionType::NAMES.join(", ")), actual: type_str, span: v.span(), } @@ -99,6 +158,29 @@ If no input is provided, the current commandline contents will be used instead." None => None, }; + // Interactive completers require the line editor's terminal. + if completion_type.is_none() + && !engine_state.is_interactive + && CompletionEngine::new(engine_state, stack) + .interactive_completer_at(&buffer, cursor_position) + { + return Err(ShellError::Generic( + GenericError::new( + "An `@interactive` completer cannot run here", + "this position completes with a terminal picker, which needs the terminal", + call_span, + ) + .with_help( + "`@interactive` completers are run by the line editor, which is the only \ + thing that can hand one the terminal. \ + To inspect this completer from a script instead, \ + `commandline complete --input` returns the record it would receive \ + without running it.", + ) + .with_code("nu::shell::interactive_completer_needs_a_terminal"), + )); + } + let completions = fetch_completions( engine_state, stack, @@ -107,6 +189,9 @@ If no input is provided, the current commandline contents will be used instead." cursor_position, ); + // Flush warnings now. + flush_completion_warnings(engine_state, stack); + let result_values: Vec = completions .into_iter() .map(|suggestion| match is_detailed { @@ -152,6 +237,24 @@ If no input is provided, the current commandline contents will be used instead." example: "commandline complete | append 'foo'", result: None, }, + Example { + description: "Compose a built-in source inside a completer: the engine's \ + command names beside your own.", + example: "def comp [token: record] { [my-alias] ++ ($token.text | commandline complete --type command) }", + result: None, + }, + Example { + description: "Return `fallback: true` to add completions beside the built-in \ + ones rather than replacing them.", + example: "def comp [token: record] { {completions: [my-preset], fallback: true} }", + result: None, + }, + Example { + description: "Inspect what a completer would be handed at the cursor, including \ + the argument's declared shape.", + example: "'cd ma' | commandline complete --input | get place.shape", + result: None, + }, ] } } @@ -192,9 +295,7 @@ fn fetch_completions( buffer: &str, cursor_position: usize, ) -> Vec { - // TODO: it should be possible to add something like a `NuCompleter::borrowed()` - // to avoid cloning the entire stack + engine state here, as a future optimization. - let completer = CompletionEngine::new(Arc::new(engine_state.clone()), Arc::new(stack.clone())); + let completer = CompletionEngine::new(engine_state, stack); completion_type .map(|parsed_type| { @@ -228,9 +329,12 @@ fn generate_typed_suggestions( let context = completer.context( &working_set, + Buffer { + text: &buffer[..cursor_position], + offset: file_span.start, + }, file_span, &buffer_bytes[..cursor_position], - file_span.start, ); // Explicit matching avoids boxing the source into a `dyn` trait object. @@ -239,5 +343,10 @@ fn generate_typed_suggestions( CompletionType::Path | CompletionType::Glob => { FileCompletion.fetch(&context).into_suggestions() } + CompletionType::Command => CommandCompletion::new(CommandScope::All) + .fetch(&context) + .into_suggestions(), + CompletionType::Variable => VariableCompletion.fetch(&context).into_suggestions(), + CompletionType::EnvVar => EnvVarCompletion.fetch(&context).into_suggestions(), } } diff --git a/crates/nu-cli/src/completions/arg_value_completion.rs b/crates/nu-cli/src/completions/arg_value_completion.rs index 7e4f6009ec..d5689e17a7 100644 --- a/crates/nu-cli/src/completions/arg_value_completion.rs +++ b/crates/nu-cli/src/completions/arg_value_completion.rs @@ -1,6 +1,6 @@ use super::completer::touches; use crate::{ - CompletionEngine, FileCompletion, + FileCompletion, completions::{ Completer, Context, DirectoryCompletion, ExportableCompletion, Fetched, SemanticSuggestion, completion_options::NuMatcher, to_reedline_span, @@ -18,9 +18,7 @@ pub struct ArgValueCompletion<'a> { pub arg_type: ArgType<'a>, /// Whether to fall back to file completion when no source matches. pub need_fallback: bool, - pub completer: &'a CompletionEngine, - /// Index into `call.arguments`, or `call.arguments.len()` for a synthesized - /// trailing slot the parser produced no argument for (e.g. `open `). + /// Index into `call.arguments`, or `.len()` for a trailing slot with no argument. pub arg_idx: usize, /// The `SyntaxShape` this argument is declared with in the command's signature, if /// known. Used to pick a type-specific fallback (e.g. directories for `cd `) when @@ -36,15 +34,12 @@ impl<'a> Completer for ArgValueCompletion<'a> { return fetched_completion; } - let working_set = context.working_set; let prefix_string = context.prefix_str(); - let completion_context = self.completer.context( - working_set, - context.span, - prefix_string.as_ref().as_bytes(), - context.offset, - ); + let completion_context = Context { + prefix: prefix_string.as_ref().as_bytes(), + ..*context + }; // Command-specific completions are dispatched earlier via `BuiltinCompletion`; // here we handle only the generic argument-value fallbacks. @@ -94,7 +89,7 @@ impl<'a> ArgValueCompletion<'a> { matcher.add_semantic_suggestion(suggestion); } - Some(Fetched::Cacheable(matcher.suggestion_results())) + Some(Fetched::answering(matcher.suggestion_results()).worth_keeping()) } Ok(None) => None, // fallback to type based completion, file completion, etc. Err(error) => { @@ -124,13 +119,13 @@ impl<'a> ArgValueCompletion<'a> { ) -> Fetched { let expression = self.arg_expr(); let Some((module_name, span)) = self.find_module_name_and_span() else { - return Fetched::Pure(vec![]); + return Fetched::answering(vec![]); }; let Some((module_id, temp_working_set)) = self.resolve_module(working_set, module_name, span) else { - return Fetched::Pure(vec![]); + return Fetched::answering(vec![]); }; let mut exportable_completion = ExportableCompletion { @@ -147,14 +142,16 @@ impl<'a> ArgValueCompletion<'a> { completion_context, &mut exportable_completion, ), - _ => Fetched::Pure(vec![]), + _ => Fetched::answering(vec![]), }, // No expression at all or any other scalar shape (`Expr::String`, plus `Expr::Nothing` // for the `null` keyword): search exports by the raw prefix text. _ => exportable_completion.fetch(completion_context), }; - fetched_result.caching() + // Reaching here may have parsed a module off disk, so the answer is worth keeping + // even where the source that gave it was cheap. + fetched_result.worth_keeping() } fn find_module_name_and_span(&self) -> Option<(&[u8], Span)> { @@ -224,12 +221,11 @@ impl<'a> ArgValueCompletion<'a> { completion_context.span.end.min(item_span.end), ); - let item_context = self.completer.context( - completion_context.working_set, - new_span, - sliced_prefix, - completion_context.offset, - ); + let item_context = Context { + span: new_span, + prefix: sliced_prefix, + ..*completion_context + }; exportable_completion.fetch(&item_context) } @@ -258,7 +254,7 @@ impl<'a> ArgValueCompletion<'a> { Some(SyntaxShape::Filepath | SyntaxShape::GlobPattern) => complete_file(), // fallback to file completion if necessary _ if self.need_fallback => complete_file(), - _ => Fetched::Pure(vec![]), + _ => Fetched::answering(vec![]), } } } diff --git a/crates/nu-cli/src/completions/attribute_completions.rs b/crates/nu-cli/src/completions/attribute_completions.rs index 4f1effcc72..cebff7a040 100644 --- a/crates/nu-cli/src/completions/attribute_completions.rs +++ b/crates/nu-cli/src/completions/attribute_completions.rs @@ -30,7 +30,7 @@ impl Completer for AttributeCompletion { }); } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } @@ -62,6 +62,6 @@ impl Completer for AttributableCompletion { }); } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/base.rs b/crates/nu-cli/src/completions/base.rs index 4836dc9632..599c5565d7 100644 --- a/crates/nu-cli/src/completions/base.rs +++ b/crates/nu-cli/src/completions/base.rs @@ -8,47 +8,103 @@ pub trait Completer { fn fetch(&mut self, ctx: &Context) -> Fetched; } -/// The outcome of one source's [`Completer::fetch`]. Caching and fallback are encoded in -/// the variant, so a declining result cannot carry suggestions. +/// Result from one completion source. #[derive(Debug, Default)] -pub enum Fetched { - /// Cheap engine-state result: never cached, never falls back. - Pure(Vec), - /// Impure source result (filesystem, `PATH`, user/plugin code); worth caching. - Cacheable(Vec), - /// Impure source declined: fall back, but cache the attempt. - Declined, - /// No source ran: fall back cheaply. - #[default] - Absent, +pub struct Fetched { + suggestions: Vec, + /// Source answered; no fallback. + answered: bool, + /// Worth caching between keystrokes. + reusable: bool, } impl Fetched { - /// The suggestions this outcome carries; declining outcomes carry none. + /// No source ran here. + pub(crate) fn absent() -> Self { + Self::default() + } + + /// Answer with `suggestions`; empty still counts as answered. + pub(crate) fn answering(suggestions: Vec) -> Self { + Self { + suggestions, + answered: true, + reusable: false, + } + } + + /// Contribute suggestions and allow fallback. + pub(crate) fn contributing(suggestions: Vec) -> Self { + Self { + suggestions, + answered: false, + reusable: true, + } + } + + /// Decline; next source answers. + pub(crate) fn declining() -> Self { + Self { + suggestions: Vec::new(), + answered: false, + reusable: true, + } + } + + /// Mark answer cacheable. + pub(crate) fn worth_keeping(mut self) -> Self { + self.reusable = true; + self + } + + /// The suggestions this outcome carries. pub(crate) fn into_suggestions(self) -> Vec { - match self { - Self::Pure(suggestions) | Self::Cacheable(suggestions) => suggestions, - Self::Declined | Self::Absent => Vec::new(), - } + self.suggestions } - /// Impure source ran; result worth reusing between keystrokes. - pub(crate) fn is_cacheable(&self) -> bool { - matches!(self, Self::Cacheable(_) | Self::Declined) + /// Did source answer? + pub(crate) fn answered(&self) -> bool { + self.answered } - /// Whether this source declined, so the next one should be tried. - pub(crate) fn needs_fallback(&self) -> bool { - matches!(self, Self::Declined | Self::Absent) + /// Cacheable between keystrokes? + pub(crate) fn is_reusable(&self) -> bool { + self.reusable } - /// Mark cheap results cacheable when the caller did expensive work. - pub(crate) fn caching(self) -> Self { - match self { - Self::Pure(suggestions) => Self::Cacheable(suggestions), - Self::Absent => Self::Declined, - already => already, - } + /// Any suggestions yet, without consuming them. + pub(crate) fn is_empty(&self) -> bool { + self.suggestions.is_empty() + } + + /// Keep only matching suggestions (the shorter-head reading drops commands). + pub(crate) fn retain(&mut self, f: impl FnMut(&SemanticSuggestion) -> bool) { + self.suggestions.retain(f); + } + + /// Append another outcome's suggestions and state: answered only if both + /// answered, reusable if either is. + pub(crate) fn merge(&mut self, other: Fetched) { + self.answered |= other.answered; + self.reusable |= other.reusable; + self.suggestions.extend(other.suggestions); + } + + /// Merge one source's outcome and report whether it answered. + pub(crate) fn absorb(&mut self, attempt: Fetched) -> bool { + let answered = attempt.answered(); + self.merge(attempt); + answered + } + + /// Prepend another outcome's suggestions, sharing reusability but leaving + /// answeredness untouched (for the shorter-head argument reading, whose + /// ranking comes first but which never settles the site). + pub(crate) fn prepend_from(&mut self, other: Fetched) { + self.reusable |= other.reusable; + let mut combined = other.suggestions; + combined.append(&mut self.suggestions); + self.suggestions = combined; } } @@ -111,6 +167,32 @@ impl IntoValue for SemanticSuggestion { record.insert("description", description.into_value(span)); } + if let Some(extra) = self.suggestion.extra { + record.insert("extra", extra.into_value(span)); + } + + // Omit default fields to keep the common output compact. + if self.suggestion.append_whitespace { + record.insert("append_whitespace", Value::bool(true, span)); + } + + if let Some(match_indices) = self + .suggestion + .match_indices + .filter(|indices| !indices.is_empty()) + { + record.insert( + "match_indices", + Value::list( + match_indices + .into_iter() + .map(|index| Value::int(index as i64, span)) + .collect(), + span, + ), + ); + } + if let Some(kind) = self.kind { let (kind_str, ty) = match kind { SuggestionKind::Command(ty, _) => ("command", Some(ty.to_string())), @@ -164,11 +246,25 @@ impl From for SemanticSuggestion { mod tests { use super::*; - /// `complete_argument_value` relies on this to treat `need_fallback`-requesting - /// outcomes as always empty (a dead check was removed on that assumption). #[test] - fn fallback_variants_carry_no_suggestions() { - assert!(Fetched::Declined.into_suggestions().is_empty()); - assert!(Fetched::Absent.into_suggestions().is_empty()); + fn declining_carries_no_suggestions() { + assert!(!Fetched::declining().answered()); + assert!(Fetched::declining().into_suggestions().is_empty()); + assert!(!Fetched::absent().answered()); + } + + #[test] + fn answering_with_nothing_still_answers() { + let dismissed = Fetched::answering(vec![]); + assert!(dismissed.answered()); + assert!(!dismissed.is_reusable()); + assert!(dismissed.into_suggestions().is_empty()); + } + + #[test] + fn contributing_keeps_its_suggestions_and_the_site_open() { + let fetched = Fetched::contributing(vec![SemanticSuggestion::default()]); + assert!(!fetched.answered()); + assert_eq!(fetched.into_suggestions().len(), 1); } } diff --git a/crates/nu-cli/src/completions/cell_path_completions.rs b/crates/nu-cli/src/completions/cell_path_completions.rs index 0a85ec1d01..9714603ad9 100644 --- a/crates/nu-cli/src/completions/cell_path_completions.rs +++ b/crates/nu-cli/src/completions/cell_path_completions.rs @@ -74,7 +74,7 @@ impl Completer for CellPathCompletion<'_> { } } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/command_completions.rs b/crates/nu-cli/src/completions/command_completions.rs index 1fadd1323b..677afa0dff 100644 --- a/crates/nu-cli/src/completions/command_completions.rs +++ b/crates/nu-cli/src/completions/command_completions.rs @@ -304,8 +304,8 @@ impl Completer for CommandCompletion { } match externals { - true => Fetched::Cacheable(suggestions), - false => Fetched::Pure(suggestions), + true => Fetched::answering(suggestions).worth_keeping(), + false => Fetched::answering(suggestions), } } } diff --git a/crates/nu-cli/src/completions/completer.rs b/crates/nu-cli/src/completions/completer.rs index ac13d0bfc4..ba47ed0e95 100644 --- a/crates/nu-cli/src/completions/completer.rs +++ b/crates/nu-cli/src/completions/completer.rs @@ -1,20 +1,20 @@ use crate::completions::{ ArgValueCompletion, AttributableCompletion, AttributeCompletion, CellPathCompletion, - CommandCompletion, CommandScope, Completer, CompletionOptions, CustomCompletion, - DotNuCompletion, EnvVarCompletion, FileCompletion, FlagCompletion, NuMatcher, - OperatorCompletion, VariableCompletion, + CommandCompletion, CommandScope, Completer, CompletionOptions, DotNuCompletion, + EnvVarCompletion, FileCompletion, FlagCompletion, NuMatcher, OperatorCompletion, + VariableCompletion, base::{Fetched, SemanticSuggestion}, }; use lru::LruCache; use nu_parser::{parse, parse_shorter_head_reading}; use nu_protocol::{ - BuiltinCompletion, CommandWideCompleter, Completion, DeclId, Flag, Signature, Span, - SuggestionKind, + BlockId, BuiltinCompletion, CommandWideCompleter, Completion, DeclId, Flag, Record, Signature, + Span, SuggestionKind, SyntaxShape, Value, ast::{ Argument, AttributeBlock, Block, Call, Expr, Expression, ExternalArgument, FlagRef, FullCellPath, PipelineRedirection, RedirectionTarget, Traverse, }, - engine::{ArgType, EngineState, Stack, StateWorkingSet}, + engine::{ArgType, Closure, Command, EngineState, Stack, StateWorkingSet}, }; use nu_utils::time::Instant; use reedline::{ @@ -28,15 +28,13 @@ use std::thread; use std::time::Duration; use std::{borrow::Cow, ops::ControlFlow, path::is_separator}; -/// Max cache entries before evicting the least recently used; overridden per completer by -/// `$env.config.completions.cache_size` (`0` disables the cache). const DEFAULT_CACHE_SIZE: usize = 100; -use super::{StaticCompletion, custom_completions::CommandWideCompletion}; +use super::{ + StaticCompletion, + custom_completions::{DeclaredInputs, UserCompletion, completer_input}, +}; -/// Used as the function `f` in find_map Traverse -/// -/// returns the inner-most pipeline_element of interest that reaches the given position fn find_pipeline_element_by_position<'a>( expr: &'a Expression, working_set: &'a StateWorkingSet, @@ -68,8 +66,7 @@ fn find_pipeline_element_by_position<'a>( .iter() .find_map(|arg| arg.expr().find_map(working_set, &recurse)) .or_else(|| { - // `touches`, not `contains`: the cursor sits at the head's trailing - // edge (issue #7648). + // `touches` includes the head's trailing edge (#7648). touches(head.span, pos) .then(|| head.as_ref().find_map(working_set, &recurse)) .flatten() @@ -80,8 +77,7 @@ fn find_pipeline_element_by_position<'a>( .or_else(|| rhs.find_map(working_set, &recurse)), ), Expr::FullCellPath(fcp) => { - // `use std/util [E, T⌶`: the import list is a `List` in a `FullCellPath`; leave it - // to the enclosing call, which knows the module, to complete its members. + // `use std/util [E, T⌶`: the import list is the enclosing call's slot. if touches(fcp.head.span, pos) && matches!(fcp.head.expr, Expr::List(_)) { return ControlFlow::Continue(()); } @@ -99,40 +95,127 @@ fn find_pipeline_element_by_position<'a>( } } -/// Whether `position` is inside `span` or exactly at its trailing edge. -/// -/// Completion happens at a token's trailing edge, which the end-exclusive -/// [`Span::contains`] would miss. +/// True if expr owns trailing gap after its span. +fn owns_trailing_gap(expr: &Expr) -> bool { + matches!( + expr, + Expr::Call(_) | Expr::ExternalCall(_, _) | Expr::AttributeBlock(_) + ) +} + +/// Whether `pos` is inside `span` or at its trailing edge. pub(crate) fn touches(span: Span, position: usize) -> bool { span.contains(position) || span.end == position } -/// The last element when the cursor trails it over whitespace only (`ls ⌶`) — an empty -/// new slot for that element. Non-whitespace gaps fall through to -/// [`CompletionEngine::resolve_fallback_site`]. +/// The block a nesting expression runs. +fn nested_block(expr: &Expression) -> Option { + match expr.expr { + Expr::RowCondition(block_id) + | Expr::Subexpression(block_id) + | Expr::Block(block_id) + | Expr::Closure(block_id) => Some(block_id), + _ => None, + } +} + +/// The block the cursor descends into from `element`, and its span. +fn descent_from<'a>( + element: &'a Expression, + working_set: &'a StateWorkingSet, + pos: usize, +) -> Option<(BlockId, Span)> { + element.find_map(working_set, &|expr: &'a Expression| { + if !touches(expr.span, pos) { + // Skip this subtree; the caller keeps searching its siblings. + return ControlFlow::Break(None); + } + match nested_block(expr) { + Some(block_id) => ControlFlow::Break(Some((block_id, expr.span))), + None => ControlFlow::Continue(()), + } + }) +} + +/// The element the cursor is on; a trailing gap belongs to the last. +fn element_at<'a>( + block: &'a Block, + working_set: &StateWorkingSet, + pos: usize, +) -> Option<&'a Expression> { + block + .pipelines + .iter() + .flat_map(|pipeline| &pipeline.elements) + .map(|element| &element.expr) + .find(|expr| touches(expr.span, pos)) + .or_else(|| trailing_gap_element(block, working_set, pos)) +} + +/// The elements enclosing the cursor, outermost first. +fn enclosing_elements<'a>( + block: &'a Block, + working_set: &'a StateWorkingSet, + pos: usize, +) -> Vec<(&'a Expression, Option)> { + let mut chain = Vec::new(); + let mut block = block; + + while let Some(element) = element_at(block, working_set, pos) { + let descent = descent_from(element, working_set, pos); + chain.push((element, descent.map(|(_, span)| span))); + + let Some((block_id, _)) = descent else { break }; + block = working_set.get_block(block_id); + } + + chain +} + +/// The expression the cursor completes; a strictly inner chain element beats the search. +fn innermost_expression<'a>( + found: Option<&'a Expression>, + chain: &[(&'a Expression, Option)], +) -> Option<&'a Expression> { + let innermost = chain.last().map(|&(element, _)| element); + let (Some(outer), Some(inner)) = (found, innermost) else { + return found.or(innermost); + }; + + let strictly_inside = outer.span.start <= inner.span.start + && inner.span.end <= outer.span.end + && inner.span != outer.span; + + Some(if strictly_inside { inner } else { outer }) +} + +/// The last element when the cursor trails it over whitespace. fn trailing_gap_element<'a>( block: &'a Block, working_set: &StateWorkingSet, absolute_position: usize, ) -> Option<&'a Expression> { let expression = &block.pipelines.last()?.elements.last()?.expr; + + // The element can end past the cursor (whole files, later statements); a backwards + // span would panic. + if expression.span.end > absolute_position { + return None; + } + let gap = working_set.get_span_contents(Span::new(expression.span.end, absolute_position)); gap.iter() .all(u8::is_ascii_whitespace) .then_some(expression) } -/// The span a command-name completion replaces, given the parsed `head` and the whole -/// `element` it heads. +/// The span a command-name completion replaces. fn command_name_span(head: Span, element: Span) -> Span { Span::new(head.start, head.end.max(element.end)) } -/// Whether `token` is a flag being typed — i.e. it begins with `-`. -/// -/// The parser stores in-progress flags as positionals, so the leading dash is the only -/// reliable flag/positional test; the cache relies on it too. -fn is_flag_text(token: impl AsRef<[u8]>) -> bool { +/// Whether the token starts a flag; in-progress flags parse as positionals. +pub(crate) fn is_flag_text(token: impl AsRef<[u8]>) -> bool { token.as_ref().starts_with(b"-") } @@ -141,8 +224,7 @@ fn is_flag_token(working_set: &StateWorkingSet, span: Span) -> bool { is_flag_text(working_set.get_span_contents(span)) } -/// Whether `expr` is a value an operator can trail (`1 ⌶`, `'str' ⌶`). Exhaustive, so a -/// new [`Expr`] variant must be classified rather than defaulting. +/// Whether `expr` can be an operator's left-hand side. fn is_operator_lhs(expr: &Expr) -> bool { match expr { Expr::Int(_) @@ -195,7 +277,7 @@ fn find_flag(signature: &Signature, flag: FlagRef<'_>) -> Option { } } -/// Non-named arguments before `before_index` — the positional index of that slot. +/// Positional arguments before `before_index`. fn count_positionals(call: &Call, before_index: usize) -> usize { call.arguments .iter() @@ -204,7 +286,133 @@ fn count_positionals(call: &Call, before_index: usize) -> usize { .count() } -/// Helper function to extract file-path expression from redirection target +/// A call and the pipeline element it lives in: the shared payload of every +/// call-bound [`SiteKind`]. Carrying them as one value keeps an element from +/// being paired with another call's arguments. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CallSite<'a> { + pub call: &'a Call, + pub element: &'a Expression, +} + +/// A call's `Expr::RowCondition` argument. Values of this type prove the +/// condition exists, so operator/value-gap reads share one lookup and one +/// signature-slot rule instead of re-searching the arguments each time. +#[derive(Debug, Clone, Copy)] +struct RowCondition<'a> { + site: CallSite<'a>, + arg_slot: usize, + block_id: BlockId, + arg_span: Span, +} + +impl<'a> RowCondition<'a> { + fn find(site: CallSite<'a>, want: Option) -> Option { + site.call + .arguments + .iter() + .enumerate() + .rev() + .find_map(|(arg_slot, arg)| match arg { + Argument::Positional(Expression { + expr: Expr::RowCondition(block_id), + span, + .. + }) if want.is_none_or(|wanted| wanted == *span) => Some(Self { + site, + arg_slot, + block_id: *block_id, + arg_span: *span, + }), + _ => None, + }) + } + + /// The trailing condition of `site`, if any. + fn trailing(site: CallSite<'a>) -> Option { + Self::find(site, None) + } + + /// The condition whose span is `descent_span` (a File leaf promoting outward), if any. + fn matching(site: CallSite<'a>, descent_span: Span) -> Option { + Self::find(site, Some(descent_span)) + } + + /// Declared slot of the condition (e.g. 0 for `where`), not raw arg position, so a + /// leading path arg cannot steal the condition's index. + fn index(&self, working_set: &StateWorkingSet) -> usize { + fn is_cond(shape: &SyntaxShape) -> bool { + matches!(shape, SyntaxShape::RowCondition) + || matches!(shape, SyntaxShape::OneOf(shapes) if shapes.iter().any(is_cond)) + } + let signature = working_set.get_decl(self.site.call.decl_id).signature(); + signature + .required_positional + .iter() + .chain(signature.optional_positional.iter()) + .enumerate() + .find(|(_, p)| is_cond(&p.shape)) + .map(|(i, _)| i) + .unwrap_or_else(|| count_positionals(self.site.call, self.arg_slot)) + } + + /// Positional site for this condition. + fn site_at( + &self, + working_set: &StateWorkingSet, + cursor: usize, + span: Span, + ) -> CompletionSite<'a> { + CompletionSite::at( + cursor, + SiteKind::Positional { + site: self.site, + sig_positional: self.index(working_set), + arg_slot: self.arg_slot, + }, + span, + ) + } + + /// Last term of the condition body. + fn last_term(&self, working_set: &'a StateWorkingSet) -> Option<&'a Expression> { + Some( + &working_set + .get_block(self.block_id) + .pipelines + .last()? + .elements + .last()? + .expr, + ) + } +} + +/// A File leaf inside a row condition is really the enclosing condition positional. +fn promote_row_condition_file<'a>( + site: CompletionSite<'a>, + chain: &[(&'a Expression, Option)], + working_set: &StateWorkingSet, + cursor: usize, +) -> CompletionSite<'a> { + if !matches!(site.kind, SiteKind::File) { + return site; + } + for &(element, descent) in chain { + let Some(descent_span) = descent else { + continue; + }; + let Expr::Call(call) = &element.expr else { + continue; + }; + if let Some(cond) = RowCondition::matching(CallSite { call, element }, descent_span) { + return cond.site_at(working_set, cursor, site.span); + } + } + site +} + +/// A redirection target the cursor touches, as a file path. fn check_redirection_target(target: &RedirectionTarget, pos: usize) -> Option<&Expression> { let expr = target.expr(); expr.and_then(|expression| { @@ -218,7 +426,7 @@ fn check_redirection_target(target: &RedirectionTarget, pos: usize) -> Option<&E }) } -/// For redirection target completion +/// A redirection target the cursor touches, in any block. fn check_redirection_in_block(block: &Block, pos: usize) -> Option<&Expression> { block .pipelines @@ -233,19 +441,15 @@ fn check_redirection_in_block(block: &Block, pos: usize) -> Option<&Expression> }) } -/// Cache key and worker message identity: the text typed up to the cursor. -/// -/// Excludes trailing text and derives `cursor()` from `typed.len()` so the two never -/// disagree. +/// The text typed up to the cursor; also the worker message identity. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CompletionQuery { - /// The prefix of the line buffer up to the (floored) cursor position. typed: Arc, } impl CompletionQuery { fn new(line: &str, cursor: usize) -> Self { - let floored = line.floor_char_boundary(cursor); + let floored = line.floor_char_boundary(cursor.min(line.len())); Self { typed: Arc::from(&line[..floored]), } @@ -259,9 +463,7 @@ impl CompletionQuery { self.typed.len() } - /// Whether `self` is `base` with more characters typed into the same `token`. The - /// appended text must stay within one token and must not turn it into a flag — a - /// different completion site than the cached result came from. + /// Whether `self` extends `base` within one token. fn narrows(&self, base: &CompletionQuery, token: reedline::Span) -> bool { let Some(appended) = self.typed().strip_prefix(base.typed()) else { return false; @@ -324,31 +526,36 @@ impl CacheEnv { } } -struct CacheEntry { - suggestions: Suggestions, - env: CacheEnv, +/// One generation of cached completions. Entries are only valid for the +/// environment they were computed in, so the whole map carries a single env: +/// a mismatch clears it in O(1) instead of purging entry by entry. +struct CacheState { + /// `None` disables the cache; the map is then never consulted. + capacity: Option, + /// The generation `entries` were computed in. + env: Option, + entries: LruCache, } -impl CacheEntry { - /// Whether this entry may still answer a query: produced in the same environment. - fn is_usable(&self, env: CacheEnv) -> bool { - self.env == env - } - - /// The span the cursor extends: the range the *last* suggestion replaces. - /// - /// `fetch_completions_by_block` keeps the cursor-anchored family last, so reading the - /// last span is the correct one to extend. - fn reference_span(&self) -> Option { - self.suggestions.last().map(|suggestion| suggestion.span) +impl CacheState { + /// The live map for `env`, clearing a stale generation; `None` when disabled. + fn live( + state: &mut Self, + env: CacheEnv, + ) -> Option<&mut LruCache> { + state.capacity?; + if state.env != Some(env) { + state.env = Some(env); + state.entries.clear(); + } + Some(&mut state.entries) } } -/// Cross-prompt completion cache bounded by entry count (`$env.config.completions.cache_size`), -/// evicting least recently used entries. Capacity `0` disables the cache. +/// Cross-prompt completion cache, LRU by entry count; capacity `0` disables it. #[derive(Clone)] pub(crate) struct NarrowingCache { - entries: Arc>>>, + state: Arc>, } impl Default for NarrowingCache { @@ -361,21 +568,26 @@ impl NarrowingCache { /// `0` isn't a valid `LruCache` capacity; it means the cache is disabled. pub(crate) fn new(capacity: usize) -> Self { Self { - entries: Arc::new(Mutex::new(NonZeroUsize::new(capacity).map(LruCache::new))), + state: Arc::new(Mutex::new(CacheState { + capacity: NonZeroUsize::new(capacity), + env: None, + // Never consulted while disabled; resized on enable. + entries: LruCache::new(NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::MIN)), + })), } } - /// Resizes the cache in place, dropping LRU entries when shrinking. Capacity `0` - /// disables it. Called once per prompt so `cache_size` config changes take effect. + /// Resize in place; `0` disables the cache. Reapplied each prompt. pub(crate) fn set_capacity(&self, capacity: usize) { - if let Ok(mut cache_guard) = self.entries.lock() { - *cache_guard = NonZeroUsize::new(capacity).map(|new_capacity| { - let mut cache = cache_guard - .take() - .unwrap_or_else(|| LruCache::new(new_capacity)); - cache.resize(new_capacity); - cache - }); + if let Ok(mut state) = self.state.lock() { + state.capacity = NonZeroUsize::new(capacity); + match state.capacity { + Some(capacity) => state.entries.resize(capacity), + None => { + state.entries.clear(); + state.env = None; + } + } } } @@ -384,12 +596,10 @@ impl NarrowingCache { query: &CompletionQuery, environment: CacheEnv, ) -> Option { - let mut cache_guard = self.entries.lock().ok()?; - let entry = cache_guard.as_mut()?.get(query)?; - - entry - .is_usable(environment) - .then(|| entry.suggestions.clone()) + let mut state = self.state.lock().ok()?; + CacheState::live(&mut state, environment)? + .get(query) + .cloned() } pub(crate) fn store( @@ -398,26 +608,10 @@ impl NarrowingCache { environment: CacheEnv, suggestions: Suggestions, ) { - if let Ok(mut cache_guard) = self.entries.lock() - && let Some(cache) = cache_guard.as_mut() + if let Ok(mut state) = self.state.lock() + && let Some(cache) = CacheState::live(&mut state, environment) { - let stale_keys: Vec<_> = cache - .iter() - .filter(|(_, entry)| !entry.is_usable(environment)) - .map(|(key, _)| key.clone()) - .collect(); - - for key in stale_keys { - cache.pop(&key); - } - - cache.put( - query, - CacheEntry { - suggestions, - env: environment, - }, - ); + cache.put(query, suggestions); } } @@ -428,30 +622,27 @@ impl NarrowingCache { options: &CompletionOptions, ) -> Suggestions { let Some((base_suggestions, ref_span, search_token)) = - self.entries.lock().ok().and_then(|guard| { - let (_, entry, span) = guard - .as_ref()? + self.state.lock().ok().and_then(|mut state| { + let cache = CacheState::live(&mut state, environment)?; + let (_, suggestions, span) = cache .iter() - .filter_map(|(bq, e)| { - let s = e.reference_span()?; - (e.is_usable(environment) && query.narrows(bq, s)).then_some(( - bq.cursor(), - e, - s, - )) + .filter_map(|(base, suggestions)| { + // The span the last suggestion replaces; the one the cursor extends. + let span = suggestions.last().map(|suggestion| suggestion.span)?; + query + .narrows(base, span) + .then_some((base.cursor(), suggestions, span)) }) - .max_by_key(|&(c, ..)| c)?; + .max_by_key(|&(cursor, ..)| cursor)?; let token = query.typed().get(span.start..)?; - Some((Arc::clone(&entry.suggestions), span, token)) + Some((Arc::clone(suggestions), span, token)) }) else { return Suggestions::default(); }; - // Don't re-sort: the producing completer ranks a directory by its bare name and - // appends the separator afterwards, so sorting here would rank it `config/` and - // land it after `config.nu`. Filtering alone preserves the order it chose. + // Keep the producer's ranking: re-sorting a stale list makes the menu flip between keystrokes. let mut matcher = NuMatcher::new(search_token, options, false); base_suggestions @@ -487,7 +678,17 @@ struct CompletionWorker { request_tx: mpsc::Sender, result_rx: mpsc::Receiver, pending: Option, - latest: Option, +} + +/// The completion behaviour configured in `$env.config.completions`. +fn configured_options(engine_state: &EngineState) -> CompletionOptions { + let config = engine_state.get_config(); + CompletionOptions { + case_sensitive: config.completions.case_sensitive, + match_algorithm: config.completions.algorithm.into(), + sort: config.completions.sort, + match_description: false, + } } fn isolated_stack(parent: Arc, suppress_stdin: bool) -> Arc { @@ -502,29 +703,90 @@ fn isolated_stack(parent: Arc, suppress_stdin: bool) -> Arc { }) } +/// The user completer that would run first at `site`, mirroring dispatch order. `None` when +/// no user completer runs there. +fn site_completer(site: &CompletionSite, working_set: &StateWorkingSet) -> Option { + let call = match &site.kind { + SiteKind::ExternalArg { .. } => return Some(SiteCompleter::External), + kind => kind.call_site()?.call, + }; + let signature = working_set.get_decl(call.decl_id).signature(); + + // An argument's own completer runs before the command-wide one. + let argument_completer = match &site.kind { + SiteKind::FlagValue { flag, .. } => { + find_flag(&signature, *flag).and_then(|flag| flag.completion) + } + SiteKind::Positional { sig_positional, .. } => signature + .get_positional(*sig_positional) + .and_then(|positional| positional.completion.clone()), + _ => None, + }; + if let Some(Completion::Command(decl_id)) = argument_completer { + return Some(SiteCompleter::Decl(decl_id)); + } + + match signature.complete { + Some(CommandWideCompleter::Command(decl_id)) => Some(SiteCompleter::Decl(decl_id)), + Some(CommandWideCompleter::External) => Some(SiteCompleter::External), + None => None, + } +} + +/// The user completer a site would run. +enum SiteCompleter { + /// A named declaration carrying `@interactive` directly. + Decl(DeclId), + /// The configured external closure; interactivity is read from the command it dispatches to. + External, +} + +/// Whether `decl` names an `@interactive` command, seeing through aliases as execution does. +pub(crate) fn decl_is_interactive(working_set: &StateWorkingSet, decl_id: DeclId) -> bool { + fn command_is_interactive(command: &dyn Command) -> bool { + command + .attributes() + .iter() + .any(|(name, value)| name == "interactive" && value.as_bool().unwrap_or(false)) + || command + .as_alias() + .and_then(|alias| alias.command.as_deref()) + .is_some_and(command_is_interactive) + } + command_is_interactive(working_set.get_decl(decl_id)) +} + +/// Whether the configured external completer closure runs an `@interactive` command. A closure +/// cannot carry the attribute itself, so we see through it to the command producing its result +/// — the terminal call of its block — exactly as a named completer carries the attribute. +pub(crate) fn closure_is_interactive(working_set: &StateWorkingSet, closure: &Closure) -> bool { + let block = working_set.get_block(closure.block_id); + matches!( + block + .pipelines + .last() + .and_then(|pipeline| pipeline.elements.last()) + .map(|element| &element.expr.expr), + Some(Expr::Call(call)) if decl_is_interactive(working_set, call.decl_id) + ) +} + /// What the cursor is completing; each variant carries exactly the AST it needs. #[derive(Debug, Clone)] pub(crate) enum SiteKind<'a> { - /// A command head. `node` is the whole call expression, used to detect a `^`/`%` sigil. + /// A command head; `node` is the whole call (for `^`/`%` sigils). Command { node: Option<&'a Expression> }, /// A flag name being typed (`--`, `-x`). - FlagName { - call: &'a Call, - element: &'a Expression, - }, - /// The value of a flag (`--opt `). `flag` preserves long/short identity; - /// `arg_slot` indexes `call.arguments`. + FlagName(CallSite<'a>), + /// The value of a flag; `flag` keeps long/short identity. FlagValue { - call: &'a Call, - element: &'a Expression, + site: CallSite<'a>, flag: FlagRef<'a>, arg_slot: usize, }, - /// A positional argument. `sig_positional` indexes the signature's positionals, - /// `arg_slot` indexes `call.arguments`. + /// A positional argument; `sig_positional` indexes the signature. Positional { - call: &'a Call, - element: &'a Expression, + site: CallSite<'a>, sig_positional: usize, arg_slot: usize, }, @@ -534,28 +796,138 @@ pub(crate) enum SiteKind<'a> { CellPath { path: &'a FullCellPath }, /// A `$var` name. Variable, - /// An attribute name (`@`). + /// An attribute name. AttributeName, - /// The item an attribute block decorates (`def`, `extern`, …). + /// The item an attribute block decorates. AttributableItem, - /// An argument of a bare external call; `index` is the argument slot. + /// An argument of a bare external call. ExternalArg { call: &'a Expression, index: usize }, - /// A file path — the base/fallback completion. + /// A file path (the fallback). File, } impl<'a> SiteKind<'a> { - /// A command head backed by an existing call expression (used for sigil detection). + /// A command head backed by its whole call (for sigil detection). fn command(node: &'a Expression) -> Self { Self::Command { node: Some(node) } } + + /// The [`ResolvedCursor`] a user completer sees. + fn resolved(&self) -> ResolvedCursor<'a> { + match *self { + Self::Command { .. } => ResolvedCursor::Command, + Self::FlagName { .. } => ResolvedCursor::FlagName, + Self::FlagValue { flag, .. } => ResolvedCursor::FlagValue { flag: flag.name() }, + Self::Positional { sig_positional, .. } => ResolvedCursor::Positional { + index: sig_positional, + }, + Self::Operator { .. } => ResolvedCursor::Operator, + Self::CellPath { .. } => ResolvedCursor::CellPath, + Self::Variable => ResolvedCursor::Variable, + Self::AttributeName => ResolvedCursor::AttributeName, + Self::AttributableItem => ResolvedCursor::AttributableItem, + Self::ExternalArg { index, .. } => ResolvedCursor::ExternalArg { index }, + Self::File => ResolvedCursor::File, + } + } + + /// The element this kind is backed by, if any. + fn element(&self) -> Option<&'a Expression> { + match *self { + Self::Command { node } => node, + Self::FlagName(site) | Self::FlagValue { site, .. } | Self::Positional { site, .. } => { + Some(site.element) + } + Self::ExternalArg { call, .. } => Some(call), + _ => None, + } + } + + /// The call this kind completes arguments for, if any. + fn call_site(&self) -> Option> { + match *self { + Self::FlagName(site) | Self::FlagValue { site, .. } | Self::Positional { site, .. } => { + Some(site) + } + _ => None, + } + } } -/// A fully resolved completion site: the span to replace, the typed text, the cursor, and -/// the [`SiteKind`]. -/// -/// `typed_prefix`/`cursor` are derived centrally in [`CompletionEngine::finalize_site`] so -/// they can never disagree with the span. +/// What the cursor completes, as a user completer sees it in `cursor.kind`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResolvedCursor<'a> { + /// A command head. + Command, + /// A flag name. + FlagName, + /// The value of a flag; `flag` is the long name. + FlagValue { flag: &'a str }, + /// A positional argument. + Positional { index: usize }, + /// A binary-operator position. + Operator, + /// A cell path. + CellPath, + /// A `$var` name. + Variable, + /// An attribute name. + AttributeName, + /// The item an attribute block decorates. + AttributableItem, + /// An argument of a bare external call; externals have no signature, so flags count. + ExternalArg { index: usize }, + /// A file path. + File, +} + +impl ResolvedCursor<'_> { + /// The `cursor.kind` discriminant. + fn kind(&self) -> &'static str { + match self { + Self::Command => "command", + Self::FlagName => "flag-name", + Self::FlagValue { .. } => "flag-value", + Self::Positional { .. } => "positional", + Self::Operator => "operator", + Self::CellPath => "cell-path", + Self::Variable => "variable", + Self::AttributeName => "attribute-name", + Self::AttributableItem => "attributable-item", + Self::ExternalArg { .. } => "external-arg", + Self::File => "file", + } + } + + /// The record a completer's `cursor` gets; the caller adds `token`/`byte`. + pub(crate) fn into_record(self, span: Span) -> Record { + let mut record = Record::new(); + record.insert("kind", Value::string(self.kind(), span)); + + match self { + Self::FlagValue { flag } => record.insert("flag", Value::string(flag, span)), + Self::Positional { index } | Self::ExternalArg { index } => { + record.insert("index", Value::int(index as i64, span)) + } + _ => None, + }; + + record + } +} + +/// One nesting level the cursor lives in, as a completer sees it. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CompletionContext<'a> { + /// What the cursor completes at this level. + pub cursor: ResolvedCursor<'a>, + /// The element this level is; the source of its tokens. + pub element: Option<&'a Expression>, + /// The nesting expression the cursor descends into (all but the last level). + pub descent: Option, +} + +/// A resolved completion site: kind, span, typed prefix, cursor, and enclosing contexts. #[derive(Debug, Clone)] pub(crate) struct CompletionSite<'a> { pub kind: SiteKind<'a>, @@ -563,52 +935,74 @@ pub(crate) struct CompletionSite<'a> { pub typed_prefix: Cow<'a, str>, /// The cursor, in absolute working-set (span) coordinates. pub cursor: usize, + /// Enclosing contexts, outermost first; empty until resolved. + pub contexts: Vec>, } impl<'a> CompletionSite<'a> { - /// A site with the given kind and span; `typed_prefix`/`cursor` are filled later by - /// [`CompletionEngine::finalize_site`]. - fn new(kind: SiteKind<'a>, span: Span) -> Self { + /// Site at cursor; asserts span contains cursor. + fn at(cursor: usize, kind: SiteKind<'a>, span: Span) -> Self { + debug_assert!( + touches(span, cursor), + "a {} site spans {}..{}, which the cursor at {cursor} is not on", + kind.resolved().kind(), + span.start, + span.end, + ); Self { kind, span, typed_prefix: Cow::Borrowed(""), cursor: 0, + contexts: Vec::new(), + } + } + + /// This site as its own single context. + fn own_context(&self) -> CompletionContext<'a> { + CompletionContext { + cursor: self.kind.resolved(), + element: self.kind.element(), + descent: None, } } } -/// Engine dispatch output: suggestions plus whether an impure source ran (worth caching). -#[derive(Default)] -struct Dispatched { - suggestions: Vec, - cacheable: bool, -} - -impl Dispatched { - /// Append another dispatch's suggestions, propagating its cacheability. - fn merge(&mut self, other: Dispatched) { - self.cacheable |= other.cacheable; - self.suggestions.extend(other.suggestions); - } -} - -impl From for Dispatched { - fn from(fetched: Fetched) -> Self { - Self { - // Read the cacheable flag before `into_suggestions` consumes the outcome. - cacheable: fetched.is_cacheable(), - suggestions: fetched.into_suggestions(), - } - } -} - -pub struct CompletionEngine { - engine_state: Arc, +/// Completions for one commandline against borrowed state. +pub struct CompletionEngine<'a> { + engine_state: &'a EngineState, + /// An isolated child of the caller's stack. stack: Arc, options: CompletionOptions, } +/// The commandline facts constant for one dispatch. +#[derive(Clone, Copy)] +pub(crate) struct Buffer<'a> { + /// The commandline (or file) up to the cursor. + pub text: &'a str, + /// Start of `text` in working-set coordinates; `span - offset` indexes `text`. + pub offset: usize, +} + +impl Buffer<'_> { + /// The cursor as a byte offset into [`Self::text`]. + fn cursor(&self) -> usize { + self.text.len() + } + + /// The cursor in working-set (span) coordinates. + fn absolute_cursor(&self) -> usize { + self.offset + self.text.len() + } + + /// A working-set offset as a byte index into [`Self::text`], saturating so + /// spans before the buffer can't underflow. + fn relative(&self, absolute: usize) -> usize { + absolute.saturating_sub(self.offset) + } +} + #[derive(Clone, Copy)] pub(crate) struct Context<'a> { pub working_set: &'a StateWorkingSet<'a>, @@ -617,207 +1011,155 @@ pub(crate) struct Context<'a> { pub span: Span, pub prefix: &'a [u8], pub offset: usize, + /// The commandline up to the cursor. See [`Buffer`]. + pub buffer: &'a str, + /// The nesting levels the cursor lives in, read by a completer declaring `contexts`. + pub contexts: &'a [CompletionContext<'a>], } -impl Context<'_> { +impl<'a> Context<'a> { pub(crate) fn prefix_str(&self) -> Cow<'_, str> { String::from_utf8_lossy(self.prefix) } + + /// Attach a resolved site, read by a completer declaring a `contexts` parameter. + fn at_site(mut self, site: &'a CompletionSite<'a>) -> Self { + self.contexts = &site.contexts; + self + } } -impl CompletionEngine { - pub fn new(engine_state: Arc, stack: Arc) -> Self { - Self::with_stack(engine_state, isolated_stack(stack, false)) +impl<'engine> CompletionEngine<'engine> { + /// A completer for one synchronous query; only the stack is copied. + pub fn new(engine_state: &'engine EngineState, stack: &Stack) -> Self { + Self::isolated(engine_state, Arc::new(stack.clone()), false) } - fn for_background(engine_state: Arc, stack: Arc) -> Self { - Self::with_stack(engine_state, isolated_stack(stack, true)) - } - - fn with_stack(engine_state: Arc, stack: Arc) -> Self { - let config = engine_state.get_config(); - let options = CompletionOptions { - case_sensitive: config.completions.case_sensitive, - match_algorithm: config.completions.algorithm.into(), - sort: config.completions.sort, - match_description: false, - }; + /// Like [`Self::new`], on a shared, stdin-suppressed stack. + fn isolated( + engine_state: &'engine EngineState, + stack: Arc, + suppress_stdin: bool, + ) -> Self { Self { engine_state, - stack, - options, + stack: isolated_stack(stack, suppress_stdin), + options: configured_options(engine_state), } } - fn to_background(&self) -> Self { - Self::for_background(Arc::clone(&self.engine_state), Arc::clone(&self.stack)) - } - + /// Answer one request, returning suggestions and whether the result is cacheable. fn suggestions_for(&self, query: &CompletionQuery) -> (Suggestions, bool) { let dispatched = self.dispatch_completions_at(query.typed(), query.cursor()); + let reusable = dispatched.is_reusable(); let suggestions = dispatched - .suggestions + .into_suggestions() .into_iter() .map(|semantic_suggestion| semantic_suggestion.suggestion) .collect(); - (suggestions, dispatched.cacheable) + (suggestions, reusable) } - /// Parse `query` once and run `f` with the resolved completion site. - /// - /// `CompletionQuery` is already the prefix up to the floored cursor, so this - /// does not slice or floor again. - fn with_completion_site( - &self, - query: &CompletionQuery, - f: impl FnOnce(&StateWorkingSet, &Arc, &CompletionSite, &str, usize) -> R, - ) -> R { - let line = query.typed(); - let position = query.cursor(); - let mut working_set = StateWorkingSet::new(&self.engine_state); + pub fn fetch_completions_at(&self, line: &str, position: usize) -> Vec { + self.dispatch_completions_at(line, position) + .into_suggestions() + } + + /// Input record for a completer, menu source, or `commandline complete --input`. + /// Site identity (`$token`/`$place`) is always the parsed site; reedline's + /// replacement range never enters here. + pub fn completer_input_at(&self, line: &str, position: usize, wanted: DeclaredInputs) -> Value { + self.input_at(line, position, wanted) + } + + fn input_at(&self, line: &str, position: usize, wanted: DeclaredInputs) -> Value { + let cursor = line.floor_char_boundary(position.min(line.len())); + let sliced_line = &line[..cursor]; + + let mut working_set = StateWorkingSet::new(self.engine_state); let offset = working_set.next_span_start(); - let block = parse(&mut working_set, Some("completer"), line.as_bytes(), false); - let site = self.resolve_completion_site(&block, &working_set, position, offset, line); - f(&working_set, &block, &site, line, offset) + let block = parse( + &mut working_set, + Some("completer"), + sliced_line.as_bytes(), + false, + ); + + let buffer = Buffer { + text: sliced_line, + offset, + }; + let site = Self::resolve_completion_site(&block, &working_set, buffer, sliced_line); + + completer_input( + &self + .context( + &working_set, + buffer, + site.span, + site.typed_prefix.as_bytes(), + ) + .at_site(&site), + wanted, + ) } - fn query_runs_user_closure( - &self, - working_set: &StateWorkingSet, - site: &CompletionSite, - line: &str, - offset: usize, - ) -> bool { - self.site_runs_user_closure(site, working_set) - || self.multiword_argument_runs_user_closure(site, working_set, line, offset) + /// Whether the completer that would run at `position` must run inline (`@interactive`) + /// rather than on the background worker. Parses and resolves the site; runs no completer. + pub(crate) fn interactive_completer_at(&self, line: &str, position: usize) -> bool { + let safe_position = line.floor_char_boundary(position); + let sliced_line = &line[..safe_position]; + + let mut working_set = StateWorkingSet::new(self.engine_state); + let offset = working_set.next_span_start(); + let block = parse( + &mut working_set, + Some("completer"), + sliced_line.as_bytes(), + false, + ); + + let buffer = Buffer { + text: sliced_line, + offset, + }; + let site = Self::resolve_completion_site(&block, &working_set, buffer, sliced_line); + + self.site_is_interactive(&site, &working_set) + || self + .with_shorter_head_reading( + &site, + &working_set, + buffer, + sliced_line, + |engine, shorter, shorter_ws| engine.site_is_interactive(shorter, shorter_ws), + ) + .unwrap_or(false) } - /// User-closure completers (external completer, `@complete`, custom commands) - /// take the TTY. They must run on the REPL thread with reedline blocked, not - /// on the background worker. - #[cfg(test)] - fn should_complete_on_repl_thread(&self, query: &CompletionQuery) -> bool { - self.with_completion_site(query, |working_set, _, site, line, offset| { - self.query_runs_user_closure(working_set, site, line, offset) - }) - } - - /// Parse once; if this site needs a user closure, dispatch on this thread. - /// - /// Results are not cached: a picker (`fzf`, `input list`, `carapace`) is not a function - /// of the line, so a stored pick would skip the UI on the next Tab. - fn complete_user_closure_on_repl_thread(&self, query: &CompletionQuery) -> Option { - self.with_completion_site(query, |working_set, block, site, line, offset| { - if !self.query_runs_user_closure(working_set, site, line, offset) { - return None; - } - let _tty = crate::util::ReplTerminalGuard::capture(); - let dispatched = self.fetch_completions_by_block( - Arc::clone(block), - working_set, - query.cursor(), - offset, - line, - ); - Some( - dispatched - .suggestions - .into_iter() - .map(|semantic_suggestion| semantic_suggestion.suggestion) - .collect(), - ) - }) - } - - fn has_external_completer(&self) -> bool { - self.engine_state - .get_config() - .completions - .external - .completer - .is_some() - } - - fn signature_runs_user_closure(&self, signature: &Signature) -> bool { - match signature.complete { - Some(CommandWideCompleter::Command(_)) => true, - Some(CommandWideCompleter::External) => self.has_external_completer(), + /// Whether the completer that would run first at `site` is interactive. + fn site_is_interactive(&self, site: &CompletionSite, working_set: &StateWorkingSet) -> bool { + match site_completer(site, working_set) { + Some(SiteCompleter::Decl(decl_id)) => decl_is_interactive(working_set, decl_id), + Some(SiteCompleter::External) => self + .engine_state + .get_config() + .completions + .external + .completer + .as_ref() + .is_some_and(|closure| closure_is_interactive(working_set, closure)), None => false, } } - fn site_runs_user_closure(&self, site: &CompletionSite, working_set: &StateWorkingSet) -> bool { - match &site.kind { - SiteKind::ExternalArg { .. } => self.has_external_completer(), - SiteKind::FlagName { call, .. } => { - self.signature_runs_user_closure(&working_set.get_decl(call.decl_id).signature()) - } - SiteKind::FlagValue { call, flag, .. } => { - let signature = working_set.get_decl(call.decl_id).signature(); - let resolved = find_flag(&signature, *flag); - let custom = resolved.as_ref().and_then(|flag| flag.completion.as_ref()); - matches!(custom, Some(Completion::Command(_))) - || self.signature_runs_user_closure(&signature) - } - SiteKind::Positional { - call, - sig_positional, - .. - } => { - let signature = working_set.get_decl(call.decl_id).signature(); - let custom = signature - .get_positional(*sig_positional) - .and_then(|positional| positional.completion.as_ref()); - matches!(custom, Some(Completion::Command(_))) - || self.signature_runs_user_closure(&signature) - } - _ => false, - } - } - - fn multiword_argument_runs_user_closure( - &self, - site: &CompletionSite, - working_set: &StateWorkingSet, - contents: &str, - offset: usize, - ) -> bool { - if !matches!(site.kind, SiteKind::Command { .. }) - || !working_set - .get_span_contents(site.span) - .iter() - .any(u8::is_ascii_whitespace) - { - return false; - } - - let mut parse_ws = StateWorkingSet::new(&self.engine_state); - let _ = parse_ws.add_file("completer", contents.as_bytes()); - let Some(shorter) = parse_shorter_head_reading(&mut parse_ws, site.span, None) else { - return false; - }; - let position = site.cursor.saturating_sub(offset); - let shorter_site = self.finalize_site( - self.resolve_expression_site(&shorter, site.cursor, &parse_ws), - contents, - position, - offset, - ); - self.site_runs_user_closure(&shorter_site, &parse_ws) - } - - pub fn fetch_completions_at(&self, line: &str, position: usize) -> Vec { - self.dispatch_completions_at(line, position).suggestions - } - - fn dispatch_completions_at(&self, line: &str, position: usize) -> Dispatched { + fn dispatch_completions_at(&self, line: &str, position: usize) -> Fetched { let safe_position = line.floor_char_boundary(position); - // Parse only up to the cursor, so the last pipeline element is always the token (or - // gap) being edited; trailing whitespace is kept to distinguish a gap from the token. + // Parse only up to the cursor. let sliced_line = &line[..safe_position]; - let mut working_set = StateWorkingSet::new(&self.engine_state); + let mut working_set = StateWorkingSet::new(self.engine_state); let span_offset = working_set.next_span_start(); let block = parse( @@ -830,8 +1172,10 @@ impl CompletionEngine { self.fetch_completions_by_block( block, &working_set, - safe_position, - span_offset, + Buffer { + text: sliced_line, + offset: span_offset, + }, sliced_line, ) } @@ -842,7 +1186,7 @@ impl CompletionEngine { position: usize, contents: &str, ) -> Vec { - let mut working_set = StateWorkingSet::new(&self.engine_state); + let mut working_set = StateWorkingSet::new(self.engine_state); // `parse` must run first: it registers the file and its spans in `working_set`. let block = parse(&mut working_set, Some(filename), contents.as_bytes(), false); @@ -851,161 +1195,178 @@ impl CompletionEngine { return Vec::new(); }; - self.fetch_completions_by_block(block, &working_set, position, file_span.start, contents) - .suggestions + self.fetch_completions_by_block( + block, + &working_set, + Buffer { + text: contents.get(..position).unwrap_or(contents), + offset: file_span.start, + }, + contents, + ) + .into_suggestions() } - /// `position` is the cursor as a buffer-relative byte offset into `contents`. + /// `buffer` is the commandline; `contents` is the text the block was parsed from. fn fetch_completions_by_block( &self, block: Arc, working_set: &StateWorkingSet, - position: usize, - offset: usize, + buffer: Buffer, contents: &str, - ) -> Dispatched { - let site = self.resolve_completion_site(&block, working_set, position, offset, contents); - let mut dispatched = self.dispatch_completion_site(&site, working_set, offset); + ) -> Fetched { + let site = Self::resolve_completion_site(&block, working_set, buffer, contents); + let mut dispatched = self.dispatch_completion_site(&site, working_set, buffer); - // A multi-word head is ambiguous: also recover the argument reading of the shorter - // command and offer it before the subcommand name. - let argument_reading = - self.complete_multiword_head_as_argument(&site, working_set, offset, contents); - dispatched.cacheable |= argument_reading.cacheable; - dispatched - .suggestions - .splice(..0, argument_reading.suggestions); + // A multi-word head is ambiguous: offer the shorter command's argument reading too. + dispatched.prepend_from(self.complete_multiword_head_as_argument( + &site, + working_set, + buffer, + contents, + )); dispatched } - /// A multi-word head is ambiguous: `baz --test bar` is also `bar`, the value of - /// `baz --test`'s flag. Recover that argument reading via [`parse_shorter_head_reading`] - /// over the real buffer spans (avoiding the stale-span hazard of #5127), dropping - /// command-kind results the primary dispatch already offers. + /// `baz --test bar` can also read as the value of `baz --test`'s flag. fn complete_multiword_head_as_argument( &self, site: &CompletionSite, working_set: &StateWorkingSet, - offset: usize, + buffer: Buffer, contents: &str, - ) -> Dispatched { + ) -> Fetched { + self.with_shorter_head_reading( + site, + working_set, + buffer, + contents, + |engine, shorter, shorter_ws| { + let mut dispatched = engine.dispatch_completion_site(shorter, shorter_ws, buffer); + // Keep only the argument value; drop command-kind results. + dispatched.retain(|candidate| { + !matches!(candidate.kind, Some(SuggestionKind::Command(..))) + }); + dispatched + }, + ) + .unwrap_or_default() + } + + /// Run `f` on the shorter command's reading of an ambiguous multi-word head (`foo bar` + /// also reading as `foo`'s argument). One re-parse shared by dispatch and interactive + /// routing so they cannot drift. `None` when there is no distinct argument site. + fn with_shorter_head_reading( + &self, + site: &CompletionSite, + working_set: &StateWorkingSet, + buffer: Buffer, + contents: &str, + f: impl FnOnce(&Self, &CompletionSite, &StateWorkingSet) -> T, + ) -> Option { if !matches!(site.kind, SiteKind::Command { .. }) || !working_set .get_span_contents(site.span) .iter() .any(u8::is_ascii_whitespace) { - return Dispatched::default(); + return None; } - let mut parse_ws = StateWorkingSet::new(&self.engine_state); - let _ = parse_ws.add_file("completer", contents.as_bytes()); - let Some(shorter) = parse_shorter_head_reading(&mut parse_ws, site.span, None) else { - return Dispatched::default(); - }; + // Owns the spans the resolved site borrows. + let mut shorter_ws = StateWorkingSet::new(self.engine_state); + let _ = shorter_ws.add_file("completer", contents.as_bytes()); + let shorter = parse_shorter_head_reading(&mut shorter_ws, site.span, None)?; - let position = site.cursor.saturating_sub(offset); - let shorter_site = self.finalize_site( - self.resolve_expression_site(&shorter, site.cursor, &parse_ws), + let mut shorter_site = Self::finalize_site( + Self::resolve_expression_site(&shorter, site.cursor, &shorter_ws), + buffer, contents, - position, - offset, ); // A command-head result means no distinct argument; leave it to the primary dispatch. if matches!(shorter_site.kind, SiteKind::Command { .. }) { - return Dispatched::default(); + return None; } + // A single-expression re-parse has no enclosing chain. + shorter_site.contexts = vec![shorter_site.own_context()]; - let mut dispatched = self.dispatch_completion_site(&shorter_site, &parse_ws, offset); - // Drop command-kind results; only the argument value is contributed here. - dispatched - .suggestions - .retain(|candidate| !matches!(candidate.kind, Some(SuggestionKind::Command(..)))); - dispatched + Some(f(self, &shorter_site, &shorter_ws)) } - /// Dispatches the completion site to the appropriate specialized completer. - fn dispatch_completion_site( - &self, - site: &CompletionSite, - working_set: &StateWorkingSet, - offset: usize, - ) -> Dispatched { - let completion_context = - self.context(working_set, site.span, site.typed_prefix.as_bytes(), offset); + /// Dispatch the site to the appropriate specialized completer. + fn dispatch_completion_site<'a>( + &'a self, + site: &'a CompletionSite<'a>, + working_set: &'a StateWorkingSet, + buffer: Buffer<'a>, + ) -> Fetched { + let completion_context = self + .context(working_set, buffer, site.span, site.typed_prefix.as_bytes()) + .at_site(site); match &site.kind { SiteKind::Command { node } => { let completions = self.command_completion_helper( working_set, + buffer, site.span, - offset, self.command_completion_for_head(*node, site.span, working_set), ); - if completions.suggestions.is_empty() { - self.suggestions_at(&mut FileCompletion, working_set, site.span, offset) + if completions.is_empty() { + self.suggestions_at(&mut FileCompletion, &completion_context) } else { completions } } - SiteKind::FlagName { .. } - | SiteKind::FlagValue { .. } - | SiteKind::Positional { .. } => { - self.dispatch_call_completion_site(site, working_set, offset, &completion_context) + SiteKind::FlagName(_) | SiteKind::FlagValue { .. } | SiteKind::Positional { .. } => { + self.dispatch_call_completion_site(site, working_set, buffer, &completion_context) } SiteKind::Operator { lhs } => OperatorCompletion { left_hand_side: lhs, } - .fetch(&completion_context) - .into(), + .fetch(&completion_context), SiteKind::CellPath { path } => CellPathCompletion { full_cell_path: path, cursor: site.cursor, } - .fetch(&completion_context) - .into(), + .fetch(&completion_context), - SiteKind::Variable => { - self.variable_names_completion_helper(working_set, site.span, offset) - } + SiteKind::Variable => self.variable_names_completion_helper(&completion_context), - SiteKind::AttributeName => AttributeCompletion.fetch(&completion_context).into(), + SiteKind::AttributeName => AttributeCompletion.fetch(&completion_context), - SiteKind::AttributableItem => AttributableCompletion.fetch(&completion_context).into(), + SiteKind::AttributableItem => AttributableCompletion.fetch(&completion_context), SiteKind::ExternalArg { .. } => { - self.dispatch_external_arg(site, working_set, offset, &completion_context) + self.dispatch_external_arg(site, working_set, buffer, &completion_context) } - SiteKind::File => { - self.suggestions_at(&mut FileCompletion, working_set, site.span, offset) - } + SiteKind::File => self.suggestions_at(&mut FileCompletion, &completion_context), } } - /// Complete an external call argument: `sudo`/`doas` special-case, the configured - /// external completer, then file completion as a fallback. + /// Complete an external call argument. fn dispatch_external_arg( &self, site: &CompletionSite, working_set: &StateWorkingSet, - offset: usize, + buffer: Buffer, completion_context: &Context, - ) -> Dispatched { + ) -> Fetched { let SiteKind::ExternalArg { call: external_call, index, } = &site.kind else { - return Dispatched::default(); + return Fetched::default(); }; let external_call = *external_call; let Expr::ExternalCall(head, _) = &external_call.expr else { - return Dispatched::default(); + return Fetched::default(); }; // The first argument of `sudo`/`doas` is a command run under the wrapper. @@ -1014,103 +1375,91 @@ impl CompletionEngine { if head_command == b"sudo" || head_command == b"doas" { let commands = self.command_completion_helper( working_set, + buffer, site.span, - offset, CommandCompletion::new(CommandScope::All), ); - if !commands.suggestions.is_empty() { + if !commands.is_empty() { return commands; } } } - let mut dispatched = Dispatched::default(); - let mut external_answered = false; + // Neutral seed: answered until a source declines, unreusable until one keeps. + let mut dispatched = Fetched::default(); - // The user's configured external completer (`$env.config.completions.external.completer`). - if let Some(closure) = self + // The user's configured external completer. + let external_answered = self .engine_state .get_config() .completions .external .completer .as_ref() - { - let mut completion = CommandWideCompletion::closure(closure, external_call); - let fetched = completion.fetch(completion_context); - external_answered = !fetched.needs_fallback(); - dispatched.merge(fetched.into()); - } + .is_some_and(|completer| { + dispatched.absorb( + UserCompletion::closure(working_set, completer).fetch(completion_context), + ) + }); - // Internal subcommands extending this call (e.g. `fod br` → `food bar`), which - // suppress the file fallback like an internal call's arguments do. + // Subcommands extending this call suppress the file fallback. let subcommands = - self.subcommand_suggestions(working_set, external_call.span.start, site.cursor, offset); + self.subcommand_suggestions(working_set, buffer, external_call.span.start, site.cursor); - // File completion for path arguments, only when nothing more specific answered. - if !external_answered - && dispatched.suggestions.is_empty() - && subcommands.suggestions.is_empty() - { - dispatched.merge(self.suggestions_at( - &mut FileCompletion, - working_set, - site.span, - offset, - )); + // Add file completion when the source leaves the slot open. + let wants_file = subcommands.is_empty() + && (!dispatched.answered() || (!external_answered && dispatched.is_empty())); + if wants_file { + dispatched.merge(self.suggestions_at(&mut FileCompletion, completion_context)); } dispatched.merge(subcommands); dispatched } - /// Dispatch completions for call-bound sites (FlagName, FlagValue, Positional). + /// Dispatch completions for call-bound sites. fn dispatch_call_completion_site( &self, site: &CompletionSite, working_set: &StateWorkingSet, - offset: usize, + buffer: Buffer, completion_context: &Context, - ) -> Dispatched { - // Only call-bound kinds carry a call and element; anything else is an error here. - let (call, element) = match &site.kind { - SiteKind::FlagName { call, element } - | SiteKind::FlagValue { call, element, .. } - | SiteKind::Positional { call, element, .. } => (*call, *element), - _ => return Dispatched::default(), + ) -> Fetched { + // Only call-bound kinds carry a call; anything else is an error here. + let call = match &site.kind { + SiteKind::FlagName(site) + | SiteKind::FlagValue { site, .. } + | SiteKind::Positional { site, .. } => site.call, + _ => return Fetched::default(), }; let signature = working_set.get_decl(call.decl_id).signature(); - // Subcommands extending this command line are always offered, and suppress the - // file-path fallback: a matched subcommand shouldn't also dump the whole directory. + // Subcommands are always offered and suppress the file-path fallback. let subcommands = - self.subcommand_suggestions(working_set, call.head.start, site.cursor, offset); + self.subcommand_suggestions(working_set, buffer, call.head.start, site.cursor); - // The value kinds share one shape; only the `ArgType`, custom-completer, and declared - // shape lookups differ. + // Value kinds share one shape; only the `ArgType`, completer lookup, and the + // `declared_shape` used for the empty-slot fallback differ. let argument_value = |engine: &Self, arg_type, custom, arg_slot, declared_shape| { engine.complete_argument_value( custom, ArgValueCompletion { call, arg_type, - need_fallback: subcommands.suggestions.is_empty(), - completer: engine, + need_fallback: subcommands.is_empty(), arg_idx: arg_slot, declared_shape, cursor: site.cursor, }, completion_context, &signature, - element, - site.cursor, ) }; let mut results = match &site.kind { - SiteKind::FlagName { .. } => { - self.complete_flag_names(call.decl_id, completion_context, &signature, element) + SiteKind::FlagName(_) => { + self.complete_flag_names(call.decl_id, completion_context, &signature) } SiteKind::FlagValue { flag, arg_slot, .. } => { let resolved = find_flag(&signature, *flag); @@ -1119,7 +1468,7 @@ impl CompletionEngine { ArgType::Flag(Cow::Borrowed(flag.name())), resolved.as_ref().and_then(|flag| flag.completion.clone()), *arg_slot, - resolved.and_then(|flag| flag.arg), + resolved.as_ref().and_then(|flag| flag.arg.clone()), ) } SiteKind::Positional { @@ -1136,88 +1485,134 @@ impl CompletionEngine { positional.map(|positional| positional.shape.clone()), ) } - _ => Dispatched::default(), + _ => Fetched::default(), }; results.merge(subcommands); results } - /// Resolves the contextual state and constraints at the cursor's location. + /// Resolve the contextual state and constraints at the cursor's location. pub(crate) fn resolve_completion_site<'a>( - &self, block: &'a Block, working_set: &'a StateWorkingSet, - position: usize, - offset: usize, + buffer: Buffer, contents: &'a str, ) -> CompletionSite<'a> { - let absolute_position = position + offset; + let absolute_position = buffer.absolute_cursor(); + + // The closures and subexpressions the cursor is nested in, outermost first. + let chain = enclosing_elements(block, working_set, absolute_position); // The token whose span the cursor is inside of, or at the trailing edge of. let touched_expression = block .find_map(working_set, &|expression: &Expression| { find_pipeline_element_by_position(expression, working_set, absolute_position) }) - .or_else(|| check_redirection_in_block(block, absolute_position)) - // Otherwise the cursor is in a whitespace gap after the element it trails. - .or_else(|| trailing_gap_element(block, working_set, absolute_position)); + .or_else(|| check_redirection_in_block(block, absolute_position)); - let site = match touched_expression { + let mut site = match innermost_expression(touched_expression, &chain) { Some(expression) => { - self.resolve_expression_site(expression, absolute_position, working_set) + Self::resolve_expression_site(expression, absolute_position, working_set) } - None => self.resolve_fallback_site(block, working_set, absolute_position), + None => Self::resolve_fallback_site(block, working_set, absolute_position), }; - self.finalize_site(site, contents, position, offset) + site = promote_row_condition_file(site, &chain, working_set, absolute_position); + + site.contexts = Self::contexts_of(&chain, working_set, absolute_position, &site); + Self::finalize_site(site, buffer, contents) } - /// Fill the centrally-derived `typed_prefix`/`cursor` fields from the final `site.span`, - /// so the prefix and replacement span can never disagree. Point spans yield an empty - /// prefix. + /// The chain of contexts the cursor lives in, outermost first. `site` supplies the + /// innermost one, so it stays the resolution the dispatcher itself acts on rather than + /// a second, possibly disagreeing, reading of the same position. + fn contexts_of<'a>( + chain: &[(&'a Expression, Option)], + working_set: &'a StateWorkingSet, + absolute_position: usize, + site: &CompletionSite<'a>, + ) -> Vec> { + let target_element = site.kind.element(); + + // Locate the target element's index or default to the final index safely. + let innermost_index = target_element + .and_then(|target| { + chain + .iter() + .position(|(candidate, _)| std::ptr::eq(*candidate, target)) + }) + .unwrap_or_else(|| chain.len().saturating_sub(1)); + + chain[..innermost_index] + .iter() + .map(|&(expression, descent)| CompletionContext { + cursor: Self::resolve_expression_site(expression, absolute_position, working_set) + .kind + .resolved(), + element: Some(expression), + descent, + }) + .chain(std::iter::once(CompletionContext { + element: target_element.or_else(|| { + chain + .get(innermost_index) + .map(|&(expression, _)| expression) + }), + ..site.own_context() + })) + .collect() + } + + /// Fill `typed_prefix`/`cursor` from the final span so they never disagree. fn finalize_site<'a>( - &self, mut site: CompletionSite<'a>, + buffer: Buffer, contents: &'a str, - position: usize, - offset: usize, ) -> CompletionSite<'a> { - let token_start = site.span.start.saturating_sub(offset); + let token_start = buffer.relative(site.span.start); site.typed_prefix = contents - .get(token_start..position) + .get(token_start..buffer.cursor()) .map(Cow::Borrowed) .unwrap_or(Cow::Borrowed("")); - site.cursor = position + offset; + site.cursor = buffer.absolute_cursor(); site } fn resolve_expression_site<'a>( - &self, expression: &'a Expression, absolute_position: usize, working_set: &'a StateWorkingSet, ) -> CompletionSite<'a> { - // Cursor in whitespace after a completed value (`1 ⌶`) is an operator position. - if absolute_position > expression.span.end && is_operator_lhs(&expression.expr) { - return CompletionSite::new( - SiteKind::Operator { lhs: expression }, - Span::point(absolute_position), - ); + // Cursor past expression: only calls/attribute blocks own trailing gap. + if absolute_position > expression.span.end && !owns_trailing_gap(&expression.expr) { + let kind = if is_operator_lhs(&expression.expr) { + SiteKind::Operator { lhs: expression } + } else { + SiteKind::File + }; + return CompletionSite::at(absolute_position, kind, Span::point(absolute_position)); } - // Base case: file completion; overridden below where the expression warrants it. + // Default to file completion; overridden below where the expression warrants it. match &expression.expr { - Expr::Call(call) => { - self.resolve_call_site(call, expression, absolute_position, working_set) - } + Expr::Call(call) => Self::resolve_call_site( + CallSite { + call, + element: expression, + }, + absolute_position, + working_set, + ), Expr::ExternalCall(head, arguments) => { - self.resolve_external_call_site(expression, head, arguments, absolute_position) + Self::resolve_external_call_site(expression, head, arguments, absolute_position) } Expr::AttributeBlock(attribute_block) => { - self.resolve_attribute_site(attribute_block, absolute_position) + Self::resolve_attribute_site(attribute_block, absolute_position) + } + Expr::Var(_) => { + CompletionSite::at(absolute_position, SiteKind::Variable, expression.span) } - Expr::Var(_) => CompletionSite::new(SiteKind::Variable, expression.span), // `$foo` alone is the variable; `$foo.bar` or `$foo.` is a cell path. Expr::FullCellPath(full_cell_path) => { let has_dot = working_set @@ -1232,29 +1627,44 @@ impl CompletionEngine { } }; - CompletionSite::new(kind, expression.span) + CompletionSite::at(absolute_position, kind, expression.span) } - Expr::BinaryOp(left_hand_side, operator, _) => CompletionSite::new( - SiteKind::Operator { - lhs: left_hand_side.as_ref(), - }, - operator.span, - ), - _ => CompletionSite::new(SiteKind::File, expression.span), // The default `File` setup holds + // Operator site only on its token; otherwise resolve value side. + Expr::BinaryOp(left_hand_side, operator, right_hand_side) => { + let value_side = match absolute_position { + before if before < operator.span.start => Some(left_hand_side), + after if after > operator.span.end => Some(right_hand_side), + _ => None, + }; + + match value_side { + Some(side) => { + Self::resolve_expression_site(side, absolute_position, working_set) + } + None => CompletionSite::at( + absolute_position, + SiteKind::Operator { + lhs: left_hand_side.as_ref(), + }, + operator.span, + ), + } + } + _ => CompletionSite::at(absolute_position, SiteKind::File, expression.span), } } - /// Resolve a bare external call (`git checkout`). The head completes as a command; - /// other positions are [`SiteKind::ExternalArg`]. + /// Resolve a bare external call; the head completes as a command, else + /// [`SiteKind::ExternalArg`]. fn resolve_external_call_site<'a>( - &self, expression: &'a Expression, head: &'a Expression, arguments: &'a [ExternalArgument], absolute_position: usize, ) -> CompletionSite<'a> { if absolute_position <= head.span.end { - return CompletionSite::new( + return CompletionSite::at( + absolute_position, SiteKind::command(expression), command_name_span(head.span, expression.span), ); @@ -1270,7 +1680,8 @@ impl CompletionEngine { }) .unwrap_or((arguments.len(), Span::point(absolute_position))); - CompletionSite::new( + CompletionSite::at( + absolute_position, SiteKind::ExternalArg { call: expression, index, @@ -1279,54 +1690,55 @@ impl CompletionEngine { ) } + /// Classify the cursor in a call, most specific first: head, existing argument, + /// row-condition operator/value gaps, then the trailing slot. fn resolve_call_site<'a>( - &self, - call: &'a Call, - expression: &'a Expression, + site: CallSite<'a>, absolute_position: usize, working_set: &'a StateWorkingSet, ) -> CompletionSite<'a> { - // Cursor in (or right after) the command head: complete the command name. - if absolute_position <= call.head.end { - return CompletionSite::new( - SiteKind::command(expression), - command_name_span(call.head, expression.span), + if absolute_position <= site.call.head.end { + return CompletionSite::at( + absolute_position, + SiteKind::command(site.element), + command_name_span(site.call.head, site.element.span), ); } + Self::argument_touching(site, absolute_position, working_set) + .or_else(|| Self::row_condition_operator(site, working_set, absolute_position)) + .or_else(|| Self::row_condition_value_gap(site, working_set, absolute_position)) + .unwrap_or_else(|| Self::trailing_slot(site, working_set, absolute_position)) + } - // Cursor on an existing argument. - if let Some((argument_index, argument)) = call + /// The existing argument the cursor touches, if any. + fn argument_touching<'a>( + site: CallSite<'a>, + absolute_position: usize, + working_set: &StateWorkingSet, + ) -> Option> { + let (argument_index, argument) = site + .call .arguments .iter() .enumerate() - .find(|(_, argument)| touches(argument.span(), absolute_position)) - { - return self.resolve_argument_site( - call, - expression, - argument, - argument_index, - absolute_position, - working_set, - ); - } + .find(|(_, argument)| touches(argument.span(), absolute_position))?; + Some(Self::resolve_argument_site( + site, + argument, + argument_index, + absolute_position, + working_set, + )) + } - // A trailing gap after a row condition (`where name ⌶`) is an operator position. - if let Some(operator_left_hand_side) = - self.row_condition_operator_lhs(call, working_set, absolute_position) - { - return CompletionSite::new( - SiteKind::Operator { - lhs: operator_left_hand_side, - }, - Span::point(absolute_position), - ); - } - - // Classify the slot the cursor trails after the last argument: a pending flag value, - // a new flag name, or a new positional. Looking only at the non-whitespace token - // ending at the cursor keeps `cmd -f val ⌶` (positional) and `cmd --⌶` (flag name) - // distinct, and its span preserves the `-`/`--` prefix. + /// Classify the trailing slot (flag value, flag name, or positional) by the + /// trailing non-whitespace token. + fn trailing_slot<'a>( + site: CallSite<'a>, + working_set: &StateWorkingSet, + absolute_position: usize, + ) -> CompletionSite<'a> { + let CallSite { call, .. } = site; let gap_start = call .arguments .last() @@ -1345,31 +1757,24 @@ impl CompletionEngine { let point = Span::point(absolute_position); - if let Some(flag_ref) = self.pending_flag_value(call, working_set) { - // Intentionally out-of-range `arg_slot`: there is no in-progress argument node - // yet, and `ArgValueCompletion` reads `None` as exactly that. - CompletionSite::new( + if let Some(flag_ref) = Self::pending_flag_value(call, working_set) { + // `arg_slot` past the last argument: no node exists yet; `ArgValueCompletion` reads `None`. + CompletionSite::at( + absolute_position, SiteKind::FlagValue { - call, - element: expression, + site, flag: flag_ref, arg_slot: call.arguments.len(), }, point, ) } else if token_is_flag { - CompletionSite::new( - SiteKind::FlagName { - call, - element: expression, - }, - trailing_token, - ) + CompletionSite::at(absolute_position, SiteKind::FlagName(site), trailing_token) } else { - CompletionSite::new( + CompletionSite::at( + absolute_position, SiteKind::Positional { - call, - element: expression, + site, sig_positional: count_positionals(call, call.arguments.len()), arg_slot: call.arguments.len(), }, @@ -1378,45 +1783,56 @@ impl CompletionEngine { } } - /// The last row-condition term when the cursor trails it (`where name ⌶`): the LHS of - /// an operator the user is about to type. - fn row_condition_operator_lhs<'a>( - &self, - call: &'a Call, + /// Trailing `where name ⌶` is an operator position. + fn row_condition_operator<'a>( + site: CallSite<'a>, working_set: &'a StateWorkingSet, absolute_position: usize, - ) -> Option<&'a Expression> { - let block_id = call - .arguments - .iter() - .rev() - .find_map(|argument| match argument { - Argument::Positional(Expression { - expr: Expr::RowCondition(block_id), - .. - }) => Some(*block_id), - _ => None, - })?; - - let last_term = &working_set - .get_block(block_id) - .pipelines - .last()? - .elements - .last()? - .expr; - - if absolute_position <= last_term.span.end || !is_operator_lhs(&last_term.expr) { - return None; - } - - let gap = working_set.get_span_contents(Span::new(last_term.span.end, absolute_position)); - gap.iter().all(u8::is_ascii_whitespace).then_some(last_term) + ) -> Option> { + let last_term = RowCondition::trailing(site)?.last_term(working_set)?; + (absolute_position > last_term.span.end + && is_operator_lhs(&last_term.expr) + && working_set + .get_span_contents(Span::new(last_term.span.end, absolute_position)) + .iter() + .all(u8::is_ascii_whitespace)) + .then(|| { + CompletionSite::at( + absolute_position, + SiteKind::Operator { lhs: last_term }, + Span::point(absolute_position), + ) + }) } - /// The [`FlagRef`] of a last-argument flag still awaiting its value (`cmd --opt ⌶`). + /// `where size > ⌶` / `where size > 1 ⌶` is still the condition, not a new positional. + fn row_condition_value_gap<'a>( + site: CallSite<'a>, + working_set: &'a StateWorkingSet, + absolute_position: usize, + ) -> Option> { + let cond = RowCondition::trailing(site)?; + let last_term = cond.last_term(working_set)?; + let Expr::BinaryOp(_, operator, _) = &last_term.expr else { + return None; + }; + (absolute_position >= cond.arg_span.end + && absolute_position > operator.span.end + && working_set + .get_span_contents(Span::new(cond.arg_span.end, absolute_position)) + .iter() + .all(u8::is_ascii_whitespace)) + .then(|| { + cond.site_at( + working_set, + absolute_position, + Span::point(absolute_position), + ) + }) + } + + /// The [`FlagRef`] of a last-argument flag still awaiting its value. fn pending_flag_value<'a>( - &self, call: &'a Call, working_set: &StateWorkingSet, ) -> Option> { @@ -1434,18 +1850,13 @@ impl CompletionEngine { } fn resolve_argument_site<'a>( - &self, - call: &'a Call, - expression: &'a Expression, + site: CallSite<'a>, argument: &'a Argument, argument_index: usize, absolute_position: usize, working_set: &StateWorkingSet, ) -> CompletionSite<'a> { - let flag_name = SiteKind::FlagName { - call, - element: expression, - }; + let flag_name = SiteKind::FlagName(site); let (kind, span) = match argument { Argument::Named((name, short, optional_value)) => { @@ -1455,17 +1866,14 @@ impl CompletionEngine { { ( SiteKind::FlagValue { - call, - element: expression, + site, flag: FlagRef::from_named(name, short.as_ref()), arg_slot: argument_index, }, value_expression.span, ) } else { - // Only the name is being completed: `Argument::span` would also cover - // the value written after it (`--endian big`), and `name.span` is the - // flag token itself for both spellings. + // Only the name is completed; `Argument::span` would cover the value after it. (flag_name, name.span) } } @@ -1475,9 +1883,8 @@ impl CompletionEngine { flag_name } else { SiteKind::Positional { - call, - element: expression, - sig_positional: count_positionals(call, argument_index), + site, + sig_positional: count_positionals(site.call, argument_index), arg_slot: argument_index, } }; @@ -1486,11 +1893,10 @@ impl CompletionEngine { Argument::Spread(_) => (SiteKind::File, argument.span()), }; - CompletionSite::new(kind, span) + CompletionSite::at(absolute_position, kind, span) } fn resolve_attribute_site<'a>( - &self, attribute_block: &'a AttributeBlock, absolute_position: usize, ) -> CompletionSite<'a> { @@ -1499,26 +1905,31 @@ impl CompletionEngine { .iter() .find(|attribute| touches(attribute.expr.span, absolute_position)) { - return CompletionSite::new(SiteKind::AttributeName, attribute.expr.span); + return CompletionSite::at( + absolute_position, + SiteKind::AttributeName, + attribute.expr.span, + ); } if touches(attribute_block.item.span, absolute_position) { - return CompletionSite::new(SiteKind::AttributableItem, attribute_block.item.span); + return CompletionSite::at( + absolute_position, + SiteKind::AttributableItem, + attribute_block.item.span, + ); } - // Past the last attribute is the decorated item's slot, even when the parser found - // no item to give a span to (`@complete "c"⏎⌶`). Earlier gaps sit between two - // attributes, where another attribute name is what's being typed. + // Past the last attribute is the item's slot; earlier gaps type another attribute. let kind = match attribute_block.attributes.last() { Some(last) if absolute_position >= last.expr.span.end => SiteKind::AttributableItem, _ => SiteKind::AttributeName, }; - CompletionSite::new(kind, Span::point(absolute_position)) + CompletionSite::at(absolute_position, kind, Span::point(absolute_position)) } fn resolve_fallback_site<'a>( - &self, block: &'a Block, working_set: &'a StateWorkingSet, absolute_position: usize, @@ -1529,8 +1940,7 @@ impl CompletionEngine { .and_then(|pipeline| pipeline.elements.last()) .map(|element| &element.expr); - // A bare `@` opens an attribute name; trailing a completed attribute block completes - // the attributable item itself; otherwise a fresh command position. + // A bare `@` opens an attribute name; trailing an attribute block completes its item. let kind = if last_element .map(|element| working_set.get_span_contents(element.span)) .is_some_and(|bytes| bytes.ends_with(b"@")) @@ -1542,7 +1952,7 @@ impl CompletionEngine { SiteKind::Command { node: None } }; - CompletionSite::new(kind, Span::point(absolute_position)) + CompletionSite::at(absolute_position, kind, Span::point(absolute_position)) } fn complete_argument_value( &self, @@ -1550,41 +1960,42 @@ impl CompletionEngine { mut arg_value: ArgValueCompletion, context: &Context, signature: &Signature, - element_expression: &Expression, - cursor: usize, - ) -> Dispatched { - let mut results = Dispatched::default(); + ) -> Fetched { + // Neutral seed: answered until a source declines, unreusable until one keeps. + let mut results = Fetched::default(); if let Some(custom) = custom { let attempt = match custom { // A command declared an engine-provided completion for this argument. Completion::Builtin(kind) => self.complete_builtin(kind, &arg_value, context), - // A custom completer receives the element text up to the cursor - // (`my-command foobar`), so its spans are anchored to the element's start. - other => { - let element_line = String::from_utf8_lossy( - context - .working_set - .get_span_contents(Span::new(element_expression.span.start, cursor)), - ); - self.custom_completion_helper(other, element_line.as_ref(), context, cursor) + // A user-defined completer, called with the unified input record. + Completion::Command(decl_id) => { + match UserCompletion::parameter(context.working_set, decl_id) { + Some(mut completer) => completer.fetch(context), + None => { + // A builtin/plugin runs no block; empty beats dumping the directory. + log::error!( + "`{}` cannot be used as a completer: it runs no block", + context.working_set.get_decl(decl_id).name() + ); + Fetched::answering(vec![]).worth_keeping() + } + } } + Completion::List(list) => StaticCompletion::new(list).fetch(context), }; - let need_fallback = attempt.needs_fallback(); - results.merge(attempt.into()); - if !need_fallback { + if results.absorb(attempt) { return results; } } - let attempt = self.command_wide_completion_helper(signature, element_expression, context); - let need_fallback = attempt.needs_fallback(); - results.merge(attempt.into()); - if !need_fallback { + if results.absorb(self.command_wide_completion_helper(signature, context)) { return results; } - results.merge(arg_value.fetch(context).into()); + // A fallthrough result keeps type-based completion enabled. + arg_value.need_fallback &= results.is_empty() || !results.answered(); + results.merge(arg_value.fetch(context)); results } @@ -1619,62 +2030,38 @@ impl CompletionEngine { decl_id: DeclId, context: &Context, signature: &Signature, - element_expression: &Expression, - ) -> Dispatched { - let mut results: Dispatched = FlagCompletion { decl_id }.fetch(context).into(); - results.merge( - self.command_wide_completion_helper(signature, element_expression, context) - .into(), - ); + ) -> Fetched { + let mut results = FlagCompletion { decl_id }.fetch(context); + results.merge(self.command_wide_completion_helper(signature, context)); results } - fn suggestions_at( - &self, - completer: &mut C, - working_set: &StateWorkingSet, - span: Span, - offset: usize, - ) -> Dispatched { - completer - .fetch(&self.context( - working_set, - span, - working_set.get_span_contents(span), - offset, - )) - .into() + fn suggestions_at(&self, completer: &mut C, context: &Context) -> Fetched { + completer.fetch(context) } - fn variable_names_completion_helper( - &self, - working_set: &StateWorkingSet, - span: Span, - offset: usize, - ) -> Dispatched { - let prefix = working_set.get_span_contents(span); - if !prefix.starts_with(b"$") { - return Dispatched::default(); + fn variable_names_completion_helper(&self, context: &Context) -> Fetched { + if !context.prefix.starts_with(b"$") { + return Fetched::default(); } - let ctx = self.context(working_set, span, prefix, offset); - VariableCompletion.fetch(&ctx).into() + VariableCompletion.fetch(context) } fn command_completion_helper( &self, working_set: &StateWorkingSet, + buffer: Buffer, span: Span, - offset: usize, mut command_completion: CommandCompletion, - ) -> Dispatched { + ) -> Fetched { let prefix = working_set.get_span_contents(span); - let ctx = self.context(working_set, span, prefix, offset); - command_completion.fetch(&ctx).into() + let ctx = self.context(working_set, buffer, span, prefix); + command_completion.fetch(&ctx) } - /// Command-completion scope for a command head, honouring a leading sigil: `^` → - /// externals only, `%` → built-ins only, otherwise everything. The sigil is the byte - /// between the call's own span and its head span. + /// Command scope for a head, honouring a leading sigil: `^` → externals only, `%` → + /// builtins only, otherwise everything. The sigil is the byte between the call's own + /// span and its head span. fn command_completion_for_head( &self, node: Option<&Expression>, @@ -1692,59 +2079,31 @@ impl CompletionEngine { }) } - /// Internal commands whose name extends the command line typed so far (`foo test⌶` - /// also offers `foo test bar`). Externals are excluded: a multi-word line names only + /// Internal commands whose name extends the line so far (`foo test⌶` also offers + /// `foo test bar`); externals are excluded, since a multi-word line names only /// internal subcommands. fn subcommand_suggestions( &self, working_set: &StateWorkingSet, + buffer: Buffer, command_start: usize, cursor: usize, - offset: usize, - ) -> Dispatched { + ) -> Fetched { if cursor <= command_start { - return Dispatched::default(); + return Fetched::default(); } self.command_completion_helper( working_set, + buffer, Span::new(command_start, cursor), - offset, CommandCompletion::new(CommandScope::InternalsOnly), ) } - fn custom_completion_helper( - &self, - custom_completion: Completion, - input: &str, - context: &Context, - pos: usize, - ) -> Fetched { - match custom_completion { - Completion::Command(decl_id) => { - let mut completer = - CustomCompletion::new(decl_id, input.into(), pos - context.offset); - completer.fetch(context) - } - Completion::List(list) => { - let mut completer = StaticCompletion::new(list); - completer.fetch(context) - } - // Engine-provided completions are handled in `complete_argument_value`; decline - // if one arrives by another path. - Completion::Builtin(_) => Fetched::Absent, - } - } - - fn command_wide_completion_helper( - &self, - signature: &Signature, - element_expression: &Expression, - context: &Context, - ) -> Fetched { + fn command_wide_completion_helper(&self, signature: &Signature, context: &Context) -> Fetched { let completion = match signature.complete { Some(CommandWideCompleter::Command(decl_id)) => { - CommandWideCompletion::command(context.working_set, decl_id, element_expression) + UserCompletion::command(context.working_set, decl_id) } Some(CommandWideCompleter::External) => self .engine_state @@ -1753,28 +2112,26 @@ impl CompletionEngine { .external .completer .as_ref() - .map(|closure| CommandWideCompletion::closure(closure, element_expression)), + .map(|closure| UserCompletion::closure(context.working_set, closure)), None => None, }; match completion { - Some(mut completion) => { - let context = Context { - prefix: b"", - ..*context - }; - completion.fetch(&context) - } - None => Fetched::Absent, + // Answers for the whole call, never narrowed against the token prefix. + Some(mut completion) => completion.fetch(&Context { + prefix: b"", + ..*context + }), + None => Fetched::absent(), } } pub(crate) fn context<'a>( &'a self, working_set: &'a StateWorkingSet, + buffer: Buffer<'a>, span: Span, prefix: &'a [u8], - offset: usize, ) -> Context<'a> { Context { working_set, @@ -1782,25 +2139,23 @@ impl CompletionEngine { options: &self.options, span, prefix, - offset, + offset: buffer.offset, + buffer: buffer.text, + // No site resolved yet; `Context::at_site` fills the chain in. + contexts: &[], } } - - pub(crate) fn options(&self) -> &CompletionOptions { - &self.options - } } pub struct NuCompleter { - engine: CompletionEngine, + engine_state: Arc, + stack: Arc, + options: CompletionOptions, cache: NarrowingCache, - /// The [`CacheEnv`] of every entry this completer stores/reads; computed once per - /// completer, not on [`CompletionEngine`] (which non-caching callers also build). cache_env: CacheEnv, worker: Option, - /// Whether [`complete`](ReedlineCompleter::complete) offloads to a worker. - /// False only on the REPL path with `background-completions` disabled. - background: bool, + /// Latest answer and its query. + latest: Option, } impl NuCompleter { @@ -1808,40 +2163,28 @@ impl NuCompleter { Self::with_cache(engine_state, stack, NarrowingCache::default()) } - /// The reedline completer; the only constructor that consults the - /// `background-completions` experimental option. - pub(crate) fn for_repl( - engine_state: Arc, - stack: Arc, - cache: NarrowingCache, - ) -> Self { - let mut completer = Self::with_cache(engine_state, stack, cache); - completer.background = nu_experimental::BACKGROUND_COMPLETIONS.get(); - completer - } - pub(crate) fn with_cache( engine_state: Arc, stack: Arc, cache: NarrowingCache, ) -> Self { - let engine = CompletionEngine::new(engine_state, stack); - let cache_env = CacheEnv::of(&engine.engine_state, &engine.stack); + let cache_env = CacheEnv::of(&engine_state, &stack); // Read fresh each prompt so `cache_size` config changes take effect. - let cache_size = engine.engine_state.get_config().completions.cache_size; + let cache_size = engine_state.get_config().completions.cache_size; cache.set_capacity(cache_size.try_into().unwrap_or(0)); Self { - engine, + options: configured_options(&engine_state), + engine_state, + stack, cache, cache_env, worker: None, - background: true, + latest: None, } } fn fresh_for(&self, query: &CompletionQuery) -> Option { - if let Some(worker) = self.worker.as_ref() - && let Some(latest) = &worker.latest + if let Some(latest) = &self.latest && &latest.query == query { return Some(latest.suggestions.clone()); @@ -1859,15 +2202,17 @@ impl NuCompleter { fn stale_fallback(&self, query: &CompletionQuery) -> Suggestions { self.cache - .narrowed_fallback(query, self.cache_env, self.engine.options()) + .narrowed_fallback(query, self.cache_env, &self.options) } - fn spawn_worker(engine: &CompletionEngine) -> CompletionWorker { + fn spawn_worker(engine_state: Arc, stack: Arc) -> CompletionWorker { let (request_tx, request_rx) = mpsc::channel::(); let (result_tx, result_rx) = mpsc::channel::(); - let engine = engine.to_background(); thread::spawn(move || { + // The thread owns the state; nothing is cloned per query. + let engine = CompletionEngine::isolated(&engine_state, stack, true); + while let Ok(mut query) = request_rx.recv() { while let Ok(newer) = request_rx.try_recv() { query = newer; @@ -1889,25 +2234,20 @@ impl NuCompleter { request_tx, result_rx, pending: None, - latest: None, } } fn fold_completed(&mut self, done: Completed) -> bool { - let Self { - cache, - cache_env, - worker, - .. - } = self; - let Some(worker) = worker.as_mut() else { - return false; - }; - let settled = worker.pending.as_ref() == Some(&done.query); + let settled = self + .worker + .as_mut() + .is_some_and(|worker| worker.pending.as_ref() == Some(&done.query)); + if done.cacheable { - cache.store(done.query.clone(), *cache_env, done.suggestions.clone()); + self.cache + .store(done.query.clone(), self.cache_env, done.suggestions.clone()); } - worker.latest = Some(done); + self.latest = Some(done); settled } @@ -1965,7 +2305,7 @@ fn partial_of(line: &str, suggestions: &[Suggestion]) -> Option { .filter(|suggestion| suggestion.span == span) .map(|suggestion| suggestion.value.as_str()); - // Narrow a window into the first value rather than allocating a `String`; runs every + // Slice a window into the first value rather than allocating a `String`; runs every // keystroke. let first = matching_values.next()?; let shared_len = matching_values.try_fold(first.len(), |shared, value| { @@ -1988,20 +2328,6 @@ fn partial_of(line: &str, suggestions: &[Suggestion]) -> Option { impl ReedlineCompleter for NuCompleter { fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { - if !self.background { - // Inline on this thread, skipping worker and cache: the pre-#18334 - // blocking behavior, which had neither. Blocking is the point, so - // an interactive completer can own the terminal. - let suggestions: Suggestions = self - .engine - .fetch_completions_at(line, pos) - .into_iter() - .map(|s| s.suggestion) - .collect(); - let partial = partial_of(line, &suggestions); - return CompletionResult::fresh(suggestions).with_partial(partial); - } - let query = CompletionQuery::new(line, pos); self.drain_completed(); @@ -2011,8 +2337,15 @@ impl ReedlineCompleter for NuCompleter { return CompletionResult::fresh(suggestions).with_partial(partial); } - if let Some(suggestions) = self.engine.complete_user_closure_on_repl_thread(&query) { - self.settle_pending(&query); + // Interactive completers run on UI thread to own terminal. + let engine = CompletionEngine::isolated(&self.engine_state, Arc::clone(&self.stack), false); + if engine.interactive_completer_at(line, pos) { + let (suggestions, _cacheable) = engine.suggestions_for(&query); + self.latest = Some(Completed { + query, + suggestions: suggestions.clone(), + cacheable: false, + }); let partial = partial_of(line, &suggestions); return CompletionResult::fresh(suggestions).with_partial(partial); } @@ -2020,16 +2353,16 @@ impl ReedlineCompleter for NuCompleter { let fallback = self.stale_fallback(&query); let partial = partial_of(line, &fallback); - let worker = self - .worker - .get_or_insert_with(|| Self::spawn_worker(&self.engine)); + let worker = self.worker.get_or_insert_with(|| { + Self::spawn_worker(Arc::clone(&self.engine_state), Arc::clone(&self.stack)) + }); if worker.pending.as_ref() != Some(&query) { if worker.request_tx.send(query.clone()).is_ok() { worker.pending = Some(query); } else { - // Worker died (a panic in a user completer closure kills it); drop it so the - // next request spawns a replacement. + // Worker died (a user completer closure panicked); drop it so the next + // request spawns a replacement. self.worker = None; } } @@ -2067,6 +2400,210 @@ mod completer_tests { Arc::new(engine) } + /// Corpus covering resolver branches. + const CORPUS: &[&str] = &[ + "ls", + "ls foo", + "ls foo bar", + "ls --long", + "ls --long foo", + "ls -a foo", + "^ls foo", + "sudo ls foo", + "ls | where size > 1", + "ls | where size > 1 ", + "ls foo ", + "1 + 2 ", + "$env.PATH ", + "ls | where size > ", + "ls | where size >", + "ls | where name == foo and size > 2", + "1 + 2", + "1 + ", + "1 ", + "$env.PATH", + "$env.", + "$nu", + "each { |x| $x.name }", + "let x = (ls | where name == foo)", + "def foo [] { ls }", + "@example\ndef foo [] {}", + "use std/util [", + "ls é foo", + "", + " ", + ]; + + /// All sites must contain cursor. + #[test] + fn every_site_holds_the_cursor() { + let engine_state = test_engine(); + + for line in CORPUS { + for cursor in (0..=line.len()).filter(|at| line.is_char_boundary(*at)) { + let contents = &line[..cursor]; + let mut working_set = StateWorkingSet::new(&engine_state); + let offset = working_set.next_span_start(); + let block = parse(&mut working_set, Some("sweep"), contents.as_bytes(), false); + let buffer = Buffer { + text: contents, + offset, + }; + + let site = CompletionEngine::resolve_completion_site( + &block, + &working_set, + buffer, + contents, + ); + assert!( + touches(site.span, cursor + offset), + "{line:?} at {cursor}: a {} site spans {}..{}, off the cursor at {}", + site.kind.resolved().kind(), + site.span.start, + site.span.end, + cursor + offset, + ); + } + } + } + + /// `commandline complete --input` place.kind/index for the HackMD where-size cases. + fn place_of(engine: &CompletionEngine, line: &str) -> (String, Option) { + let input = engine.completer_input_at(line, line.len(), DeclaredInputs::all()); + let place = input.get_data_by_key("place").expect("place field"); + let kind = place + .get_data_by_key("kind") + .expect("kind") + .as_str() + .expect("kind string") + .to_string(); + let index = place.get_data_by_key("index").and_then(|v| v.as_int().ok()); + (kind, index) + } + + /// HackMD issue 6: operator vs value vs trailing positional index of `where`. + #[test] + fn where_size_place_kind_matches_hackmd() { + let engine_state = test_engine(); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + + assert_eq!( + place_of(&engine, "ls | where size >"), + ("operator".into(), None), + "cursor on `>` is an operator site" + ); + assert_eq!( + place_of(&engine, "ls | where size > 1"), + ("positional".into(), Some(0)), + "cursor on the comparison value is where's condition, not a file" + ); + assert_eq!( + place_of(&engine, "ls | where size > "), + ("positional".into(), Some(0)), + "trailing space after `>` is still where's only positional (index 0)" + ); + } + + /// Mid-token and mid-flag adversarial cases for place/token agreement. + #[test] + fn token_span_matches_place_target_for_flags_and_values() { + let engine_state = test_engine(); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + + let check = |line: &str, cursor: usize, text: &str, start: usize| { + let input = engine.completer_input_at(line, cursor, DeclaredInputs::all()); + let token = input.get_data_by_key("token").expect("token"); + assert_eq!( + token.get_data_by_key("text").unwrap().as_str().unwrap(), + text, + "token.text for {line:?}@{cursor}" + ); + assert_eq!( + token + .get_data_by_key("span") + .unwrap() + .get_data_by_key("start") + .unwrap() + .as_int() + .unwrap(), + start as i64, + "token.span.start for {line:?}@{cursor}" + ); + let place = input.get_data_by_key("place").expect("place"); + assert_eq!( + place + .get_data_by_key("target") + .unwrap() + .get_data_by_key("start") + .unwrap() + .as_int() + .unwrap(), + start as i64, + "place.target.start for {line:?}@{cursor}" + ); + }; + + check("ls -al", 6, "-al", 3); + // Cursor mid-flag: only text up to the cursor is parsed, so the token is the prefix. + check("ls -al", 4, "-", 3); + check("ls -al", 5, "-a", 3); + check("str tri", 7, "tri", 4); + check("str tri", 5, "t", 4); // mid-token prefix + check("str tri", 6, "tr", 4); + + // Mid-cursor where-size: prefix-parsed site matches place.target. + check("ls | where size > 1", 13, "si", 11); // mid-`size` + check("ls | where size > 1", 15, "size", 11); + check("ls | where size > 1", 17, ">", 16); // on operator + check("ls | where size > 1", 19, "1", 18); // on value + check("ls | where size > ", 18, "", 18); // trailing space after `>` + } + + /// Token text/span equals target shown to completer. + #[test] + fn the_token_is_what_the_answer_replaces() { + let engine_state = test_engine(); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + + let range = |record: &Value, field: &str| { + let span = record.get_data_by_key(field).expect("a span field"); + let at = |end| { + span.get_data_by_key(end) + .and_then(|value| value.as_int().ok()) + .expect("an offset") as usize + }; + (at("start"), at("end")) + }; + + for line in CORPUS { + for cursor in (0..=line.len()).filter(|at| line.is_char_boundary(*at)) { + let contents = &line[..cursor]; + let input = engine.completer_input_at(contents, cursor, DeclaredInputs::all()); + + let token = input.get_data_by_key("token").expect("a token"); + let place = input.get_data_by_key("place").expect("a place"); + let text = token + .get_data_by_key("text") + .and_then(|value| value.coerce_into_string().ok()) + .expect("the token's text"); + + let at = range(&token, "span"); + let target = range(&place, "target"); + assert_eq!( + at, + (target.0, target.1.min(contents.len())), + "{line:?} at {cursor}: the token is not the target the completer saw" + ); + assert_eq!( + Some(text.as_str()), + contents.get(at.0..at.1), + "{line:?} at {cursor}: the token is not the text it spans" + ); + } + } + } + fn q(s: &str) -> CompletionQuery { CompletionQuery::new(s, s.len()) } @@ -2076,6 +2613,124 @@ mod completer_tests { reedline::Span::new(start, start) } + /// Adversarial: bare `where` must stay a Call (not Garbage→File). `ls | where` is + /// command; `ls | where ` is positional 0 — same shape as `get`. + #[test] + fn pipeline_head_where_is_command_not_file() { + let engine_state = test_engine(); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + for line in ["ls | where", "ls | get", "ls | each", "ls | filter"] { + let (kind, _) = place_of(&engine, line); + assert_eq!( + kind, "command", + "{line:?} at EOL should be command head, got {kind}" + ); + } + // Trailing space after where: first positional (condition), not bare File. + let (kind, index) = place_of(&engine, "ls | where "); + assert_eq!(kind, "positional", "ls | where trailing: got {kind}"); + assert_eq!(index, Some(0), "where condition is positional 0"); + } + + /// RowConditionSlot prefers the signature's condition index, not raw arg counting — + /// a leading path arg must not steal index 0 from the condition. + #[test] + fn row_condition_index_follows_signature_slot_not_arg_order() { + use nu_protocol::engine::{Call as EngineCall, Command, CommandType}; + use nu_protocol::{Category, PipelineData, ShellError, Signature, SyntaxShape}; + + #[derive(Clone)] + struct MyWhere; + impl Command for MyWhere { + fn name(&self) -> &str { + "mywhere" + } + fn description(&self) -> &str { + "test: path then row condition" + } + fn signature(&self) -> Signature { + Signature::build(self.name()) + .required("path", SyntaxShape::Filepath, "path") + .required("cond", SyntaxShape::RowCondition, "condition") + .category(Category::Custom("test".into())) + } + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + _call: &EngineCall, + _input: PipelineData, + ) -> Result { + Ok(PipelineData::empty()) + } + fn command_type(&self) -> CommandType { + CommandType::Builtin + } + } + + let mut engine_state = + nu_command::add_shell_command_context(nu_cmd_lang::create_default_context()); + { + let mut working_set = StateWorkingSet::new(&engine_state); + working_set.add_decl(Box::new(MyWhere)); + engine_state + .merge_delta(working_set.render()) + .expect("merge mywhere"); + } + let engine_state = Arc::new(engine_state); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + + assert_eq!( + place_of(&engine, "mywhere ./data size > 1"), + ("positional".into(), Some(1)), + "condition after a path arg is signature index 1" + ); + assert_eq!( + place_of(&engine, "mywhere ./data size > "), + ("positional".into(), Some(1)), + "trailing gap after operator stays condition index 1" + ); + assert_eq!( + place_of(&engine, "mywhere ./da"), + ("positional".into(), Some(0)), + "path arg stays index 0" + ); + } + + /// Adversarial operators/units/nested pipeline places. + #[test] + fn where_operator_variants_and_downstream_get() { + let engine_state = test_engine(); + let engine = CompletionEngine::new(&engine_state, &Stack::new()); + assert_eq!( + place_of(&engine, "ls | where size >= 1"), + ("positional".into(), Some(0)) + ); + assert_eq!( + place_of(&engine, "ls | where size =="), + ("operator".into(), None) + ); + assert_eq!( + place_of(&engine, "ls | where size == "), + ("positional".into(), Some(0)) + ); + assert_eq!( + place_of(&engine, "ls | where size > 1kb"), + ("positional".into(), Some(0)) + ); + assert_eq!( + place_of(&engine, "ls | where size > 1 | get name"), + ("positional".into(), Some(0)) + ); + assert_eq!( + place_of(&engine, "ls | where size > 1 | get "), + ("positional".into(), Some(0)) + ); + // Incomplete pipe is a command head. + assert_eq!(place_of(&engine, "ls |"), ("command".into(), None)); + assert_eq!(place_of(&engine, "ls | "), ("command".into(), None)); + } + #[test] fn narrows_stays_within_one_token() { assert!(q("ls foobar").narrows(&q("ls foo"), token(3))); @@ -2101,30 +2756,29 @@ mod completer_tests { #[test] fn narrows_rejects_a_token_that_becomes_a_flag() { - // The empty positional slot after `from csv ` is answered with file names at a point - // span; typing `--sep` appends no boundary, so only the flag check keeps them from - // following it. + // The empty slot after `from csv ` is answered at a point span; `--sep` adds no + // boundary, so only the flag check keeps it from following. let base = q("from csv "); - assert!(!q("from csv --sep").narrows(&base, token(base.cursor()))); + assert!(!q("from csv --sep").narrows(&q("from csv "), token(base.cursor()))); // Extending a flag the user was already typing stays sound. assert!(q("from csv --sep").narrows(&q("from csv --s"), token(9))); } + /// A cursor mid-char or past the end must not panic or split a char; `typed()` slices + /// the buffer directly. #[test] - fn background_engine_suppresses_stdin() { - let engine = test_engine(); - let stack = Arc::new(Stack::new()); - let foreground = CompletionEngine::new(engine, stack); - assert!(!foreground.stack.suppress_stdin); - let background = foreground.to_background(); - assert!(background.stack.suppress_stdin); - } + fn the_cursor_is_clamped_to_a_char_boundary() { + // "é" occupies bytes 3..5, so a cursor at 4 is mid-character. + assert_eq!(CompletionQuery::new("ls é", 4).typed(), "ls "); - #[test] - fn internal_command_completion_stays_on_the_worker() { - let engine = CompletionEngine::new(test_engine(), Arc::new(Stack::new())); - assert!(!engine.should_complete_on_repl_thread(&q("ls | c"))); + let past_end = CompletionQuery::new("ls", 99); + assert_eq!(past_end.typed(), "ls"); + assert_eq!(past_end.cursor(), 2); + + let empty = CompletionQuery::new("", 7); + assert_eq!(empty.typed(), ""); + assert_eq!(empty.cursor(), 0); } fn apply_source(engine: &mut EngineState, stack: &mut Stack, source: &[u8]) { @@ -2146,113 +2800,6 @@ mod completer_tests { engine.merge_env(stack).expect("merge_env"); } - fn engine_with_external_completer() -> Arc { - let mut engine = (*test_engine()).clone(); - let mut stack = Stack::new(); - apply_source( - &mut engine, - &mut stack, - b"$env.config.completions.external.completer = {|spans| $spans}", - ); - Arc::new(engine) - } - - #[test] - fn external_arg_with_completer_runs_on_the_repl_thread() { - let engine = - CompletionEngine::new(engine_with_external_completer(), Arc::new(Stack::new())); - assert!(engine.should_complete_on_repl_thread(&q("nvim foo"))); - assert!(!engine.should_complete_on_repl_thread(&q("ls | c"))); - } - - /// Opted out: settles inline, spawns no worker, leaves the cache alone. - #[test] - fn opted_out_completer_settles_inline() { - let mut completer = NuCompleter::new(test_engine(), Arc::new(Stack::new())); - completer.background = false; - - let result = completer.complete("ls | c", 6); - assert!( - matches!(result, CompletionResult::Fresh { .. }), - "expected a settled result, got {result:?}" - ); - assert!(result.suggestions().iter().any(|s| s.value == "cd")); - assert!(completer.worker.is_none(), "a worker was spawned anyway"); - assert_eq!(completer.poll_completion(), CompletionStatus::Idle); - } - - /// Engine whose external completer reports what its evaluation environment - /// allowed: `piped-N` if a piped external's stdout reached `lines`, - /// `direct-S` if a final external's stdout was captured. - fn probe_engine() -> (Arc, Arc) { - let mut engine = - nu_command::add_shell_command_context(nu_cmd_lang::create_default_context()); - let mut stack = Stack::new(); - let cwd = std::env::temp_dir() - .to_string_lossy() - .trim_end_matches(['/', '\\']) - .replace('\\', "/"); - stack.add_env_var( - "PWD".to_string(), - nu_protocol::Value::string(&cwd, Span::unknown()), - ); - // External lookup needs a PATH; `Stack::new` starts with no env at all. - stack.add_env_var( - "PATH".to_string(), - nu_protocol::Value::string(std::env::var("PATH").unwrap_or_default(), Span::unknown()), - ); - - let setup = r#"$env.config.completions.external = { - enable: true - completer: {|spans| - let piped = ("alpha\nbeta\n" | lines | length) - let direct = ('gamma' | str trim) - [$"piped-($piped)" $"direct-($direct)"] - } - }"#; - let mut working_set = StateWorkingSet::new(&engine); - let block = nu_parser::parse(&mut working_set, None, setup.as_bytes(), false); - assert!(working_set.parse_errors.is_empty(), "setup failed to parse"); - engine.merge_delta(working_set.render()).expect("merge"); - nu_engine::eval_block::( - &engine, - &mut stack, - &block, - nu_protocol::PipelineData::empty(), - ) - .expect("eval setup"); - engine.merge_env(&mut stack).expect("merge env"); - - (Arc::new(engine), Arc::new(stack)) - } - - /// Externals inside a completer keep their stdout on both stacks: - /// `suppress_output` only sets `out_dest.stdout` (final command), while - /// `collect_value` sets `pipe_stdout` (piped stages). Thus the opt-out only - /// has to stop offloading; the stack needs no changes. - #[rstest::rstest] - #[case::foreground(false)] - #[case::background(true)] - fn externals_in_a_completer_keep_their_stdout(#[case] suppress_stdin: bool) { - let (engine, stack) = probe_engine(); - let engine = CompletionEngine::new(engine, isolated_stack(stack, suppress_stdin)); - - let values: Vec = engine - .fetch_completions_at("somecmd x", 9) - .into_iter() - .map(|s| s.suggestion.value) - .collect(); - - assert!( - values.iter().any(|v| v == "piped-2"), - "a piped external lost its stdout: {values:?}" - ); - assert!( - values.iter().any(|v| v == "direct-gamma"), - "a final external lost its stdout: {values:?}" - ); - } - /// The worker runs on an isolated stack and must still produce identical results. #[test] fn background_result_matches_the_synchronous_engine() { @@ -2264,7 +2811,7 @@ mod completer_tests { values }; let expected = sorted( - CompletionEngine::new(engine, Arc::new(Stack::new())) + CompletionEngine::new(&engine, &Stack::new()) .fetch_completions_at("ls | c", 6) .into_iter() .map(|s| s.suggestion.value) @@ -2307,7 +2854,7 @@ mod completer_tests { assert!(answer.suggestions().iter().any(|s| s.value == "cd")); } - /// …but not across a `cd`/`$env.PATH` change — the reason [`CacheEnv`] exists. + /// ...but not across a `cd`/`$env.PATH` change, the reason [`CacheEnv`] exists. #[test] fn cache_is_not_reused_in_a_different_environment() { use nu_protocol::Value; @@ -2407,38 +2954,52 @@ mod completer_tests { ); } - /// A cached answer stands in for the computed one, so the two must agree on order. - /// Re-sorting the cache put `config/` after `config.nu`, inverting every keystroke. + /// A narrowed fallback keeps the producer's order rather than re-sorting. #[test] - fn a_narrowed_cache_answer_keeps_the_order_it_was_given() { + fn a_narrowed_fallback_keeps_the_producers_order() { + let mut engine = + nu_command::add_shell_command_context(nu_cmd_lang::create_default_context()); + // An unsorted (`sort: false`) command-wide completer seeds the narrowing cache. + let src = " + def comp [token] { {completions: [zzz-two, zzz-one], options: {sort: false, filter: false}} } + @complete comp + def --wrapped my-cmd [...rest] {}"; + let delta = { + let mut working_set = StateWorkingSet::new(&engine); + parse(&mut working_set, None, src.as_bytes(), false); + assert!( + working_set.parse_errors.is_empty(), + "{:?}", + working_set.parse_errors + ); + working_set.render() + }; + engine.merge_delta(delta).expect("merge_delta"); + let engine = Arc::new(engine); + let cache = NarrowingCache::default(); - let env = CacheEnv::of(&test_engine(), &Stack::new()); - let span = reedline::Span::new(3, 5); - - // The order file completion produces: the directory first, ranked as `config`. - let cached: Suggestions = ["config/", "config.nu"] + let mut warming = + NuCompleter::with_cache(engine.clone(), Arc::new(Stack::new()), cache.clone()); + let warmed: Vec<_> = warming + .complete_blocking("my-cmd zzz", 10) .iter() - .map(|value| Suggestion { - value: (*value).to_string(), - span, - ..Default::default() - }) - .collect::>() - .into(); + .map(|s| s.value.clone()) + .collect(); + assert_eq!(warmed, ["zzz-two", "zzz-one"], "producer order, unsorted"); + drop(warming); - cache.store(CompletionQuery::new("ls co", 5), env, cached); - - let narrowed = cache.narrowed_fallback( - &CompletionQuery::new("ls con", 6), - env, - &CompletionOptions::default(), - ); - - let values: Vec<&str> = narrowed.iter().map(|s| s.value.as_str()).collect(); + // Narrowing one more character keeps that order instead of sorting. + let mut narrowing = NuCompleter::with_cache(engine, Arc::new(Stack::new()), cache); + let values: Vec<_> = narrowing + .complete("my-cmd zzz-", 11) + .suggestions() + .iter() + .map(|s| s.value.clone()) + .collect(); assert_eq!( values, - ["config/", "config.nu"], - "the cached answer must not reorder what it stands in for" + ["zzz-two", "zzz-one"], + "narrowed fallback must not re-sort" ); } } diff --git a/crates/nu-cli/src/completions/completion_common.rs b/crates/nu-cli/src/completions/completion_common.rs index a97fdd36b0..60f85802c4 100644 --- a/crates/nu-cli/src/completions/completion_common.rs +++ b/crates/nu-cli/src/completions/completion_common.rs @@ -255,7 +255,7 @@ pub(crate) fn complete_paths(ctx: &Context, directories_only: bool) -> Fetched { .collect(); hidden_files_last(&mut items); - Fetched::Cacheable(items) + Fetched::answering(items).worth_keeping() } /// # Parameters @@ -456,8 +456,13 @@ pub fn adjust_if_intermediate( let mut prefix = prefix.to_string(); // A difference of 1 because of the cursor's unicode code point in between. - // Using .chars().count() because unicode and Windows. - let readjusted = span_contents.chars().count() - prefix.chars().count() > 1; + // Using .chars().count() because unicode and Windows. Saturating: the prefix can run + // past the span's end into a trailing gap (`ls | where ⌶`), leaving nothing to readjust. + let readjusted = span_contents + .chars() + .count() + .saturating_sub(prefix.chars().count()) + > 1; if readjusted { let remnant: String = span_contents .chars() diff --git a/crates/nu-cli/src/completions/custom_completions.rs b/crates/nu-cli/src/completions/custom_completions.rs deleted file mode 100644 index 2e371cb12d..0000000000 --- a/crates/nu-cli/src/completions/custom_completions.rs +++ /dev/null @@ -1,472 +0,0 @@ -use crate::completions::{ - Completer, Context, Fetched, MatchAlgorithm, SemanticSuggestion, to_reedline_span, -}; -use nu_color_config::{color_record_to_nustyle, lookup_ansi_color_style}; -use nu_engine::{compile, eval_call}; -use nu_parser::flatten_expression; -use nu_protocol::{ - BlockId, DeclId, GetSpan, IntoSpanned, PipelineData, Record, ShellError, Span, Spanned, - SuggestionKind, Type, Value, VarId, - ast::{Argument, Call, Expr, Expression}, - debugger::WithoutDebug, - engine::{Closure, EngineState, StateWorkingSet}, - shell_error::generic::GenericError, -}; -use nu_utils::{SharedCow, strip_ansi_string_unlikely}; -use reedline::Suggestion; -use std::{borrow::Cow, collections::HashMap, sync::Arc}; - -use super::completion_options::NuMatcher; - -fn map_value_completions<'a>( - list: impl Iterator, - span: Span, - input_start: usize, - offset: usize, -) -> Vec { - list.filter_map(move |x| { - // Match for string values - if let Ok(s) = x.coerce_string() { - return Some(SemanticSuggestion { - suggestion: Suggestion { - value: strip_ansi_string_unlikely(s), - span: to_reedline_span(span, offset), - ..Suggestion::default() - }, - kind: Some(SuggestionKind::Value(x.get_type())), - }); - } - - // Match for record values - if let Ok(record) = x.as_record() { - let mut suggestion = Suggestion { - value: String::from(""), - span: to_reedline_span(span, offset), - ..Suggestion::default() - }; - let mut value_type = Type::String; - - // Iterate the cols looking for `value` and `description` - record.iter().for_each(|(key, value)| { - match key.as_str() { - "value" => { - value_type = value.get_type(); - if let Ok(val_str) = value.coerce_string() { - suggestion.value = strip_ansi_string_unlikely(val_str); - } - } - "display_override" => { - if let Ok(display_str) = value.coerce_string() { - suggestion.display_override = Some(display_str); - } - } - "description" => { - if let Ok(desc_str) = value.coerce_string() { - suggestion.description = Some(desc_str); - } - } - "style" => { - suggestion.style = match value { - Value::String { val, .. } => Some(lookup_ansi_color_style(val)), - Value::Record { .. } => Some(color_record_to_nustyle(value)), - _ => None, - }; - } - "span" => { - if let Value::Record { val: span_rec, .. } = value { - // TODO: error on invalid spans? - if let Some(end) = read_span_field(span_rec, "end") { - suggestion.span.end = suggestion.span.end.min(end + input_start); - } - if let Some(start) = read_span_field(span_rec, "start") { - suggestion.span.start = start + input_start; - } - if suggestion.span.start > suggestion.span.end { - suggestion.span.start = suggestion.span.end; - log::error!( - "Custom span start ({}) is greater than end ({})", - suggestion.span.start, - suggestion.span.end - ); - } - } - } - _ => (), - } - }); - - return Some(SemanticSuggestion { - suggestion, - kind: Some(SuggestionKind::Value(value_type)), - }); - } - - None - }) - .collect() -} - -fn read_span_field(span: &SharedCow, field: &str) -> Option { - let Ok(val) = span.get(field)?.as_int() else { - log::error!("Expected span field {field} to be int"); - return None; - }; - let Ok(val) = usize::try_from(val) else { - log::error!("Couldn't convert span {field} to usize"); - return None; - }; - - Some(val) -} - -/// Borrows the permanent engine state when the completer lives in it, avoiding a -/// per-keystroke clone; otherwise clones it and merges the working set delta. -fn engine_state_for_completion<'a>( - working_set: &'a StateWorkingSet<'_>, - is_permanent: bool, -) -> Cow<'a, EngineState> { - if is_permanent { - Cow::Borrowed(working_set.permanent_state) - } else { - let mut engine_state = working_set.permanent_state.clone(); - let _ = engine_state.merge_delta(working_set.delta.clone()); - Cow::Owned(engine_state) - } -} - -pub struct CustomCompletion { - decl_id: DeclId, - line: String, - line_pos: usize, -} - -impl CustomCompletion { - pub fn new(decl_id: DeclId, line: String, line_pos: usize) -> Self { - Self { - decl_id, - line, - line_pos, - } - } -} - -impl Completer for CustomCompletion { - fn fetch(&mut self, ctx: &Context) -> Fetched { - let working_set = ctx.working_set; - let span = ctx.span; - let offset = ctx.offset; - let orig_options = ctx.options; - let prefix = ctx.prefix_str(); - // Call custom declaration - let mut stack_mut = ctx.stack.clone(); - let mut eval = |engine_state: &EngineState| { - eval_call::( - engine_state, - &mut stack_mut, - &Call { - decl_id: self.decl_id, - head: span, - arguments: vec![ - Argument::Positional(Expression::new_unknown( - Expr::String(self.line.clone()), - span, - Type::String, - )), - Argument::Positional(Expression::new_unknown( - Expr::Int(self.line_pos as i64), - span, - Type::Int, - )), - ], - parser_info: HashMap::new(), - }, - PipelineData::empty(), - ) - }; - let engine_state = engine_state_for_completion( - working_set, - self.decl_id.get() < working_set.permanent_state.num_decls(), - ); - let result = eval(engine_state.as_ref()); - - let mut completion_options = orig_options.clone(); - let mut should_sort = true; - let mut should_filter = true; - - // Parse result - let suggestions = match result.and_then(|data| data.into_value(span)) { - Ok(value) => match &value { - Value::Record { val, .. } => { - let completions = val - .get("completions") - .and_then(|val| { - val.as_list().ok().map(|it| { - map_value_completions( - it.iter(), - span, - self.line_pos - self.line.len(), - offset, - ) - }) - }) - .unwrap_or_default(); - let options = val.get("options"); - - if let Some(Value::Record { val: options, .. }) = &options { - if let Some(filter) = - options.get("filter").and_then(|val| val.as_bool().ok()) - { - should_filter = filter; - } - - if let Some(sort) = options.get("sort").and_then(|val| val.as_bool().ok()) { - should_sort = sort; - - if should_sort && !should_filter { - log::warn!("Sorting won't happen because filtering is disabled.") - }; - } - - if let Some(case_sensitive) = options - .get("case_sensitive") - .and_then(|val| val.as_bool().ok()) - { - completion_options.case_sensitive = case_sensitive; - } - - if let Some(md) = options - .get("match_description") - .and_then(|val| val.as_bool().ok()) - { - completion_options.match_description = md; - } - - let positional = - options.get("positional").and_then(|val| val.as_bool().ok()); - if positional.is_some() { - log::warn!( - "Use of the positional option is deprecated. Use the substring match algorithm instead." - ); - } - if let Some(algorithm) = options - .get("completion_algorithm") - .and_then(|option| option.coerce_string().ok()) - .and_then(|option| option.try_into().ok()) - { - completion_options.match_algorithm = algorithm; - if let Some(false) = positional - && completion_options.match_algorithm == MatchAlgorithm::Prefix - { - completion_options.match_algorithm = MatchAlgorithm::Substring - } - } - } - - completions - } - Value::List { vals, .. } => map_value_completions( - vals.iter(), - span, - self.line_pos - self.line.len(), - offset, - ), - Value::Nothing { .. } => return Fetched::Declined, - _ => { - log::error!( - "Custom completer returned invalid value of type {}", - value.get_type() - ); - return Fetched::Cacheable(vec![]); - } - }, - Err(e) => { - log::error!("Error getting custom completions: {e}"); - // Error does not equal empty success (matches `CommandWideCompletion`): - // fall back so file completion still runs when a custom completer errors. - return Fetched::Declined; - } - }; - - if !should_filter { - return Fetched::Cacheable(suggestions); - } - - let mut matcher = NuMatcher::new(prefix.as_ref(), &completion_options, should_sort); - - for sugg in suggestions { - let value = strip_ansi_string_unlikely(sugg.suggestion.display_value().to_string()); - if matcher.check_match(&value).is_some() { - matcher.add(value, sugg); - } else if completion_options.match_description - && let Some(description) = sugg.suggestion.description.as_deref() - && matcher.check_match(description).is_some() - { - let description = description.to_string(); - matcher.add(description, sugg); - } - } - Fetched::Cacheable(matcher.suggestion_results()) - } -} - -pub fn get_command_arguments( - working_set: &StateWorkingSet<'_>, - element_expression: &Expression, -) -> Spanned>> { - let span = element_expression.span(&working_set); - flatten_expression(working_set, element_expression) - .iter() - .map(|(span, _)| { - String::from_utf8_lossy(working_set.get_span_contents(*span)) - .into_owned() - .into_spanned(*span) - }) - .collect::>() - .into_spanned(span) -} - -pub struct CommandWideCompletion<'e> { - block_id: BlockId, - captures: Vec<(VarId, Value)>, - expression: &'e Expression, -} - -impl<'a> CommandWideCompletion<'a> { - pub fn command( - working_set: &StateWorkingSet<'_>, - decl_id: DeclId, - expression: &'a Expression, - ) -> Option { - let block_id = (decl_id.get() < working_set.num_decls()) - .then(|| working_set.get_decl(decl_id)) - .and_then(|command| command.block_id())?; - - Some(Self { - block_id, - captures: vec![], - expression, - }) - } - - pub fn closure(closure: &'a Closure, expression: &'a Expression) -> Self { - Self { - block_id: closure.block_id, - captures: closure.captures.clone(), - expression, - } - } -} - -impl<'a> Completer for CommandWideCompletion<'a> { - fn fetch(&mut self, ctx: &Context) -> Fetched { - let working_set = ctx.working_set; - let stack = ctx.stack; - let span = ctx.span; - let offset = ctx.offset; - let Spanned { - mut item, - span: args_span, - } = get_command_arguments(working_set, self.expression); - // An empty span is a trailing argument slot the parser produced no argument for; - // append an empty entry so the completer sees one `$spans` entry per argument. - if ctx.span.is_empty() { - item.push(String::new().into_spanned(ctx.span)); - } - let args = item; - - let mut block = working_set.get_block(self.block_id).clone(); - - // LSP completion where custom def is parsed but not compiled - if block.ir_block.is_none() - && let Ok(ir_block) = compile(working_set, &block) - { - let mut new_block = (*block).clone(); - new_block.ir_block = Some(ir_block); - block = Arc::new(new_block); - } - - let mut callee_stack = stack.captures_to_stack_preserve_out_dest(self.captures.clone()); - - if let Some(pos_arg) = block.signature.required_positional.first() - && let Some(var_id) = pos_arg.var_id - { - callee_stack.add_var( - var_id, - Value::list( - args.into_iter() - .map(|Spanned { item, span }| Value::string(item, span)) - .collect(), - args_span, - ), - ); - } - let engine_state = engine_state_for_completion( - working_set, - self.block_id.get() < working_set.permanent_state.num_blocks(), - ); - let result = nu_engine::eval_block_with_early_return::( - engine_state.as_ref(), - &mut callee_stack, - &block, - PipelineData::empty(), - ) - .map(|p| p.body); - - let command_span = working_set.get_span(self.expression.span_id); - if let Some(results) = - convert_whole_command_completion_results(offset, span, result, command_span) - { - Fetched::Cacheable(results) - } else { - Fetched::Declined - } - } -} - -/// Converts the output of the external completion closure and whole command custom completion -/// commands' -fn convert_whole_command_completion_results( - offset: usize, - span: Span, - result: Result, - command_span: Span, -) -> Option> { - let value = match result.and_then(|pipeline_data| pipeline_data.into_value(span)) { - Ok(value) => value, - Err(err) => { - log::error!( - "{}", - ShellError::Generic( - GenericError::new_internal( - "nu::shell::completion", - "failed to eval completer block", - ) - .with_inner([err]), - ) - ); - // Error does not equal empty success: fall back so file completion - // still runs when an external completer fails. - return None; - } - }; - - match value { - Value::List { vals, .. } => Some(map_value_completions( - vals.iter(), - span, - command_span.start.saturating_sub(offset), - offset, - )), - Value::Nothing { .. } => None, - _ => { - log::error!( - "{}", - ShellError::Generic(GenericError::new_internal( - "nu::shell::completion", - "completer returned invalid value of type", - )), - ); - Some(vec![]) - } - } -} diff --git a/crates/nu-cli/src/completions/custom_completions/input.rs b/crates/nu-cli/src/completions/custom_completions/input.rs new file mode 100644 index 0000000000..a778fa98b8 --- /dev/null +++ b/crates/nu-cli/src/completions/custom_completions/input.rs @@ -0,0 +1,357 @@ +use crate::completions::{ + Context, + completer::{CompletionContext, ResolvedCursor, is_flag_text}, + to_reedline_span, +}; +use nu_parser::{FlatShape, flatten_expression}; +use nu_protocol::{Record, Signature, Span, SyntaxShape, Value, ast::Expr, record}; + +/// A `{start, end}` record of byte offsets into the commandline. +fn span_record(start: usize, end: usize, span: Span) -> Value { + Value::record( + record! { + "start" => Value::int(start as i64, span), + "end" => Value::int(end as i64, span), + }, + span, + ) +} + +/// Token kind and span. +struct Token { + /// `head`, `flag`, `block`, or `value`. + kind: &'static str, + /// Byte range the token occupies on the line; absent for alias-expanded tokens. + at: Option<(usize, usize)>, +} + +impl Token { + /// A token spanning `start..end` of the line. + fn on_line( + buffer: &str, + (start, end): (usize, usize), + is_first: bool, + is_flag_shape: bool, + ) -> Self { + Self { + kind: Self::classify( + buffer.get(start..end).unwrap_or_default(), + is_first, + is_flag_shape, + ), + at: Some((start, end)), + } + } + + /// A token an alias expansion produced. + fn expanded(text: &str, is_first: bool, is_flag_shape: bool) -> Self { + Self { + kind: Self::classify(text, is_first, is_flag_shape), + at: None, + } + } + + /// The closure or subexpression the cursor descends into. + fn nesting(range: (usize, usize)) -> Self { + Self { + kind: "block", + at: Some(range), + } + } + + /// Classifies the token strictly based on position and shape. + fn classify(text: &str, is_first: bool, is_flag_shape: bool) -> &'static str { + if is_first { + "head" + } else if is_flag_shape || is_flag_text(text) { + "flag" + } else { + "value" + } + } +} + +/// The tokens of one context's command. +fn context_tokens(context: &Context, level: &CompletionContext) -> Vec { + let cursor = context.buffer.len(); + let element_span = level.element.map(|element| element.span); + + // Clever: Pre-allocate a reasonable capacity to avoid reallocation overhead. + let mut tokens: Vec = Vec::with_capacity(16); + + let flattened = level + .element + .map(|element| flatten_expression(context.working_set, element)) + .unwrap_or_default(); + + let mut previous_span = None; + + for (token_span, shape) in &flattened { + // Break exactly when we cross into nested territory. + if level + .descent + .is_some_and(|descent| token_span.start >= descent.start) + { + break; + } + + // Bypass zero-width or duplicated implicit variable spans cleanly. + if token_span.start >= token_span.end || previous_span == Some(*token_span) { + continue; + } + previous_span = Some(*token_span); + + let contents = context.working_set.get_span_contents(*token_span); + if contents.iter().all(u8::is_ascii_whitespace) { + continue; + } + + let is_on_line = token_span.start >= context.offset + && element_span.is_some_and(|element| { + token_span.start >= element.start && token_span.end <= element.end + }); + + let is_first = tokens.is_empty(); + let is_flag_shape = matches!(shape, FlatShape::Flag); + + if is_on_line { + let start = token_span.start.saturating_sub(context.offset); + if start >= cursor { + continue; + } + let end = (token_span.end.saturating_sub(context.offset)).min(cursor); + tokens.push(Token::on_line( + context.buffer, + (start, end), + is_first, + is_flag_shape, + )); + } else { + let text = String::from_utf8_lossy(contents); + tokens.push(Token::expanded(&text, is_first, is_flag_shape)); + } + } + + if let Some(descent) = level.descent { + let start = descent.start.saturating_sub(context.offset); + let end = descent.end.saturating_sub(context.offset).min(cursor); + tokens.push(Token::nesting((start, end))); + return tokens; + } + + if locate(&tokens, cursor).is_none() { + // Clever: We can safely grab the last known end point because tokens are appended sequentially. + let last_end = tokens + .iter() + .filter_map(|token| token.at) + .next_back() + .map(|(_, end)| end) + .unwrap_or(0); + let replacing = to_reedline_span(context.span, context.offset); + let start = replacing.start.max(last_end).min(cursor); + + tokens.push(Token::on_line( + context.buffer, + (start, cursor), + tokens.is_empty(), + false, + )); + } + + tokens +} + +/// The token `offset` falls in, and its byte offset within that token. +fn locate(tokens: &[Token], offset: usize) -> Option<(usize, usize)> { + let (index, token) = tokens.iter().enumerate().find(|(_, token)| { + token + .at + .is_some_and(|(start, end)| start <= offset && offset <= end) + })?; + + let start = token + .at + .expect("Predicate guarantees token position is valid") + .0; + Some((index, offset - start)) +} + +/// Return the declared shape for the argument under the cursor. +fn expected_shape(context: &Context, level: &CompletionContext) -> Option { + let Expr::Call(call) = &level.element?.expr else { + return None; + }; + let signature = context.working_set.get_decl(call.decl_id).signature(); + + match level.cursor { + ResolvedCursor::Positional { index } => signature + .get_positional(index) + .map(|positional| positional.shape.clone()), + // `flag` may be either the long name or a short-only flag. + ResolvedCursor::FlagValue { flag } => signature + .get_long_flag(flag) + .or_else(|| { + flag.chars() + .next() + .and_then(|c| signature.get_short_flag(c)) + }) + .and_then(|flag| flag.arg), + _ => None, + } +} + +fn place_value(context: &Context, cursor: Value, target: Value) -> Value { + let span = context.span; + let mut place = record! { + "cursor" => cursor, + "target" => target, + }; + + if let Some(level) = context.contexts.last() { + place.extend(level.cursor.into_record(span)); + if let Some(shape) = expected_shape(context, level) { + place.insert("shape", Value::string(shape.to_string(), span)); + } + } + + Value::record(place, span) +} + +/// Which of `token`, `place`, and `buffer` a completer asked for. The input record is +/// overloaded on this: each field is computed and handed over only when it is declared, so a +/// completer is never given -- and never pays for -- a field it did not ask for. +#[derive(Debug, Clone, Copy)] +pub struct DeclaredInputs { + pub token: bool, + pub place: bool, + pub buffer: bool, +} + +impl DeclaredInputs { + /// Every field; what `commandline complete --input` reports for inspection. + pub fn all() -> Self { + Self { + token: true, + place: true, + buffer: true, + } + } + + /// The recognized input fields a completer's signature declares as positionals. + pub(crate) fn from_signature(signature: &Signature) -> Self { + let declares = |name: &str| { + signature + .required_positional + .iter() + .chain(&signature.optional_positional) + .any(|positional| positional.name == name) + }; + Self { + token: declares("token"), + place: declares("place"), + buffer: declares("buffer"), + } + } +} + +/// Token text/span/kind for replacement range. +fn token_value(context: &Context) -> Value { + let span = context.span; + let replacing = to_reedline_span(span, context.offset); + let tokens = context + .contexts + .last() + .map(|level| context_tokens(context, level)) + .unwrap_or_default(); + + let kind = locate(&tokens, context.buffer.len()) + .map(|(index, _)| index) + .or_else(|| tokens.len().checked_sub(1)) + .and_then(|index| tokens.get(index)) + .map_or("value", |token| token.kind); + + let (start, end) = (replacing.start, replacing.end.min(context.buffer.len())); + let text = context.buffer.get(start..end).unwrap_or_default(); + + Value::record( + record! { + "text" => Value::string(text, span), + "kind" => Value::string(kind, span), + "span" => span_record(start, end, span), + }, + span, + ) +} + +/// Place: cursor, target, and resolved site. +fn place_of(context: &Context) -> Value { + let span = context.span; + let replacing = to_reedline_span(span, context.offset); + place_value( + context, + Value::int(context.buffer.len() as i64, span), + span_record(replacing.start, replacing.end, span), + ) +} + +/// Record for completer with only declared fields. +pub(crate) fn completer_input(context: &Context, wanted: DeclaredInputs) -> Value { + let span = context.span; + let mut record = Record::new(); + + if wanted.token { + record.insert("token", token_value(context)); + } + if wanted.place { + record.insert("place", place_of(context)); + } + if wanted.buffer { + record.insert("buffer", Value::string(context.buffer, span)); + } + + Value::record(record, span) +} + +/// our legacy compat shim! +pub(crate) fn legacy_context(context: &Context) -> Value { + let cursor = context.offset + context.buffer.len(); + let start = context + .contexts + .last() + .and_then(|level| level.element) + .map(|element| element.span.start) + .unwrap_or(cursor) + .clamp(context.offset, cursor); + let text = String::from_utf8_lossy( + context + .working_set + .get_span_contents(Span::new(start, cursor)), + ) + .into_owned(); + Value::string(text, context.span) +} + +/// Buffer-relative cursor, i.e. the old second positional (`pos`/`position`). +pub(crate) fn legacy_pos(context: &Context) -> Value { + Value::int(context.buffer.len() as i64, context.span) +} + +/// Flattened element tokens, plus `""` for a trailing empty slot: the old `spans`. +pub(crate) fn legacy_spans(context: &Context) -> Value { + let Some(element) = context.contexts.last().and_then(|level| level.element) else { + return Value::list(vec![], context.span); + }; + let mut spans: Vec = flatten_expression(context.working_set, element) + .iter() + .map(|(span, _)| { + Value::string( + String::from_utf8_lossy(context.working_set.get_span_contents(*span)).into_owned(), + *span, + ) + }) + .collect(); + if context.span.is_empty() { + spans.push(Value::string("", context.span)); + } + Value::list(spans, context.span) +} diff --git a/crates/nu-cli/src/completions/custom_completions/mod.rs b/crates/nu-cli/src/completions/custom_completions/mod.rs new file mode 100644 index 0000000000..7de65ef16d --- /dev/null +++ b/crates/nu-cli/src/completions/custom_completions/mod.rs @@ -0,0 +1,460 @@ +mod input; +mod output; + +pub(crate) use output::report; +pub(crate) use output::{Returned, SpanClamp, map_value_completions}; + +use crate::completions::{ + Completer, Context, Fetched, + completer::{closure_is_interactive, decl_is_interactive}, +}; +pub use input::DeclaredInputs; +pub(crate) use input::{completer_input, legacy_context, legacy_pos, legacy_spans}; +use nu_engine::compile; +use nu_protocol::{ + BlockId, DeclId, PipelineData, ReportMode, ShellError, ShellWarning, Signature, Span, Value, + VarId, + ast::Block, + debugger::WithoutDebug, + engine::{Closure, Command, EngineState, Stack, StateWorkingSet}, + report_shell_warning, +}; +pub(crate) use output::CompleterOutput; +use std::{ + borrow::Cow, + hash::{DefaultHasher, Hash, Hasher}, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Arc, Mutex}, +}; + +thread_local! { + static COMPLETION_PANIC_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether the current thread is evaluating a completion source. The main panic hook uses this +/// to avoid printing a panic that completion already converted into a `ShellError`. +pub fn completion_panic_is_active() -> bool { + COMPLETION_PANIC_ACTIVE.with(std::cell::Cell::get) +} + +/// Catch a completion-source panic while marking it for the process panic hook. +pub(crate) fn catch_completion_panic( + f: impl FnOnce() -> T, +) -> Result> { + COMPLETION_PANIC_ACTIVE.with(|active| { + let previous = active.replace(true); + let result = catch_unwind(AssertUnwindSafe(f)); + active.set(previous); + result + }) +} + +/// Who filters the candidates against the typed prefix; overridable via `options.filter`. +#[derive(Debug, Clone, Copy)] +pub(crate) enum Narrowing { + /// The engine filters; parameter completers list every candidate. + Engine, + /// The completer narrowed its own list; command-wide/external completers see the typed + /// text and may match fuzzily (e.g. carapace). + Completer, +} + +impl Narrowing { + /// Whether the engine filters when the completer expresses no `options.filter`. + fn filters_by_default(self) -> bool { + matches!(self, Self::Engine) + } +} + +/// Borrow the permanent engine state when the completer lives in it (no per-keystroke +/// clone); otherwise clone it and merge the working-set delta. +fn engine_state_for_completion<'a>( + working_set: &'a StateWorkingSet<'_>, + is_permanent: bool, +) -> Cow<'a, EngineState> { + if is_permanent { + Cow::Borrowed(working_set.permanent_state) + } else { + let mut engine_state = working_set.permanent_state.clone(); + let _ = engine_state.merge_delta(working_set.delta.clone()); + Cow::Owned(engine_state) + } +} + +/// Fields available to a custom completer. +pub(crate) const INPUT_FIELDS: [&str; 3] = ["token", "place", "buffer"]; + +/// A legacy positional interface which predates named completion inputs. +#[derive(Debug, Clone, Copy)] +pub(crate) enum LegacyInputKind { + /// A custom completer attached to one parameter. + Parameter, + /// A command-wide or configured external completer. + Command, + /// A menu source. + Menu, +} + +impl LegacyInputKind { + fn migration(self) -> &'static str { + match self { + Self::Parameter => { + "use `[buffer, place]`; `$buffer` replaces the old context and \ + `$place.cursor` replaces its position" + } + Self::Command => { + "use `[buffer]`; split or parse `$buffer` if the old token list is needed" + } + Self::Menu => "use `[buffer, place]`; `$place.cursor` replaces the old position", + } + } + + fn name(self) -> &'static str { + match self { + Self::Parameter => "parameter", + Self::Command => "command or external", + Self::Menu => "menu", + } + } +} + +/// Values supplied to an old positional completer interface. +/// +/// New-style inputs are always selected by name. Only the first two unrecognised positionals +/// can opt into this bridge, mirroring the two values the old interfaces accepted. +pub(crate) struct LegacyInputs { + kind: LegacyInputKind, + values: [Option; 2], + /// The completer's definition, which the deprecation points at. + definition: Span, +} + +impl LegacyInputs { + /// Construct a bridge only when the signature actually needs it, avoiding legacy parsing + /// and allocations for completers that already use named inputs. + /// + /// `site` locates the completer when its block carries no span of its own. + fn when_needed( + kind: LegacyInputKind, + block: &Block, + site: Span, + values: impl FnOnce() -> [Value; 2], + ) -> Self { + let needs_legacy = block + .signature + .required_positional + .iter() + .chain(&block.signature.optional_positional) + .take(2) + .any(|positional| !INPUT_FIELDS.contains(&positional.name.as_str())); + + let values = needs_legacy + .then(values) + .map(|[first, second]| [Some(first), Some(second)]) + .unwrap_or([None, None]); + Self { + kind, + values, + definition: block.span.unwrap_or(site), + } + } + + pub(crate) fn parameter(ctx: &Context, block: &Block) -> Self { + Self::when_needed(LegacyInputKind::Parameter, block, ctx.span, || { + [legacy_context(ctx), legacy_pos(ctx)] + }) + } + + pub(crate) fn command(ctx: &Context, block: &Block) -> Self { + Self::when_needed(LegacyInputKind::Command, block, ctx.span, || { + // Command-wide and external completers historically received only `$spans`. + [legacy_spans(ctx), Value::nothing(ctx.span)] + }) + } + + pub(crate) fn menu(block: &Block, buffer: &str, position: usize, span: Span) -> Self { + Self::when_needed(LegacyInputKind::Menu, block, span, || { + [ + Value::string(buffer, span), + Value::int(position as i64, span), + ] + }) + } + + fn value(&self, index: usize) -> Option { + self.values.get(index).and_then(Clone::clone) + } + + /// Warn that this completer runs on the bridge. Unlike the mistakes [`report`] logs, + /// this one costs nothing today and everything once the bridge is removed, so it is + /// raised where a user will actually see it: queued for [`flush_completion_warnings`]. + fn deprecate(&self, names: &[String]) { + let (plural, still) = match names.len() { + 1 => ("", "still receives its old positional value"), + _ => ("s", "still receive their old positional values"), + }; + let names = names.join("`, `"); + + queue(ShellWarning::Deprecated { + dep_type: "Positional completer input".into(), + label: format!("declares the legacy positional input{plural} `{names}`"), + span: self.definition, + help: Some(format!( + "A {} completer is handed one record, whose fields bind to the parameters it \ + names: {}. `{names}` {still} for compatibility, but this bridge will be \ + removed in a future release. To migrate, {}.", + self.kind.name(), + INPUT_FIELDS.join(", "), + self.kind.migration(), + )), + report_mode: ReportMode::FirstUse, + }); + } +} + +/// Deprecations raised while completing, each with the hash that keeps a duplicate out. +/// +/// Completion runs while the line editor owns the terminal — for a background query, on +/// another thread — so a warning printed here would land in the middle of the line being +/// typed. They wait here for the next caller that can safely print instead. +static PENDING: Mutex> = Mutex::new(Vec::new()); + +/// Queue a deprecation, unless the same one is already waiting. +fn queue(warning: ShellWarning) { + let mut hasher = DefaultHasher::new(); + warning.hash(&mut hasher); + let digest = hasher.finish(); + + // A poisoned lock must never interfere with completion. + if let Ok(mut pending) = PENDING.lock() + && !pending.iter().any(|(queued, _)| *queued == digest) + { + pending.push((digest, warning)); + } +} + +/// Print the deprecations completion raised, now that printing is safe: the REPL calls this +/// once the line editor hands back the line, and `commandline complete` when it returns. +/// Each is still shown only once a session, through [`ReportMode::FirstUse`]. +pub fn flush_completion_warnings(engine_state: &EngineState, stack: &Stack) { + // Taken, not printed under the lock: a background completion may be queueing into it. + let pending = match PENDING.lock() { + Ok(mut pending) => std::mem::take(&mut *pending), + Err(_) => return, + }; + + for (_, warning) in pending { + report_shell_warning(Some(stack), engine_state, &warning); + } +} + +/// Bind declared positional names to matching fields in the input record. +/// +/// A non-`token`/`place`/`buffer` name in either of the first two slots receives its old +/// positional value through [`LegacyInputs`] instead of `nothing`. This keeps scripts such as +/// fzf's `{|spans|}` and zoxide's `[context, pos]` working while they migrate. Other unknown +/// inputs still receive `nothing` with a diagnostic. +pub(crate) fn bind_declared_inputs( + stack: &mut Stack, + signature: &Signature, + input: Value, + legacy: LegacyInputs, +) { + let span = input.span(); + let Ok(record) = input.into_record() else { + return; + }; + + let mut legacy_names = Vec::new(); + for (index, positional) in signature + .required_positional + .iter() + .chain(&signature.optional_positional) + .enumerate() + { + if let Some(var_id) = positional.var_id { + if INPUT_FIELDS.contains(&positional.name.as_str()) { + let value = record + .get(positional.name.as_str()) + .cloned() + .unwrap_or_else(|| Value::nothing(span)); + stack.add_var(var_id, value); + } else if let Some(old) = legacy.value(index) { + legacy_names.push(positional.name.clone()); + stack.add_var(var_id, old); + } else { + report(format!( + "a completer declares `{}`, which is not a completion input; expected one \ + of {} — it will receive nothing", + positional.name, + INPUT_FIELDS.join(", ") + )); + stack.add_var(var_id, Value::nothing(span)); + } + } + } + + if !legacy_names.is_empty() { + legacy.deprecate(&legacy_names); + } +} + +/// The block a declaration runs, seeing through aliases (a completer named by one). +fn block_of(command: &dyn Command) -> Option { + command + .block_id() + .or_else(|| block_of(command.as_alias()?.command.as_deref()?)) +} + +/// A user-defined completer: a block called with the input record. Parameter, +/// command-wide, and external completers share this one implementation. +pub(crate) struct UserCompletion { + block_id: BlockId, + captures: Vec<(VarId, Value)>, + narrowing: Narrowing, + /// Whether this completer is `@interactive`: it takes the terminal and asks the user. + interactive: bool, +} + +impl UserCompletion { + /// A completer attached to one parameter (`x: string@"nu-complete foo"`); the engine + /// narrows its results. See [`Narrowing`]. + pub(crate) fn parameter(working_set: &StateWorkingSet<'_>, decl_id: DeclId) -> Option { + Self::from_decl(working_set, decl_id, Narrowing::Engine) + } + + /// A completer attached to a whole command (`@complete "nu-complete foo"`). + pub(crate) fn command(working_set: &StateWorkingSet<'_>, decl_id: DeclId) -> Option { + Self::from_decl(working_set, decl_id, Narrowing::Completer) + } + + /// The configured external completer closure. + pub(crate) fn closure(working_set: &StateWorkingSet<'_>, closure: &Closure) -> Self { + Self { + block_id: closure.block_id, + captures: closure.captures.clone(), + narrowing: Narrowing::Completer, + interactive: closure_is_interactive(working_set, closure), + } + } + + /// A block-backed declaration, seeing through aliases. Builtins and plugin commands + /// run no block and cannot serve as completers. + fn from_decl( + working_set: &StateWorkingSet<'_>, + decl_id: DeclId, + narrowing: Narrowing, + ) -> Option { + let block_id = (decl_id.get() < working_set.num_decls()) + .then(|| working_set.get_decl(decl_id)) + .and_then(block_of)?; + + Some(Self { + block_id, + captures: vec![], + narrowing, + interactive: decl_is_interactive(working_set, decl_id), + }) + } + + /// Call the completer with the record it asked for; panics become `Err` + /// so the prompt can recover instead of unwinding the worker/REPL. + fn eval(&self, ctx: &Context) -> Result { + let working_set = ctx.working_set; + let mut block = working_set.get_block(self.block_id).clone(); + + // LSP completion, where a custom `def` is parsed but never compiled. + if block.ir_block.is_none() + && let Ok(ir_block) = compile(working_set, &block) + { + let mut new_block = (*block).clone(); + new_block.ir_block = Some(ir_block); + block = Arc::new(new_block); + } + + let mut callee_stack = ctx + .stack + .captures_to_stack_preserve_out_dest(self.captures.clone()); + + // A completer opts into what it receives through the positional parameters it + // declares: the input record is overloaded on them, carrying exactly the recognized + // fields (`token`, `place`, `buffer`) it names, bound by name. Order is free. The + // compatibility bridge retains old positional inputs for one migration cycle. + let wanted = DeclaredInputs::from_signature(&block.signature); + let legacy = match self.narrowing { + Narrowing::Engine => LegacyInputs::parameter(ctx, &block), + Narrowing::Completer => LegacyInputs::command(ctx, &block), + }; + bind_declared_inputs( + &mut callee_stack, + &block.signature, + completer_input(ctx, wanted), + legacy, + ); + + let engine_state = engine_state_for_completion( + working_set, + self.block_id.get() < working_set.permanent_state.num_blocks(), + ); + + let span = ctx.span; + match catch_completion_panic(|| { + nu_engine::eval_block_with_early_return::( + engine_state.as_ref(), + &mut callee_stack, + &block, + PipelineData::empty(), + ) + .and_then(|data| data.body.into_value(span)) + }) { + Ok(result) => result, + Err(payload) => Err(panic_to_shell_error(&payload, span)), + } + } +} + +/// Convert a `catch_unwind` payload into a recoverable [`ShellError`]. +pub(crate) fn panic_to_shell_error(payload: &(dyn std::any::Any + Send), span: Span) -> ShellError { + let message = if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "Box".into() + }; + ShellError::Generic( + nu_protocol::shell_error::generic::GenericError::new("Completer panicked", message, span) + .with_help( + "A custom completer panicked; the panic was caught so the prompt can recover.", + ), + ) +} + +impl UserCompletion { + /// Empty answer when the block declines (`failed: false`) or errors (`failed: true`): + /// interactive pickers answer empty on dismiss, and engine-narrowed (parameter) + /// completers answer empty on error since no fallback can serve their slot — + /// anything else declines so the next source runs. + fn empty(&self, failed: bool) -> Fetched { + if self.interactive || (failed && matches!(self.narrowing, Narrowing::Engine)) { + Fetched::answering(vec![]) + } else { + Fetched::declining() + } + } +} + +impl Completer for UserCompletion { + fn fetch(&mut self, ctx: &Context) -> Fetched { + match self.eval(ctx) { + Err(err) => { + report(format!("failed to eval completer block: {err}")); + self.empty(true).worth_keeping() + } + Ok(value) => match CompleterOutput::read(value, ctx, self.narrowing) { + None => self.empty(false), + Some(output) => output.into_fetched(ctx), + }, + } + } +} diff --git a/crates/nu-cli/src/completions/custom_completions/output.rs b/crates/nu-cli/src/completions/custom_completions/output.rs new file mode 100644 index 0000000000..5a9d85ed4e --- /dev/null +++ b/crates/nu-cli/src/completions/custom_completions/output.rs @@ -0,0 +1,472 @@ +use super::Narrowing; +use crate::completions::{ + CompletionOptions, Context, Fetched, MatchAlgorithm, NuMatcher, SemanticSuggestion, + to_reedline_span, +}; +use nu_color_config::{color_record_to_nustyle, lookup_ansi_color_style}; +use nu_protocol::{FromValue, ShellError, SuggestionKind, Type, Value, engine::CommandType}; +use nu_utils::strip_ansi_string_unlikely; +use reedline::Suggestion; + +/// A completer's return value, after both accepted shapes are normalized. +pub(crate) struct CompleterOutput { + suggestions: Vec, + options: CompletionOptions, + sort: bool, + filter: bool, + /// Whether the completer requested the next source. + fallback: bool, +} + +/// Log a non-fatal completer error without disrupting line editing. +pub(crate) fn report(message: impl AsRef) { + log::error!("nu::shell::completion: {}", message.as_ref()); +} + +/// Convert a completer result field, logging conversion failures. +fn read_part(value: Value, what: &str) -> Option { + T::from_value(value) + .map_err(|err| report(format!("a completer's {what} is not usable: {err}"))) + .ok() +} + +/// A string field read leniently: anything that coerces to a string becomes one, so +/// `{description: 5}` reads as `"5"` rather than costing the suggestion it describes. +struct Text(String); + +impl FromValue for Text { + fn from_value(value: Value) -> Result { + value.coerce_into_string().map(Self) + } + + fn expected_type() -> Type { + Type::String + } +} + +impl From for String { + fn from(text: Text) -> Self { + text.0 + } +} + +/// Warn on unknown keys in completer output. +fn lint_keys(value: &Value, what: &str, keys: &[&str]) { + let Ok(record) = value.as_record() else { + return; + }; + + for unknown in record + .columns() + .filter(|column| !keys.contains(&column.as_str())) + { + report(format!( + "a {what} has no `{unknown}` field, so it is ignored; expected one of {}", + keys.join(", ") + )); + } +} + +/// Record returned by completer with exact accepted keys. +trait ReturnedRecord: FromValue { + /// What to call it in a message. + const LABEL: &'static str; + /// Every key accepted, whether read into a field or merely tolerated. + const KEYS: &'static [&'static str]; + + /// Read one; unknown keys linted and ignored. + fn read(value: Value) -> Option { + lint_keys(&value, Self::LABEL, Self::KEYS); + read_part(value, Self::LABEL) + } +} + +/// Declare a record a completer returns: the struct [`FromValue`] reads, and the exact set +/// of keys it accepts. `accepting` names keys tolerated but not read. +macro_rules! returned_record { + ( + $(#[$doc:meta])* + struct $name:ident is $label:literal $(, accepting [$($extra:literal),* $(,)?])? { + $( $(#[$field_doc:meta])* $field:ident : $ty:ty ),* $(,)? + } + ) => { + $(#[$doc])* + #[derive(FromValue)] + #[nu_value(type_name = $label)] + struct $name { + $( $(#[$field_doc])* $field: $ty, )* + } + + impl ReturnedRecord for $name { + const LABEL: &'static str = $label; + const KEYS: &'static [&'static str] = + &[$(stringify!($field),)* $($($extra),*)?]; + } + }; +} + +returned_record! { + /// The `options` a completer may return beside its completions. Every key is optional; + /// one left out keeps the engine's configured setting. + struct ReturnedOptions is "completion options" { + /// Whether the engine narrows the list against the typed text. Defaults by completer + /// kind; see [`Narrowing`]. + filter: Option, + sort: Option, + case_sensitive: Option, + match_description: Option, + completion_algorithm: Option, + /// Deprecated; use the substring match algorithm. + positional: Option, + } +} + +returned_record! { + /// A suggestion returned by a custom completer. + struct ReturnedSuggestion is "suggestion", accepting ["type"] { + /// Value to insert, coerced to a string. + value: Value, + display_override: Option, + description: Option, + /// Optional suggestion kind. + kind: Option, + /// A colour name or colour record. + style: Option, + /// Read on its own for the same reason as `style`. + span: Option, + /// Extra columns beside the value, in description menus. + extra: Option>, + /// Whether to append a space after the value. + append_whitespace: Option, + /// Character indices matching the typed text. + match_indices: Option>, + } +} + +/// Convert a custom suggestion kind, defaulting to the value's type. +fn suggestion_kind(kind: Option, value: &Value) -> SuggestionKind { + let by_type = || SuggestionKind::Value(value.get_type()); + let Some(kind) = kind else { return by_type() }; + + let Ok(name) = kind.as_str() else { + report(format!( + "a suggestion's `kind` must be a name like `directory` or `command`; got {}", + kind.get_type() + )); + return by_type(); + }; + + match name { + "value" => by_type(), + "command" => SuggestionKind::Command(CommandType::Custom, None), + "cell-path" => SuggestionKind::CellPath, + "directory" => SuggestionKind::Directory, + "file" => SuggestionKind::File, + "flag" => SuggestionKind::Flag, + "module" => SuggestionKind::Module, + "operator" => SuggestionKind::Operator, + "variable" => SuggestionKind::Variable, + other => { + report(format!( + "a suggestion's `kind` is `{other}`; expected one of value, command, \ + cell-path, directory, file, flag, module, operator, variable" + )); + by_type() + } + } +} + +returned_record! { + /// A `span` a suggestion returned. Each end is optional and falls back to the span the + /// engine would have replaced, so `{end: 20}` moves only the end (#tested by + /// `custom_completions_override_span`). + struct ReturnedSpan is "suggestion span" { + start: Option, + end: Option, + } +} + +/// Keys accepted by the completion envelope. +const ENVELOPE_KEYS: [&str; 3] = ["completions", "options", "fallback"]; + +/// What a completer or menu source returned, before any options are applied. See the +/// [module docs](self) for the shapes accepted. +pub(crate) struct Returned { + pub(crate) completions: Vec, + options: Option, + /// Whether to continue with the next source. + fallback: bool, +} + +impl Returned { + /// `None` where the source declined with `null`, letting the next one answer. + pub(crate) fn read(value: Value) -> Option { + let bare = |completions| { + Some(Self { + completions, + options: None, + fallback: false, + }) + }; + + // Records without envelope keys are treated as lone suggestions. + let is_envelope = match &value { + Value::Record { val, .. } => ENVELOPE_KEYS.iter().any(|key| val.contains(key)), + _ => false, + }; + + if is_envelope { + lint_keys(&value, "completion envelope", &ENVELOPE_KEYS); + let mut record = value.into_record().unwrap_or_default(); + + // Read each part independently. + return Some(Self { + completions: record + .remove("completions") + .and_then(|completions| read_part(completions, "`completions`")) + .unwrap_or_default(), + options: record.remove("options").and_then(ReturnedOptions::read), + fallback: record + .remove("fallback") + .and_then(|fallback| read_part(fallback, "`fallback`")) + .unwrap_or(false), + }); + } + + match value { + Value::Nothing { .. } => None, + Value::List { vals, .. } => bare(vals.into_owned()), + // A list is many suggestions and anything else is one. A picker returns the item + // it was given, not a list of one: `input list` and `fzf` both answer with a bare + // string, and rejecting it would cost the completion the user just chose. + one => bare(vec![one]), + } + } +} + +impl CompleterOutput { + /// Read what a completer returned; `None` when it declined with `null`. + pub(crate) fn read(value: Value, ctx: &Context, narrowing: Narrowing) -> Option { + let returned = Returned::read(value)?; + + let mut output = Self { + suggestions: map_value_completions( + returned.completions.into_iter(), + to_reedline_span(ctx.span, ctx.offset), + SpanClamp::within(ctx.buffer), + ), + options: ctx.options.clone(), + sort: true, + filter: narrowing.filters_by_default(), + fallback: returned.fallback, + }; + + if let Some(options) = returned.options { + output.read_options(options); + } + + Some(output) + } + + /// Apply the `options` record a completer returned alongside its completions. + fn read_options(&mut self, options: ReturnedOptions) { + if let Some(filter) = options.filter { + self.filter = filter; + } + + if let Some(sort) = options.sort { + self.sort = sort; + if self.sort && !self.filter { + log::warn!("Sorting won't happen because filtering is disabled."); + } + } + + if let Some(case_sensitive) = options.case_sensitive { + self.options.case_sensitive = case_sensitive; + } + + if let Some(match_description) = options.match_description { + self.options.match_description = match_description; + } + + if options.positional.is_some() { + log::warn!( + "Use of the positional option is deprecated. Use the substring match algorithm instead." + ); + } + + if let Some(algorithm) = options + .completion_algorithm + .and_then(|algorithm| MatchAlgorithm::try_from(algorithm).ok()) + { + self.options.match_algorithm = algorithm; + } + + if let Some(positional) = options.positional + && !positional + && self.options.match_algorithm == MatchAlgorithm::Prefix + { + self.options.match_algorithm = MatchAlgorithm::Substring; + } + } + + /// Narrow suggestions and preserve the completer's fallback request. + pub(crate) fn into_fetched(self, ctx: &Context) -> Fetched { + let fallback = self.fallback; + let outcome = |suggestions| { + if fallback { + Fetched::contributing(suggestions) + } else { + Fetched::answering(suggestions).worth_keeping() + } + }; + + if !self.filter { + return outcome(self.suggestions); + } + + let prefix = ctx.prefix_str(); + let mut matcher = NuMatcher::new(prefix.as_ref(), &self.options, self.sort); + + for suggestion in self.suggestions { + let value = + strip_ansi_string_unlikely(suggestion.suggestion.display_value().to_string()); + if matcher.check_match(&value).is_some() { + matcher.add(value, suggestion); + } else if self.options.match_description + && let Some(description) = suggestion.suggestion.description.as_deref() + && matcher.check_match(description).is_some() + { + let description = description.to_string(); + matcher.add(description, suggestion); + } + } + + outcome(matcher.suggestion_results()) + } +} + +/// The colour a suggestion asked for: a name (`"green"`, `"bg_red"`), or a record of +/// `{fg, bg, attr}`. +fn style_of(style: Value) -> Option { + match style { + Value::String { val, .. } => Some(lookup_ansi_color_style(&val)), + Value::Record { .. } => Some(color_record_to_nustyle(&style)), + other => { + report(format!( + "a suggestion's `style` must be a colour name or a record of {{fg, bg, attr}}; \ + got {}", + other.get_type() + )); + None + } + } +} + +impl ReturnedSuggestion { + /// Resolve against the span a suggestion replaces when it names none. + fn into_semantic( + self, + default_span: reedline::Span, + clamp: SpanClamp<'_>, + ) -> SemanticSuggestion { + let kind = suggestion_kind(self.kind, &self.value); + + SemanticSuggestion { + suggestion: Suggestion { + value: self + .value + .coerce_into_string() + .map(strip_ansi_string_unlikely) + .unwrap_or_default(), + display_override: self.display_override.map(Into::into), + description: self.description.map(Into::into), + style: self.style.and_then(style_of), + span: self + .span + .and_then(ReturnedSpan::read) + .map_or(default_span, |span| span.resolve(default_span, clamp)), + extra: self + .extra + .map(|extra| extra.into_iter().map(Into::into).collect()), + append_whitespace: self.append_whitespace.unwrap_or_default(), + match_indices: self.match_indices, + }, + kind: Some(kind), + } + } +} + +impl ReturnedSpan { + /// Clamp so a returned span can't index out of bounds or mid-character (#5127), nor + /// replace text the completer was never shown. + fn resolve(self, default: reedline::Span, clamp: SpanClamp<'_>) -> reedline::Span { + let offset = |value: Option, fallback: usize| match value { + // A negative offset names no position on the line. Keeping the default is the + // safe reading; clamping it to `0` would silently replace from the line's start. + Some(value) if value < 0 => { + report(format!("a suggestion's span offset ({value}) is negative")); + fallback + } + Some(value) => clamp.apply(value as usize), + None => fallback, + }; + + let start = offset(self.start, default.start); + let end = offset(self.end, default.end); + + if start > end { + report(format!( + "a suggestion's span start ({start}) is greater than its end ({end})" + )); + return reedline::Span::new(end, end); + } + + reedline::Span::new(start, end) + } +} + +/// Parse one `Value` of a completer's list into a SemanticSuggestion. +pub(crate) fn map_value_completions( + list: impl Iterator, + default_span: reedline::Span, + clamp: SpanClamp<'_>, +) -> Vec { + list.filter_map(move |value| { + if matches!(value, Value::Record { .. }) { + return ReturnedSuggestion::read(value) + .map(|suggestion| suggestion.into_semantic(default_span, clamp)); + } + + let kind = nu_protocol::SuggestionKind::Value(value.get_type()); + value + .coerce_into_string() + .ok() + .map(|string| SemanticSuggestion { + suggestion: Suggestion { + value: strip_ansi_string_unlikely(string), + span: default_span, + ..Suggestion::default() + }, + kind: Some(kind), + }) + }) + .collect() +} + +/// Clamps returned spans to the text the source saw, snapping offsets to its char boundaries. +#[derive(Clone, Copy)] +pub(crate) struct SpanClamp<'a> { + seen: &'a str, +} + +impl<'a> SpanClamp<'a> { + /// Clamped to `text`. + pub(crate) fn within(text: &'a str) -> Self { + Self { seen: text } + } + + pub(crate) fn apply(&self, value: usize) -> usize { + self.seen.floor_char_boundary(value.min(self.seen.len())) + } +} diff --git a/crates/nu-cli/src/completions/dotnu_completions.rs b/crates/nu-cli/src/completions/dotnu_completions.rs index b06411a305..74b0e1abf4 100644 --- a/crates/nu-cli/src/completions/dotnu_completions.rs +++ b/crates/nu-cli/src/completions/dotnu_completions.rs @@ -161,6 +161,6 @@ impl Completer for DotNuCompletion { .collect::>(), ); - Fetched::Cacheable(all_results) + Fetched::answering(all_results).worth_keeping() } } diff --git a/crates/nu-cli/src/completions/env_var_completions.rs b/crates/nu-cli/src/completions/env_var_completions.rs index 572018d8b3..382eda4ebb 100644 --- a/crates/nu-cli/src/completions/env_var_completions.rs +++ b/crates/nu-cli/src/completions/env_var_completions.rs @@ -25,6 +25,6 @@ impl Completer for EnvVarCompletion { }); } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/exportable_completions.rs b/crates/nu-cli/src/completions/exportable_completions.rs index 75c2b6bdef..a79140aa8b 100644 --- a/crates/nu-cli/src/completions/exportable_completions.rs +++ b/crates/nu-cli/src/completions/exportable_completions.rs @@ -93,6 +93,6 @@ impl Completer for ExportableCompletion<'_> { ); } } - Fetched::Pure(results) + Fetched::answering(results) } } diff --git a/crates/nu-cli/src/completions/flag_completions.rs b/crates/nu-cli/src/completions/flag_completions.rs index f1ae011e45..b7c20c6db6 100644 --- a/crates/nu-cli/src/completions/flag_completions.rs +++ b/crates/nu-cli/src/completions/flag_completions.rs @@ -42,6 +42,6 @@ impl Completer for FlagCompletion { add_suggestion(format!("--{long}"), named.desc.clone()); } } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/mod.rs b/crates/nu-cli/src/completions/mod.rs index 56e0fb0f40..27c3cf8f66 100644 --- a/crates/nu-cli/src/completions/mod.rs +++ b/crates/nu-cli/src/completions/mod.rs @@ -23,10 +23,16 @@ pub(crate) use base::to_reedline_span; pub use base::{Completer, Fetched, SemanticSuggestion}; pub use cell_path_completions::CellPathCompletion; pub use command_completions::{CommandCompletion, CommandScope}; +pub(crate) use completer::{Buffer, Context, NarrowingCache}; pub use completer::{CompletionEngine, NuCompleter}; -pub(crate) use completer::{Context, NarrowingCache}; pub use completion_options::{CompletionOptions, MatchAlgorithm, NuMatcher}; -pub use custom_completions::CustomCompletion; +pub use custom_completions::DeclaredInputs; +pub use custom_completions::completion_panic_is_active; +pub use custom_completions::flush_completion_warnings; +pub(crate) use custom_completions::{ + LegacyInputs, Returned, SpanClamp, bind_declared_inputs, catch_completion_panic, + map_value_completions, panic_to_shell_error, report, +}; pub use directory_completions::DirectoryCompletion; pub use dotnu_completions::DotNuCompletion; pub use env_var_completions::EnvVarCompletion; diff --git a/crates/nu-cli/src/completions/operator_completions.rs b/crates/nu-cli/src/completions/operator_completions.rs index 2de40a88cd..6c16da722e 100644 --- a/crates/nu-cli/src/completions/operator_completions.rs +++ b/crates/nu-cli/src/completions/operator_completions.rs @@ -235,7 +235,7 @@ impl Completer for OperatorCompletion<'_> { Expr::FullCellPath(path) => { // for `$ ` if let Expr::Garbage = path.head.expr { - return Fetched::Pure(vec![]); + return Fetched::answering(vec![]); } let value = eval_cell_path(working_set, stack, &path.head, &path.tail, path.head.span) @@ -279,6 +279,6 @@ impl Completer for OperatorCompletion<'_> { kind: Some(SuggestionKind::Operator), }); } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/static_completions.rs b/crates/nu-cli/src/completions/static_completions.rs index 17df758dce..4c8ca596a0 100644 --- a/crates/nu-cli/src/completions/static_completions.rs +++ b/crates/nu-cli/src/completions/static_completions.rs @@ -45,6 +45,6 @@ impl Completer for StaticCompletion { } } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/completions/variable_completions.rs b/crates/nu-cli/src/completions/variable_completions.rs index eef8222bd4..fdd7028d47 100644 --- a/crates/nu-cli/src/completions/variable_completions.rs +++ b/crates/nu-cli/src/completions/variable_completions.rs @@ -67,6 +67,6 @@ impl Completer for VariableCompletion { }); } - Fetched::Pure(matcher.suggestion_results()) + Fetched::answering(matcher.suggestion_results()) } } diff --git a/crates/nu-cli/src/lib.rs b/crates/nu-cli/src/lib.rs index 126750cce1..7551ce438b 100644 --- a/crates/nu-cli/src/lib.rs +++ b/crates/nu-cli/src/lib.rs @@ -20,6 +20,7 @@ mod validation; pub use commands::add_cli_context; pub use completions::{ CompletionEngine, FileCompletion, NuCompleter, SemanticSuggestion, SuggestionKind, + completion_panic_is_active, }; pub use config_files::{eval_config_contents, eval_config_contents_with_kind}; pub use eval_cmds::{EvaluateCommandsOpts, evaluate_commands}; diff --git a/crates/nu-cli/src/menus/menu_completions.rs b/crates/nu-cli/src/menus/menu_completions.rs index e707c76dc2..89bccd074e 100644 --- a/crates/nu-cli/src/menus/menu_completions.rs +++ b/crates/nu-cli/src/menus/menu_completions.rs @@ -1,3 +1,10 @@ +use crate::{ + completions::{ + CompletionEngine, DeclaredInputs, LegacyInputs, Returned, SpanClamp, bind_declared_inputs, + catch_completion_panic, map_value_completions, panic_to_shell_error, report, + }, + menus::MenuLine, +}; use nu_engine::eval_block; use nu_protocol::{ BlockId, IntoPipelineData, Span, Value, @@ -17,6 +24,8 @@ pub struct NuMenuCompleter { stack: Stack, engine_state: Arc, input_mode: InputMode, + /// The line the menu is on, which reedline hands the menu rather than its source. + line: MenuLine, } impl NuMenuCompleter { @@ -26,6 +35,7 @@ impl NuMenuCompleter { stack: Stack, engine_state: Arc, input_mode: InputMode, + line: MenuLine, ) -> Self { Self { block_id, @@ -33,6 +43,7 @@ impl NuMenuCompleter { stack: stack.reset_out_dest().collect_value(), engine_state, input_mode, + line, } } } @@ -40,54 +51,65 @@ impl NuMenuCompleter { impl Completer for NuMenuCompleter { fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { let parsed = parse_selection_char(line, SELECTION_CHAR); + let handed = parsed.remainder; - let block = self.engine_state.get_block(self.block_id); + let block = self.engine_state.get_block(self.block_id).clone(); + // Reedline replacement range — never fed into `$token`/`$place`. + let replacing = default_span(handed, pos, self.input_mode); - if let Some(buffer) = block.signature.get_positional(0) - && let Some(buffer_id) = &buffer.var_id - { - let line_buffer = Value::string(parsed.remainder, self.span); - self.stack.add_var(*buffer_id, line_buffer); - } + // Menu buffer from recorded line or padded handed text. + let buffer = self + .line + .read() + .unwrap_or_else(|| " ".repeat(replacing.start) + handed); + let cursor = buffer.floor_char_boundary(pos.min(buffer.len())); - if let Some(position) = block.signature.get_positional(1) - && let Some(position_id) = &position.var_id - { - let line_buffer = Value::int(pos as i64, self.span); - self.stack.add_var(*position_id, line_buffer); + // Menu sources opt into inputs; parsed per keystroke only if declared. + let declares_positional = !block.signature.required_positional.is_empty() + || !block.signature.optional_positional.is_empty(); + if declares_positional { + let wanted = DeclaredInputs::from_signature(&block.signature); + let record = CompletionEngine::new(&self.engine_state, &self.stack) + .completer_input_at(&buffer, cursor, wanted); + bind_declared_inputs( + &mut self.stack, + &block.signature, + record, + LegacyInputs::menu(&block, handed, pos, self.span), + ); } let input = Value::nothing(self.span).into_pipeline_data(); - // Do not wrap this in `ReplTerminalGuard`. That disable/enable bounce - // runs on every menu refresh. Nested `input list` restores via - // `RawModeGuard`; fzf restores termios itself. - let res = eval_block::(&self.engine_state, &mut self.stack, block, input) - .map(|p| p.body); + let res = catch_completion_panic(|| { + eval_block::(&self.engine_state, &mut self.stack, &block, input) + .map(|p| p.body) + }) + .map_err(|payload| panic_to_shell_error(&payload, self.span)) + .and_then(|result| result); - let suggestions = if let Ok(values) = res.and_then(|data| data.into_value(self.span)) { - convert_to_suggestions(values, line, pos, self.input_mode) - } else { - Vec::new() + let suggestions = match res.and_then(|data| data.into_value(self.span)) { + Ok(value) => convert_to_suggestions(value, replacing, &buffer[..cursor]), + Err(err) => { + report(format!("failed to eval menu source: {err}")); + Vec::new() + } }; - // Menu sources are evaluated synchronously, so results are always final. + // Menu sources run synchronously, so results are always final. CompletionResult::fresh(suggestions) } } -/// Replacement span used when the menu source doesn't provide one, matching -/// what reedline feeds the completer in each input mode. +/// Replacement span when the source names none: what reedline feeds the completer. fn default_span(line: &str, pos: usize, input_mode: InputMode) -> reedline::Span { match input_mode { - // `line` is only the text typed since the menu opened; replace it in place + // `line` is only text typed since the menu opened; replace it in place. InputMode::Diff => reedline::Span { - start: pos - line.len(), + start: pos.saturating_sub(line.len()), end: pos, }, - // CursorPrefix (buffer up to cursor), FullBuffer (entire buffer), and - // any future mode (`InputMode` is non_exhaustive): the suggestion - // replaces everything the completer received + // Other (non_exhaustive) modes replace all handed text. _ => reedline::Span { start: 0, end: line.len(), @@ -95,72 +117,332 @@ fn default_span(line: &str, pos: usize, input_mode: InputMode) -> reedline::Span } } -fn convert_to_suggestions( - value: Value, - line: &str, - pos: usize, - input_mode: InputMode, -) -> Vec { - match value { - Value::Record { val, .. } => { - let text = val - .get("value") - .and_then(|val| val.coerce_string().ok()) - .unwrap_or_else(|| "No value key".to_string()); +/// Read a menu source's output the way a completer's is read: same accepted shapes and span +/// handling, clamped against `seen`. The `options` of the record form are dropped — reedline +/// filters and sorts its own list. +fn convert_to_suggestions(value: Value, default: reedline::Span, seen: &str) -> Vec { + // `null` declines. A menu has no next source to fall through to, so it shows nothing. + let Some(returned) = Returned::read(value) else { + return Vec::new(); + }; - let description = val - .get("description") - .and_then(|val| val.coerce_string().ok()); + map_value_completions( + returned.completions.into_iter(), + default, + SpanClamp::within(seen), + ) + .into_iter() + .map(|semantic| semantic.suggestion) + .collect() +} - let span = match val.get("span") { - Some(Value::Record { val: span, .. }) => { - let start = span.get("start").and_then(|val| val.as_int().ok()); - let end = span.get("end").and_then(|val| val.as_int().ok()); - match (start, end) { - (Some(start), Some(end)) => { - let start = start.min(end); - reedline::Span { - start: start as usize, - end: end as usize, - } - } - _ => default_span(line, pos, input_mode), - } - } - _ => default_span(line, pos, input_mode), +#[cfg(test)] +mod tests { + use super::*; + use nu_parser::parse; + use nu_protocol::engine::StateWorkingSet; + use rstest::rstest; + + /// A menu whose source is `source`, over an engine that knows `nu-command`'s builtins so + /// a line like `str tri` resolves the way it would at the prompt. + fn menu(source: &str) -> NuMenuCompleter { + let mut engine_state = + nu_command::add_shell_command_context(nu_cmd_lang::create_default_context()); + + let block_id = { + let mut working_set = StateWorkingSet::new(&engine_state); + let block = parse(&mut working_set, None, source.as_bytes(), false); + let closure = block + .pipelines + .first() + .and_then(|pipeline| pipeline.elements.first()) + .expect("the source parses to one closure"); + + let nu_protocol::ast::Expr::Closure(block_id) = closure.expr.expr else { + panic!("the source is a closure"); }; - let extra = match val.get("extra") { - Some(Value::List { vals, .. }) => { - let extra: Vec = vals - .iter() - .filter_map(|extra| match extra { - Value::String { val, .. } => Some(val.clone()), - _ => None, - }) - .collect(); + let delta = working_set.render(); + engine_state + .merge_delta(delta) + .expect("the parsed closure merges"); + block_id + }; - Some(extra) - } - _ => None, - }; + NuMenuCompleter::new( + block_id, + Span::unknown(), + Stack::new(), + Arc::new(engine_state), + InputMode::CursorPrefix, + MenuLine::default(), + ) + } - vec![Suggestion { - value: text, - description, - extra, - span, - ..Suggestion::default() - }] + /// The same, on a menu that recorded the line the editor is on -- what a real menu does + /// on its way to the source. Without it a source sees only the text it was handed. + fn menu_on(source: &str, line: &str) -> NuMenuCompleter { + let completer = menu(source); + completer.line.record(line); + completer + } + + fn values(result: CompletionResult) -> Vec { + result + .suggestions() + .iter() + .map(|suggestion| suggestion.value.clone()) + .collect() + } + + /// A source reads the same inputs a completer / `commandline complete --input` does: + /// `token` is the completion site at the cursor (not the whole buffer), even when the + /// menu's reedline replacement range is the whole handed line. + #[test] + fn menu_source_receives_the_unified_input() { + let source = r#"{|token, place| [ + $"token=($token.text)" + $"kind=($place.kind)" + $"target=($place.target.start)..($place.target.end)" + ]}"#; + + assert_eq!( + values(menu(source).complete("str tri", 7)), + ["token=tri", "kind=positional", "target=4..7"], + ); + } + + /// The line a `Diff` menu is on reaches its source, though reedline hands the source + /// only what was typed since the menu opened: opening one on `ls ` completes `ls`'s + /// first argument, not a command on a blank line. + #[test] + fn menu_source_sees_the_line_the_menu_is_on() { + let source = r#"{|token, place, buffer| [ + $"buffer=($buffer)" + $"kind=($place.kind)" + $"token=($token.kind)" + $"target=($place.target.start)..($place.target.end)" + ]}"#; + + let mut completer = menu_on(source, "ls "); + completer.input_mode = InputMode::Diff; + + assert_eq!( + values(completer.complete("", 3)), + [ + "buffer=ls ", + "kind=positional", + "token=value", + "target=3..3" + ], + ); + } + + /// Declaring a `buffer` parameter opts a source into the whole line up to the cursor, + /// exactly as a completer does. + #[test] + fn menu_source_can_declare_buffer() { + let source = "{|buffer| [$buffer]}"; + + assert_eq!( + values(menu(source).complete("each { str tri", 14)), + ["each { str tri"], + ); + } + + /// A source returns what a completer returns, so one can serve as either. The `options` + /// of the record form name engine behaviour a menu has no say in, and are dropped. + #[rstest] + #[case::list("{|input| [alpha]}")] + #[case::envelope("{|input| {completions: [alpha], options: {filter: false}}}")] + #[case::lone_suggestion("{|input| {value: alpha}}")] + #[case::bare_value("{|input| 'alpha'}")] + fn menu_source_accepts_every_completer_shape(#[case] source: &str) { + assert_eq!(values(menu(source).complete("a", 1)), ["alpha"]); + } + + /// `null` declines. A menu has no next source to fall through to, so it shows nothing. + #[test] + fn menu_source_returning_null_offers_nothing() { + assert!(values(menu("{|input| null}").complete("a", 1)).is_empty()); + } + + #[test] + fn panicking_menu_source_returns_no_values() { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + values(menu("{|input| panic 'boom'}").complete("a", 1)) + })); + + assert_eq!( + result.expect("menu source panic must be contained"), + Vec::::new() + ); + } + + /// In `Diff` mode reedline hands the source only the text typed since the menu opened, + /// while still replacing spans in the whole line. The record's offsets must be in that + /// same coordinate system, or a source reading `place.target` to build its own `span` + /// would replace the wrong bytes. + #[test] + fn menu_input_offsets_match_the_span_reedline_replaces() { + let mut completer = menu(r#"{|place| [$"($place.target.start)"]}"#); + completer.input_mode = InputMode::Diff; + + // `tri` typed since the menu opened, at column 4 of `str tri`. + assert_eq!(values(completer.complete("tri", 7)), ["4"]); + } + + /// In `FullBuffer` mode the record still describes the site at the cursor, not the + /// buffer tail, even though the menu replaces the whole buffer. + #[test] + fn full_buffer_input_describes_the_cursor_not_the_tail() { + let mut completer = menu_on( + r#"{|place| [$"($place.kind) ($place.index)"]}"#, + "str tri foo", + ); + completer.input_mode = InputMode::FullBuffer; + + // Cursor after `tri` in `str tri foo`: `str`'s first argument, not the trailing `foo`. + assert_eq!( + values(completer.complete("str tri foo", 7)), + ["positional 0"] + ); + } + + /// Suggestion spans are clamped against the seen text and floored to a char boundary (#5127). + #[test] + fn menu_suggestion_span_is_clamped_to_a_char_boundary() { + // `é` occupies bytes 4..6 of `str é`, so 5 is mid-character and 99 is past the end. + let mut completer = menu("{|place| [{value: v, span: {start: 5, end: 99}}]}"); + let result = completer.complete("str é", "str é".len()); + let suggestions = result.suggestions(); + + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].span, reedline::Span::new(4, 6)); + } + + /// Legacy compat: a source declaring the old `{|buffer, position|}` positionals + /// receives the line text and the cursor, exactly as before. + #[test] + fn menu_source_receives_legacy_buffer_and_position() { + let source = "{|buffer, position| [$buffer, $\"pos=($position)\"]}"; + assert_eq!( + values(menu(source).complete("str tri", 7)), + ["str tri", "pos=7"], + ); + } + /// Discord: `ls -al` at the final `l` must hand the flag token, not the whole buffer. + #[test] + fn menu_token_for_a_flag_matches_commandline_complete() { + let source = r#"{|token| [ + $"text=($token.text)" + $"start=($token.span.start)" + $"kind=($token.kind)" + ]}"#; + assert_eq!( + values(menu_on(source, "ls -al").complete("ls -al", 6)), + ["text=-al", "start=3", "kind=flag"], + ); + } + + /// Discord: inside `each {|r|` the token is the block, not the whole pipeline. + #[test] + fn menu_token_inside_a_block_is_not_the_whole_buffer() { + let line = "ls | each {|r|"; + let source = r#"{|token, buffer| [ + $"text=($token.text)" + $"start=($token.span.start)" + $"buffer=($buffer)" + ]}"#; + assert_eq!( + values(menu_on(source, line).complete(line, line.len())), + vec![ + "text={|r|".into(), + "start=10".into(), + format!("buffer={line}"), + ], + ); + } + + /// Discord: at the end of `… | str uppercase` the site token is the multi-word + /// command head, not the whole buffer / pipeline. + #[test] + fn menu_token_for_str_uppercase_pipeline_is_the_site() { + let line = "ls | each {|r| $r.name | str uppercase"; + let source = r#"{|token, buffer| [ + $"text=($token.text)" + $"start=($token.span.start)" + $"kind=($token.kind)" + $"buffer=($buffer)" + ]}"#; + assert_eq!( + values(menu_on(source, line).complete(line, line.len())), + vec![ + "text=str uppercase".into(), + "start=25".into(), + "kind=head".into(), + format!("buffer={line}"), + ], + ); + } + + /// Menu `$token` / `$place` match `commandline complete --input` / `completer_input_at` + /// for the Discord + HackMD adversarial lines (parity across the two surfaces). + #[test] + fn menu_token_matches_completer_input_at_for_adversarial_lines() { + let source = r#"{|token, place| [ + $"text=($token.text)" + $"start=($token.span.start)" + $"kind=($place.kind)" + $"index=($place.index? | default 'none')" + ]}"#; + + let cases = [ + ( + "ls -al", + ["text=-al", "start=3", "kind=flag-name", "index=none"], + ), + ( + "ls | where size >", + ["text=>", "start=16", "kind=operator", "index=none"], + ), + ( + "ls | where size > 1", + ["text=1", "start=18", "kind=positional", "index=0"], + ), + ( + "ls | where size > ", + ["text=", "start=18", "kind=positional", "index=0"], + ), + ( + "ls | each {|r|", + ["text={|r|", "start=10", "kind=positional", "index=0"], + ), + ( + "ls | each {|r| $r.name | str uppercase", + [ + "text=str uppercase", + "start=25", + "kind=command", + "index=none", + ], + ), + ]; + + for (line, expected) in cases { + assert_eq!( + values(menu_on(source, line).complete(line, line.len())), + expected, + "menu token/place for {line:?}" + ); } - Value::List { vals, .. } => vals - .into_iter() - .flat_map(|val| convert_to_suggestions(val, line, pos, input_mode)) - .collect(), - _ => vec![Suggestion { - value: format!("Not a record: {value:?}"), - span: default_span(line, pos, input_mode), - ..Suggestion::default() - }], + } + + /// `only_buffer_difference: false` (CursorPrefix) still resolves the site token. + #[test] + fn cursor_prefix_menu_token_is_the_site_not_the_line() { + let mut completer = menu_on("{|token| [$token.text]}", "ls -al"); + completer.input_mode = InputMode::CursorPrefix; + assert_eq!(values(completer.complete("ls -al", 6)), ["-al"]); } } diff --git a/crates/nu-cli/src/menus/mod.rs b/crates/nu-cli/src/menus/mod.rs index 35dccf2e9f..cca7f9f8c9 100644 --- a/crates/nu-cli/src/menus/mod.rs +++ b/crates/nu-cli/src/menus/mod.rs @@ -1,5 +1,7 @@ mod help_completions; mod menu_completions; +mod sourced_menu; pub use help_completions::NuHelpCompleter; pub use menu_completions::NuMenuCompleter; +pub use sourced_menu::{MenuLine, SourcedMenu}; diff --git a/crates/nu-cli/src/menus/sourced_menu.rs b/crates/nu-cli/src/menus/sourced_menu.rs new file mode 100644 index 0000000000..9160e2a08d --- /dev/null +++ b/crates/nu-cli/src/menus/sourced_menu.rs @@ -0,0 +1,287 @@ +use reedline::{Completer, Editor, Menu, MenuEvent, Painter, Suggestion}; +use std::sync::{Arc, Mutex}; + +/// Shared command line for menu source. +#[derive(Clone, Default)] +pub struct MenuLine(Arc>>); + +impl MenuLine { + /// Record current editor line. + pub(crate) fn record(&self, line: &str) { + if let Ok(mut recorded) = self.0.lock() { + *recorded = Some(line.to_string()); + } + } + + /// Last recorded line. + pub(crate) fn read(&self) -> Option { + self.0.lock().ok()?.clone() + } +} + +/// Menu wrapper carrying the editor line to its source, and abandoning the menu on a +/// fresh empty answer so a cancelled picker (fzf Esc / `input list` Esc) cannot leave +/// an empty menu that relaunches on type. +pub struct SourcedMenu { + menu: M, + line: Option, +} + +impl SourcedMenu { + pub fn new(menu: M, line: MenuLine) -> Self { + Self { + menu, + line: Some(line), + } + } + + /// Engine-completer menus have no source line to carry, only abandon-on-empty. + pub fn abandoning(menu: M) -> Self { + Self { menu, line: None } + } + + fn record(&self, editor: &Editor) { + if let Some(line) = &self.line { + line.record(editor.get_buffer()); + } + } + + /// Close on a fresh empty answer; provisional/awaiting emptiness is still computing. + fn abandon_if_empty(&mut self) + where + M: Menu, + { + if self.menu.is_active() + && self.menu.get_values().is_empty() + && !self.menu.results_are_provisional() + && !self.menu.is_awaiting_first_answer() + { + self.menu.menu_event(MenuEvent::Deactivate); + } + } +} + +impl Menu for SourcedMenu { + fn name(&self) -> &str { + self.menu.name() + } + + fn indicator(&self) -> &str { + self.menu.indicator() + } + + fn is_active(&self) -> bool { + self.menu.is_active() + } + + fn set_active(&mut self, active: bool) { + self.menu.set_active(active); + } + + fn clear_input(&mut self) { + self.menu.clear_input(); + } + + fn on_activate(&mut self) { + self.menu.on_activate(); + } + + fn on_deactivate(&mut self) { + self.menu.on_deactivate(); + } + + fn menu_event(&mut self, event: MenuEvent) { + self.menu.menu_event(event); + } + + fn can_quick_complete(&self) -> bool { + self.menu.can_quick_complete() + } + + fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + ) -> bool { + self.record(editor); + // No abandon check here!!! reedline probes partial completion when a menu + // opens, even before the source has ever run, so the values are legitimately + // empty. + self.menu + .can_partially_complete(values_updated, editor, completer) + } + + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { + self.record(editor); + self.menu.update_values(editor, completer); + self.abandon_if_empty(); + } + + fn reset_position(&mut self) { + self.menu.reset_position(); + } + + fn reload(&mut self, updated: bool, editor: &mut Editor, completer: &mut dyn Completer) { + self.record(editor); + self.menu.reload(updated, editor, completer); + self.abandon_if_empty(); + } + + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ) { + self.record(editor); + self.menu.update_working_details(editor, completer, painter); + self.abandon_if_empty(); + } + + fn replace_in_buffer(&self, editor: &mut Editor) { + self.menu.replace_in_buffer(editor); + } + + fn menu_required_lines(&self, terminal_columns: u16) -> u16 { + self.menu.menu_required_lines(terminal_columns) + } + + fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { + self.menu.menu_string(available_lines, use_ansi_coloring) + } + + fn min_rows(&self) -> u16 { + self.menu.min_rows() + } + + fn get_values(&self) -> &[Suggestion] { + self.menu.get_values() + } + + fn results_are_provisional(&self) -> bool { + self.menu.results_are_provisional() + } + + fn is_awaiting_first_answer(&self) -> bool { + self.menu.is_awaiting_first_answer() + } + + fn set_cursor_pos(&mut self, pos: (u16, u16)) { + self.menu.set_cursor_pos(pos); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reedline::{ + ColumnarMenu, CompletionResult, InputMode, MenuBuilder, MenuEvent, UndoBehavior, + }; + + struct Recorder(Arc>>); + + impl Completer for Recorder { + fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { + if let Ok(mut seen) = self.0.lock() { + seen.push((line.to_string(), pos)); + } + CompletionResult::fresh(vec![Suggestion { + value: "alpha".into(), + ..Suggestion::default() + }]) + } + } + + struct Empty; + impl Completer for Empty { + fn complete(&mut self, _line: &str, _pos: usize) -> CompletionResult { + CompletionResult::fresh(Vec::::new()) + } + } + + fn active_menu() -> (Editor, SourcedMenu) { + let editor = Editor::default(); + let mut menu = SourcedMenu::abandoning(ColumnarMenu::default()); + menu.menu_event(MenuEvent::Activate(false)); + assert!(menu.is_active()); + (editor, menu) + } + + #[test] + fn a_diff_menu_leaves_the_line_it_is_on() { + let mut editor = Editor::default(); + editor.edit_buffer( + |buffer| { + buffer.set_buffer("ls ".into()); + buffer.set_insertion_point(3); + }, + UndoBehavior::CreateUndoPoint, + ); + + let line = MenuLine::default(); + let mut menu = SourcedMenu::new( + ColumnarMenu::default().with_input_mode(InputMode::Diff), + line.clone(), + ); + + let seen = Arc::new(Mutex::new(Vec::new())); + menu.update_values(&mut editor, &mut Recorder(Arc::clone(&seen))); + + assert_eq!(line.read().as_deref(), Some("ls ")); + assert_eq!( + seen.lock().expect("what the menu handed over").as_slice(), + [(String::new(), 3)] + ); + } + + #[test] + fn empty_fresh_abandons_but_pending_does_not() { + let (mut editor, mut menu) = active_menu(); + menu.update_values(&mut editor, &mut Empty); + assert!(!menu.is_active(), "empty fresh must abandon"); + + // Edit must not relaunch an abandoned menu, even with later values. + menu.menu_event(MenuEvent::Edit(false)); + menu.update_values(&mut editor, &mut Empty); + assert!(!menu.is_active()); + menu.menu_event(MenuEvent::Edit(false)); + menu.update_values(&mut editor, &mut Recorder(Arc::new(Mutex::new(Vec::new())))); + assert!(!menu.is_active(), "only Activate may reopen"); + + // Provisional emptiness is still computing, not cancel. + struct Pending; + impl Completer for Pending { + fn complete(&mut self, _line: &str, _pos: usize) -> CompletionResult { + CompletionResult::Pending + } + } + let (mut editor, mut menu) = active_menu(); + menu.update_values(&mut editor, &mut Pending); + assert!(menu.is_active()); + assert!(menu.get_values().is_empty()); + + // Non-empty keeps it open. + let (mut editor, mut menu) = active_menu(); + menu.update_values(&mut editor, &mut Recorder(Arc::new(Mutex::new(Vec::new())))); + assert!(menu.is_active()); + assert_eq!(menu.get_values().len(), 1); + } + + #[test] + fn opening_probe_does_not_abandon_before_the_first_fetch() { + // Reedline probes partial completion right after Activate, while the + // source has not run yet and the values are still empty. That probe + // must not close the menu; the first real fetch decides. + let (mut editor, mut menu) = active_menu(); + menu.can_partially_complete(false, &mut editor, &mut Empty); + assert!( + menu.is_active(), + "a just-opened menu with no values yet must survive the opening probe" + ); + + // The subsequent fetch is what may abandon it. + menu.update_values(&mut editor, &mut Empty); + assert!(!menu.is_active()); + } +} diff --git a/crates/nu-cli/src/reedline_config.rs b/crates/nu-cli/src/reedline_config.rs index a687db89b3..3df765b9a9 100644 --- a/crates/nu-cli/src/reedline_config.rs +++ b/crates/nu-cli/src/reedline_config.rs @@ -1,18 +1,21 @@ -use crate::{NuHelpCompleter, menus::NuMenuCompleter}; +use crate::{ + NuHelpCompleter, + menus::{MenuLine, NuMenuCompleter, SourcedMenu}, +}; use crossterm::event::{KeyCode, KeyModifiers}; use nu_ansi_term::Style; use nu_color_config::{color_record_to_nustyle, lookup_ansi_color_style}; use nu_protocol::{ Config, EditBindings, ParsedKeybinding, ParsedMenu, Record, ShellError, Span, Type, Value, - engine::{EngineState, Stack}, + engine::{Closure, EngineState, Stack}, extract_value, }; use reedline::{ ColumnarMenu, DescriptionMenu, DescriptionMode, DescriptionPosition, Direction, EditCommand, EditCommandDiscriminants, FindStop, Granularity, IdeMenu, InputMode, Keybindings, ListMenu, - MenuBuilder, MotionTarget, OutputMode, PromptEditModeDiscriminants, Reedline, ReedlineEvent, - ReedlineEventDiscriminants, ReedlineMenu, TextObject, TextObjectScope, TextObjectType, - TraversalDirection, WordEdge, WordKind, default_emacs_keybindings, + Menu, MenuBuilder, MotionTarget, OutputMode, PromptEditModeDiscriminants, Reedline, + ReedlineEvent, ReedlineEventDiscriminants, ReedlineMenu, TextObject, TextObjectScope, + TextObjectType, TraversalDirection, WordEdge, WordKind, default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, }; use reedline::{ @@ -160,6 +163,31 @@ fn parse_description_position( } } +/// Menu with nushell source carrying editor line. +fn menu_with_source( + menu: M, + source: &Closure, + span: Span, + stack: &Stack, + engine_state: Arc, + input_mode: InputMode, +) -> ReedlineMenu { + let line = MenuLine::default(); + let completer = NuMenuCompleter::new( + source.block_id, + span, + stack.captures_to_stack(source.captures.clone()), + engine_state, + input_mode, + line.clone(), + ); + + ReedlineMenu::WithCompleter { + menu: Box::new(SourcedMenu::new(menu, line)), + completer: Box::new(completer), + } +} + /// Resolve the menu's effective reedline `InputMode` from the optional /// `input_mode` and legacy `only_buffer_difference` fields. The result drives /// both the reedline menu and `NuMenuCompleter`'s span math, so it must be @@ -259,19 +287,16 @@ pub(crate) fn add_columnar_menu( columnar_menu = apply_output_mode(columnar_menu, menu, config)?; let completer = if let Some(closure) = &menu.source { - let menu_completer = NuMenuCompleter::new( - closure.block_id, + menu_with_source( + columnar_menu, + closure, span, - stack.captures_to_stack(closure.captures.clone()), + stack, engine_state, input_mode, - ); - ReedlineMenu::WithCompleter { - menu: Box::new(columnar_menu), - completer: Box::new(menu_completer), - } + ) } else { - ReedlineMenu::EngineCompleter(Box::new(columnar_menu)) + ReedlineMenu::EngineCompleter(Box::new(SourcedMenu::abandoning(columnar_menu))) }; Ok(line_editor.with_menu(completer)) @@ -316,17 +341,7 @@ pub(crate) fn add_list_menu( list_menu = apply_output_mode(list_menu, menu, &config)?; let completer = if let Some(closure) = &menu.source { - let menu_completer = NuMenuCompleter::new( - closure.block_id, - span, - stack.captures_to_stack(closure.captures.clone()), - engine_state, - input_mode, - ); - ReedlineMenu::WithCompleter { - menu: Box::new(list_menu), - completer: Box::new(menu_completer), - } + menu_with_source(list_menu, closure, span, stack, engine_state, input_mode) } else { ReedlineMenu::HistoryMenu(Box::new(list_menu)) }; @@ -491,19 +506,9 @@ pub(crate) fn add_ide_menu( ide_menu = apply_output_mode(ide_menu, menu, &config)?; let completer = if let Some(closure) = &menu.source { - let menu_completer = NuMenuCompleter::new( - closure.block_id, - span, - stack.captures_to_stack(closure.captures.clone()), - engine_state, - input_mode, - ); - ReedlineMenu::WithCompleter { - menu: Box::new(ide_menu), - completer: Box::new(menu_completer), - } + menu_with_source(ide_menu, closure, span, stack, engine_state, input_mode) } else { - ReedlineMenu::EngineCompleter(Box::new(ide_menu)) + ReedlineMenu::EngineCompleter(Box::new(SourcedMenu::abandoning(ide_menu))) }; Ok(line_editor.with_menu(completer)) @@ -573,17 +578,14 @@ pub(crate) fn add_description_menu( description_menu = apply_output_mode(description_menu, menu, &config)?; let completer = if let Some(closure) = &menu.source { - let menu_completer = NuMenuCompleter::new( - closure.block_id, + menu_with_source( + description_menu, + closure, span, - stack.captures_to_stack(closure.captures.clone()), + stack, engine_state, input_mode, - ); - ReedlineMenu::WithCompleter { - menu: Box::new(description_menu), - completer: Box::new(menu_completer), - } + ) } else { let menu_completer = NuHelpCompleter::new(engine_state, config); ReedlineMenu::WithCompleter { diff --git a/crates/nu-cli/src/repl.rs b/crates/nu-cli/src/repl.rs index 7d788fad5e..4a399b56c9 100644 --- a/crates/nu-cli/src/repl.rs +++ b/crates/nu-cli/src/repl.rs @@ -7,7 +7,7 @@ use crate::prompt_update::{ }; use crate::{ NuHighlighter, NuValidator, NushellPrompt, - completions::{NarrowingCache, NuCompleter}, + completions::{NarrowingCache, NuCompleter, flush_completion_warnings}, hints::ExternalHinter, prompt_update, reedline_config::{KeybindingsMode, add_menus, create_keybindings}, @@ -113,18 +113,9 @@ pub fn evaluate_repl( // can't modify the stack, but at the end of the loop we take back ownership // from the Arc. This lets us avoid copying stack variables needlessly let mut unique_stack = stack.clone(); - let config = engine_state.get_config(); - let use_color = config.use_ansi_coloring.get(engine_state); - let mut entry_num = 0; let mut is_hostcommand = false; - // Let's grab the shell_integration configs - let shell_integration_osc2 = config.shell_integration.osc2; - let shell_integration_osc7 = config.shell_integration.osc7; - let shell_integration_osc9_9 = config.shell_integration.osc9_9; - let shell_integration_osc633 = config.shell_integration.osc633; - // Seed env vars — no source span exists at REPL startup unique_stack.add_env_var( "CMD_DURATION_MS".into(), @@ -133,7 +124,6 @@ pub fn evaluate_repl( unique_stack.set_last_exit_code(0, Span::unknown()); - let mut line_editor = get_line_editor(engine_state, use_color)?; let temp_file = temp_dir().join(format!("{}.nu", uuid::Uuid::new_v4())); if let Some(s) = prerun_command { @@ -148,6 +138,21 @@ pub fn evaluate_repl( engine_state.merge_env(&mut unique_stack)?; } + let config = engine_state.get_config(); + let use_color = config.use_ansi_coloring.get(engine_state); + + // Read the shell integration toggles after the optional prerun command too, so a + // config sourced by `--execute` governs this session's OSC emissions. + let shell_integration_osc2 = config.shell_integration.osc2; + let shell_integration_osc7 = config.shell_integration.osc7; + let shell_integration_osc9_9 = config.shell_integration.osc9_9; + let shell_integration_osc633 = config.shell_integration.osc633; + + // Build reedline after the optional prerun command so ANSI coloring and the other + // editor settings a `--execute`-sourced config changes are picked up for the + // session. Menus and keybindings additionally refresh every prompt iteration. + let mut line_editor = get_line_editor(engine_state, use_color)?; + confirm_stdin_is_terminal()?; let hostname = System::host_name(); @@ -634,7 +639,7 @@ fn loop_iteration(ctx: LoopContext) -> (bool, Stack, Reedline) { .with_validator(Box::new(NuValidator { engine_state: engine_reference.clone(), })) - .with_completer(Box::new(NuCompleter::for_repl( + .with_completer(Box::new(NuCompleter::with_cache( engine_reference.clone(), // STACK-REFERENCE 2 stack_arc.clone(), @@ -797,6 +802,9 @@ fn loop_iteration(ctx: LoopContext) -> (bool, Stack, Reedline) { perf!("line_editor setup", start_time, use_color); + // Flush queued deprecation warnings. + flush_completion_warnings(engine_state, &stack); + let line_editor_input_time = Instant::now(); match input { Ok(Signal::Success(command)) => { diff --git a/crates/nu-cli/src/util.rs b/crates/nu-cli/src/util.rs index 495e2b36a4..13f800729f 100644 --- a/crates/nu-cli/src/util.rs +++ b/crates/nu-cli/src/util.rs @@ -22,35 +22,6 @@ use nu_utils::time::Instant; use nu_utils::{escape_quote_string, perf}; use std::path::Path; -/// Captures whether the process was already in raw mode, then restores that -/// on drop after a Tab completer closure that may have taken the TTY (`fzf`). -/// -/// Tests and other cooked-mode callers stay cooked: we only re-enable raw -/// mode when it was on before the closure ran. Menu sources must not use this; -/// bouncing raw mode on every refresh flickers the prompt. -#[must_use = "captures raw-mode state that is restored on drop"] -pub(crate) struct ReplTerminalGuard { - was_raw: bool, -} - -impl ReplTerminalGuard { - pub(crate) fn capture() -> Self { - let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); - Self { was_raw } - } -} - -impl Drop for ReplTerminalGuard { - fn drop(&mut self) { - if self.was_raw { - // Crossterm's Unix `enable_raw_mode` is a no-op when it already - // recorded raw mode, so it will not `tcsetattr` after fzf. - let _ = crossterm::terminal::disable_raw_mode(); - let _ = crossterm::terminal::enable_raw_mode(); - } - } -} - // This will collect environment variables from std::env and adds them to a stack. // // In order to ensure the values have spans, it first creates a dummy file, writes the collected diff --git a/crates/nu-cli/tests/completions/mod.rs b/crates/nu-cli/tests/completions/mod.rs index 2b52c5bfcc..b2cfa58972 100644 --- a/crates/nu-cli/tests/completions/mod.rs +++ b/crates/nu-cli/tests/completions/mod.rs @@ -170,7 +170,7 @@ fn external_completer_error_falls_back_to_file_completion() { let record = r#" $env.config.completions.external = { enable: true - completer: {|spans| error make {msg: "simulated completer failure"} } + completer: {|input| error make {msg: "simulated completer failure"} } } "#; assert!(support::merge_input(record.as_bytes(), &mut engine, &mut stack).is_ok()); @@ -191,8 +191,8 @@ fn custom_completer() -> NuCompleter { // Add record value as example let record = " - let external_completer = {|spans| - $spans + let external_completer = {|buffer| + [$buffer] } $env.config.completions.external = { @@ -342,11 +342,21 @@ fn customcompletions_no_filter() { } #[rstest] -#[case::happy("{ start: 1, end: 14 }", (7, 20))] -#[case::no_start("{ end: 14 }", (17, 20))] -#[case::no_end("{ start: 1 }", (7, 23))] +/// A returned `span` is byte offsets; ends beyond what was shown clamp. +#[case::happy("{ start: 7, end: 20 }", (7, 20))] +#[case::no_start("{ end: 20 }", (17, 20))] +#[case::no_end("{ start: 7 }", (7, 23))] #[case::bad_start("{ start: 100 }", (23, 23))] #[case::bad_end("{ end: 100 }", (17, 23))] +// A span that cannot be read costs the span, not the suggestion around it: the default — +// the range the engine would have replaced — still stands. +#[case::not_a_record("\"nope\"", (17, 23))] +#[case::not_an_int("{ start: \"3\" }", (17, 23))] +#[case::misspelled_key("{ strat: 3 }", (17, 23))] +// A negative offset names no position on the line. Clamping it to `0` would silently +// replace from the start of the line, so the default stands here too. +#[case::negative_start("{ start: -1 }", (17, 23))] +#[case::negative_end("{ end: -1 }", (17, 23))] fn custom_completions_override_span( #[case] span_string: &str, #[case] expected_span: (usize, usize), @@ -367,6 +377,53 @@ fn custom_completions_override_span( assert_eq!(Span::new(start, end), suggestions[0].span); } +/// A completer's return is read part by part, and a part that does not fit costs that part +/// alone. Each case returns two completions, the first malformed in one place; the engine +/// keeps as much of it as it can and never lets it take the other down with it. +#[rstest] +// A string field takes anything that coerces, as it did before it was declared. +#[case::coerced_description("{value: alpha, description: 5}", vec!["alpha", "beta"])] +#[case::coerced_extra("{value: alpha, extra: [1 2]}", vec!["alpha", "beta"])] +// Unknown keys ignored; carapace display field tolerated. +#[case::unknown_key("{value: alpha, discription: hi}", vec!["alpha", "beta"])] +#[case::carapace_display("{value: alpha, display: 'alpha (a)'}", vec!["alpha", "beta"])] +// `--detailed` emits these beside a suggestion, so its output pipes straight back in. +#[case::echoed_keys("{value: alpha, kind: {Value: string}, type: string}", vec!["alpha", "beta"])] +fn a_malformed_part_costs_only_that_part(#[case] first: &str, #[case] expected: Vec<&str>) { + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + " + def comp [] {{ [{first}, beta] }} + def my-command [arg: string@comp] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let suggestions = completer.complete_blocking("my-command ", 11); + match_suggestions(&expected, &suggestions); +} + +/// An `options` that cannot be read costs the settings it names, not the completions +/// returned beside it. Without `filter: false` the engine narrows against the typed `zzz`, +/// so the completion surviving at all is what the case proves. +#[rstest] +#[case::not_a_record("options: \"nope\"")] +#[case::unknown_key("options: {fiter: false}")] +#[case::bad_value("options: {filter: 0}")] +fn a_malformed_options_keeps_the_completions_beside_it(#[case] options: &str) { + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + " + def comp [] {{ {{completions: [alpha], {options}}} }} + def my-command [arg: string@comp] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let suggestions = completer.complete_blocking("my-command ", 11); + match_suggestions(&vec!["alpha"], &suggestions); +} + #[test] fn custom_completions_override_display_value() { let (_, _, mut engine, mut stack) = new_engine(); @@ -474,7 +531,7 @@ fn custom_completions_wraps_builtin_commandline_complete() { def comp [] { '%ls ' | commandline complete --detailed | prepend { value: 'test', - display: 'test', + display_override: 'test', description: 'dummy', style: { attr: b }, } @@ -501,9 +558,8 @@ fn custom_completions_wraps_builtin_commandline_complete() { fn custom_completions_wraps_builtin_commandline_complete_path() { let (_, _, mut engine, mut stack) = new_quote_engine(); let command = " - def completer [context: string] { - $context - | split row ' ' + def completer [buffer] { + ($buffer | split row ' ') | last | commandline complete --type path --detailed | reject span # original spans are less useful with --path @@ -662,7 +718,7 @@ fn custom_completion_for_list_typed_argument( ) { let (_, _, mut engine, mut stack) = new_engine(); let command = /* lang=nu */ r#" - def comp_foo [input pos] { + def comp_foo [input] { ["[foo]", "[f, bar]", "[f, baz]"] } @@ -913,62 +969,352 @@ fn hide_env_completions() { match_suggestions(&expected, &suggestions); } -/// A custom completer that errors (unlike an explicit `null` decline) must still fall -/// back to file completion. +/// A parameter completer that errors declines with an empty result rather than dumping the +/// whole directory into the argument slot; only external/command-wide completers fall back +/// to file completion. See `UserCompletion::fetch`. #[test] -fn customcompletions_error_falls_back_to_file_completion() { +fn parameter_completer_error_yields_no_suggestions() { let (_, _, mut engine, mut stack) = new_engine(); let command = "def comp [] { error make {msg: 'boom'} }; def my-command [arg: string@comp] {}"; assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); let line = "my-command custom_completion."; - match_suggestions( - &vec!["custom_completion.nu"], - &completer.complete_blocking(line, line.len()), - ); + assert!(completer.complete_blocking(line, line.len()).is_empty()); } -/// A completer that calls an interactive command (`input`, `input list`, `input listen`, -/// `term query`) runs on the REPL thread so it can take the TTY. With no console the -/// command errors and the completer falls back to file completion. Windows test -/// processes have a console, so detach stdin there: the command declines via -/// `require_stdin` instead of blocking on keys, and we still assert file fallback. +/// Without `@interactive`, a completer runs on the stdin-suppressed background worker, so a +/// picker (`input`, `input list`, `input listen`, `term query`) cannot take the TTY: it +/// declines, and the parameter completer yields nothing rather than the directory. The +/// `@interactive` opt-in is what routes such a completer to the line-editor thread instead; +/// see `NuCompleter::complete` and `interactive_completer_at`. #[rstest] #[case::input("input")] #[case::input_reedline("input --reedline")] #[case::input_list("['a' 'b'] | input list")] #[case::input_listen("input listen")] #[case::term_query("term query 'x'")] -fn interactive_command_in_completer_falls_back_to_file_completion(#[case] body: &str) { +fn non_interactive_completer_calling_a_picker_yields_no_suggestions(#[case] body: &str) { let (_, _, mut engine, mut stack) = new_engine(); let command = format!("def comp [] {{ {body} }}; def my-command [arg: string@comp] {{}}"); assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); - #[cfg(windows)] - let stack = stack.suppress_stdin(); - let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); let line = "my-command custom_completion."; - match_suggestions( - &vec!["custom_completion.nu"], - &completer.complete_blocking(line, line.len()), + assert!(completer.complete_blocking(line, line.len()).is_empty()); +} + +/// The attribute selects inline completion; ordinary completers use the worker. +#[rstest] +#[case::carries_the_attribute("@interactive\n", true)] +#[case::plain_completer("", false)] +fn the_interactive_attribute_decides_inline_or_worker( + #[case] attribute: &str, + #[case] runs_inline: bool, +) { + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + "{attribute}\ + def comp [token] {{ [alpha beta] }}\n\ + def my-command [arg: string@comp] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let line = "my-command al"; + let result = completer.complete(line, line.len()); + + assert_eq!( + matches!(result, CompletionResult::Fresh { .. }), + runs_inline, + "@interactive={runs_inline:?} should decide inline-vs-worker, got {result:?}" + ); + + if runs_inline { + match_suggestions(&vec!["alpha"], result.suggestions()); + } +} + +/// Detection resolves the same shorter-head reading dispatch tries, so an `@interactive` +/// completer reached through it still routes inline. +#[test] +fn interactive_completer_on_a_shorter_head_argument_runs_inline() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = "\ + @interactive + def comp [token] { [alpha beta] } + def foo [arg: string@comp] {} + def \"foo bar\" [] {}"; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let line = "foo bar"; + let result = completer.complete(line, line.len()); + assert!( + matches!(result, CompletionResult::Fresh { .. }), + "an @interactive completer on a shorter-head argument must run inline, got {result:?}" ); } -/// Suppress completions for invalid values +/// Detection sees through aliases as execution does: an alias of an `@interactive` +/// completer routes inline. #[test] -fn customcompletions_invalid() { +fn interactive_completer_through_an_alias_runs_inline() { let (_, _, mut engine, mut stack) = new_engine(); - let command = " - def comp [] { 123 } + let command = "\ + @interactive + def comp [token] { [alpha beta] } + alias comp-alias = comp + def my-command [arg: string@comp-alias] {}"; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let line = "my-command al"; + let result = completer.complete(line, line.len()); + assert!( + matches!(result, CompletionResult::Fresh { .. }), + "an aliased @interactive completer must run inline, got {result:?}" + ); +} + +/// Routing also sees the command-wide `@interactive` completer at a flag *name*, where +/// `complete_flag_names` merges it. +#[test] +fn interactive_command_wide_completer_runs_inline_at_a_flag_name() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = "\ + @interactive + def comp [token] { [--alpha --beta] } + @complete comp + def my-command [--alpha, --beta] {}"; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let line = "my-command --al"; + let result = completer.complete(line, line.len()); + assert!( + matches!(result, CompletionResult::Fresh { .. }), + "an @interactive command-wide completer must run inline at a flag name, got {result:?}" + ); +} + +/// An external completer is a closure, so its interactivity comes from the `@interactive` +/// command it dispatches to -- there is no separate switch. A closure calling such a command +/// (here one declaring `buffer`, the picker-driving case) runs inline; a plain closure stays +/// on the background worker. +#[test] +fn external_completer_is_interactive_through_the_command_it_calls() { + let line = "some-external al"; + + let (_, _, mut engine, mut stack) = new_engine(); + let config = "\ + @interactive + def pick [buffer] { [$buffer] } + $env.config.completions.external.completer = {|buffer| pick $buffer }"; + assert!(support::merge_input(config.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let result = completer.complete(line, line.len()); + assert!( + matches!(result, CompletionResult::Fresh { .. }), + "a completer calling an @interactive command must run inline, got {result:?}" + ); + + // A plain closure has no @interactive command to see through, so it stays on the worker. + let (_, _, mut engine, mut stack) = new_engine(); + let config = "$env.config.completions.external.completer = {|buffer| [$buffer] }"; + assert!(support::merge_input(config.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let result = completer.complete(line, line.len()); + assert!( + !matches!(result, CompletionResult::Fresh { .. }), + "a plain external completer must stay on the worker, got {result:?}" + ); +} + +/// Interactive completer caches answer per line. +#[test] +fn an_interactive_completer_runs_once_per_line() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = "\ + @interactive + def comp [token] { [$'($token.text)(random chars --length 8)'] } def my-command [arg: string@comp] {}"; assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); - let completion_str = "my-command foo"; + let answered = |completer: &mut NuCompleter, line: &str| { + let suggestions = completer.complete_blocking(line, line.len()); + assert_eq!(suggestions.len(), 1, "the picker answered {suggestions:?}"); + suggestions[0].value.clone() + }; + + let asked = answered(&mut completer, "my-command "); + assert_eq!( + answered(&mut completer, "my-command "), + asked, + "the picker was put back on screen for a line it had already answered" + ); + assert_ne!( + answered(&mut completer, "my-command a"), + asked, + "a different line is a new question" + ); +} + +/// Cancelled interactive completer offers nothing. +#[rstest] +#[case::block_failed("error make {msg: 'cancelled'}")] +#[case::returned_null("null")] +fn a_cancelled_interactive_completer_offers_nothing(#[case] body: &str) { + // A command-wide completer, so declining would fall through to file completion. + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + "@interactive + def comp [token] {{ {body} }} + @complete comp + def my-command [arg: string] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let line = "my-command "; + let suggestions = completer.complete_blocking(line, line.len()); + assert!( + suggestions.is_empty(), + "a cancelled picker was answered with {suggestions:?}" + ); +} + +/// Cancelled external interactive completer offers nothing. +#[test] +fn a_cancelled_interactive_external_completer_offers_nothing() { + let (_, _, mut engine, mut stack) = new_engine(); + let config = "\ + @interactive + def pick [buffer] { error make {msg: 'cancelled'} } + $env.config.completions.external.completer = {|buffer| pick $buffer }"; + assert!(support::merge_input(config.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let line = "some-external "; + let suggestions = completer.complete_blocking(line, line.len()); + assert!( + suggestions.is_empty(), + "a cancelled picker was answered with {suggestions:?}" + ); +} + +/// A panicking completer must surface a ShellError-shaped empty answer, not unwind the +/// worker/REPL. `panic` inside the completer block is caught. +#[test] +fn a_panicking_completer_does_not_unwind_the_engine() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = r#" + def c-panic [token: record] { panic "boom" } + def f7-panic [x: string@c-panic] { $x } + def c-ok [token: record] { [alive] } + def f7-ok [x: string@c-ok] { $x } + "#; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let line = "f7-panic "; + // Must not unwind; empty answered list (failed interactive/non-interactive → answering []). + let suggestions = completer.complete_blocking(line, line.len()); + assert!( + suggestions.is_empty(), + "panicking completer must not yield suggestions, got {suggestions:?}" + ); + + // Prompt-equivalent: a second completion attempt still works (worker/engine alive). + let again = completer.complete_blocking(line, line.len()); + assert!( + again.is_empty(), + "second panicking completion must stay empty, got {again:?}" + ); + + // A different, healthy completer still answers after the panic was caught. + let ok_line = "f7-ok "; + let ok = completer.complete_blocking(ok_line, ok_line.len()); + match_suggestions(&vec!["alive"], &ok); +} + +/// Interactive cancel caches an empty answer for the exact query so reedline's menu refresh +/// does not relaunch the picker for the same line. +#[test] +fn a_cancelled_interactive_answer_is_sticky_for_the_same_query() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = " + @interactive + def comp [token] { null } + def my-command [arg: string@comp] {} + "; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let line = "my-command "; + assert!(completer.complete_blocking(line, line.len()).is_empty()); + // Same query again hits latest cache — still empty, still no panic/relaunch side effects. + assert!(completer.complete_blocking(line, line.len()).is_empty()); +} + +/// fzf Esc (exit 130, empty stdout) through an `@interactive` completer must answer with +/// empty suggestions — the same cancel path as `input list` Esc / returning null. +#[cfg(unix)] +#[test] +fn fzf_esc_exit_130_interactive_completer_offers_nothing() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir for fzf stub"); + let stub = dir.path().join("fzf"); + fs::write( + &stub, + "#!/bin/sh\n# simulate fzf Esc: no selection, exit 130\nexit 130\n", + ) + .expect("write fzf stub"); + let mut perms = fs::metadata(&stub).expect("stub meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&stub, perms).expect("chmod fzf stub"); + + let (_, _, mut engine, mut stack) = new_engine(); + // new_engine's PATH is a colon string; prepend the stub dir so `^fzf` hits exit 130. + let path_prefix = dir.path().display().to_string(); + let command = format!( + r#" + $env.PATH = $"{path_prefix}:($env.PATH)" + @interactive + def comp [token] {{ ^fzf }} + def my-command [arg: string@comp] {{}} + "# + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let line = "my-command "; + let suggestions = completer.complete_blocking(line, line.len()); + assert!( + suggestions.is_empty(), + "fzf Esc (exit 130) must yield empty suggestions, got {suggestions:?}" + ); +} + +/// Bare value is one completion. +#[rstest] +#[case::string("'123'")] +#[case::int("123")] +fn a_bare_value_is_one_completion(#[case] body: &str) { + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + "def comp [] {{ {body} }} + def my-command [arg: string@comp] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + let completion_str = "my-command 12"; let suggestions = completer.complete_blocking(completion_str, completion_str.len()); - assert!(suggestions.is_empty()); + match_suggestions(&vec!["123"], &suggestions); } #[test] @@ -1239,22 +1585,10 @@ fn dotnu_completions_const_nu_lib_dirs() { match_suggestions(&vec!["./asdf.nu"], &suggestions); } -/// Interactive pickers in the external completer need the TTY, so that closure -/// must not go through the background worker (`complete()` returns Fresh). -#[test] -fn external_completer_runs_on_the_repl_thread() { - let mut completer = custom_completer(); - let result = completer.complete("foo test", 8); - assert!( - matches!(result, CompletionResult::Fresh { .. }), - "external completer must run on the REPL thread, got {result:?}" - ); -} - #[test] fn external_completer_trailing_space() { // https://github.com/nushell/nushell/issues/6378 - let block = "{|spans| $spans}"; + let block = "{|buffer| ($buffer | split row ' ')}"; let input = "gh alias "; let suggestions = run_external_completion(block, input); @@ -1263,7 +1597,7 @@ fn external_completer_trailing_space() { #[test] fn external_completer_no_trailing_space() { - let block = "{|spans| $spans}"; + let block = "{|buffer| ($buffer | split row ' ')}"; let input = "gh alias"; let suggestions = run_external_completion(block, input); @@ -1274,7 +1608,7 @@ fn external_completer_no_trailing_space() { #[test] fn external_completer_pass_flags() { - let block = "{|spans| $spans}"; + let block = "{|buffer| ($buffer | split row ' ')}"; let input = "gh api --"; let suggestions = run_external_completion(block, input); @@ -1287,7 +1621,7 @@ fn external_completer_pass_flags() { /// Fallback to file completions when external completer returns null #[test] fn external_completer_fallback() { - let block = "{|spans| null}"; + let block = "{|input| null}"; let input = "foo test"; let expected = [folder("test_a"), folder("test_a_symlink"), folder("test_b")]; @@ -1322,16 +1656,17 @@ fn external_completer_fallback() { } #[rstest] -#[case::happy("{ start: 1, end: 14 }", (7, 20))] -#[case::no_start("{ end: 14 }", (17, 20))] -#[case::no_end("{ start: 1 }", (7, 23))] +/// A returned `span` is byte offsets; ends beyond what was shown clamp. +#[case::happy("{ start: 7, end: 20 }", (7, 20))] +#[case::no_start("{ end: 20 }", (17, 20))] +#[case::no_end("{ start: 7 }", (7, 23))] #[case::bad_start("{ start: 100 }", (23, 23))] #[case::bad_end("{ end: 100 }", (17, 23))] fn external_completer_override_span( #[case] span_string: &str, #[case] expected_span: (usize, usize), ) { - let block = format!("{{|spans| [{{ value: foobarbaz, span: {span_string} }}]}}"); + let block = format!("{{|input| [{{ value: foobarbaz, span: {span_string} }}]}}"); let input = "foo | extcommand foobar"; let suggestions = run_external_completion(&block, input); @@ -1346,7 +1681,7 @@ fn external_completer_override_span( #[test] fn external_completer_override_display_value() { - let block = "{|spans| [{ value: foo, display_override: blah }] }"; + let block = "{|input| [{ value: foo, display_override: blah }] }"; let suggestions = run_external_completion(block, "extcommand irrelevant"); assert_eq!(1, suggestions.len()); assert_eq!("blah", suggestions[0].display_value()); @@ -1355,7 +1690,7 @@ fn external_completer_override_display_value() { /// Fallback to external completions for flags of `sudo` #[test] fn external_completer_sudo() { - let block = "{|spans| ['--background']}"; + let block = "{|input| ['--background']}"; let input = "sudo --back"; let expected = vec!["--background"]; @@ -1365,8 +1700,8 @@ fn external_completer_sudo() { #[test] fn external_completer_wraps_builtin_commandline_complete() { - let block = "{|spans| - $spans | last | commandline complete | prepend 'bar' + let block = "{|token| + $token.text | commandline complete | prepend 'bar' }"; let input = "foo test"; @@ -1380,27 +1715,32 @@ fn external_completer_wraps_builtin_commandline_complete() { match_suggestions_by_string(&expected, &suggestions); } -/// Suppress completions when external completer returns invalid value +/// A bare value from an external completer is one suggestion, the way a picker answers. #[test] -fn external_completer_invalid() { - let block = "{|spans| 123}"; +fn external_completer_bare_value() { + let block = "{|input| 123}"; let input = "foo "; let suggestions = run_external_completion(block, input); - assert!(suggestions.is_empty()); + match_suggestions(&vec!["123"], &suggestions); } #[test] fn command_wide_completion_external() { - let mut completer = custom_completer(); + let (_, _, mut engine, mut stack) = new_engine(); + // The external completer sees the whole line, so define `gh` in the engine and complete + // a single command: a Carapace-style completer just splits the line into words. + let setup = /* lang=nu */ r#" + $env.config.completions.external.completer = {|buffer| $buffer | split row ' ' } - let sample = /* lang=nu */ r#" @complete external extern "gh" [] + "#; + assert!(support::merge_input(setup.as_bytes(), &mut engine, &mut stack).is_ok()); - gh alias one two"#; - - let suggestions = completer.complete_blocking(sample, sample.len()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let input = "gh alias one two"; + let suggestions = completer.complete_blocking(input, input.len()); let expected = vec!["gh", "alias", "one", "two"]; match_suggestions(&expected, &suggestions); } @@ -1410,19 +1750,20 @@ fn command_wide_completion_external() { #[case::explicit_return("return")] #[case::implicit_return("")] fn command_wide_completion_custom(#[case] return_code: &str) { - let mut completer = custom_completer(); - - let sample = /* lang=nu */ format!(r#" - def "nu-complete foo" [spans: list] {{ - {return_code} ($spans ++ [some more]) + let (_, _, mut engine, mut stack) = new_engine(); + let setup = /* lang=nu */ format!(r#" + def "nu-complete foo" [buffer] {{ + {return_code} (($buffer | split row ' ') ++ [some more]) }} @complete "nu-complete foo" def --wrapped "foo" [...rest] {{}} + "#); + assert!(support::merge_input(setup.as_bytes(), &mut engine, &mut stack).is_ok()); - foo bar baz"#); - - let suggestions = completer.complete_blocking(&sample, sample.len()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let input = "foo bar baz"; + let suggestions = completer.complete_blocking(input, input.len()); let expected = vec!["foo", "bar", "baz", "some", "more"]; match_suggestions(&expected, &suggestions); } @@ -1432,8 +1773,8 @@ fn command_wide_completion_wrapped_untyped_equals_flag_value() { let mut completer = custom_completer(); let sample = r#" - def "nu-complete wt" [spans: list] { - let last = ($spans | last) + def "nu-complete wt" [token] { + let last = $token.text if ($last | str starts-with "--base=") { [--base=releases/4.x.x --base=main] } else { @@ -1453,13 +1794,13 @@ fn command_wide_completion_wrapped_untyped_equals_flag_value() { #[rstest] #[case::custom( - r#"def "nu-complete foo" [spans: list] { null } + r#"def "nu-complete foo" [] { null } @complete "nu-complete foo""# )] #[case::external( " - let external_completer = {|spans| null } + let external_completer = {|input| null } $env.config.completions.external = { enable: true @@ -1495,7 +1836,7 @@ fn parameter_completion_overrides_command_wide_completion() { let mut completer = custom_completer(); let sample = /* lang=nu */ r#" - def "nu-complete cmd" [spans: list] { + def "nu-complete cmd" [] { [command wide completion] } @@ -1522,8 +1863,8 @@ fn command_wide_completion_flag_completion() { let mut completer = custom_completer(); let sample = /* lang=nu */ r#" - def "nu-complete cmd" [spans: list] { - let last = $spans | last + def "nu-complete cmd" [token] { + let last = $token.text [command wide --with --external] | where $it starts-with $last } @@ -2314,7 +2655,7 @@ fn subcommand_vs_external_completer() { let (_, _, mut engine, mut stack) = new_engine(); let commands = r#" $env.config.completions.algorithm = "fuzzy" - $env.config.completions.external.completer = {|spans| ["external"]} + $env.config.completions.external.completer = {|input| ["external"]} def foo-test-command [] {} def "foo-test-command bar" [] {} def "foo-test-command aagap bcr" [] {} @@ -3182,6 +3523,15 @@ fn run_external_completion_within_pwd( completer: &str, input: &str, pwd: AbsolutePathBuf, +) -> Suggestions { + run_external_completion_at_within_pwd(completer, input, input.len(), pwd) +} + +fn run_external_completion_at_within_pwd( + completer: &str, + input: &str, + cursor: usize, + pwd: AbsolutePathBuf, ) -> Suggestions { let completer = format!("$env.config.completions.external.completer = {completer}"); @@ -3209,13 +3559,13 @@ fn run_external_completion_within_pwd( { let mut repl = engine_state.repl_state.lock().expect("repl state"); repl.buffer = input.to_string(); - repl.cursor_pos = input.len(); + repl.cursor_pos = cursor; } // Instantiate a new completer let mut completer = NuCompleter::new(Arc::new(engine_state), Arc::new(stack)); - completer.complete_blocking(input, input.len()) + completer.complete_blocking(input, cursor) } fn run_external_completion(completer: &str, input: &str) -> Suggestions { @@ -3229,6 +3579,161 @@ fn run_external_completion(completer: &str, input: &str) -> Suggestions { ) } +/// Like [`run_external_completion`], with the cursor before the end of the line. +fn run_external_completion_at(completer: &str, input: &str, cursor: usize) -> Suggestions { + run_external_completion_at_within_pwd( + completer, + input, + cursor, + FIXTURES + .join("completions") + .try_into() + .expect("fixtures is absolute"), + ) +} + +/// The `buffer` view is the whole line up to the cursor; `place` resolves the site. The +/// cursor is mid-line, so the buffer stops there. +#[test] +fn external_completer_receives_the_unified_input() { + let input = "before | ext --ma later"; + let cursor = "before | ext --ma".len(); + let block = r#"{|buffer, place| [ + # The buffer spans the pipe; `later` is past the cursor and deliberately absent. + $buffer + $"cursor=($place.cursor)" + $"place-kind=($place.kind)" + $"target=($place.target | to nuon)" + ]}"#; + + let suggestions = run_external_completion_at(block, input, cursor); + match_suggestions( + &vec![ + "before | ext --ma", + "cursor=17", + "place-kind=external-arg", + "target={start: 13, end: 17}", + ], + &suggestions, + ); +} + +/// A cursor inside a closure still receives the whole line up to it — closure and all — as +/// the buffer, and `place` resolves against the command the cursor is actually in. +#[test] +fn a_completer_inside_a_closure_receives_the_whole_line() { + let input = "ignored | each { ext --ma"; + let block = r#"{|buffer, place| [ + $buffer + $"place-kind=($place.kind)" + ]}"#; + + let suggestions = run_external_completion_at(block, input, input.len()); + match_suggestions( + &vec!["ignored | each { ext --ma", "place-kind=external-arg"], + &suggestions, + ); +} + +/// A parameter completer gets the same record as an external one; only the resolved site +/// differs, since the engine knows this command's signature. +#[test] +fn parameter_completer_receives_the_unified_input() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = r#" + def comp [buffer, place] { + { + # The engine would otherwise narrow these against the typed `ma`. + options: { filter: false } + completions: [ + $buffer + $"cursor=($place.cursor)" + $"place-kind=($place.kind)" + $"index=($place.index)" + $"target=($place.target | to nuon)" + ] + } + } + def my-command [arg: string@comp] {} + "#; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let input = "my-command ma later"; + let suggestions = completer.complete_blocking(input, "my-command ma".len()); + + match_suggestions( + &vec![ + "my-command ma", + "cursor=13", + "place-kind=positional", + "index=0", + "target={start: 11, end: 13}", + ], + &suggestions, + ); +} + +/// The engine narrows a parameter completer's list, but not a command-wide or external +/// one's (it got the typed text itself). Either can override with `options.filter`. +#[test] +fn parameter_completions_are_narrowed_but_external_ones_are_not() { + let candidates = "[alpha, beta]"; + + let (_, _, mut engine, mut stack) = new_engine(); + let command = format!( + "def comp [] {{ {candidates} }} + def my-command [arg: string@comp] {{}}" + ); + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let typed = "my-command al"; + match_suggestions( + &vec!["alpha"], + &completer.complete_blocking(typed, typed.len()), + ); + + // The same list from an external completer is not narrowed. + let suggestions = run_external_completion(&format!("{{|input| {candidates} }}"), "ext al"); + match_suggestions(&vec!["alpha", "beta"], &suggestions); +} + +/// A span is clamped to the buffer and floored to a char boundary (#5127). +#[test] +fn a_returned_span_is_clamped_to_the_buffer() { + // `é` occupies bytes 4..6, so 5 is mid-character and 99 is past the end. + let block = "{|input| [{ value: v, span: { start: 5, end: 99 } }] }"; + let suggestions = run_external_completion(block, "ext é"); + + assert_eq!(1, suggestions.len()); + assert_eq!(Span::new(4, 6), suggestions[0].span); + + // A span past the cursor clamps to it: all the completer was shown. + let past = run_external_completion_at( + "{|input| [{ value: v, span: { start: 4, end: 99 } }] }", + "ext ab cd", + "ext ab".len(), + ); + assert_eq!(Span::new(4, 6), past[0].span); +} + +/// Text past the cursor never reaches a completer, so one cache entry serves every tail. +#[test] +fn the_text_after_the_cursor_never_reaches_the_completer() { + let block = "{|buffer| [$buffer]}"; + let cursor = "ext ".len(); + + // Same prefix, different tails: the completer sees the same buffer either way. + match_suggestions( + &vec!["ext "], + &run_external_completion_at(block, "ext bar", cursor), + ); + match_suggestions( + &vec!["ext "], + &run_external_completion_at(block, "ext qux", cursor), + ); +} + #[test] fn unknown_command_completion() { let (_, _, engine, stack) = new_engine(); @@ -3357,20 +3862,20 @@ fn extern_custom_completion( match_suggestions(&expected, &suggestions); } +/// The external completer triggers wherever the cursor sits in an argument position — in a +/// gap, on a word boundary, or after a word — and receives the line up to that cursor. #[rstest] -#[case::cursor_before_word(8, vec![""])] -#[case::cursor_on_word_left_boundary(9, vec![""])] -#[case::cursor_next_to_word(12, vec!["bar"])] -#[case::cursor_after_word(13, vec!["bar", ""])] -fn custom_completer_triggers_cursor_before_word( +#[case::cursor_before_word(8, "cmd foo ")] +#[case::cursor_on_word_left_boundary(9, "cmd foo ")] +#[case::cursor_next_to_word(12, "cmd foo bar")] +#[case::cursor_after_word(13, "cmd foo bar ")] +fn custom_completer_triggers_at_the_cursor( mut custom_completer: NuCompleter, #[case] position: usize, - #[case] extra: Vec<&str>, + #[case] buffer: &str, ) { let suggestions = custom_completer.complete_blocking("cmd foo bar ", position); - let mut expected: Vec<_> = vec!["cmd", "foo"]; - expected.extend(extra); - match_suggestions(&expected, &suggestions); + match_suggestions(&vec![buffer], &suggestions); } #[rstest] @@ -3666,43 +4171,23 @@ fn cellpath_assignment_operator_completions() { match_suggestions(&expected, &suggestions); } +/// The `buffer` view is the line exactly as typed: an aliased command reaches the completer +/// as its alias, not its expansion. A completer that wants the expansion can re-parse the +/// buffer itself (e.g. `std/util structure`, which resolves the alias head). #[test] -fn alias_expansion_for_external_completions() { +fn external_completer_receives_the_raw_line_with_aliases_unexpanded() { let (_, _, mut engine, mut stack) = new_engine(); let command = "alias example_alias = example_cmd arg1 arg2"; assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); - // Define an external completer that returns the arguments passed to it - let command = "$env.config.completions.external.completer = {|s| $s }"; + let command = + "$env.config.completions.external.completer = {|buffer| ($buffer | split row ' ') }"; assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); let completion_str = "example_alias extra_arg"; let suggestions = completer.complete_blocking(completion_str, completion_str.len()); - let expected: Vec<_> = vec!["example_cmd", "arg1", "arg2", "extra_arg"]; - match_suggestions(&expected, &suggestions); -} - -#[test] -fn nested_alias_expansion_for_external_completions() { - let (_, _, mut engine, mut stack) = new_engine(); - let command = "alias example_alias = example_cmd arg1 arg2; alias nested_alias = example_alias nested_alias_arg"; - assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); - - // Define an external completer that returns the arguments passed to it - let command = "$env.config.completions.external.completer = {|s| $s }"; - assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); - - let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); - let completion_str = "nested_alias extra_arg"; - let suggestions = completer.complete_blocking(completion_str, completion_str.len()); - let expected: Vec<_> = vec![ - "example_cmd", - "arg1", - "arg2", - "nested_alias_arg", - "extra_arg", - ]; + let expected: Vec<_> = vec!["example_alias", "extra_arg"]; match_suggestions(&expected, &suggestions); } @@ -3883,3 +4368,71 @@ fn empty_directory_arg_slot_completes_directories_only() { ); } } + +/// Legacy compat: the `[context, pos]` shape used by older zoxide integrations receives the +/// element text and buffer-relative cursor. +#[test] +fn legacy_zoxide_style_parameter_completer_receives_context_and_pos() { + let (_, _, mut engine, mut stack) = new_engine(); + let command = r#" + def comp [context: string, pos: int] { + { options: { filter: false }, completions: [$context, $"pos=($pos)"] } + } + def my-command [arg: string@comp] {} + "#; + assert!(support::merge_input(command.as_bytes(), &mut engine, &mut stack).is_ok()); + + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + let input = "my-command foo"; + let suggestions = completer.complete_blocking(input, input.len()); + match_suggestions(&vec!["my-command foo", "pos=14"], &suggestions); +} + +/// Legacy compat: a command-wide completer declaring `[spans]` receives the flattened +/// tokens, plus `""` for a trailing empty slot. +#[test] +fn legacy_command_wide_completer_receives_spans() { + let mut completer = custom_completer(); + let sample = r#" + def "nu-complete foo" [spans] { + [($spans | str join ",")] + } + + @complete "nu-complete foo" + def --wrapped "foo" [...rest] {} + + foo bar baz"#; + + let suggestions = completer.complete_blocking(sample, sample.len()); + match_suggestions(&vec!["foo,bar,baz"], &suggestions); +} + +/// Legacy compat: fzf's `completion.nu` registers `{|spans|}` and reads the last token for its +/// `**` trigger. It continues to receive the token list and can decline with `null`. +#[test] +fn legacy_fzf_style_external_completer_receives_spans() { + // Trigger present: the completer answers. + let block = r#"{|spans| + let trigger = "**" + if ($spans | last | str ends-with $trigger) { + let prefix = $spans | last | str substring 0..(-1 * ($trigger | str length) - 1) + [$"picked=($prefix)"] + } else { + null + } + }"#; + let suggestions = run_external_completion(block, "vim file**"); + match_suggestions(&vec!["picked=file"], &suggestions); + + // Trigger absent: `null` declines to file completion. + let suggestions = run_external_completion(block, "vim file"); + assert!( + !suggestions.iter().any(|s| s.value.starts_with("picked=")), + "a declining legacy external completer must not answer, got: {:?}", + suggestions.iter().map(|s| &s.value).collect::>() + ); + + // Trailing empty slot is visible, exactly as before. + let suggestions = run_external_completion("{|spans| $spans}", "gh alias "); + match_suggestions(&vec!["gh", "alias", ""], &suggestions); +} diff --git a/crates/nu-cmd-lang/src/core_commands/attr/complete.rs b/crates/nu-cmd-lang/src/core_commands/attr/complete.rs index 04079dea05..1ee64fe85b 100644 --- a/crates/nu-cmd-lang/src/core_commands/attr/complete.rs +++ b/crates/nu-cmd-lang/src/core_commands/attr/complete.rs @@ -55,8 +55,8 @@ impl Command for AttrComplete { vec![Example { description: "Use another command as completion source.", example: "\ - def complete-foo [spans: list] {\n \ - [bar baz qux spam eggs] | where $it not-in $spans\n\ + def complete-foo [token: record] {\n \ + [bar baz qux spam eggs] | where $it != $token.text\n\ }\n\n\ @complete 'complete-foo'\n\ def foo [...args] { $args }\ diff --git a/crates/nu-cmd-lang/src/core_commands/attr/interactive.rs b/crates/nu-cmd-lang/src/core_commands/attr/interactive.rs new file mode 100644 index 0000000000..c186631aa1 --- /dev/null +++ b/crates/nu-cmd-lang/src/core_commands/attr/interactive.rs @@ -0,0 +1,72 @@ +use nu_engine::command_prelude::*; + +#[derive(Clone)] +pub struct AttrInteractive; + +impl Command for AttrInteractive { + fn name(&self) -> &str { + "attr interactive" + } + + fn signature(&self) -> Signature { + Signature::build("attr interactive") + .input_output_type(Type::Nothing, Type::Bool) + .category(Category::Core) + } + + fn description(&self) -> &str { + "Attribute marking a completer as interactive." + } + + fn extra_description(&self) -> &str { + "An interactive completer runs on the line-editor thread, with the terminal to \ +itself, instead of on the background completion worker. This lets it drive a terminal \ +picker such as `fzf` or `input list`, which need stdin and the TTY. Every other completer \ +stays on the worker, so it never blocks the line editor and its result can be cached; only \ +completers that must own the terminal should opt in. + +The attribute only chooses where a completer runs; it does not make the command one. Attach \ +it as usual, `arg: type@name` or `@complete 'name'`; and the completion engine calls it \ +with the `token` record when you press Tab. Called directly it is an ordinary command, so \ +declare `token: record`: that way `pick-file foo` is rejected at the call site instead of \ +failing deep inside the body on `$token.text`. To try a completer by hand, get a real record \ +from `commandline complete --input`." + } + + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + Ok(Value::bool(true, call.head).into_pipeline_data()) + } + + fn run_const( + &self, + _working_set: &StateWorkingSet, + call: &Call, + _input: PipelineData, + ) -> Result { + Ok(Value::bool(true, call.head).into_pipeline_data()) + } + + fn is_const(&self) -> bool { + true + } + + fn examples(&self) -> Vec> { + vec![Example { + description: "Drive a terminal picker from a completer, then attach it to an argument.", + example: "\ + @interactive\n\ + def pick-file [token: record] {\n \ + ls | get name | to text | ^fzf --query $token.text | lines\n\ + }\n\n\ + def open-file [path: string@pick-file] { open $path }\ + ", + result: None, + }] + } +} diff --git a/crates/nu-cmd-lang/src/core_commands/attr/mod.rs b/crates/nu-cmd-lang/src/core_commands/attr/mod.rs index 518371d0b0..700fcb6e9d 100644 --- a/crates/nu-cmd-lang/src/core_commands/attr/mod.rs +++ b/crates/nu-cmd-lang/src/core_commands/attr/mod.rs @@ -3,6 +3,7 @@ mod category; mod complete; mod deprecated; mod example; +mod interactive; mod search_terms; pub use attr_::Attr; @@ -10,4 +11,5 @@ pub use category::AttrCategory; pub use complete::{AttrComplete, AttrCompleteExternal}; pub use deprecated::AttrDeprecated; pub use example::AttrExample; +pub use interactive::AttrInteractive; pub use search_terms::AttrSearchTerms; diff --git a/crates/nu-cmd-lang/src/default_context.rs b/crates/nu-cmd-lang/src/default_context.rs index 806da795a3..4108728b23 100644 --- a/crates/nu-cmd-lang/src/default_context.rs +++ b/crates/nu-cmd-lang/src/default_context.rs @@ -25,6 +25,7 @@ pub fn add_default_context(mut engine_state: EngineState) -> EngineState { AttrCompleteExternal, AttrDeprecated, AttrExample, + AttrInteractive, AttrSearchTerms, Break, Collect, diff --git a/crates/nu-config/default_files/doc_config.nu b/crates/nu-config/default_files/doc_config.nu index 0efbbdbd35..5a372f8a8b 100644 --- a/crates/nu-config/default_files/doc_config.nu +++ b/crates/nu-config/default_files/doc_config.nu @@ -321,21 +321,74 @@ $env.config.completions.external.enable = true # Default: 100 $env.config.completions.external.max_results = 100 -# completions.external.completer (closure|null): Custom closure for argument completions. -# The closure receives a |spans| parameter - a list of strings representing -# tokens on the current commandline. Usually set to call a third-party -# completion system like Carapace. -# Evaluating this closure blocks the line editor and may take the TTY, which -# interactive pickers (fzf, `input list`) need. Completers that only print a -# list should return quickly. Custom menu `source` closures already run on -# the REPL thread the same way. +# completions.external.completer (closure|record|null): Custom closure for argument +# completions. Usually set to call a third-party completion system like Carapace. +# +# A completer's input is overloaded on the parameters it declares: it is handed exactly the +# fields it names, and nothing it did not ask for (order does not matter; an unrecognized name +# receives nothing). The three it can ask for: +# token: record the token being completed, as {text, kind, span} +# place: record where in the line that is: `cursor` (a byte offset) and `target` +# ({start, end}, the range a suggestion replaces), plus the resolution +# (`kind`, plus `flag`/`index`). Read `target` rather than `token.span`: +# they differ wherever a completion spans several tokens, such as a +# multiword command head or a cell path. +# buffer: string the whole command line up to the cursor, across pipes and closures, for +# completers that need more than the current token -- an external completer +# like Carapace reads this. `std/util structure` turns it into a +# {text, kind, span} table. Prefer it over calling `commandline` from inside +# a completer: `buffer` is always the line being completed, whereas +# `commandline` reads editor state and can come back empty depending on how +# completion was triggered (e.g. after a `;`). +# `commandline complete --input` returns all three at once, for inspecting a completer. +# A completer never sees text past the cursor. +# +# Returns a list of suggestions (a string, or a record of {value, description?, style?, +# span?, extra?}) or a record of {options, completions}. External/command-wide completers +# are not filtered by default; parameter completers are. See `options.filter`. # Default: null $env.config.completions.external.completer = null -# Example: A simplified Carapace completer (use the official one from Carapace docs): -# $env.config.completions.external.completer = {|spans| -# carapace $spans.0 nushell ...$spans | from json +# Example: A simplified Carapace completer (use the official one from Carapace docs). +# The whole line is wanted, so it declares `buffer` and splits it into words: +# $env.config.completions.external.completer = {|buffer| +# let words = $buffer | split row " " +# carapace ($words | first) nushell ...$words | from json # } +# +# Example: branch off the resolved cursor instead of re-parsing the line, by declaring +# only the `place` it needs: +# $env.config.completions.external.completer = {|place| +# if $place.kind == "flag-value" and $place.flag == "base" { +# git branch | lines | str trim +# } +# } +# +# Example: inspect the parsed tokens of the line the cursor is on: +# use std/util +# def line-tokens [buffer] { +# util structure $buffer # -> [{text, kind, span}, ...] +# } +# +# A `def`-based completer (attached with `@complete` or to a parameter with `@`) can carry +# the `@interactive` attribute to run on the line-editor thread with the terminal to itself, +# so it can drive a picker like `fzf` or `input list`: +# @interactive +# def pick-file [token: record] { ls | get name | to text | ^fzf --query $token.text | lines } +# Every other completer runs on a background worker instead: non-blocking, and cached. +# +# `external.completer` is a plain closure, which cannot carry an attribute. To make it run +# inline (the picker-driving case), have it call an `@interactive` command: interactivity is +# seen through the closure to that command, so there is no separate switch. The command +# usually declares `buffer` to feed the whole line to its picker: +# @interactive +# def carapace-fzf [buffer] { +# let words = $buffer | split row ' ' +# carapace ($words | first) nushell ...$words | from json | ^fzf | lines +# } +# $env.config.completions.external.completer = {|buffer| carapace-fzf $buffer } +# A closure that does not reach an `@interactive` command stays on the background worker: +# non-blocking, and cached. # -------------------- # Terminal Integration @@ -723,6 +776,14 @@ $env.config.abbreviations = {} # `type` record, controlling whether an entry's description is shown before or # after its value. Unset keeps reedline's default. # +# A menu's `source` receives the same inputs as every completer, bound by the parameters it +# declares, in place of the old `{|buffer, position|}` pair -- see +# completions.external.completer above. A source that used to write +# `$buffer | split row ' ' | last` now declares `{|token|}` and reads `$token.text`, and one +# that needs the whole line declares `{|buffer|}` the same way a completer does. +# It returns what a completer returns, so one source can serve as either; the `options` of a +# {completions, options} record name engine behaviour a menu has no say in and are ignored. +# # Default: completion_menu, ide_completion_menu, history_menu, help_menu. # Inspect with `$env.config.menus`. Assigning this list merges into the # defaults by `name` rather than replacing them, so `=` never clears them. diff --git a/crates/nu-experimental/src/options/background_completions.rs b/crates/nu-experimental/src/options/background_completions.rs deleted file mode 100644 index a9592cb42a..0000000000 --- a/crates/nu-experimental/src/options/background_completions.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::*; - -/// Run completions on a background thread instead of blocking the line editor. -/// -/// Disabling restores the pre-0.115 behavior: completions settle on the -/// reedline thread, which blocks while they run, so a custom completer can -/// hand the terminal to an interactive picker (`$candidates | fzf`) without -/// the line editor stealing its keystrokes. -pub static BACKGROUND_COMPLETIONS: ExperimentalOption = - ExperimentalOption::new(&BackgroundCompletions); - -// No documentation needed here since this type isn't public. -// The static above provides all necessary details. -struct BackgroundCompletions; - -impl ExperimentalOptionMarker for BackgroundCompletions { - const IDENTIFIER: &'static str = "background-completions"; - const DESCRIPTION: &'static str = "\ - Runs completions on a background thread so typing never blocks. \ - Disable to let custom completers drive interactive child processes."; - const STATUS: Status = Status::OptOut; - const SINCE: Version = (0, 115, 0); - const ISSUE: u32 = 18839; -} diff --git a/crates/nu-experimental/src/options/mod.rs b/crates/nu-experimental/src/options/mod.rs index 63ec1e96e7..eaa1853ec5 100644 --- a/crates/nu-experimental/src/options/mod.rs +++ b/crates/nu-experimental/src/options/mod.rs @@ -5,7 +5,6 @@ use crate::*; -mod background_completions; mod cell_path_types; mod dc_glob; mod enforce_runtime_annotations; @@ -62,7 +61,6 @@ pub(crate) trait ExperimentalOptionMarker { // Export only the static values. // The marker structs are not relevant and needlessly clutter the generated docs. -pub use background_completions::BACKGROUND_COMPLETIONS; pub use cell_path_types::CELL_PATH_TYPES; pub use dc_glob::DC_GLOB; pub use enforce_runtime_annotations::ENFORCE_RUNTIME_ANNOTATIONS; @@ -86,7 +84,6 @@ pub static ALL: &[&ExperimentalOption] = &[ &ENFORCE_RUNTIME_ANNOTATIONS, &NATIVE_CLIP, &CELL_PATH_TYPES, - &BACKGROUND_COMPLETIONS, ]; #[cfg(test)] diff --git a/crates/nu-explore/src/explore_config/example_data.rs b/crates/nu-explore/src/explore_config/example_data.rs index a30ee1f05a..5cf5e4d0f6 100644 --- a/crates/nu-explore/src/explore_config/example_data.rs +++ b/crates/nu-explore/src/explore_config/example_data.rs @@ -702,7 +702,7 @@ pub fn get_example_json() -> Value { "col_width": 20, "col_padding": 2 }, - "source": "{|buffer, position|\n open $nu.history-path | get history.command_line | to text | fzf +s --tac | str trim\n | where $it =~ $buffer\n | each {|v| {value: ($v | str trim)} }\n }" + "source": "{|token|\n let buffer = $token.text\n open $nu.history-path | get history.command_line | to text | fzf +s --tac | str trim\n | where $it =~ $buffer\n | each {|v| {value: ($v | str trim)} }\n }" }, { "name": "fzf_history_menu_nu_ui", @@ -720,7 +720,7 @@ pub fn get_example_json() -> Value { "layout": "list", "page_size": 10 }, - "source": "{|buffer, position|\n open $nu.history-path | get history.command_line | to text\n | fzf -f $buffer\n | lines\n | each {|v| {value: ($v | str trim)} }\n }" + "source": "{|token|\n let buffer = $token.text\n open $nu.history-path | get history.command_line | to text\n | fzf -f $buffer\n | lines\n | each {|v| {value: ($v | str trim)} }\n }" }, { "name": "fzf_dir_menu_nu_ui", @@ -738,7 +738,7 @@ pub fn get_example_json() -> Value { "layout": "list", "page_size": 10 }, - "source": "{|buffer, position|\n ls $env.PWD | where type == dir\n | sort-by name | get name | to text\n | fzf -f $buffer\n | each {|v| {value: ($v | str trim)} }\n }" + "source": "{|token|\n let buffer = $token.text\n ls $env.PWD | where type == dir\n | sort-by name | get name | to text\n | fzf -f $buffer\n | each {|v| {value: ($v | str trim)} }\n }" }, { "name": "commands_menu", @@ -755,7 +755,7 @@ pub fn get_example_json() -> Value { "col_width": 20, "col_padding": 2 }, - "source": "{|buffer, position|\n scope commands\n | where name =~ $buffer\n | each {|it| {value: $it.name description: $it.usage} }\n }" + "source": "{|token|\n let buffer = $token.text\n scope commands\n | where name =~ $buffer\n | each {|it| {value: $it.name description: $it.usage} }\n }" }, { "name": "vars_menu", @@ -770,7 +770,7 @@ pub fn get_example_json() -> Value { "layout": "list", "page_size": 10 }, - "source": "{|buffer, position|\n scope variables\n | where name =~ $buffer\n | sort-by name\n | each {|it| {value: $it.name description: $it.type} }\n }" + "source": "{|token|\n let buffer = $token.text\n scope variables\n | where name =~ $buffer\n | sort-by name\n | each {|it| {value: $it.name description: $it.type} }\n }" }, { "name": "commands_with_description", @@ -789,7 +789,7 @@ pub fn get_example_json() -> Value { "selection_rows": 4, "description_rows": 10 }, - "source": "{|buffer, position|\n scope commands\n | where name =~ $buffer\n | each {|it| {value: $it.name description: $it.usage} }\n }" + "source": "{|token|\n let buffer = $token.text\n scope commands\n | where name =~ $buffer\n | each {|it| {value: $it.name description: $it.usage} }\n }" }, { "name": "abbr_menu", @@ -806,7 +806,7 @@ pub fn get_example_json() -> Value { "col_width": 20, "col_padding": 2 }, - "source": "{|buffer, position|\n scope aliases\n | where name == $buffer\n | each {|it| {value: $it.expansion} }\n }" + "source": "{|token|\n let buffer = $token.text\n scope aliases\n | where name == $buffer\n | each {|it| {value: $it.expansion} }\n }" }, { "name": "history_menu_by_session", @@ -821,7 +821,7 @@ pub fn get_example_json() -> Value { "layout": "list", "page_size": 10 }, - "source": "{|buffer, position|\n history -l\n | where session_id == (history session)\n | select command\n | where command =~ $buffer\n | each {|it| {value: $it.command} }\n | reverse\n | uniq\n }" + "source": "{|token|\n let buffer = $token.text\n history -l\n | where session_id == (history session)\n | select command\n | where command =~ $buffer\n | each {|it| {value: $it.command} }\n | reverse\n | uniq\n }" } ], "hooks": { diff --git a/crates/nu-lsp/src/completion.rs b/crates/nu-lsp/src/completion.rs index 44b4283611..15513f6678 100644 --- a/crates/nu-lsp/src/completion.rs +++ b/crates/nu-lsp/src/completion.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use crate::{LanguageServer, span_to_range, uri_to_path}; use lsp_types::{ CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionParams, @@ -35,8 +33,8 @@ impl LanguageServer { .is_some_and(|c| c.is_whitespace() || "|(){}[]<>,:;".contains(c)); self.need_parse |= need_fallback; - let engine_state = Arc::new(self.new_engine_state(Some(path_uri))); - let completer = CompletionEngine::new(engine_state.clone(), Arc::new(Stack::new())); + let engine_state = self.new_engine_state(Some(path_uri)); + let completer = CompletionEngine::new(&engine_state, &Stack::new()); let results = if need_fallback { completer.fetch_completions_at(&file_text[..location], location) } else { @@ -317,7 +315,7 @@ mod tests { ]))] #[case::external_completer( "external.nu", (0, 11), - Some("$env.config.completions.external.completer = {|spans| ['--background']}"), + Some("$env.config.completions.external.completer = {|input| ['--background']}"), serde_json::json!([{ "label": "--background", "labelDetails": { "description": "string" }, @@ -476,7 +474,7 @@ mod tests { ]))] #[case::external_fallback( "external.nu", (0, 5), - Some("$env.config.completions.external.completer = {|spans| ['--background']}"), + Some("$env.config.completions.external.completer = {|input| ['--background']}"), serde_json::json!([{ "label": "alias", "labelDetails": { "description": "keyword" }, @@ -488,21 +486,22 @@ mod tests { "kind": 14 }]) )] + // The cursor is inside `bar`, so the last token is the typed prefix `b`, not `baz`. #[case::command_wide_custom( "command.nu", (23, 5), None, serde_json::json!([{ - "label": "baz", + "label": "b", "labelDetails": { "description": "string" }, "textEdit": { "range": { "start": { "line": 23, "character": 4 }, "end": { "line": 23, "character": 7 } }, - "newText": "baz" + "newText": "b" }, "kind": 12 }]) )] #[case::command_wide_external( "command.nu", (28, 8), - Some("$env.config.completions.external.completer = {|spans| ['text']}"), + Some("$env.config.completions.external.completer = {|input| ['text']}"), serde_json::json!([{ "label": "text", "labelDetails": { "description": "string" }, diff --git a/crates/nu-parser/src/parse_source.rs b/crates/nu-parser/src/parse_source.rs index b6d94603bc..1d3332bc6a 100644 --- a/crates/nu-parser/src/parse_source.rs +++ b/crates/nu-parser/src/parse_source.rs @@ -480,13 +480,10 @@ pub fn parse_where_expr(working_set: &mut StateWorkingSet, spans: &[Span]) -> Ex return garbage(working_set, Span::concat(spans)); } - if spans.len() < 2 { - working_set.error(ParseError::MissingPositional( - "row condition".into(), - Span::concat(spans), - "where ".into(), - )); - return garbage(working_set, Span::concat(spans)); + // Bare `where` stays a Call so completion sees command/positional, not Garbage; + // `parse_internal_call` reports the missing condition via `check_call`. + if spans.is_empty() { + return garbage(working_set, Span::unknown()); } let call = match working_set.find_decl(b"where") { diff --git a/crates/nu-protocol/src/config/completions.rs b/crates/nu-protocol/src/config/completions.rs index b3a5ccf15a..e4975a7b39 100644 --- a/crates/nu-protocol/src/config/completions.rs +++ b/crates/nu-protocol/src/config/completions.rs @@ -58,6 +58,9 @@ impl UpdateFromValue for CompletionSort { pub struct ExternalCompleterConfig { pub enable: bool, pub max_results: i64, + /// A closure completing external-command arguments. To drive a picker like `fzf`, have it + /// call a command carrying the `@interactive` attribute; interactivity is seen through the + /// closure to that command, so there is no separate switch to set. pub completer: Option, } diff --git a/crates/nu-std/std/util/mod.nu b/crates/nu-std/std/util/mod.nu index 62a397efb7..0d5649a07c 100644 --- a/crates/nu-std/std/util/mod.nu +++ b/crates/nu-std/std/util/mod.nu @@ -111,3 +111,31 @@ export const null_device = if $nu.os-info.name == "windows" { export def null-device []: nothing -> path { $null_device } + +# Structure a command-line string into its tokens, for custom completers. +# +# A completer that declares a `buffer` parameter receives the whole command line up to the +# cursor as a string. This turns it into a clean `{text, kind, span}` table: the parser's +# duplicate spans are collapsed and the `shape_` prefix is dropped from each `kind`. It is +# steadier than a bare `ast --flatten` for this, though the parser is still lossy — reach for +# the raw `buffer` when you need the text exactly as it was typed. +@example "structure a command line" { + std structure "ls -a | where name == foo" +} --result [ + [text, kind, span]; + [ls, internalcall, {start: 0, end: 2}] + [-a, flag, {start: 3, end: 5}] + ["|", pipe, {start: 6, end: 7}] + [where, internalcall, {start: 8, end: 13}] + [name, string, {start: 14, end: 18}] + ["==", operator, {start: 19, end: 21}] + [foo, string, {start: 22, end: 25}] +] +export def structure [ + buffer: string # the command line to structure, e.g. a completer's `buffer` input +]: nothing -> table> { + ast --flatten $buffer + | rename text kind span + | update kind { str replace "shape_" "" } + | uniq-by span +} diff --git a/crates/nu-std/tests/test_util.nu b/crates/nu-std/tests/test_util.nu index c99587649e..cdfeb3ffde 100644 --- a/crates/nu-std/tests/test_util.nu +++ b/crates/nu-std/tests/test_util.nu @@ -88,3 +88,18 @@ def repeat_things [] { assert equal ($x | repeat 2) [$x $x] } } + +@test +def structure_tokenizes_a_command_line [] { + use std/assert + + let tokens = structure "ls -a | where name == foo" + assert equal ($tokens | get text) [ls -a "|" where name "==" foo] + assert equal ($tokens | get kind) [internalcall flag pipe internalcall string operator string] + + # the parser's duplicate spans are collapsed to one row each + assert equal ($tokens | length) ($tokens | get span | uniq | length) + + # an empty line structures to no tokens + assert equal (structure "") [] +} diff --git a/crates/nu_plugin_query/src/query_web.rs b/crates/nu_plugin_query/src/query_web.rs index 81618087ac..dcf63a1e32 100644 --- a/crates/nu_plugin_query/src/query_web.rs +++ b/crates/nu_plugin_query/src/query_web.rs @@ -213,14 +213,14 @@ pub fn retrieve_tables( } if inspect_mode { - eprintln!("Passed in Column Headers = {:?}\n", &cols); + eprintln!("Passed in Column Headers = {:?}\n", cols); eprintln!("First 2048 HTML chars = {}\n", &html[0..2047]); } let tables = match WebTable::find_by_headers(html, &cols, inspect_mode) { Some(t) => { if inspect_mode { - eprintln!("Table Found = {:#?}", &t); + eprintln!("Table Found = {:#?}", t); } t } @@ -300,10 +300,10 @@ fn retrieve_table(mut table: WebTable, columns: &Value, span: Span) -> Value { .map(|col| { let val = row .get(col) - .unwrap_or(&format!("Missing column: '{}'", &col)) + .unwrap_or(&format!("Missing column: '{}'", col)) .to_string(); - if !at_least_one_row_filled && val != format!("Missing column: '{}'", &col) { + if !at_least_one_row_filled && val != format!("Missing column: '{}'", col) { at_least_one_row_filled = true; } (col.clone(), Value::string(val, span)) diff --git a/crates/nu_plugin_query/src/web_tables.rs b/crates/nu_plugin_query/src/web_tables.rs index 4bd06144c1..295292b672 100644 --- a/crates/nu_plugin_query/src/web_tables.rs +++ b/crates/nu_plugin_query/src/web_tables.rs @@ -69,7 +69,7 @@ impl WebTable { table.select(&sel_tr).next().is_some_and(|tr| { let cells = select_cells(tr, &sel_th, true); if inspect_mode { - eprintln!("Potential HTML Headers = {:?}\n", &cells); + eprintln!("Potential HTML Headers = {:?}\n", cells); } headers.iter().all(|h| contains_str(&cells, h.as_ref())) }) diff --git a/src/main.rs b/src/main.rs index f5fc41c0a1..3255a49ed1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,11 @@ fn main() -> Result<()> { // handler — and Rust escalates the double-panic to `abort()`, producing a crash // report for what should be a clean shutdown. std::panic::set_hook(Box::new(|info| { + // Completion sources are isolated and convert their panics into ShellErrors. The hook + // runs before catch_unwind, so do not print a second, misleading prompt-level panic. + if nu_cli::completion_panic_is_active() { + return; + } use miette::Context; // Best-effort terminal restore; never panic from inside the hook. diff --git a/tests/fixtures/lsp/completion/command.nu b/tests/fixtures/lsp/completion/command.nu index 21e0694f4d..072c3d0fd6 100644 --- a/tests/fixtures/lsp/completion/command.nu +++ b/tests/fixtures/lsp/completion/command.nu @@ -16,7 +16,7 @@ config n # don't panic! '1' | into int -e big --endian big # command-wide completion -def "nu-complete foo" [spans: list] { [($spans | last)] } +def "nu-complete foo" [token] { [$token.text] } @complete "nu-complete foo" def --wrapped "foo" [...rest] { } diff --git a/tests/fixtures/lsp/completion/custom.nu b/tests/fixtures/lsp/completion/custom.nu index 002e5d9918..f0cd0f9e61 100644 --- a/tests/fixtures/lsp/completion/custom.nu +++ b/tests/fixtures/lsp/completion/custom.nu @@ -1,6 +1,6 @@ -# custom completion should be called with correct position -def comp_with_span [context, pos] { - let end = $context | str length +# a completer never sees text past the cursor, so the last token it gets ends there +def comp_with_span [token] { + let end = $token.span.end [{ value: "foo", span: { diff --git a/tests/integration/completions.rs b/tests/integration/completions.rs new file mode 100644 index 0000000000..857a04e87e --- /dev/null +++ b/tests/integration/completions.rs @@ -0,0 +1,214 @@ +//! End-to-end completion behavior and diagnostics. + +use assert_cmd::cargo_bin; +use nu_test_support::prelude::*; +use rstest::rstest; +use std::process::Command; + +fn completing_with(attribute: &str, body: &str) -> String { + format!( + "{attribute}def comp [token: record] {{ {body} }}\n\ + def use-it [arg: string@comp] {{ $arg }}\n\ + 'use-it a' | commandline complete" + ) +} + +/// Completer examples should type their token record. +#[test] +fn documented_completer_examples_declare_a_typed_token() -> Result { + let examples: Vec = test().run( + "scope commands + | each {|command| $command.examples | each {|example| $example.example } } + | flatten + | where {|example| $example =~ 'token' }", + )?; + + assert!( + !examples.is_empty(), + "no completer examples found at all — has the query gone stale?" + ); + + let untyped: Vec<&String> = examples + .iter() + .filter(|example| !example.contains("token: record")) + .collect(); + + assert!( + untyped.is_empty(), + "these completer examples still declare an untyped `token`: {untyped:#?}" + ); + Ok(()) +} + +/// Everything `source` wrote to stderr, run with no logging beyond `extra`. +fn completion_stderr(source: &str, extra: &[&str]) -> Result { + let output = Command::new(cargo_bin!()) + .args(["--no-config-file", "--no-std-lib"]) + .args(extra) + .args(["-c", source]) + .output()?; + + assert!( + output.status.success(), + "expected the completion to be survivable, but nu failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stderr).into_owned()) +} + +fn completion_log(source: &str) -> Result { + completion_stderr(source, &["--log-level", "error"]) +} + +/// Malformed completer results are logged without failing the shell. +#[rstest] +#[case::block_failed( + "error make { msg: 'completer exploded' }", + &["failed to eval completer block", "completer exploded"] +)] +#[case::completions_field_unusable( + "{completions: 'nope'}", + &["a completer's `completions` is not usable", "Can't convert to list"] +)] +#[case::suggestion_span_unusable( + "[{value: a, span: 'nope'}]", + &["a completer's suggestion span is not usable", "Can't convert to record"] +)] +#[case::unknown_envelope_field( + "{completions: [a], bogus: 1}", + &["a completion envelope has no `bogus` field", "completions, options, fallback"] +)] +fn a_completers_mistake_is_explained_in_the_log( + #[case] body: &str, + #[case] expected: &[&str], +) -> Result { + let log = completion_log(&format!("{} | ignore", completing_with("", body)))?; + + for fragment in expected { + assert!( + log.contains(fragment), + "the log never mentioned {fragment:?}\nfull log: {log}" + ); + } + Ok(()) +} + +/// Unknown field still offered. +#[test] +fn a_suggestion_with_an_unknown_field_is_still_offered() -> Result { + let source = completing_with("", "[{value: alpha, display: 'alpha (a)'}]"); + + assert_eq!(test().run::>(&source)?, ["alpha"]); + Ok(()) +} + +/// Bare value is one suggestion. +#[test] +fn a_bare_value_is_read_as_one_suggestion() -> Result { + let source = completing_with("", "'alpha'"); + + assert_eq!(test().run::>(&source)?, ["alpha"]); + Ok(()) +} + +/// Legacy positional bridge warns once. +#[test] +fn the_legacy_positional_bridge_warns_once_without_a_log_level() -> Result { + let complete = "'use-it a' | commandline complete | ignore"; + let stderr = completion_stderr( + &format!( + "def comp [context: string, pos: int] {{ [alpha] }}\n\ + def use-it [arg: string@comp] {{ $arg }}\n\ + {complete}\n{complete}" + ), + &[], + )?; + + assert!( + stderr.contains("Positional completer input deprecated"), + "the bridge warned silently: {stderr}" + ); + assert!( + stderr.contains("`context`, `pos`"), + "the warning never named the parameters to migrate: {stderr}" + ); + assert_eq!( + stderr + .matches("Positional completer input deprecated") + .count(), + 1, + "a completer must not warn again on every use: {stderr}" + ); + Ok(()) +} + +const NEEDS_A_TERMINAL: &str = "nu::shell::interactive_completer_needs_a_terminal"; + +/// Interactive completers require a terminal; ordinary ones remain available. +#[rstest] +#[case::carries_the_attribute("@interactive\n", true)] +#[case::plain_completer("", false)] +fn only_an_interactive_completer_is_refused_without_a_terminal( + #[case] attribute: &str, + #[case] refused: bool, +) -> Result { + let source = completing_with(attribute, "[alpha]"); + + match test().run::>(&source) { + Ok(completions) => { + assert!( + !refused, + "expected a refusal, but it ran and returned {completions:?}" + ); + assert_eq!( + completions, + ["alpha"], + "a plain completer must still answer" + ); + } + Err(err) => { + assert!(refused, "a plain completer must not be refused: {err:?}"); + match err.shell()? { + ShellError::Generic(generic) => assert_eq!(generic.code, NEEDS_A_TERMINAL), + other => panic!("expected the refusal, got {other:?}"), + } + } + } + Ok(()) +} + +/// Input inspection still works for interactive completers. +#[rstest] +#[case::input_record("--input | get token.text", "a")] +#[case::input_buffer("--input | get buffer", "use-it a")] +fn a_refused_completer_can_still_be_inspected( + #[case] tail: &str, + #[case] expected: &str, +) -> Result { + let source = format!( + "@interactive\n\ + def comp [token: record] {{ [alpha] }}\n\ + def use-it [arg: string@comp] {{ $arg }}\n\ + 'use-it a' | commandline complete {tail}" + ); + + let got: String = test().run(&source)?; + assert_eq!(got, expected); + Ok(()) +} + +/// Built-in completion sources bypass custom completers. +#[test] +fn a_builtin_source_is_unaffected_by_the_refusal() -> Result { + let source = format!( + "{} --type command", + completing_with("@interactive\n", "[alpha]") + ); + + let refused = test().run::>(&source).is_err(); + assert!( + !refused, + "--type runs no completer, so it must not be refused" + ); + Ok(()) +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 50579208bf..282141de76 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1,6 +1,7 @@ use nu_test_support::prelude::*; mod cli; +mod completions; mod posix_end_of_options; mod shell_integration; diff --git a/tests/repl/test_commandline.rs b/tests/repl/test_commandline.rs index b7f9a2f6a0..3c09c643fe 100644 --- a/tests/repl/test_commandline.rs +++ b/tests/repl/test_commandline.rs @@ -195,9 +195,10 @@ fn commandline_test_complete_reentrant() -> TestResult { } #[rstest] +// Detailed output includes suggestion metadata. #[case::cmd( "test-", - r#"{value: test-cmd, span: {start: 0, end: 5}, description: "", kind: command, type: custom}"# + r#"{value: test-cmd, span: {start: 0, end: 5}, description: "", append_whitespace: true, match_indices: [0, 1, 2, 3, 4], kind: command, type: custom}"# )] #[case::int( "test-cmd --int ", @@ -227,7 +228,16 @@ fn commandline_test_complete_detailed(#[case] cmd: &str, #[case] expected: &str) #[case::invalid_input("123 | commandline complete", "command doesn't support int input")] #[case::invalid_type( "commandline complete --type foo", - r#"expected type "directory", "path", or "glob""# + "one of directory, path, glob, command, variable, env-var" +)] +// `--input` returns the completer input record, not completions; reject flags that shape output. +#[case::input_with_detailed( + "'ls ' | commandline complete --input --detailed", + "cannot be used with --input" +)] +#[case::input_with_type( + "'ls ' | commandline complete --input --type path", + "cannot be used with --input" )] fn commandline_test_complete_invalid_input( #[case] cmd: &str, @@ -235,3 +245,284 @@ fn commandline_test_complete_invalid_input( ) -> TestResult { fail_test(cmd, expected_err) } + +/// `--input` reports the resolved cursor and declared shape. +#[rstest] +#[case::command("test-", "{kind: command}")] +#[case::flag_name("test-cmd --", "{kind: flag-name}")] +#[case::flag_value( + "test-cmd --string ", + "{kind: flag-value, flag: string, shape: string}" +)] +#[case::short_flag_value("test-cmd -s ", "{kind: flag-value, flag: string, shape: string}")] +// Short-only flags still report their declared shape. +#[case::short_only_flag_value("test-cmd -e ", "{kind: flag-value, flag: e, shape: directory}")] +#[case::positional("test-cmd ", "{kind: positional, index: 0, shape: string}")] +#[case::variable("$ni", "{kind: variable}")] +fn commandline_test_complete_input_cursor(#[case] cmd: &str, #[case] expected: &str) -> TestResult { + run_test( + &format!( + " + def test-cmd [ + first?: string, + --string(-s): string@[a], + -e: directory, + ] {{}}\n\ + \n\ + '{cmd}' | commandline complete --input \ + | get place | reject cursor target | to nuon" + ), + expected, + ) +} + +/// The whole record for a flat commandline: one context, head first and the completed +/// token last, and both walks landing on that token. +#[test] +fn commandline_test_complete_input_record() -> TestResult { + run_test( + "def test-cmd [first?: string] {}\n\ + 'test-cmd ab' | commandline complete --input | to nuon", + "{token: {text: ab, kind: value, span: {start: 9, end: 11}}, \ +place: {cursor: 11, target: {start: 9, end: 11}, kind: positional, index: 0, shape: string}, \ +buffer: \"test-cmd ab\"}", + ) +} + +/// `buffer` is the whole line up to the cursor, with `place` resolving against the command +/// the cursor is actually in — here `test-cmd`'s positional, even though the cursor is nested +/// in a closure. +#[test] +fn commandline_test_complete_input_buffer() -> TestResult { + run_test( + "def test-cmd [first?: string] {}\n\ + 'ignored | each { test-cmd ab' | commandline complete --input | reject token | to nuon", + "{place: {cursor: 28, target: {start: 26, end: 28}, kind: positional, index: 0, shape: string}, \ +buffer: \"ignored | each { test-cmd ab\"}", + ) +} + +/// `buffer` is the line being completed, so it carries the whole line up to the cursor even +/// where the `commandline` command comes back empty -- e.g. the fresh command after a `;`. +/// This is why an external completer reads `buffer` instead of calling `commandline`. +#[test] +fn commandline_test_complete_input_buffer_survives_a_semicolon() -> TestResult { + run_test( + "'foo; ' | commandline complete --input\n\ + | {buffer: $in.buffer, kind: $in.place.kind} | to nuon", + "{buffer: \"foo; \", kind: command}", + ) +} + +/// Resolve a completer named by an alias, through the alias. +#[rstest] +#[case::direct("comp-alias")] +#[case::nested("comp-nested")] +fn commandline_test_complete_alias_as_completer(#[case] completer: &str) -> TestResult { + run_test( + &format!( + "def \"nu-complete a\" [input] {{ [alpha beta] }}\n\ + alias comp-alias = nu-complete a\n\ + alias comp-nested = comp-alias\n\ + def foo [x: string@\"{completer}\"] {{}}\n\ + 'foo a' | commandline complete | to nuon" + ), + "[alpha]", + ) +} + +/// `tokens` is never empty, even where the parser produces none. +#[rstest] +#[case::variable("$")] +#[case::open_paren("(")] +#[case::open_bracket("[")] +#[case::bare_flag("cmd --")] +#[case::cell_path("$env.")] +#[case::assignment("a=b")] +#[case::unclosed_quote("cmd \"un")] +fn commandline_test_complete_input_tokens_never_empty(#[case] line: &str) -> TestResult { + run_test( + &format!("('{line}' | commandline complete --input | get token | is-empty)"), + "false", + ) +} + +/// A failing parameter completer is empty, not the working directory; a failing external +/// one still falls back to files. +#[test] +fn commandline_test_complete_failing_completer_is_empty() -> TestResult { + run_test( + "def \"nu-complete boom\" [input] { error make {msg: boom} }\n\ + def foo [x: string@\"nu-complete boom\"] {}\n\ + 'foo a' | commandline complete | length", + "0", + ) +} + +/// A token the parser consumes outright (a bare `--`) still gets a slot, so `cursor.token` +/// and `replacing` always have one to point at. +#[rstest] +#[case::bare_flag("cmd --", "{text: --, kind: flag}")] +#[case::trailing_slot("cmd ", r#"{text: "", kind: value}"#)] +#[case::partial_flag("cmd --al", "{text: --al, kind: flag}")] +fn commandline_test_complete_input_last_token( + #[case] line: &str, + #[case] expected: &str, +) -> TestResult { + run_test( + &format!( + "def cmd [--alpha, --beta] {{}}\n\ + '{line}' | commandline complete --input \ + | get token | select text kind | to nuon" + ), + expected, + ) +} + +/// A closure flattens to its delimiters and the padding around them, so the cursor in a +/// trailing gap must not land on whitespace: `each { ls ⌶` is completing `ls`'s first +/// argument, an empty slot, and never the space before it. +#[rstest] +#[case::closure("each { ls ")] +#[case::nested_closure("do { each { ls ")] +#[case::past_a_pipe("ls | each { ls ")] +fn commandline_test_complete_input_skips_whitespace_tokens(#[case] line: &str) -> TestResult { + run_test( + &format!("'{line}' | commandline complete --input | to nuon"), + &format!( + "{{token: {{text: \"\", kind: value, span: {{start: {cursor}, end: {cursor}}}}}, \ +place: {{cursor: {cursor}, target: {{start: {cursor}, end: {cursor}}}, \ +kind: positional, index: 0, shape: \"oneof\"}}, buffer: \"{line}\"}}", + cursor = line.len() + ), + ) +} + +/// The gap inside a closure belongs to the command the gap is in, not the one enclosing it: +/// the buffer spans the whole closure, but `place` resolves to `ls`'s argument (its shape), +/// never `each`'s block. +#[test] +fn commandline_test_complete_input_gap_resolves_inside_the_closure() -> TestResult { + run_test( + "'each { ls ' | commandline complete --input\n\ + | {buffer: $in.buffer, kind: $in.place.kind, shape: $in.place.shape} | to nuon", + "{buffer: \"each { ls \", kind: positional, shape: \"oneof\"}", + ) +} + +/// Resolution is as deep as the cursor goes: an external call inside a subexpression inside a +/// pipeline still resolves to the argument it is really completing, while `buffer` keeps the +/// whole line up to the cursor. +#[test] +fn commandline_test_complete_input_resolves_deep_in_subexpressions() -> TestResult { + run_test( + "'ls | where a == (^ext ' | commandline complete --input\n\ + | {buffer: $in.buffer, place: ($in.place | reject cursor target)} | to nuon", + "{buffer: \"ls | where a == (^ext \", place: {kind: external-arg, index: 0}}", + ) +} + +/// Every offset is a byte offset: `é` is two bytes, so the `héllo` token — and the target +/// that replaces it — is six bytes wide, not five. +#[test] +fn commandline_test_complete_input_offsets_are_bytes() -> TestResult { + run_test( + "'str replace héllo' | commandline complete --input\n\ + | {token: $in.token.text, width: ($in.place.target.end - $in.place.target.start)} | to nuon", + "{token: héllo, width: 6}", + ) +} + +/// An empty line still resolves: a completer reads the head it is about to complete rather +/// than a null it has to guard against, and the buffer is just empty. +#[test] +fn commandline_test_complete_input_empty_line() -> TestResult { + run_test( + "'' | commandline complete --input | to nuon", + "{token: {text: \"\", kind: head, span: {start: 0, end: 0}}, \ +place: {cursor: 0, target: {start: 0, end: 0}, kind: command}, buffer: \"\"}", + ) +} + +/// A target may run across tokens, and the `token` runs with it: completing a cell path +/// replaces the whole path, so the whole path is what is being completed. A completer that +/// echoes `$token.text` back leaves the line as it found it, rather than dropping the +/// `na.` its answer would have overwritten. +#[test] +fn commandline_test_complete_input_target_spans_tokens() -> TestResult { + run_test( + "'ls | get na.fo' | commandline complete --input\n\ + | {token: $in.token.text, target: $in.place.target} | to nuon", + "{token: \"na.fo\", target: {start: 9, end: 14}}", + ) +} + +/// `--type` selects a built-in completion source. +#[rstest] +#[case::command( + "def my-unique-cmd [] {}", + "'my-unique' | commandline complete --type command", + "[my-unique-cmd]" +)] +#[case::variable( + "", + "'$n' | commandline complete --type variable | where $it == '$nu'", + r#"["$nu"]"# +)] +#[case::env_var( + "$env.MY_UNIQUE_EV = 1", + "'MY_UNIQUE' | commandline complete --type env-var", + "[MY_UNIQUE_EV]" +)] +fn commandline_test_complete_type_sources( + #[case] setup: &str, + #[case] complete: &str, + #[case] expected: &str, +) -> TestResult { + run_test(&format!("{setup}\n{complete} | to nuon"), expected) +} + +/// `fallback: true` merges custom and command-wide completions. +#[test] +fn commandline_test_complete_fallthrough_stacks_completers() -> TestResult { + run_test( + "def per-arg [token] { {completions: [static-a], fallback: true} }\n\ + def cmd-wide [token] { [wide-x] }\n\ + @complete cmd-wide\n\ + def --wrapped my-cmd [x: string@per-arg, ...rest] {}\n\ + 'my-cmd ' | commandline complete | to nuon", + "[static-a, wide-x]", + ) +} + +/// Without `fallback`, the parameter completer claims the slot. +#[test] +fn commandline_test_complete_without_fallthrough_claims_the_slot() -> TestResult { + run_test( + "def per-arg [token] { [static-a] }\n\ + def cmd-wide [token] { [wide-x] }\n\ + @complete cmd-wide\n\ + def --wrapped my-cmd [x: string@per-arg, ...rest] {}\n\ + 'my-cmd ' | commandline complete | to nuon", + "[static-a]", + ) +} + +/// Custom suggestions preserve detailed metadata. +#[rstest] +#[case::kind("kind: directory", "get kind", "directory")] +#[case::append_whitespace("append_whitespace: true", "get append_whitespace", "true")] +fn commandline_test_complete_custom_suggestion_fields( + #[case] field: &str, + #[case] project: &str, + #[case] expected: &str, +) -> TestResult { + run_test( + &format!( + "def comp [token] {{ [{{value: foo, {field}}}] }}\n\ + def my-cmd [x: string@comp] {{}}\n\ + 'my-cmd f' | commandline complete --detailed | first | {project}" + ), + expected, + ) +} diff --git a/typos.toml b/typos.toml index 0ca125dad9..d017961e5b 100644 --- a/typos.toml +++ b/typos.toml @@ -24,6 +24,13 @@ extend-ignore-re = [ "\\*\\.caf=0;38;5;208", "\\*\\.hda=0;38;5;208", "ue", + # Intentional partial token and misspelled/unknown keys in completer tests + "na\\.fo", + "\\{token: fo", + "na, fo", + "strat: 3", + "discription: hi", + "fiter: false", ] [type.rust.extend-words]