Skip to main content

nu_cli/completions/
completer.rs

1use crate::completions::{
2    ArgValueCompletion, AttributableCompletion, AttributeCompletion, CellPathCompletion,
3    CommandCompletion, CommandScope, Completer, CompletionOptions, CustomCompletion,
4    DotNuCompletion, EnvVarCompletion, FileCompletion, FlagCompletion, NuMatcher,
5    OperatorCompletion, VariableCompletion,
6    base::{Fetched, SemanticSuggestion},
7};
8use lru::LruCache;
9use nu_parser::{parse, parse_shorter_head_reading};
10use nu_protocol::{
11    BuiltinCompletion, CommandWideCompleter, Completion, DeclId, Flag, Signature, Span,
12    SuggestionKind,
13    ast::{
14        Argument, AttributeBlock, Block, Call, Expr, Expression, ExternalArgument, FlagRef,
15        FullCellPath, PipelineRedirection, RedirectionTarget, Traverse,
16    },
17    engine::{ArgType, EngineState, Stack, StateWorkingSet},
18};
19use nu_utils::time::Instant;
20use reedline::{
21    Completer as ReedlineCompleter, CompletionOrigin, CompletionResult, CompletionStatus, Partial,
22    Suggestion, Suggestions,
23};
24use std::hash::{DefaultHasher, Hash, Hasher};
25use std::num::NonZeroUsize;
26use std::sync::{Arc, Mutex, mpsc};
27use std::thread;
28use std::time::Duration;
29use std::{borrow::Cow, ops::ControlFlow, path::is_separator};
30
31/// Max cache entries before evicting the least recently used; overridden per completer by
32/// `$env.config.completions.cache_size` (`0` disables the cache).
33const DEFAULT_CACHE_SIZE: usize = 100;
34
35use super::{StaticCompletion, custom_completions::CommandWideCompletion};
36
37/// Used as the function `f` in find_map Traverse
38///
39/// returns the inner-most pipeline_element of interest that reaches the given position
40fn find_pipeline_element_by_position<'a>(
41    expr: &'a Expression,
42    working_set: &'a StateWorkingSet,
43    pos: usize,
44) -> ControlFlow<Option<&'a Expression>> {
45    if !expr.span.contains(pos) && expr.span.end != pos {
46        return ControlFlow::Break(None);
47    }
48
49    let recurse = |e: &'a Expression| find_pipeline_element_by_position(e, working_set, pos);
50    let found = |x| ControlFlow::Break(Some(x));
51    let or_self = |opt: Option<&'a Expression>| opt.map_or(found(expr), found);
52
53    match &expr.expr {
54        Expr::RowCondition(block_id)
55        | Expr::Subexpression(block_id)
56        | Expr::Block(block_id)
57        | Expr::Closure(block_id) => {
58            let block = working_set.get_block(*block_id);
59            check_redirection_in_block(block, pos).map_or(ControlFlow::Continue(()), found)
60        }
61        Expr::Call(call) => or_self(
62            call.arguments
63                .iter()
64                .find_map(|arg| arg.expr().and_then(|e| e.find_map(working_set, &recurse))),
65        ),
66        Expr::ExternalCall(head, arguments) => or_self(
67            arguments
68                .iter()
69                .find_map(|arg| arg.expr().find_map(working_set, &recurse))
70                .or_else(|| {
71                    // `touches`, not `contains`: the cursor sits at the head's trailing
72                    // edge (issue #7648).
73                    touches(head.span, pos)
74                        .then(|| head.as_ref().find_map(working_set, &recurse))
75                        .flatten()
76                }),
77        ),
78        Expr::BinaryOp(lhs, _, rhs) => or_self(
79            lhs.find_map(working_set, &recurse)
80                .or_else(|| rhs.find_map(working_set, &recurse)),
81        ),
82        Expr::FullCellPath(fcp) => {
83            // `use std/util [E, T⌶`: the import list is a `List` in a `FullCellPath`; leave it
84            // to the enclosing call, which knows the module, to complete its members.
85            if touches(fcp.head.span, pos) && matches!(fcp.head.expr, Expr::List(_)) {
86                return ControlFlow::Continue(());
87            }
88            or_self(fcp.head.find_map(working_set, &recurse))
89        }
90        Expr::Var(_) => found(expr),
91        Expr::AttributeBlock(ab) => or_self(
92            ab.attributes
93                .iter()
94                .map(|attr| &attr.expr)
95                .chain(std::iter::once(ab.item.as_ref()))
96                .find_map(|e| e.find_map(working_set, &recurse)),
97        ),
98        _ => ControlFlow::Continue(()),
99    }
100}
101
102/// Whether `position` is inside `span` or exactly at its trailing edge.
103///
104/// Completion happens at a token's trailing edge, which the end-exclusive
105/// [`Span::contains`] would miss.
106pub(crate) fn touches(span: Span, position: usize) -> bool {
107    span.contains(position) || span.end == position
108}
109
110/// The last element when the cursor trails it over whitespace only (`ls ⌶`) — an empty
111/// new slot for that element. Non-whitespace gaps fall through to
112/// [`CompletionEngine::resolve_fallback_site`].
113fn trailing_gap_element<'a>(
114    block: &'a Block,
115    working_set: &StateWorkingSet,
116    absolute_position: usize,
117) -> Option<&'a Expression> {
118    let expression = &block.pipelines.last()?.elements.last()?.expr;
119    let gap = working_set.get_span_contents(Span::new(expression.span.end, absolute_position));
120    gap.iter()
121        .all(u8::is_ascii_whitespace)
122        .then_some(expression)
123}
124
125/// The span a command-name completion replaces, given the parsed `head` and the whole
126/// `element` it heads.
127fn command_name_span(head: Span, element: Span) -> Span {
128    Span::new(head.start, head.end.max(element.end))
129}
130
131/// Whether `token` is a flag being typed — i.e. it begins with `-`.
132///
133/// The parser stores in-progress flags as positionals, so the leading dash is the only
134/// reliable flag/positional test; the cache relies on it too.
135fn is_flag_text(token: impl AsRef<[u8]>) -> bool {
136    token.as_ref().starts_with(b"-")
137}
138
139/// [`is_flag_text`] for the token occupying `span`.
140fn is_flag_token(working_set: &StateWorkingSet, span: Span) -> bool {
141    is_flag_text(working_set.get_span_contents(span))
142}
143
144/// Whether `expr` is a value an operator can trail (`1 ⌶`, `'str' ⌶`). Exhaustive, so a
145/// new [`Expr`] variant must be classified rather than defaulting.
146fn is_operator_lhs(expr: &Expr) -> bool {
147    match expr {
148        Expr::Int(_)
149        | Expr::Float(_)
150        | Expr::Binary(_)
151        | Expr::Bool(_)
152        | Expr::String(_)
153        | Expr::RawString(_)
154        | Expr::StringInterpolation(_)
155        | Expr::GlobInterpolation(_, _)
156        | Expr::DateTime(_)
157        | Expr::ValueWithUnit(_)
158        | Expr::Range(_)
159        | Expr::FullCellPath(_)
160        | Expr::CellPath(_)
161        | Expr::Var(_)
162        | Expr::List(_)
163        | Expr::Record(_)
164        | Expr::Table(_)
165        | Expr::Nothing
166        | Expr::Subexpression(_)
167        | Expr::Block(_)
168        | Expr::Closure(_) => true,
169        Expr::AttributeBlock(_)
170        | Expr::VarDecl(_)
171        | Expr::Call(_)
172        | Expr::ExternalCall(_, _)
173        | Expr::Operator(_)
174        | Expr::RowCondition(_)
175        | Expr::UnaryNot(_)
176        | Expr::BinaryOp(_, _, _)
177        | Expr::Collect(_, _)
178        | Expr::MatchBlock(_)
179        | Expr::Keyword(_)
180        | Expr::Filepath(_, _)
181        | Expr::Directory(_, _)
182        | Expr::GlobPattern(_, _)
183        | Expr::ImportPattern(_)
184        | Expr::Overlay(_)
185        | Expr::Signature(_)
186        | Expr::Garbage => false,
187    }
188}
189
190/// The flag a [`FlagRef`] refers to, preserving the long/short distinction.
191fn find_flag(signature: &Signature, flag: FlagRef<'_>) -> Option<Flag> {
192    match flag {
193        FlagRef::Long(n) => signature.get_long_flag(n),
194        FlagRef::Short(s) => s.chars().next().and_then(|c| signature.get_short_flag(c)),
195    }
196}
197
198/// Non-named arguments before `before_index` — the positional index of that slot.
199fn count_positionals(call: &Call, before_index: usize) -> usize {
200    call.arguments
201        .iter()
202        .take(before_index)
203        .filter(|argument| !matches!(argument, Argument::Named(_)))
204        .count()
205}
206
207/// Helper function to extract file-path expression from redirection target
208fn check_redirection_target(target: &RedirectionTarget, pos: usize) -> Option<&Expression> {
209    let expr = target.expr();
210    expr.and_then(|expression| {
211        if let Expr::String(_) = expression.expr
212            && touches(expression.span, pos)
213        {
214            expr
215        } else {
216            None
217        }
218    })
219}
220
221/// For redirection target completion
222fn check_redirection_in_block(block: &Block, pos: usize) -> Option<&Expression> {
223    block
224        .pipelines
225        .iter()
226        .flat_map(|p| &p.elements)
227        .filter_map(|e| e.redirection.as_ref())
228        .find_map(|redir| match redir {
229            PipelineRedirection::Single { target, .. } => check_redirection_target(target, pos),
230            PipelineRedirection::Separate { out, err } => {
231                check_redirection_target(out, pos).or_else(|| check_redirection_target(err, pos))
232            }
233        })
234}
235
236/// Cache key and worker message identity: the text typed up to the cursor.
237///
238/// Excludes trailing text and derives `cursor()` from `typed.len()` so the two never
239/// disagree.
240#[derive(Debug, Clone, PartialEq, Eq, Hash)]
241pub(crate) struct CompletionQuery {
242    /// The prefix of the line buffer up to the (floored) cursor position.
243    typed: Arc<str>,
244}
245
246impl CompletionQuery {
247    fn new(line: &str, cursor: usize) -> Self {
248        let floored = line.floor_char_boundary(cursor);
249        Self {
250            typed: Arc::from(&line[..floored]),
251        }
252    }
253
254    fn typed(&self) -> &str {
255        &self.typed
256    }
257
258    fn cursor(&self) -> usize {
259        self.typed.len()
260    }
261
262    /// Whether `self` is `base` with more characters typed into the same `token`. The
263    /// appended text must stay within one token and must not turn it into a flag — a
264    /// different completion site than the cached result came from.
265    fn narrows(&self, base: &CompletionQuery, token: reedline::Span) -> bool {
266        let Some(appended) = self.typed().strip_prefix(base.typed()) else {
267            return false;
268        };
269
270        if appended.is_empty() || appended.contains(is_completion_boundary) {
271            return false;
272        }
273
274        let (Some(base_token), Some(narrowed_token)) = (
275            base.typed().get(token.start..),
276            self.typed().get(token.start..),
277        ) else {
278            return false;
279        };
280
281        is_flag_text(base_token) == is_flag_text(narrowed_token)
282    }
283}
284
285fn is_completion_boundary(c: char) -> bool {
286    c.is_whitespace()
287        || is_separator(c)
288        || matches!(
289            c,
290            '|' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | '=' | ','
291        )
292}
293
294/// The environment a cached completion was computed against.
295///
296/// Results depend on cwd, `PATH`, and known declarations, which change between prompts
297/// while the query text does not — so the query alone is not a sound cache key.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub(crate) struct CacheEnv(u64);
300
301impl CacheEnv {
302    /// Fingerprint the completion-relevant parts of `engine_state`/`stack`, once per
303    /// completer.
304    fn of(engine_state: &EngineState, stack: &Stack) -> Self {
305        let mut hasher = DefaultHasher::new();
306
307        engine_state.num_decls().hash(&mut hasher);
308        stack
309            .get_env_var(engine_state, "PATH")
310            .map(|path| path.to_expanded_string(":", engine_state.get_config()))
311            .hash(&mut hasher);
312
313        let cwd = engine_state.cwd(Some(stack)).ok();
314        // The cwd mtime, so adding/removing files invalidates stale file completions.
315        cwd.as_ref()
316            .and_then(|cwd| std::fs::metadata(cwd).ok()?.modified().ok())
317            .hash(&mut hasher);
318        cwd.hash(&mut hasher);
319
320        Self(hasher.finish())
321    }
322}
323
324struct CacheEntry {
325    suggestions: Suggestions,
326    env: CacheEnv,
327}
328
329impl CacheEntry {
330    /// Whether this entry may still answer a query: produced in the same environment.
331    fn is_usable(&self, env: CacheEnv) -> bool {
332        self.env == env
333    }
334
335    /// The span the cursor extends: the range the *last* suggestion replaces.
336    ///
337    /// `fetch_completions_by_block` keeps the cursor-anchored family last, so reading the
338    /// last span is the correct one to extend.
339    fn reference_span(&self) -> Option<reedline::Span> {
340        self.suggestions.last().map(|suggestion| suggestion.span)
341    }
342}
343
344/// Cross-prompt completion cache bounded by entry count (`$env.config.completions.cache_size`),
345/// evicting least recently used entries. Capacity `0` disables the cache.
346#[derive(Clone)]
347pub(crate) struct NarrowingCache {
348    entries: Arc<Mutex<Option<LruCache<CompletionQuery, CacheEntry>>>>,
349}
350
351impl Default for NarrowingCache {
352    fn default() -> Self {
353        Self::new(DEFAULT_CACHE_SIZE)
354    }
355}
356
357impl NarrowingCache {
358    /// `0` isn't a valid `LruCache` capacity; it means the cache is disabled.
359    pub(crate) fn new(capacity: usize) -> Self {
360        Self {
361            entries: Arc::new(Mutex::new(NonZeroUsize::new(capacity).map(LruCache::new))),
362        }
363    }
364
365    /// Resizes the cache in place, dropping LRU entries when shrinking. Capacity `0`
366    /// disables it. Called once per prompt so `cache_size` config changes take effect.
367    pub(crate) fn set_capacity(&self, capacity: usize) {
368        if let Ok(mut cache_guard) = self.entries.lock() {
369            *cache_guard = NonZeroUsize::new(capacity).map(|new_capacity| {
370                let mut cache = cache_guard
371                    .take()
372                    .unwrap_or_else(|| LruCache::new(new_capacity));
373                cache.resize(new_capacity);
374                cache
375            });
376        }
377    }
378
379    pub(crate) fn fresh(
380        &self,
381        query: &CompletionQuery,
382        environment: CacheEnv,
383    ) -> Option<Suggestions> {
384        let mut cache_guard = self.entries.lock().ok()?;
385        let entry = cache_guard.as_mut()?.get(query)?;
386
387        entry
388            .is_usable(environment)
389            .then(|| entry.suggestions.clone())
390    }
391
392    pub(crate) fn store(
393        &self,
394        query: CompletionQuery,
395        environment: CacheEnv,
396        suggestions: Suggestions,
397    ) {
398        if let Ok(mut cache_guard) = self.entries.lock()
399            && let Some(cache) = cache_guard.as_mut()
400        {
401            let stale_keys: Vec<_> = cache
402                .iter()
403                .filter(|(_, entry)| !entry.is_usable(environment))
404                .map(|(key, _)| key.clone())
405                .collect();
406
407            for key in stale_keys {
408                cache.pop(&key);
409            }
410
411            cache.put(
412                query,
413                CacheEntry {
414                    suggestions,
415                    env: environment,
416                },
417            );
418        }
419    }
420
421    pub(crate) fn narrowed_fallback(
422        &self,
423        query: &CompletionQuery,
424        environment: CacheEnv,
425        options: &CompletionOptions,
426    ) -> Suggestions {
427        let Some((base_suggestions, ref_span, search_token)) =
428            self.entries.lock().ok().and_then(|guard| {
429                let (_, entry, span) = guard
430                    .as_ref()?
431                    .iter()
432                    .filter_map(|(bq, e)| {
433                        let s = e.reference_span()?;
434                        (e.is_usable(environment) && query.narrows(bq, s)).then_some((
435                            bq.cursor(),
436                            e,
437                            s,
438                        ))
439                    })
440                    .max_by_key(|&(c, ..)| c)?;
441
442                let token = query.typed().get(span.start..)?;
443                Some((Arc::clone(&entry.suggestions), span, token))
444            })
445        else {
446            return Suggestions::default();
447        };
448
449        // Don't re-sort: the producing completer ranks a directory by its bare name and
450        // appends the separator afterwards, so sorting here would rank it `config/` and
451        // land it after `config.nu`. Filtering alone preserves the order it chose.
452        let mut matcher = NuMatcher::new(search_token, options, false);
453
454        base_suggestions
455            .iter()
456            .enumerate()
457            .filter(|(_, s)| s.span == ref_span)
458            .for_each(|(i, s)| {
459                matcher.add(s.display_value(), i);
460            });
461
462        let updated_span = reedline::Span::new(ref_span.start, query.cursor());
463
464        matcher
465            .results()
466            .into_iter()
467            .map(|(index, match_indices)| {
468                let mut suggestion = base_suggestions[index].clone();
469                suggestion.span = updated_span;
470                suggestion.match_indices = Some(match_indices);
471                suggestion
472            })
473            .collect()
474    }
475}
476
477struct Completed {
478    query: CompletionQuery,
479    suggestions: Suggestions,
480    cacheable: bool,
481}
482
483struct CompletionWorker {
484    request_tx: mpsc::Sender<CompletionQuery>,
485    result_rx: mpsc::Receiver<Completed>,
486    pending: Option<CompletionQuery>,
487    latest: Option<Completed>,
488}
489
490fn isolated_stack(parent: Arc<Stack>, suppress_stdin: bool) -> Arc<Stack> {
491    let stack = Stack::with_parent(parent)
492        .reset_out_dest()
493        .suppress_output()
494        .collect_value();
495    Arc::new(if suppress_stdin {
496        stack.suppress_stdin()
497    } else {
498        stack
499    })
500}
501
502/// What the cursor is completing; each variant carries exactly the AST it needs.
503#[derive(Debug, Clone)]
504pub(crate) enum SiteKind<'a> {
505    /// A command head. `node` is the whole call expression, used to detect a `^`/`%` sigil.
506    Command { node: Option<&'a Expression> },
507    /// A flag name being typed (`--`, `-x`).
508    FlagName {
509        call: &'a Call,
510        element: &'a Expression,
511    },
512    /// The value of a flag (`--opt <tab>`). `flag` preserves long/short identity;
513    /// `arg_slot` indexes `call.arguments`.
514    FlagValue {
515        call: &'a Call,
516        element: &'a Expression,
517        flag: FlagRef<'a>,
518        arg_slot: usize,
519    },
520    /// A positional argument. `sig_positional` indexes the signature's positionals,
521    /// `arg_slot` indexes `call.arguments`.
522    Positional {
523        call: &'a Call,
524        element: &'a Expression,
525        sig_positional: usize,
526        arg_slot: usize,
527    },
528    /// A binary-operator position trailing `lhs`.
529    Operator { lhs: &'a Expression },
530    /// A cell path into `path`.
531    CellPath { path: &'a FullCellPath },
532    /// A `$var` name.
533    Variable,
534    /// An attribute name (`@<tab>`).
535    AttributeName,
536    /// The item an attribute block decorates (`def`, `extern`, …).
537    AttributableItem,
538    /// An argument of a bare external call; `index` is the argument slot.
539    ExternalArg { call: &'a Expression, index: usize },
540    /// A file path — the base/fallback completion.
541    File,
542}
543
544impl<'a> SiteKind<'a> {
545    /// A command head backed by an existing call expression (used for sigil detection).
546    fn command(node: &'a Expression) -> Self {
547        Self::Command { node: Some(node) }
548    }
549}
550
551/// A fully resolved completion site: the span to replace, the typed text, the cursor, and
552/// the [`SiteKind`].
553///
554/// `typed_prefix`/`cursor` are derived centrally in [`CompletionEngine::finalize_site`] so
555/// they can never disagree with the span.
556#[derive(Debug, Clone)]
557pub(crate) struct CompletionSite<'a> {
558    pub kind: SiteKind<'a>,
559    pub span: Span,
560    pub typed_prefix: Cow<'a, str>,
561    /// The cursor, in absolute working-set (span) coordinates.
562    pub cursor: usize,
563}
564
565impl<'a> CompletionSite<'a> {
566    /// A site with the given kind and span; `typed_prefix`/`cursor` are filled later by
567    /// [`CompletionEngine::finalize_site`].
568    fn new(kind: SiteKind<'a>, span: Span) -> Self {
569        Self {
570            kind,
571            span,
572            typed_prefix: Cow::Borrowed(""),
573            cursor: 0,
574        }
575    }
576}
577
578/// Engine dispatch output: suggestions plus whether an impure source ran (worth caching).
579#[derive(Default)]
580struct Dispatched {
581    suggestions: Vec<SemanticSuggestion>,
582    cacheable: bool,
583}
584
585impl Dispatched {
586    /// Append another dispatch's suggestions, propagating its cacheability.
587    fn merge(&mut self, other: Dispatched) {
588        self.cacheable |= other.cacheable;
589        self.suggestions.extend(other.suggestions);
590    }
591}
592
593impl From<Fetched> for Dispatched {
594    fn from(fetched: Fetched) -> Self {
595        Self {
596            // Read the cacheable flag before `into_suggestions` consumes the outcome.
597            cacheable: fetched.is_cacheable(),
598            suggestions: fetched.into_suggestions(),
599        }
600    }
601}
602
603pub struct CompletionEngine {
604    engine_state: Arc<EngineState>,
605    stack: Arc<Stack>,
606    options: CompletionOptions,
607}
608
609#[derive(Clone, Copy)]
610pub(crate) struct Context<'a> {
611    pub working_set: &'a StateWorkingSet<'a>,
612    pub stack: &'a Stack,
613    pub options: &'a CompletionOptions,
614    pub span: Span,
615    pub prefix: &'a [u8],
616    pub offset: usize,
617}
618
619impl Context<'_> {
620    pub(crate) fn prefix_str(&self) -> Cow<'_, str> {
621        String::from_utf8_lossy(self.prefix)
622    }
623}
624
625impl CompletionEngine {
626    pub fn new(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
627        Self::with_stack(engine_state, isolated_stack(stack, false))
628    }
629
630    fn for_background(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
631        Self::with_stack(engine_state, isolated_stack(stack, true))
632    }
633
634    fn with_stack(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
635        let config = engine_state.get_config();
636        let options = CompletionOptions {
637            case_sensitive: config.completions.case_sensitive,
638            match_algorithm: config.completions.algorithm.into(),
639            sort: config.completions.sort,
640            match_description: false,
641        };
642        Self {
643            engine_state,
644            stack,
645            options,
646        }
647    }
648
649    fn to_background(&self) -> Self {
650        Self::for_background(Arc::clone(&self.engine_state), Arc::clone(&self.stack))
651    }
652
653    fn suggestions_for(&self, query: &CompletionQuery) -> (Suggestions, bool) {
654        let dispatched = self.dispatch_completions_at(query.typed(), query.cursor());
655        let suggestions = dispatched
656            .suggestions
657            .into_iter()
658            .map(|semantic_suggestion| semantic_suggestion.suggestion)
659            .collect();
660
661        (suggestions, dispatched.cacheable)
662    }
663
664    /// Parse `query` once and run `f` with the resolved completion site.
665    ///
666    /// `CompletionQuery` is already the prefix up to the floored cursor, so this
667    /// does not slice or floor again.
668    fn with_completion_site<R>(
669        &self,
670        query: &CompletionQuery,
671        f: impl FnOnce(&StateWorkingSet, &Arc<Block>, &CompletionSite, &str, usize) -> R,
672    ) -> R {
673        let line = query.typed();
674        let position = query.cursor();
675        let mut working_set = StateWorkingSet::new(&self.engine_state);
676        let offset = working_set.next_span_start();
677        let block = parse(&mut working_set, Some("completer"), line.as_bytes(), false);
678        let site = self.resolve_completion_site(&block, &working_set, position, offset, line);
679        f(&working_set, &block, &site, line, offset)
680    }
681
682    fn query_runs_user_closure(
683        &self,
684        working_set: &StateWorkingSet,
685        site: &CompletionSite,
686        line: &str,
687        offset: usize,
688    ) -> bool {
689        self.site_runs_user_closure(site, working_set)
690            || self.multiword_argument_runs_user_closure(site, working_set, line, offset)
691    }
692
693    /// User-closure completers (external completer, `@complete`, custom commands)
694    /// take the TTY. They must run on the REPL thread with reedline blocked, not
695    /// on the background worker.
696    #[cfg(test)]
697    fn should_complete_on_repl_thread(&self, query: &CompletionQuery) -> bool {
698        self.with_completion_site(query, |working_set, _, site, line, offset| {
699            self.query_runs_user_closure(working_set, site, line, offset)
700        })
701    }
702
703    /// Parse once; if this site needs a user closure, dispatch on this thread.
704    ///
705    /// Results are not cached: a picker (`fzf`, `input list`, `carapace`) is not a function
706    /// of the line, so a stored pick would skip the UI on the next Tab.
707    fn complete_user_closure_on_repl_thread(&self, query: &CompletionQuery) -> Option<Suggestions> {
708        self.with_completion_site(query, |working_set, block, site, line, offset| {
709            if !self.query_runs_user_closure(working_set, site, line, offset) {
710                return None;
711            }
712            let _tty = crate::util::ReplTerminalGuard::capture();
713            let dispatched = self.fetch_completions_by_block(
714                Arc::clone(block),
715                working_set,
716                query.cursor(),
717                offset,
718                line,
719            );
720            Some(
721                dispatched
722                    .suggestions
723                    .into_iter()
724                    .map(|semantic_suggestion| semantic_suggestion.suggestion)
725                    .collect(),
726            )
727        })
728    }
729
730    fn has_external_completer(&self) -> bool {
731        self.engine_state
732            .get_config()
733            .completions
734            .external
735            .completer
736            .is_some()
737    }
738
739    fn signature_runs_user_closure(&self, signature: &Signature) -> bool {
740        match signature.complete {
741            Some(CommandWideCompleter::Command(_)) => true,
742            Some(CommandWideCompleter::External) => self.has_external_completer(),
743            None => false,
744        }
745    }
746
747    fn site_runs_user_closure(&self, site: &CompletionSite, working_set: &StateWorkingSet) -> bool {
748        match &site.kind {
749            SiteKind::ExternalArg { .. } => self.has_external_completer(),
750            SiteKind::FlagName { call, .. } => {
751                self.signature_runs_user_closure(&working_set.get_decl(call.decl_id).signature())
752            }
753            SiteKind::FlagValue { call, flag, .. } => {
754                let signature = working_set.get_decl(call.decl_id).signature();
755                let resolved = find_flag(&signature, *flag);
756                let custom = resolved.as_ref().and_then(|flag| flag.completion.as_ref());
757                matches!(custom, Some(Completion::Command(_)))
758                    || self.signature_runs_user_closure(&signature)
759            }
760            SiteKind::Positional {
761                call,
762                sig_positional,
763                ..
764            } => {
765                let signature = working_set.get_decl(call.decl_id).signature();
766                let custom = signature
767                    .get_positional(*sig_positional)
768                    .and_then(|positional| positional.completion.as_ref());
769                matches!(custom, Some(Completion::Command(_)))
770                    || self.signature_runs_user_closure(&signature)
771            }
772            _ => false,
773        }
774    }
775
776    fn multiword_argument_runs_user_closure(
777        &self,
778        site: &CompletionSite,
779        working_set: &StateWorkingSet,
780        contents: &str,
781        offset: usize,
782    ) -> bool {
783        if !matches!(site.kind, SiteKind::Command { .. })
784            || !working_set
785                .get_span_contents(site.span)
786                .iter()
787                .any(u8::is_ascii_whitespace)
788        {
789            return false;
790        }
791
792        let mut parse_ws = StateWorkingSet::new(&self.engine_state);
793        let _ = parse_ws.add_file("completer", contents.as_bytes());
794        let Some(shorter) = parse_shorter_head_reading(&mut parse_ws, site.span, None) else {
795            return false;
796        };
797        let position = site.cursor.saturating_sub(offset);
798        let shorter_site = self.finalize_site(
799            self.resolve_expression_site(&shorter, site.cursor, &parse_ws),
800            contents,
801            position,
802            offset,
803        );
804        self.site_runs_user_closure(&shorter_site, &parse_ws)
805    }
806
807    pub fn fetch_completions_at(&self, line: &str, position: usize) -> Vec<SemanticSuggestion> {
808        self.dispatch_completions_at(line, position).suggestions
809    }
810
811    fn dispatch_completions_at(&self, line: &str, position: usize) -> Dispatched {
812        let safe_position = line.floor_char_boundary(position);
813        // Parse only up to the cursor, so the last pipeline element is always the token (or
814        // gap) being edited; trailing whitespace is kept to distinguish a gap from the token.
815        let sliced_line = &line[..safe_position];
816
817        let mut working_set = StateWorkingSet::new(&self.engine_state);
818        let span_offset = working_set.next_span_start();
819
820        let block = parse(
821            &mut working_set,
822            Some("completer"),
823            sliced_line.as_bytes(),
824            false,
825        );
826
827        self.fetch_completions_by_block(
828            block,
829            &working_set,
830            safe_position,
831            span_offset,
832            sliced_line,
833        )
834    }
835
836    pub fn fetch_completions_within_file(
837        &self,
838        filename: &str,
839        position: usize,
840        contents: &str,
841    ) -> Vec<SemanticSuggestion> {
842        let mut working_set = StateWorkingSet::new(&self.engine_state);
843
844        // `parse` must run first: it registers the file and its spans in `working_set`.
845        let block = parse(&mut working_set, Some(filename), contents.as_bytes(), false);
846
847        let Some(file_span) = working_set.get_span_for_filename(filename) else {
848            return Vec::new();
849        };
850
851        self.fetch_completions_by_block(block, &working_set, position, file_span.start, contents)
852            .suggestions
853    }
854
855    /// `position` is the cursor as a buffer-relative byte offset into `contents`.
856    fn fetch_completions_by_block(
857        &self,
858        block: Arc<Block>,
859        working_set: &StateWorkingSet,
860        position: usize,
861        offset: usize,
862        contents: &str,
863    ) -> Dispatched {
864        let site = self.resolve_completion_site(&block, working_set, position, offset, contents);
865        let mut dispatched = self.dispatch_completion_site(&site, working_set, offset);
866
867        // A multi-word head is ambiguous: also recover the argument reading of the shorter
868        // command and offer it before the subcommand name.
869        let argument_reading =
870            self.complete_multiword_head_as_argument(&site, working_set, offset, contents);
871        dispatched.cacheable |= argument_reading.cacheable;
872        dispatched
873            .suggestions
874            .splice(..0, argument_reading.suggestions);
875        dispatched
876    }
877
878    /// A multi-word head is ambiguous: `baz --test bar` is also `bar`, the value of
879    /// `baz --test`'s flag. Recover that argument reading via [`parse_shorter_head_reading`]
880    /// over the real buffer spans (avoiding the stale-span hazard of #5127), dropping
881    /// command-kind results the primary dispatch already offers.
882    fn complete_multiword_head_as_argument(
883        &self,
884        site: &CompletionSite,
885        working_set: &StateWorkingSet,
886        offset: usize,
887        contents: &str,
888    ) -> Dispatched {
889        if !matches!(site.kind, SiteKind::Command { .. })
890            || !working_set
891                .get_span_contents(site.span)
892                .iter()
893                .any(u8::is_ascii_whitespace)
894        {
895            return Dispatched::default();
896        }
897
898        let mut parse_ws = StateWorkingSet::new(&self.engine_state);
899        let _ = parse_ws.add_file("completer", contents.as_bytes());
900        let Some(shorter) = parse_shorter_head_reading(&mut parse_ws, site.span, None) else {
901            return Dispatched::default();
902        };
903
904        let position = site.cursor.saturating_sub(offset);
905        let shorter_site = self.finalize_site(
906            self.resolve_expression_site(&shorter, site.cursor, &parse_ws),
907            contents,
908            position,
909            offset,
910        );
911        // A command-head result means no distinct argument; leave it to the primary dispatch.
912        if matches!(shorter_site.kind, SiteKind::Command { .. }) {
913            return Dispatched::default();
914        }
915
916        let mut dispatched = self.dispatch_completion_site(&shorter_site, &parse_ws, offset);
917        // Drop command-kind results; only the argument value is contributed here.
918        dispatched
919            .suggestions
920            .retain(|candidate| !matches!(candidate.kind, Some(SuggestionKind::Command(..))));
921        dispatched
922    }
923
924    /// Dispatches the completion site to the appropriate specialized completer.
925    fn dispatch_completion_site(
926        &self,
927        site: &CompletionSite,
928        working_set: &StateWorkingSet,
929        offset: usize,
930    ) -> Dispatched {
931        let completion_context =
932            self.context(working_set, site.span, site.typed_prefix.as_bytes(), offset);
933
934        match &site.kind {
935            SiteKind::Command { node } => {
936                let completions = self.command_completion_helper(
937                    working_set,
938                    site.span,
939                    offset,
940                    self.command_completion_for_head(*node, site.span, working_set),
941                );
942
943                if completions.suggestions.is_empty() {
944                    self.suggestions_at(&mut FileCompletion, working_set, site.span, offset)
945                } else {
946                    completions
947                }
948            }
949
950            SiteKind::FlagName { .. }
951            | SiteKind::FlagValue { .. }
952            | SiteKind::Positional { .. } => {
953                self.dispatch_call_completion_site(site, working_set, offset, &completion_context)
954            }
955
956            SiteKind::Operator { lhs } => OperatorCompletion {
957                left_hand_side: lhs,
958            }
959            .fetch(&completion_context)
960            .into(),
961
962            SiteKind::CellPath { path } => CellPathCompletion {
963                full_cell_path: path,
964                cursor: site.cursor,
965            }
966            .fetch(&completion_context)
967            .into(),
968
969            SiteKind::Variable => {
970                self.variable_names_completion_helper(working_set, site.span, offset)
971            }
972
973            SiteKind::AttributeName => AttributeCompletion.fetch(&completion_context).into(),
974
975            SiteKind::AttributableItem => AttributableCompletion.fetch(&completion_context).into(),
976
977            SiteKind::ExternalArg { .. } => {
978                self.dispatch_external_arg(site, working_set, offset, &completion_context)
979            }
980
981            SiteKind::File => {
982                self.suggestions_at(&mut FileCompletion, working_set, site.span, offset)
983            }
984        }
985    }
986
987    /// Complete an external call argument: `sudo`/`doas` special-case, the configured
988    /// external completer, then file completion as a fallback.
989    fn dispatch_external_arg(
990        &self,
991        site: &CompletionSite,
992        working_set: &StateWorkingSet,
993        offset: usize,
994        completion_context: &Context,
995    ) -> Dispatched {
996        let SiteKind::ExternalArg {
997            call: external_call,
998            index,
999        } = &site.kind
1000        else {
1001            return Dispatched::default();
1002        };
1003        let external_call = *external_call;
1004        let Expr::ExternalCall(head, _) = &external_call.expr else {
1005            return Dispatched::default();
1006        };
1007
1008        // The first argument of `sudo`/`doas` is a command run under the wrapper.
1009        if *index == 0 {
1010            let head_command = working_set.get_span_contents(head.span);
1011            if head_command == b"sudo" || head_command == b"doas" {
1012                let commands = self.command_completion_helper(
1013                    working_set,
1014                    site.span,
1015                    offset,
1016                    CommandCompletion::new(CommandScope::All),
1017                );
1018                if !commands.suggestions.is_empty() {
1019                    return commands;
1020                }
1021            }
1022        }
1023
1024        let mut dispatched = Dispatched::default();
1025        let mut external_answered = false;
1026
1027        // The user's configured external completer (`$env.config.completions.external.completer`).
1028        if let Some(closure) = self
1029            .engine_state
1030            .get_config()
1031            .completions
1032            .external
1033            .completer
1034            .as_ref()
1035        {
1036            let mut completion = CommandWideCompletion::closure(closure, external_call);
1037            let fetched = completion.fetch(completion_context);
1038            external_answered = !fetched.needs_fallback();
1039            dispatched.merge(fetched.into());
1040        }
1041
1042        // Internal subcommands extending this call (e.g. `fod br` → `food bar`), which
1043        // suppress the file fallback like an internal call's arguments do.
1044        let subcommands =
1045            self.subcommand_suggestions(working_set, external_call.span.start, site.cursor, offset);
1046
1047        // File completion for path arguments, only when nothing more specific answered.
1048        if !external_answered
1049            && dispatched.suggestions.is_empty()
1050            && subcommands.suggestions.is_empty()
1051        {
1052            dispatched.merge(self.suggestions_at(
1053                &mut FileCompletion,
1054                working_set,
1055                site.span,
1056                offset,
1057            ));
1058        }
1059
1060        dispatched.merge(subcommands);
1061        dispatched
1062    }
1063
1064    /// Dispatch completions for call-bound sites (FlagName, FlagValue, Positional).
1065    fn dispatch_call_completion_site(
1066        &self,
1067        site: &CompletionSite,
1068        working_set: &StateWorkingSet,
1069        offset: usize,
1070        completion_context: &Context,
1071    ) -> Dispatched {
1072        // Only call-bound kinds carry a call and element; anything else is an error here.
1073        let (call, element) = match &site.kind {
1074            SiteKind::FlagName { call, element }
1075            | SiteKind::FlagValue { call, element, .. }
1076            | SiteKind::Positional { call, element, .. } => (*call, *element),
1077            _ => return Dispatched::default(),
1078        };
1079
1080        let signature = working_set.get_decl(call.decl_id).signature();
1081
1082        // Subcommands extending this command line are always offered, and suppress the
1083        // file-path fallback: a matched subcommand shouldn't also dump the whole directory.
1084        let subcommands =
1085            self.subcommand_suggestions(working_set, call.head.start, site.cursor, offset);
1086
1087        // The value kinds share one shape; only the `ArgType`, custom-completer, and declared
1088        // shape lookups differ.
1089        let argument_value = |engine: &Self, arg_type, custom, arg_slot, declared_shape| {
1090            engine.complete_argument_value(
1091                custom,
1092                ArgValueCompletion {
1093                    call,
1094                    arg_type,
1095                    need_fallback: subcommands.suggestions.is_empty(),
1096                    completer: engine,
1097                    arg_idx: arg_slot,
1098                    declared_shape,
1099                    cursor: site.cursor,
1100                },
1101                completion_context,
1102                &signature,
1103                element,
1104                site.cursor,
1105            )
1106        };
1107
1108        let mut results = match &site.kind {
1109            SiteKind::FlagName { .. } => {
1110                self.complete_flag_names(call.decl_id, completion_context, &signature, element)
1111            }
1112            SiteKind::FlagValue { flag, arg_slot, .. } => {
1113                let resolved = find_flag(&signature, *flag);
1114                argument_value(
1115                    self,
1116                    ArgType::Flag(Cow::Borrowed(flag.name())),
1117                    resolved.as_ref().and_then(|flag| flag.completion.clone()),
1118                    *arg_slot,
1119                    resolved.and_then(|flag| flag.arg),
1120                )
1121            }
1122            SiteKind::Positional {
1123                sig_positional,
1124                arg_slot,
1125                ..
1126            } => {
1127                let positional = signature.get_positional(*sig_positional);
1128                argument_value(
1129                    self,
1130                    ArgType::Positional(*sig_positional),
1131                    positional.and_then(|positional| positional.completion.clone()),
1132                    *arg_slot,
1133                    positional.map(|positional| positional.shape.clone()),
1134                )
1135            }
1136            _ => Dispatched::default(),
1137        };
1138
1139        results.merge(subcommands);
1140        results
1141    }
1142
1143    /// Resolves the contextual state and constraints at the cursor's location.
1144    pub(crate) fn resolve_completion_site<'a>(
1145        &self,
1146        block: &'a Block,
1147        working_set: &'a StateWorkingSet,
1148        position: usize,
1149        offset: usize,
1150        contents: &'a str,
1151    ) -> CompletionSite<'a> {
1152        let absolute_position = position + offset;
1153
1154        // The token whose span the cursor is inside of, or at the trailing edge of.
1155        let touched_expression = block
1156            .find_map(working_set, &|expression: &Expression| {
1157                find_pipeline_element_by_position(expression, working_set, absolute_position)
1158            })
1159            .or_else(|| check_redirection_in_block(block, absolute_position))
1160            // Otherwise the cursor is in a whitespace gap after the element it trails.
1161            .or_else(|| trailing_gap_element(block, working_set, absolute_position));
1162
1163        let site = match touched_expression {
1164            Some(expression) => {
1165                self.resolve_expression_site(expression, absolute_position, working_set)
1166            }
1167            None => self.resolve_fallback_site(block, working_set, absolute_position),
1168        };
1169
1170        self.finalize_site(site, contents, position, offset)
1171    }
1172
1173    /// Fill the centrally-derived `typed_prefix`/`cursor` fields from the final `site.span`,
1174    /// so the prefix and replacement span can never disagree. Point spans yield an empty
1175    /// prefix.
1176    fn finalize_site<'a>(
1177        &self,
1178        mut site: CompletionSite<'a>,
1179        contents: &'a str,
1180        position: usize,
1181        offset: usize,
1182    ) -> CompletionSite<'a> {
1183        let token_start = site.span.start.saturating_sub(offset);
1184        site.typed_prefix = contents
1185            .get(token_start..position)
1186            .map(Cow::Borrowed)
1187            .unwrap_or(Cow::Borrowed(""));
1188        site.cursor = position + offset;
1189        site
1190    }
1191
1192    fn resolve_expression_site<'a>(
1193        &self,
1194        expression: &'a Expression,
1195        absolute_position: usize,
1196        working_set: &'a StateWorkingSet,
1197    ) -> CompletionSite<'a> {
1198        // Cursor in whitespace after a completed value (`1 ⌶`) is an operator position.
1199        if absolute_position > expression.span.end && is_operator_lhs(&expression.expr) {
1200            return CompletionSite::new(
1201                SiteKind::Operator { lhs: expression },
1202                Span::point(absolute_position),
1203            );
1204        }
1205
1206        // Base case: file completion; overridden below where the expression warrants it.
1207        match &expression.expr {
1208            Expr::Call(call) => {
1209                self.resolve_call_site(call, expression, absolute_position, working_set)
1210            }
1211            Expr::ExternalCall(head, arguments) => {
1212                self.resolve_external_call_site(expression, head, arguments, absolute_position)
1213            }
1214            Expr::AttributeBlock(attribute_block) => {
1215                self.resolve_attribute_site(attribute_block, absolute_position)
1216            }
1217            Expr::Var(_) => CompletionSite::new(SiteKind::Variable, expression.span),
1218            // `$foo` alone is the variable; `$foo.bar` or `$foo.` is a cell path.
1219            Expr::FullCellPath(full_cell_path) => {
1220                let has_dot = working_set
1221                    .get_span_contents(expression.span)
1222                    .ends_with(b".");
1223
1224                let kind = if full_cell_path.tail.is_empty() && !has_dot {
1225                    SiteKind::Variable
1226                } else {
1227                    SiteKind::CellPath {
1228                        path: full_cell_path,
1229                    }
1230                };
1231
1232                CompletionSite::new(kind, expression.span)
1233            }
1234            Expr::BinaryOp(left_hand_side, operator, _) => CompletionSite::new(
1235                SiteKind::Operator {
1236                    lhs: left_hand_side.as_ref(),
1237                },
1238                operator.span,
1239            ),
1240            _ => CompletionSite::new(SiteKind::File, expression.span), // The default `File` setup holds
1241        }
1242    }
1243
1244    /// Resolve a bare external call (`git checkout`). The head completes as a command;
1245    /// other positions are [`SiteKind::ExternalArg`].
1246    fn resolve_external_call_site<'a>(
1247        &self,
1248        expression: &'a Expression,
1249        head: &'a Expression,
1250        arguments: &'a [ExternalArgument],
1251        absolute_position: usize,
1252    ) -> CompletionSite<'a> {
1253        if absolute_position <= head.span.end {
1254            return CompletionSite::new(
1255                SiteKind::command(expression),
1256                command_name_span(head.span, expression.span),
1257            );
1258        }
1259
1260        // An existing argument the cursor touches, or else the trailing empty slot.
1261        let (index, span) = arguments
1262            .iter()
1263            .enumerate()
1264            .find_map(|(index, argument)| {
1265                touches(argument.expr().span, absolute_position)
1266                    .then_some((index, argument.expr().span))
1267            })
1268            .unwrap_or((arguments.len(), Span::point(absolute_position)));
1269
1270        CompletionSite::new(
1271            SiteKind::ExternalArg {
1272                call: expression,
1273                index,
1274            },
1275            span,
1276        )
1277    }
1278
1279    fn resolve_call_site<'a>(
1280        &self,
1281        call: &'a Call,
1282        expression: &'a Expression,
1283        absolute_position: usize,
1284        working_set: &'a StateWorkingSet,
1285    ) -> CompletionSite<'a> {
1286        // Cursor in (or right after) the command head: complete the command name.
1287        if absolute_position <= call.head.end {
1288            return CompletionSite::new(
1289                SiteKind::command(expression),
1290                command_name_span(call.head, expression.span),
1291            );
1292        }
1293
1294        // Cursor on an existing argument.
1295        if let Some((argument_index, argument)) = call
1296            .arguments
1297            .iter()
1298            .enumerate()
1299            .find(|(_, argument)| touches(argument.span(), absolute_position))
1300        {
1301            return self.resolve_argument_site(
1302                call,
1303                expression,
1304                argument,
1305                argument_index,
1306                absolute_position,
1307                working_set,
1308            );
1309        }
1310
1311        // A trailing gap after a row condition (`where name ⌶`) is an operator position.
1312        if let Some(operator_left_hand_side) =
1313            self.row_condition_operator_lhs(call, working_set, absolute_position)
1314        {
1315            return CompletionSite::new(
1316                SiteKind::Operator {
1317                    lhs: operator_left_hand_side,
1318                },
1319                Span::point(absolute_position),
1320            );
1321        }
1322
1323        // Classify the slot the cursor trails after the last argument: a pending flag value,
1324        // a new flag name, or a new positional. Looking only at the non-whitespace token
1325        // ending at the cursor keeps `cmd -f val ⌶` (positional) and `cmd --⌶` (flag name)
1326        // distinct, and its span preserves the `-`/`--` prefix.
1327        let gap_start = call
1328            .arguments
1329            .last()
1330            .map_or(call.head.end, |argument| argument.span().end);
1331
1332        let gap = working_set.get_span_contents(Span::new(gap_start, absolute_position));
1333
1334        // Start just past the last whitespace in the gap.
1335        let token_start = gap
1336            .iter()
1337            .rposition(u8::is_ascii_whitespace)
1338            .map_or(gap_start, |index| gap_start + index + 1);
1339
1340        let trailing_token = Span::new(token_start, absolute_position);
1341        let token_is_flag = is_flag_token(working_set, trailing_token);
1342
1343        let point = Span::point(absolute_position);
1344
1345        if let Some(flag_ref) = self.pending_flag_value(call, working_set) {
1346            // Intentionally out-of-range `arg_slot`: there is no in-progress argument node
1347            // yet, and `ArgValueCompletion` reads `None` as exactly that.
1348            CompletionSite::new(
1349                SiteKind::FlagValue {
1350                    call,
1351                    element: expression,
1352                    flag: flag_ref,
1353                    arg_slot: call.arguments.len(),
1354                },
1355                point,
1356            )
1357        } else if token_is_flag {
1358            CompletionSite::new(
1359                SiteKind::FlagName {
1360                    call,
1361                    element: expression,
1362                },
1363                trailing_token,
1364            )
1365        } else {
1366            CompletionSite::new(
1367                SiteKind::Positional {
1368                    call,
1369                    element: expression,
1370                    sig_positional: count_positionals(call, call.arguments.len()),
1371                    arg_slot: call.arguments.len(),
1372                },
1373                point,
1374            )
1375        }
1376    }
1377
1378    /// The last row-condition term when the cursor trails it (`where name ⌶`): the LHS of
1379    /// an operator the user is about to type.
1380    fn row_condition_operator_lhs<'a>(
1381        &self,
1382        call: &'a Call,
1383        working_set: &'a StateWorkingSet,
1384        absolute_position: usize,
1385    ) -> Option<&'a Expression> {
1386        let block_id = call
1387            .arguments
1388            .iter()
1389            .rev()
1390            .find_map(|argument| match argument {
1391                Argument::Positional(Expression {
1392                    expr: Expr::RowCondition(block_id),
1393                    ..
1394                }) => Some(*block_id),
1395                _ => None,
1396            })?;
1397
1398        let last_term = &working_set
1399            .get_block(block_id)
1400            .pipelines
1401            .last()?
1402            .elements
1403            .last()?
1404            .expr;
1405
1406        if absolute_position <= last_term.span.end || !is_operator_lhs(&last_term.expr) {
1407            return None;
1408        }
1409
1410        let gap = working_set.get_span_contents(Span::new(last_term.span.end, absolute_position));
1411        gap.iter().all(u8::is_ascii_whitespace).then_some(last_term)
1412    }
1413
1414    /// The [`FlagRef`] of a last-argument flag still awaiting its value (`cmd --opt ⌶`).
1415    fn pending_flag_value<'a>(
1416        &self,
1417        call: &'a Call,
1418        working_set: &StateWorkingSet,
1419    ) -> Option<FlagRef<'a>> {
1420        let Argument::Named((name, short, None)) = call.arguments.last()? else {
1421            return None;
1422        };
1423
1424        let flag_ref = FlagRef::from_named(name, short.as_ref());
1425        let signature = working_set.get_decl(call.decl_id).signature();
1426
1427        find_flag(&signature, flag_ref)?
1428            .arg
1429            .is_some()
1430            .then_some(flag_ref)
1431    }
1432
1433    fn resolve_argument_site<'a>(
1434        &self,
1435        call: &'a Call,
1436        expression: &'a Expression,
1437        argument: &'a Argument,
1438        argument_index: usize,
1439        absolute_position: usize,
1440        working_set: &StateWorkingSet,
1441    ) -> CompletionSite<'a> {
1442        let flag_name = SiteKind::FlagName {
1443            call,
1444            element: expression,
1445        };
1446
1447        let (kind, span) = match argument {
1448            Argument::Named((name, short, optional_value)) => {
1449                if let Some(value_expression) = optional_value
1450                    .as_ref()
1451                    .filter(|value| touches(value.span, absolute_position))
1452                {
1453                    (
1454                        SiteKind::FlagValue {
1455                            call,
1456                            element: expression,
1457                            flag: FlagRef::from_named(name, short.as_ref()),
1458                            arg_slot: argument_index,
1459                        },
1460                        value_expression.span,
1461                    )
1462                } else {
1463                    // Only the name is being completed: `Argument::span` would also cover
1464                    // the value written after it (`--endian big`), and `name.span` is the
1465                    // flag token itself for both spellings.
1466                    (flag_name, name.span)
1467                }
1468            }
1469            // A positional/unknown token starting with `-` is a flag name being typed.
1470            Argument::Positional(_) | Argument::Unknown(_) => {
1471                let kind = if is_flag_token(working_set, argument.span()) {
1472                    flag_name
1473                } else {
1474                    SiteKind::Positional {
1475                        call,
1476                        element: expression,
1477                        sig_positional: count_positionals(call, argument_index),
1478                        arg_slot: argument_index,
1479                    }
1480                };
1481                (kind, argument.span())
1482            }
1483            Argument::Spread(_) => (SiteKind::File, argument.span()),
1484        };
1485
1486        CompletionSite::new(kind, span)
1487    }
1488
1489    fn resolve_attribute_site<'a>(
1490        &self,
1491        attribute_block: &'a AttributeBlock,
1492        absolute_position: usize,
1493    ) -> CompletionSite<'a> {
1494        if let Some(attribute) = attribute_block
1495            .attributes
1496            .iter()
1497            .find(|attribute| touches(attribute.expr.span, absolute_position))
1498        {
1499            return CompletionSite::new(SiteKind::AttributeName, attribute.expr.span);
1500        }
1501
1502        if touches(attribute_block.item.span, absolute_position) {
1503            return CompletionSite::new(SiteKind::AttributableItem, attribute_block.item.span);
1504        }
1505
1506        // Past the last attribute is the decorated item's slot, even when the parser found
1507        // no item to give a span to (`@complete "c"⏎⌶`). Earlier gaps sit between two
1508        // attributes, where another attribute name is what's being typed.
1509        let kind = match attribute_block.attributes.last() {
1510            Some(last) if absolute_position >= last.expr.span.end => SiteKind::AttributableItem,
1511            _ => SiteKind::AttributeName,
1512        };
1513
1514        CompletionSite::new(kind, Span::point(absolute_position))
1515    }
1516
1517    fn resolve_fallback_site<'a>(
1518        &self,
1519        block: &'a Block,
1520        working_set: &'a StateWorkingSet,
1521        absolute_position: usize,
1522    ) -> CompletionSite<'a> {
1523        let last_element = block
1524            .pipelines
1525            .last()
1526            .and_then(|pipeline| pipeline.elements.last())
1527            .map(|element| &element.expr);
1528
1529        // A bare `@` opens an attribute name; trailing a completed attribute block completes
1530        // the attributable item itself; otherwise a fresh command position.
1531        let kind = if last_element
1532            .map(|element| working_set.get_span_contents(element.span))
1533            .is_some_and(|bytes| bytes.ends_with(b"@"))
1534        {
1535            SiteKind::AttributeName
1536        } else if matches!(last_element.map(|e| &e.expr), Some(Expr::AttributeBlock(_))) {
1537            SiteKind::AttributableItem
1538        } else {
1539            SiteKind::Command { node: None }
1540        };
1541
1542        CompletionSite::new(kind, Span::point(absolute_position))
1543    }
1544    fn complete_argument_value(
1545        &self,
1546        custom: Option<Completion>,
1547        mut arg_value: ArgValueCompletion,
1548        context: &Context,
1549        signature: &Signature,
1550        element_expression: &Expression,
1551        cursor: usize,
1552    ) -> Dispatched {
1553        let mut results = Dispatched::default();
1554
1555        if let Some(custom) = custom {
1556            let attempt = match custom {
1557                // A command declared an engine-provided completion for this argument.
1558                Completion::Builtin(kind) => self.complete_builtin(kind, &arg_value, context),
1559                // A custom completer receives the element text up to the cursor
1560                // (`my-command foobar`), so its spans are anchored to the element's start.
1561                other => {
1562                    let element_line = String::from_utf8_lossy(
1563                        context
1564                            .working_set
1565                            .get_span_contents(Span::new(element_expression.span.start, cursor)),
1566                    );
1567                    self.custom_completion_helper(other, element_line.as_ref(), context, cursor)
1568                }
1569            };
1570            let need_fallback = attempt.needs_fallback();
1571            results.merge(attempt.into());
1572            if !need_fallback {
1573                return results;
1574            }
1575        }
1576
1577        let attempt = self.command_wide_completion_helper(signature, element_expression, context);
1578        let need_fallback = attempt.needs_fallback();
1579        results.merge(attempt.into());
1580        if !need_fallback {
1581            return results;
1582        }
1583
1584        results.merge(arg_value.fetch(context).into());
1585        results
1586    }
1587
1588    /// Dispatch a [`BuiltinCompletion`] a command declared for its argument.
1589    fn complete_builtin(
1590        &self,
1591        kind: BuiltinCompletion,
1592        arg_value: &ArgValueCompletion,
1593        context: &Context,
1594    ) -> Fetched {
1595        match kind {
1596            BuiltinCompletion::NuFile { std_virtual_path } => {
1597                DotNuCompletion { std_virtual_path }.fetch(context)
1598            }
1599            BuiltinCompletion::ModuleExports => {
1600                arg_value.complete_module_exports(context, context.working_set)
1601            }
1602            BuiltinCompletion::EnvVar => EnvVarCompletion.fetch(context),
1603            BuiltinCompletion::Command { internal_only } => {
1604                let scope = if internal_only {
1605                    CommandScope::InternalsOnly
1606                } else {
1607                    CommandScope::All
1608                };
1609                CommandCompletion::quoted(scope).fetch(context)
1610            }
1611        }
1612    }
1613
1614    fn complete_flag_names(
1615        &self,
1616        decl_id: DeclId,
1617        context: &Context,
1618        signature: &Signature,
1619        element_expression: &Expression,
1620    ) -> Dispatched {
1621        let mut results: Dispatched = FlagCompletion { decl_id }.fetch(context).into();
1622        results.merge(
1623            self.command_wide_completion_helper(signature, element_expression, context)
1624                .into(),
1625        );
1626        results
1627    }
1628
1629    fn suggestions_at<C: Completer>(
1630        &self,
1631        completer: &mut C,
1632        working_set: &StateWorkingSet,
1633        span: Span,
1634        offset: usize,
1635    ) -> Dispatched {
1636        completer
1637            .fetch(&self.context(
1638                working_set,
1639                span,
1640                working_set.get_span_contents(span),
1641                offset,
1642            ))
1643            .into()
1644    }
1645
1646    fn variable_names_completion_helper(
1647        &self,
1648        working_set: &StateWorkingSet,
1649        span: Span,
1650        offset: usize,
1651    ) -> Dispatched {
1652        let prefix = working_set.get_span_contents(span);
1653        if !prefix.starts_with(b"$") {
1654            return Dispatched::default();
1655        }
1656        let ctx = self.context(working_set, span, prefix, offset);
1657        VariableCompletion.fetch(&ctx).into()
1658    }
1659
1660    fn command_completion_helper(
1661        &self,
1662        working_set: &StateWorkingSet,
1663        span: Span,
1664        offset: usize,
1665        mut command_completion: CommandCompletion,
1666    ) -> Dispatched {
1667        let prefix = working_set.get_span_contents(span);
1668        let ctx = self.context(working_set, span, prefix, offset);
1669        command_completion.fetch(&ctx).into()
1670    }
1671
1672    /// Command-completion scope for a command head, honouring a leading sigil: `^` →
1673    /// externals only, `%` → built-ins only, otherwise everything. The sigil is the byte
1674    /// between the call's own span and its head span.
1675    fn command_completion_for_head(
1676        &self,
1677        node: Option<&Expression>,
1678        span: Span,
1679        working_set: &StateWorkingSet,
1680    ) -> CommandCompletion {
1681        let sigil = node
1682            .filter(|node| node.span.start < span.start)
1683            .and_then(|node| working_set.get_span_contents(node.span).first().copied());
1684
1685        CommandCompletion::new(match sigil {
1686            Some(b'^') => CommandScope::ExternalsOnly,
1687            Some(b'%') => CommandScope::BuiltinsOnly,
1688            _ => CommandScope::All,
1689        })
1690    }
1691
1692    /// Internal commands whose name extends the command line typed so far (`foo test⌶`
1693    /// also offers `foo test bar`). Externals are excluded: a multi-word line names only
1694    /// internal subcommands.
1695    fn subcommand_suggestions(
1696        &self,
1697        working_set: &StateWorkingSet,
1698        command_start: usize,
1699        cursor: usize,
1700        offset: usize,
1701    ) -> Dispatched {
1702        if cursor <= command_start {
1703            return Dispatched::default();
1704        }
1705        self.command_completion_helper(
1706            working_set,
1707            Span::new(command_start, cursor),
1708            offset,
1709            CommandCompletion::new(CommandScope::InternalsOnly),
1710        )
1711    }
1712
1713    fn custom_completion_helper(
1714        &self,
1715        custom_completion: Completion,
1716        input: &str,
1717        context: &Context,
1718        pos: usize,
1719    ) -> Fetched {
1720        match custom_completion {
1721            Completion::Command(decl_id) => {
1722                let mut completer =
1723                    CustomCompletion::new(decl_id, input.into(), pos - context.offset);
1724                completer.fetch(context)
1725            }
1726            Completion::List(list) => {
1727                let mut completer = StaticCompletion::new(list);
1728                completer.fetch(context)
1729            }
1730            // Engine-provided completions are handled in `complete_argument_value`; decline
1731            // if one arrives by another path.
1732            Completion::Builtin(_) => Fetched::Absent,
1733        }
1734    }
1735
1736    fn command_wide_completion_helper(
1737        &self,
1738        signature: &Signature,
1739        element_expression: &Expression,
1740        context: &Context,
1741    ) -> Fetched {
1742        let completion = match signature.complete {
1743            Some(CommandWideCompleter::Command(decl_id)) => {
1744                CommandWideCompletion::command(context.working_set, decl_id, element_expression)
1745            }
1746            Some(CommandWideCompleter::External) => self
1747                .engine_state
1748                .get_config()
1749                .completions
1750                .external
1751                .completer
1752                .as_ref()
1753                .map(|closure| CommandWideCompletion::closure(closure, element_expression)),
1754            None => None,
1755        };
1756
1757        match completion {
1758            Some(mut completion) => {
1759                let context = Context {
1760                    prefix: b"",
1761                    ..*context
1762                };
1763                completion.fetch(&context)
1764            }
1765            None => Fetched::Absent,
1766        }
1767    }
1768
1769    pub(crate) fn context<'a>(
1770        &'a self,
1771        working_set: &'a StateWorkingSet,
1772        span: Span,
1773        prefix: &'a [u8],
1774        offset: usize,
1775    ) -> Context<'a> {
1776        Context {
1777            working_set,
1778            stack: self.stack.as_ref(),
1779            options: &self.options,
1780            span,
1781            prefix,
1782            offset,
1783        }
1784    }
1785
1786    pub(crate) fn options(&self) -> &CompletionOptions {
1787        &self.options
1788    }
1789}
1790
1791pub struct NuCompleter {
1792    engine: CompletionEngine,
1793    cache: NarrowingCache,
1794    /// The [`CacheEnv`] of every entry this completer stores/reads; computed once per
1795    /// completer, not on [`CompletionEngine`] (which non-caching callers also build).
1796    cache_env: CacheEnv,
1797    worker: Option<CompletionWorker>,
1798    /// Whether [`complete`](ReedlineCompleter::complete) offloads to a worker.
1799    /// False only on the REPL path with `background-completions` disabled.
1800    background: bool,
1801}
1802
1803impl NuCompleter {
1804    pub fn new(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
1805        Self::with_cache(engine_state, stack, NarrowingCache::default())
1806    }
1807
1808    /// The reedline completer; the only constructor that consults the
1809    /// `background-completions` experimental option.
1810    pub(crate) fn for_repl(
1811        engine_state: Arc<EngineState>,
1812        stack: Arc<Stack>,
1813        cache: NarrowingCache,
1814    ) -> Self {
1815        let mut completer = Self::with_cache(engine_state, stack, cache);
1816        completer.background = nu_experimental::BACKGROUND_COMPLETIONS.get();
1817        completer
1818    }
1819
1820    pub(crate) fn with_cache(
1821        engine_state: Arc<EngineState>,
1822        stack: Arc<Stack>,
1823        cache: NarrowingCache,
1824    ) -> Self {
1825        let engine = CompletionEngine::new(engine_state, stack);
1826        let cache_env = CacheEnv::of(&engine.engine_state, &engine.stack);
1827        // Read fresh each prompt so `cache_size` config changes take effect.
1828        let cache_size = engine.engine_state.get_config().completions.cache_size;
1829        cache.set_capacity(cache_size.try_into().unwrap_or(0));
1830        Self {
1831            engine,
1832            cache,
1833            cache_env,
1834            worker: None,
1835            background: true,
1836        }
1837    }
1838
1839    fn fresh_for(&self, query: &CompletionQuery) -> Option<Suggestions> {
1840        if let Some(worker) = self.worker.as_ref()
1841            && let Some(latest) = &worker.latest
1842            && &latest.query == query
1843        {
1844            return Some(latest.suggestions.clone());
1845        }
1846        self.cache.fresh(query, self.cache_env)
1847    }
1848
1849    fn settle_pending(&mut self, query: &CompletionQuery) {
1850        if let Some(worker) = self.worker.as_mut()
1851            && worker.pending.as_ref() == Some(query)
1852        {
1853            worker.pending = None;
1854        }
1855    }
1856
1857    fn stale_fallback(&self, query: &CompletionQuery) -> Suggestions {
1858        self.cache
1859            .narrowed_fallback(query, self.cache_env, self.engine.options())
1860    }
1861
1862    fn spawn_worker(engine: &CompletionEngine) -> CompletionWorker {
1863        let (request_tx, request_rx) = mpsc::channel::<CompletionQuery>();
1864        let (result_tx, result_rx) = mpsc::channel::<Completed>();
1865
1866        let engine = engine.to_background();
1867        thread::spawn(move || {
1868            while let Ok(mut query) = request_rx.recv() {
1869                while let Ok(newer) = request_rx.try_recv() {
1870                    query = newer;
1871                }
1872
1873                let (suggestions, cacheable) = engine.suggestions_for(&query);
1874                let done = Completed {
1875                    query,
1876                    suggestions,
1877                    cacheable,
1878                };
1879                if result_tx.send(done).is_err() {
1880                    return;
1881                }
1882            }
1883        });
1884
1885        CompletionWorker {
1886            request_tx,
1887            result_rx,
1888            pending: None,
1889            latest: None,
1890        }
1891    }
1892
1893    fn fold_completed(&mut self, done: Completed) -> bool {
1894        let Self {
1895            cache,
1896            cache_env,
1897            worker,
1898            ..
1899        } = self;
1900        let Some(worker) = worker.as_mut() else {
1901            return false;
1902        };
1903        let settled = worker.pending.as_ref() == Some(&done.query);
1904        if done.cacheable {
1905            cache.store(done.query.clone(), *cache_env, done.suggestions.clone());
1906        }
1907        worker.latest = Some(done);
1908        settled
1909    }
1910
1911    fn try_recv_completed(&self) -> Option<Completed> {
1912        self.worker.as_ref()?.result_rx.try_recv().ok()
1913    }
1914
1915    fn recv_completed(&self, timeout: Duration) -> Option<Completed> {
1916        self.worker.as_ref()?.result_rx.recv_timeout(timeout).ok()
1917    }
1918
1919    fn drain_completed(&mut self) -> bool {
1920        let mut settled = false;
1921        while let Some(done) = self.try_recv_completed() {
1922            settled |= self.fold_completed(done);
1923        }
1924        settled
1925    }
1926
1927    pub fn complete_blocking(&mut self, line: &str, pos: usize) -> Suggestions {
1928        const BLOCKING_TIMEOUT: Duration = Duration::from_secs(30);
1929
1930        let fallback = match self.complete(line, pos) {
1931            CompletionResult::Fresh { suggestions, .. } => return suggestions,
1932            in_flight => in_flight.into_shared().unwrap_or_default(),
1933        };
1934
1935        let deadline = Instant::now() + BLOCKING_TIMEOUT;
1936        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
1937            let Some(done) = self.recv_completed(remaining) else {
1938                break;
1939            };
1940            if self.fold_completed(done) {
1941                return self.complete(line, pos).into_shared().unwrap_or_default();
1942            }
1943        }
1944
1945        fallback
1946    }
1947}
1948
1949/// Byte length of the longest prefix `a` and `b` share. Always a char boundary in both.
1950fn common_prefix_len(a: &str, b: &str) -> usize {
1951    a.char_indices()
1952        .zip(b.chars())
1953        .find_map(|((index, x), y)| (x != y).then_some(index))
1954        .unwrap_or_else(|| a.len().min(b.len()))
1955}
1956
1957fn partial_of(line: &str, suggestions: &[Suggestion]) -> Option<Partial> {
1958    let span = suggestions.first()?.span;
1959
1960    let mut matching_values = suggestions
1961        .iter()
1962        .filter(|suggestion| suggestion.span == span)
1963        .map(|suggestion| suggestion.value.as_str());
1964
1965    // Narrow a window into the first value rather than allocating a `String`; runs every
1966    // keystroke.
1967    let first = matching_values.next()?;
1968    let shared_len = matching_values.try_fold(first.len(), |shared, value| {
1969        let common = common_prefix_len(first.get(..shared)?, value);
1970        (common > 0).then_some(common)
1971    })?;
1972    let shared_prefix = first.get(..shared_len)?;
1973
1974    let entered = line.get(span.start..span.end)?;
1975    let extends = shared_prefix != entered
1976        && shared_prefix
1977            .to_lowercase()
1978            .starts_with(&entered.to_lowercase());
1979
1980    extends.then_some(Partial {
1981        span,
1982        insert: shared_prefix.to_string(),
1983    })
1984}
1985
1986impl ReedlineCompleter for NuCompleter {
1987    fn complete(&mut self, line: &str, pos: usize) -> CompletionResult {
1988        if !self.background {
1989            // Inline on this thread, skipping worker and cache: the pre-#18334
1990            // blocking behavior, which had neither. Blocking is the point, so
1991            // an interactive completer can own the terminal.
1992            let suggestions: Suggestions = self
1993                .engine
1994                .fetch_completions_at(line, pos)
1995                .into_iter()
1996                .map(|s| s.suggestion)
1997                .collect();
1998            let partial = partial_of(line, &suggestions);
1999            return CompletionResult::fresh(suggestions).with_partial(partial);
2000        }
2001
2002        let query = CompletionQuery::new(line, pos);
2003        self.drain_completed();
2004
2005        if let Some(suggestions) = self.fresh_for(&query) {
2006            self.settle_pending(&query);
2007            let partial = partial_of(line, &suggestions);
2008            return CompletionResult::fresh(suggestions).with_partial(partial);
2009        }
2010
2011        if let Some(suggestions) = self.engine.complete_user_closure_on_repl_thread(&query) {
2012            self.settle_pending(&query);
2013            let partial = partial_of(line, &suggestions);
2014            return CompletionResult::fresh(suggestions).with_partial(partial);
2015        }
2016
2017        let fallback = self.stale_fallback(&query);
2018        let partial = partial_of(line, &fallback);
2019
2020        let worker = self
2021            .worker
2022            .get_or_insert_with(|| Self::spawn_worker(&self.engine));
2023
2024        if worker.pending.as_ref() != Some(&query) {
2025            if worker.request_tx.send(query.clone()).is_ok() {
2026                worker.pending = Some(query);
2027            } else {
2028                // Worker died (a panic in a user completer closure kills it); drop it so the
2029                // next request spawns a replacement.
2030                self.worker = None;
2031            }
2032        }
2033
2034        CompletionResult::stale_or_pending(fallback, CompletionOrigin::new(line, pos))
2035            .with_partial(partial)
2036    }
2037
2038    fn poll_completion(&mut self) -> CompletionStatus {
2039        let settled = self.drain_completed();
2040
2041        match self.worker.as_mut() {
2042            Some(worker) if worker.pending.is_some() => {
2043                if settled {
2044                    worker.pending = None;
2045                    CompletionStatus::Ready
2046                } else {
2047                    CompletionStatus::Pending
2048                }
2049            }
2050            _ => CompletionStatus::Idle,
2051        }
2052    }
2053}
2054
2055#[cfg(test)]
2056mod completer_tests {
2057    use super::*;
2058
2059    fn test_engine() -> Arc<EngineState> {
2060        let mut engine =
2061            nu_command::add_shell_command_context(nu_cmd_lang::create_default_context());
2062        let delta = StateWorkingSet::new(&engine).render();
2063        engine.merge_delta(delta).expect("merge_delta");
2064        Arc::new(engine)
2065    }
2066
2067    fn q(s: &str) -> CompletionQuery {
2068        CompletionQuery::new(s, s.len())
2069    }
2070
2071    /// The token being extended starts at `start`; suggestions replace from there.
2072    fn token(start: usize) -> reedline::Span {
2073        reedline::Span::new(start, start)
2074    }
2075
2076    #[test]
2077    fn narrows_stays_within_one_token() {
2078        assert!(q("ls foobar").narrows(&q("ls foo"), token(3)));
2079
2080        // Not narrowing: no new text, or text removed.
2081        assert!(!q("ls foo").narrows(&q("ls foo"), token(3)));
2082        assert!(!q("ls fo").narrows(&q("ls foo"), token(3)));
2083
2084        // Each boundary character starts a new token, which a cached entry cannot answer.
2085        for narrowed in [
2086            "ls foo|from",
2087            "ls foo;ls",
2088            "ls foo/bar",
2089            "ls foo=1",
2090            "ls foo,2",
2091        ] {
2092            assert!(
2093                !q(narrowed).narrows(&q("ls foo"), token(3)),
2094                "narrowed across a boundary: {narrowed:?}"
2095            );
2096        }
2097    }
2098
2099    #[test]
2100    fn narrows_rejects_a_token_that_becomes_a_flag() {
2101        // The empty positional slot after `from csv ` is answered with file names at a point
2102        // span; typing `--sep` appends no boundary, so only the flag check keeps them from
2103        // following it.
2104        let base = q("from csv ");
2105        assert!(!q("from csv --sep").narrows(&base, token(base.cursor())));
2106
2107        // Extending a flag the user was already typing stays sound.
2108        assert!(q("from csv --sep").narrows(&q("from csv --s"), token(9)));
2109    }
2110
2111    #[test]
2112    fn background_engine_suppresses_stdin() {
2113        let engine = test_engine();
2114        let stack = Arc::new(Stack::new());
2115        let foreground = CompletionEngine::new(engine, stack);
2116        assert!(!foreground.stack.suppress_stdin);
2117        let background = foreground.to_background();
2118        assert!(background.stack.suppress_stdin);
2119    }
2120
2121    #[test]
2122    fn internal_command_completion_stays_on_the_worker() {
2123        let engine = CompletionEngine::new(test_engine(), Arc::new(Stack::new()));
2124        assert!(!engine.should_complete_on_repl_thread(&q("ls | c")));
2125    }
2126
2127    fn engine_with_external_completer() -> Arc<EngineState> {
2128        use nu_engine::eval_block;
2129        use nu_protocol::{PipelineData, debugger::WithoutDebug};
2130
2131        let mut engine = (*test_engine()).clone();
2132        let mut stack = Stack::new();
2133        let mut working_set = StateWorkingSet::new(&engine);
2134        let block = parse(
2135            &mut working_set,
2136            None,
2137            b"$env.config.completions.external.completer = {|spans| $spans}",
2138            false,
2139        );
2140        assert!(working_set.parse_errors.is_empty());
2141        engine
2142            .merge_delta(working_set.render())
2143            .expect("merge_delta");
2144        eval_block::<WithoutDebug>(&engine, &mut stack, &block, PipelineData::empty())
2145            .expect("eval completer config");
2146        engine.merge_env(&mut stack).expect("merge_env");
2147        Arc::new(engine)
2148    }
2149
2150    #[test]
2151    fn external_arg_with_completer_runs_on_the_repl_thread() {
2152        let engine =
2153            CompletionEngine::new(engine_with_external_completer(), Arc::new(Stack::new()));
2154        assert!(engine.should_complete_on_repl_thread(&q("nvim foo")));
2155        assert!(!engine.should_complete_on_repl_thread(&q("ls | c")));
2156    }
2157
2158    /// Opted out: settles inline, spawns no worker, leaves the cache alone.
2159    #[test]
2160    fn opted_out_completer_settles_inline() {
2161        let mut completer = NuCompleter::new(test_engine(), Arc::new(Stack::new()));
2162        completer.background = false;
2163
2164        let result = completer.complete("ls | c", 6);
2165        assert!(
2166            matches!(result, CompletionResult::Fresh { .. }),
2167            "expected a settled result, got {result:?}"
2168        );
2169        assert!(result.suggestions().iter().any(|s| s.value == "cd"));
2170        assert!(completer.worker.is_none(), "a worker was spawned anyway");
2171        assert_eq!(completer.poll_completion(), CompletionStatus::Idle);
2172    }
2173
2174    /// Engine whose external completer reports what its evaluation environment
2175    /// allowed: `piped-N` if a piped external's stdout reached `lines`,
2176    /// `direct-S` if a final external's stdout was captured.
2177    fn probe_engine() -> (Arc<EngineState>, Arc<Stack>) {
2178        let mut engine =
2179            nu_command::add_shell_command_context(nu_cmd_lang::create_default_context());
2180        let mut stack = Stack::new();
2181        let cwd = std::env::temp_dir()
2182            .to_string_lossy()
2183            .trim_end_matches(['/', '\\'])
2184            .replace('\\', "/");
2185        stack.add_env_var(
2186            "PWD".to_string(),
2187            nu_protocol::Value::string(&cwd, Span::unknown()),
2188        );
2189        // External lookup needs a PATH; `Stack::new` starts with no env at all.
2190        stack.add_env_var(
2191            "PATH".to_string(),
2192            nu_protocol::Value::string(std::env::var("PATH").unwrap_or_default(), Span::unknown()),
2193        );
2194
2195        let setup = r#"$env.config.completions.external = {
2196                enable: true
2197                completer: {|spans|
2198                    let piped = ("alpha\nbeta\n" | lines | length)
2199                    let direct = ('gamma' | str trim)
2200                    [$"piped-($piped)" $"direct-($direct)"]
2201                }
2202            }"#;
2203        let mut working_set = StateWorkingSet::new(&engine);
2204        let block = nu_parser::parse(&mut working_set, None, setup.as_bytes(), false);
2205        assert!(working_set.parse_errors.is_empty(), "setup failed to parse");
2206        engine.merge_delta(working_set.render()).expect("merge");
2207        nu_engine::eval_block::<nu_protocol::debugger::WithoutDebug>(
2208            &engine,
2209            &mut stack,
2210            &block,
2211            nu_protocol::PipelineData::empty(),
2212        )
2213        .expect("eval setup");
2214        engine.merge_env(&mut stack).expect("merge env");
2215
2216        (Arc::new(engine), Arc::new(stack))
2217    }
2218
2219    /// Externals inside a completer keep their stdout on both stacks:
2220    /// `suppress_output` only sets `out_dest.stdout` (final command), while
2221    /// `collect_value` sets `pipe_stdout` (piped stages). Thus the opt-out only
2222    /// has to stop offloading; the stack needs no changes.
2223    #[rstest::rstest]
2224    #[case::foreground(false)]
2225    #[case::background(true)]
2226    fn externals_in_a_completer_keep_their_stdout(#[case] suppress_stdin: bool) {
2227        let (engine, stack) = probe_engine();
2228        let engine = CompletionEngine::new(engine, isolated_stack(stack, suppress_stdin));
2229
2230        let values: Vec<String> = engine
2231            .fetch_completions_at("somecmd x", 9)
2232            .into_iter()
2233            .map(|s| s.suggestion.value)
2234            .collect();
2235
2236        assert!(
2237            values.iter().any(|v| v == "piped-2"),
2238            "a piped external lost its stdout: {values:?}"
2239        );
2240        assert!(
2241            values.iter().any(|v| v == "direct-gamma"),
2242            "a final external lost its stdout: {values:?}"
2243        );
2244    }
2245
2246    /// The worker runs on an isolated stack and must still produce identical results.
2247    #[test]
2248    fn background_result_matches_the_synchronous_engine() {
2249        let engine = test_engine();
2250        let mut completer = NuCompleter::new(engine.clone(), Arc::new(Stack::new()));
2251
2252        let sorted = |mut values: Vec<String>| {
2253            values.sort();
2254            values
2255        };
2256        let expected = sorted(
2257            CompletionEngine::new(engine, Arc::new(Stack::new()))
2258                .fetch_completions_at("ls | c", 6)
2259                .into_iter()
2260                .map(|s| s.suggestion.value)
2261                .collect(),
2262        );
2263        assert!(expected.iter().any(|value| value == "cd"));
2264
2265        // Nothing is cached yet, so the first non-blocking call can only be pending.
2266        assert!(completer.complete("ls | c", 6).is_pending());
2267
2268        let settled = sorted(
2269            completer
2270                .complete_blocking("ls | c", 6)
2271                .iter()
2272                .map(|s| s.value.clone())
2273                .collect(),
2274        );
2275        assert_eq!(expected, settled);
2276    }
2277
2278    /// A cache handed to a new per-prompt completer must still answer the previous prompt's
2279    /// queries.
2280    #[test]
2281    fn cache_outlives_the_completer_that_filled_it() {
2282        let engine = test_engine();
2283        let cache = NarrowingCache::default();
2284
2285        let mut filling_prompt =
2286            NuCompleter::with_cache(engine.clone(), Arc::new(Stack::new()), cache.clone());
2287        let warmed = filling_prompt.complete_blocking("ls | c", 6);
2288        assert!(warmed.iter().any(|s| s.value == "cd"));
2289        drop(filling_prompt);
2290
2291        let mut next_prompt = NuCompleter::with_cache(engine, Arc::new(Stack::new()), cache);
2292        let answer = next_prompt.complete("ls | c", 6);
2293        assert!(
2294            matches!(answer, CompletionResult::Fresh { .. }),
2295            "a carried-over cache entry should answer outright, got {answer:?}"
2296        );
2297        assert!(answer.suggestions().iter().any(|s| s.value == "cd"));
2298    }
2299
2300    /// …but not across a `cd`/`$env.PATH` change — the reason [`CacheEnv`] exists.
2301    #[test]
2302    fn cache_is_not_reused_in_a_different_environment() {
2303        use nu_protocol::Value;
2304
2305        let engine = test_engine();
2306        let cache = NarrowingCache::default();
2307
2308        let mut filling_prompt =
2309            NuCompleter::with_cache(engine.clone(), Arc::new(Stack::new()), cache.clone());
2310        assert!(!filling_prompt.complete_blocking("ls | c", 6).is_empty());
2311
2312        let mut moved = Stack::new();
2313        moved.add_env_var(
2314            "PATH".into(),
2315            Value::string("/somewhere/else", Span::unknown()),
2316        );
2317        let mut next_prompt = NuCompleter::with_cache(engine, Arc::new(moved), cache);
2318        assert!(
2319            next_prompt.complete("ls | c", 6).is_pending(),
2320            "entries from another environment must not answer"
2321        );
2322    }
2323
2324    /// `cache_size = 0` must disable the cache entirely, even a carried-over one.
2325    #[test]
2326    fn cache_size_zero_disables_the_cache() {
2327        let mut engine = test_engine();
2328        {
2329            let state = Arc::make_mut(&mut engine);
2330            Arc::make_mut(&mut state.config).completions.cache_size = 0;
2331        }
2332        let cache = NarrowingCache::default();
2333
2334        let mut filling_prompt =
2335            NuCompleter::with_cache(engine.clone(), Arc::new(Stack::new()), cache.clone());
2336        assert!(!filling_prompt.complete_blocking("ls | c", 6).is_empty());
2337        drop(filling_prompt);
2338
2339        let mut next_prompt = NuCompleter::with_cache(engine, Arc::new(Stack::new()), cache);
2340        assert!(
2341            next_prompt.complete("ls | c", 6).is_pending(),
2342            "a disabled cache must not answer a query it could have answered"
2343        );
2344    }
2345
2346    /// A cached answer stands in for the computed one, so the two must agree on order.
2347    /// Re-sorting the cache put `config/` after `config.nu`, inverting every keystroke.
2348    #[test]
2349    fn a_narrowed_cache_answer_keeps_the_order_it_was_given() {
2350        let cache = NarrowingCache::default();
2351        let env = CacheEnv::of(&test_engine(), &Stack::new());
2352        let span = reedline::Span::new(3, 5);
2353
2354        // The order file completion produces: the directory first, ranked as `config`.
2355        let cached: Suggestions = ["config/", "config.nu"]
2356            .iter()
2357            .map(|value| Suggestion {
2358                value: (*value).to_string(),
2359                span,
2360                ..Default::default()
2361            })
2362            .collect::<Vec<_>>()
2363            .into();
2364
2365        cache.store(CompletionQuery::new("ls co", 5), env, cached);
2366
2367        let narrowed = cache.narrowed_fallback(
2368            &CompletionQuery::new("ls con", 6),
2369            env,
2370            &CompletionOptions::default(),
2371        );
2372
2373        let values: Vec<&str> = narrowed.iter().map(|s| s.value.as_str()).collect();
2374        assert_eq!(
2375            values,
2376            ["config/", "config.nu"],
2377            "the cached answer must not reorder what it stands in for"
2378        );
2379    }
2380}