feat(completions)!: unify completer inputs and output contracts (#18791)

Custom completion entry points each had their own calling convention,
so every completer had to work out how to read its input, rebuild
context it was not given, and often call `commandline` to recover
editor state:

    parameter completer      [context, offset]
    command-wide completer   [spans]
    external completer       {|spans| ...}
    menu source              {|buffer, position| ...}

All four now take the same three named inputs, bound by parameter name
rather than destructured: `token` (the token at the cursor), `place`
(the resolved completion site and its replacement range) and `buffer`
(the command line up to the cursor). A completer declares only the
ones it needs. Prefer `buffer` over calling `commandline`, since
`commandline` can be empty in some editor states, including after `;`.

Ordinary completers run on a background worker with stdin suppressed,
which is correct for normal completion but stops a completer from
driving a terminal picker such as fzf or `input list`. The new
`@interactive` attribute marks a completer that owns the terminal and
runs it inline on the line editor thread instead. That replaces the
experimental `background-completions` option, which was global and
thus all or nothing.

Breaking:

- `background-completions` is removed; `@interactive` is the
  per-completer replacement.
- A parameter completer that fails now yields no suggestions, rather
  than falling back to completing paths in the working directory.
- Command-wide and external completers may return `null` to decline,
  letting file completion or the next source answer.

Migration:

    def comp [input pos]        ->  def comp [token: record]
    def comp [spans]            ->  def comp [buffer: string]
    {|spans| ...}               ->  {|buffer: string| ...}
    {|buffer position| ...}     ->  {|token: record| ...}
    background-completions=off  ->  @interactive on the completer

Completion state is now resolved at the cursor, so nested closures,
aliases, multi-word command heads, flag values and empty argument
slots all complete correctly, and the work no longer clones the whole
engine and stack on every keystroke.

`commandline complete --input` returns the three inputs without
running a completer, which is the supported way to develop one. The
field reference for suggestion records and the `{completions,
options, fallback}` envelope stays in the pull request.
This commit is contained in:
Miles Wirht authored and GitHub committed 2026-09-10 01:33:36 +02:00
1 parent 2cc7b086c8
commit b263714878
50 files changed
+5251 -1886

No files matched your search

@@ -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::<Value>(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<Value> = 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<SemanticSuggestion> {
// 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(),
}
}
@@ -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 <tab>`).
/// 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 <tab>`) 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![]),
}
}
}
@@ -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())
}
}
+131 -35
View File
@@ -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<SemanticSuggestion>),
/// Impure source result (filesystem, `PATH`, user/plugin code); worth caching.
Cacheable(Vec<SemanticSuggestion>),
/// Impure source declined: fall back, but cache the attempt.
Declined,
/// No source ran: fall back cheaply.
#[default]
Absent,
pub struct Fetched {
suggestions: Vec<SemanticSuggestion>,
/// 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<SemanticSuggestion>) -> Self {
Self {
suggestions,
answered: true,
reusable: false,
}
}
/// Contribute suggestions and allow fallback.
pub(crate) fn contributing(suggestions: Vec<SemanticSuggestion>) -> 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<SemanticSuggestion> {
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<Suggestion> 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);
}
}
@@ -74,7 +74,7 @@ impl Completer for CellPathCompletion<'_> {
}
}
Fetched::Pure(matcher.suggestion_results())
Fetched::answering(matcher.suggestion_results())
}
}
@@ -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),
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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()
@@ -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<Item = &'a Value>,
span: Span,
input_start: usize,
offset: usize,
) -> Vec<SemanticSuggestion> {
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<Record>, field: &str) -> Option<usize> {
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::<WithoutDebug>(
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<Vec<Spanned<String>>> {
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::<Vec<_>>()
.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<Self> {
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::<WithoutDebug>(
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<PipelineData, nu_protocol::ShellError>,
command_span: Span,
) -> Option<Vec<SemanticSuggestion>> {
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![])
}
}
}
@@ -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<Token> {
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<Token> = 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<SyntaxShape> {
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<Value> = 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)
}
@@ -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<bool> = 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<T>(
f: impl FnOnce() -> T,
) -> Result<T, Box<dyn std::any::Any + Send>> {
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<Value>; 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<Value> {
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<Vec<(u64, ShellWarning)>> = 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<BlockId> {
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> {
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> {
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<Self> {
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<Value, ShellError> {
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::<WithoutDebug>(
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::<String>() {
s.clone()
} else {
"Box<dyn Any>".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),
},
}
}
}
@@ -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<SemanticSuggestion>,
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<str>) {
log::error!("nu::shell::completion: {}", message.as_ref());
}
/// Convert a completer result field, logging conversion failures.
fn read_part<T: FromValue>(value: Value, what: &str) -> Option<T> {
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<Self, ShellError> {
value.coerce_into_string().map(Self)
}
fn expected_type() -> Type {
Type::String
}
}
impl From<Text> 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<Self> {
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<bool>,
sort: Option<bool>,
case_sensitive: Option<bool>,
match_description: Option<bool>,
completion_algorithm: Option<String>,
/// Deprecated; use the substring match algorithm.
positional: Option<bool>,
}
}
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<Text>,
description: Option<Text>,
/// Optional suggestion kind.
kind: Option<Value>,
/// A colour name or colour record.
style: Option<Value>,
/// Read on its own for the same reason as `style`.
span: Option<Value>,
/// Extra columns beside the value, in description menus.
extra: Option<Vec<Text>>,
/// Whether to append a space after the value.
append_whitespace: Option<bool>,
/// Character indices matching the typed text.
match_indices: Option<Vec<usize>>,
}
}
/// Convert a custom suggestion kind, defaulting to the value's type.
fn suggestion_kind(kind: Option<Value>, 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<i64>,
end: Option<i64>,
}
}
/// 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<Value>,
options: Option<ReturnedOptions>,
/// 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<Self> {
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<Self> {
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<nu_ansi_term::Style> {
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<i64>, 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<Item = Value>,
default_span: reedline::Span,
clamp: SpanClamp<'_>,
) -> Vec<SemanticSuggestion> {
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()))
}
}
@@ -161,6 +161,6 @@ impl Completer for DotNuCompletion {
.collect::<Vec<_>>(),
);
Fetched::Cacheable(all_results)
Fetched::answering(all_results).worth_keeping()
}
}
@@ -25,6 +25,6 @@ impl Completer for EnvVarCompletion {
});
}
Fetched::Pure(matcher.suggestion_results())
Fetched::answering(matcher.suggestion_results())
}
}
@@ -93,6 +93,6 @@ impl Completer for ExportableCompletion<'_> {
);
}
}
Fetched::Pure(results)
Fetched::answering(results)
}
}
@@ -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())
}
}
+8 -2
View File
@@ -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;
@@ -235,7 +235,7 @@ impl Completer for OperatorCompletion<'_> {
Expr::FullCellPath(path) => {
// for `$ <tab>`
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())
}
}
@@ -45,6 +45,6 @@ impl Completer for StaticCompletion {
}
}
Fetched::Pure(matcher.suggestion_results())
Fetched::answering(matcher.suggestion_results())
}
}
@@ -67,6 +67,6 @@ impl Completer for VariableCompletion {
});
}
Fetched::Pure(matcher.suggestion_results())
Fetched::answering(matcher.suggestion_results())
}
}
+1
View File
@@ -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};
+371 -89
View File
@@ -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<EngineState>,
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<EngineState>,
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::<WithoutDebug>(&self.engine_state, &mut self.stack, block, input)
.map(|p| p.body);
let res = catch_completion_panic(|| {
eval_block::<WithoutDebug>(&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<Suggestion> {
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<Suggestion> {
// `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<String> = 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<String> {
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::<String>::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"]);
}
}
+2
View File
@@ -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};
+287
View File
@@ -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<Mutex<Option<String>>>);
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<String> {
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<M> {
menu: M,
line: Option<MenuLine>,
}
impl<M> SourcedMenu<M> {
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<M: Menu> Menu for SourcedMenu<M> {
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<Mutex<Vec<(String, usize)>>>);
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::<Suggestion>::new())
}
}
fn active_menu() -> (Editor, SourcedMenu<ColumnarMenu>) {
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());
}
}
+47 -45
View File
@@ -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<M: Menu + 'static>(
menu: M,
source: &Closure,
span: Span,
stack: &Stack,
engine_state: Arc<EngineState>,
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 {
+20 -12
View File
@@ -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)) => {
-29
View File
@@ -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
File diff suppressed because it is too large. Load diff
@@ -55,8 +55,8 @@ impl Command for AttrComplete {
vec![Example {
description: "Use another command as completion source.",
example: "\
def complete-foo [spans: list<string>] {\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 }\
@@ -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<PipelineData, ShellError> {
Ok(Value::bool(true, call.head).into_pipeline_data())
}
fn run_const(
&self,
_working_set: &StateWorkingSet,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
Ok(Value::bool(true, call.head).into_pipeline_data())
}
fn is_const(&self) -> bool {
true
}
fn examples(&self) -> Vec<Example<'_>> {
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,
}]
}
}
@@ -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;
@@ -25,6 +25,7 @@ pub fn add_default_context(mut engine_state: EngineState) -> EngineState {
AttrCompleteExternal,
AttrDeprecated,
AttrExample,
AttrInteractive,
AttrSearchTerms,
Break,
Collect,
+72 -11
View File
@@ -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.
@@ -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;
}
@@ -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)]
@@ -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": {
+8 -9
View File
@@ -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" },
+4 -7
View File
@@ -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 <row_condition>".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") {
@@ -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<Closure>,
}
+28
View File
@@ -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<text: string, kind: string, span: record<start: int, end: int>> {
ast --flatten $buffer
| rename text kind span
| update kind { str replace "shape_" "" }
| uniq-by span
}
+15
View File
@@ -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 "") []
}
+4 -4
View File
@@ -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))
+1 -1
View File
@@ -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()))
})
+5
View File
@@ -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.
+1 -1
View File
@@ -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] { }
+3 -3
View File
@@ -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: {
+214
View File
@@ -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<String> = 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<String> {
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<String> {
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::<Vec<String>>(&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::<Vec<String>>(&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::<Vec<String>>(&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::<Vec<String>>(&source).is_err();
assert!(
!refused,
"--type runs no completer, so it must not be refused"
);
Ok(())
}
+1
View File
@@ -1,6 +1,7 @@
use nu_test_support::prelude::*;
mod cli;
mod completions;
mod posix_end_of_options;
mod shell_integration;
+293 -2
View File
@@ -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<glob, string>\"}}, 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<glob, string>\"}",
)
}
/// 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,
)
}
+7
View File
@@ -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]