Skip to main content

aster/
engine.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::thread;
7
8use anyhow::{Result, bail};
9
10use crate::commands::CommandCatalog;
11use crate::config::{AcceptMode, Settings};
12use crate::protocol::{Candidate, CandidateKind, CandidateSource, CompletionResponse};
13use crate::store::Store;
14
15const MAX_BUFFER_BYTES: usize = 64 * 1024;
16const FUZZY_HISTORY_LIMIT: usize = 4096;
17const MAX_DIRECTORY_ENTRIES: usize = 1024;
18
19pub fn complete(
20    store: &Store,
21    commands: &CommandCatalog,
22    buffer: &str,
23    cursor_byte: usize,
24    cwd: &str,
25    requested_limit: Option<usize>,
26    settings: &Settings,
27) -> Result<CompletionResponse> {
28    if buffer.len() > MAX_BUFFER_BYTES {
29        bail!("completion buffer exceeds {MAX_BUFFER_BYTES} bytes");
30    }
31    if cursor_byte > buffer.len() || !buffer.is_char_boundary(cursor_byte) {
32        bail!("cursor is not a valid UTF-8 byte offset");
33    }
34
35    // Mid-line replacement requires shell-aware token ranges. Abstain until that
36    // parser exists rather than risking corruption of the user's command.
37    if cursor_byte != buffer.len() || buffer.is_empty() {
38        return Ok(CompletionResponse::empty(cursor_byte));
39    }
40
41    let limit = requested_limit
42        .unwrap_or(settings.completion.max_candidates)
43        .min(settings.completion.max_candidates);
44    let history =
45        store.history_candidates(buffer, cwd, limit, settings.history.successful_first)?;
46
47    let mut candidates: Vec<_> = history
48        .into_iter()
49        .filter_map(|history| {
50            let command = history.command;
51            let suffix = command.strip_prefix(buffer)?;
52            if suffix.is_empty() {
53                return None;
54            }
55            let insert_text = suffix.to_owned();
56            let accept_text = match settings.completion.accept {
57                AcceptMode::Segment => next_segment(suffix),
58                AcceptMode::Full => insert_text.clone(),
59            };
60            Some(Candidate {
61                display: sanitize_display(&command),
62                description: history_description(history.uses, history.same_cwd),
63                description_pending: false,
64                kind: CandidateKind::History,
65                insert_text,
66                accept_text,
67                source: CandidateSource::History,
68            })
69        })
70        .collect();
71
72    if candidates.len() < limit && valid_command_prefix(buffer) {
73        let remaining = limit - candidates.len();
74        let command_candidates: Vec<_> = commands
75            .matching(buffer, limit)
76            .into_iter()
77            .filter(|entry| {
78                !candidates
79                    .iter()
80                    .any(|candidate| candidate.display == entry.name)
81            })
82            .take(remaining)
83            .map(|entry| {
84                let suffix = entry.name.strip_prefix(buffer).unwrap_or_default();
85                let insertion = if suffix.is_empty() { " " } else { suffix };
86                Candidate {
87                    display: entry.name.clone(),
88                    description: entry.description.clone(),
89                    description_pending: entry.description_pending,
90                    kind: CandidateKind::Command,
91                    insert_text: insertion.to_owned(),
92                    accept_text: insertion.to_owned(),
93                    source: CandidateSource::Command,
94                }
95            })
96            .collect();
97        candidates.extend(command_candidates);
98    }
99
100    let mut enrichment_pending = false;
101    let help_candidates = if let Some((command, option, prefix)) = value_context(buffer) {
102        let values = commands.matching_values(command, option, prefix, limit);
103        enrichment_pending = values.pending;
104        values
105            .entries
106            .into_iter()
107            .filter_map(|value| {
108                let suffix = value.value.strip_prefix(prefix)?;
109                let insertion = if suffix.is_empty() { " " } else { suffix };
110                Some(Candidate {
111                    display: if suffix.is_empty() {
112                        buffer.to_owned()
113                    } else {
114                        format!("{buffer}{suffix}")
115                    },
116                    description: if value.description.is_empty() {
117                        format!("Value for {option}")
118                    } else {
119                        value.description
120                    },
121                    description_pending: false,
122                    kind: CandidateKind::Value,
123                    insert_text: insertion.to_owned(),
124                    accept_text: insertion.to_owned(),
125                    source: CandidateSource::Help,
126                })
127            })
128            .collect()
129    } else if let Some((command, prefix)) = option_context(buffer) {
130        let options = commands.matching_options(command, prefix, limit);
131        enrichment_pending = options.pending;
132        options
133            .entries
134            .into_iter()
135            .filter_map(|option| {
136                let suffix = option.spelling.strip_prefix(prefix)?;
137                if suffix.is_empty() {
138                    return None;
139                }
140                Some(Candidate {
141                    display: format!("{buffer}{suffix}"),
142                    description: option.description,
143                    description_pending: false,
144                    kind: CandidateKind::Option,
145                    insert_text: suffix.to_owned(),
146                    accept_text: suffix.to_owned(),
147                    source: CandidateSource::Help,
148                })
149            })
150            .collect()
151    } else if let Some((command, prefix)) = subcommand_context(buffer) {
152        let subcommands = commands.matching_subcommands(command, prefix, limit);
153        enrichment_pending = subcommands.pending;
154        subcommands
155            .entries
156            .into_iter()
157            .filter_map(|subcommand| {
158                let suffix = subcommand.name.strip_prefix(prefix)?;
159                let insertion = if suffix.is_empty() { " " } else { suffix };
160                Some(Candidate {
161                    display: if suffix.is_empty() {
162                        buffer.to_owned()
163                    } else {
164                        format!("{buffer}{suffix}")
165                    },
166                    description: subcommand.description,
167                    description_pending: false,
168                    kind: CandidateKind::Subcommand,
169                    insert_text: insertion.to_owned(),
170                    accept_text: insertion.to_owned(),
171                    source: CandidateSource::Help,
172                })
173            })
174            .collect()
175    } else {
176        Vec::new()
177    };
178    if !help_candidates.is_empty() {
179        let mut help_candidates: Vec<_> = help_candidates
180            .into_iter()
181            .filter(|entry| {
182                !candidates
183                    .iter()
184                    .any(|candidate| candidate.display == entry.display)
185            })
186            .collect();
187        let help_slots = help_candidates.len().min((limit / 2).max(1));
188        candidates.truncate(limit.saturating_sub(help_slots));
189        candidates.extend(help_candidates.drain(..help_slots));
190    }
191
192    Ok(CompletionResponse {
193        replace_start_byte: cursor_byte,
194        replace_end_byte: cursor_byte,
195        candidates,
196        enrichment_pending,
197    })
198}
199
200pub fn fuzzy(
201    store: &Store,
202    commands: &CommandCatalog,
203    query: &str,
204    cwd: &str,
205    requested_limit: Option<usize>,
206    settings: &Settings,
207) -> Result<CompletionResponse> {
208    if query.len() > MAX_BUFFER_BYTES {
209        bail!("fuzzy query exceeds {MAX_BUFFER_BYTES} bytes");
210    }
211    let limit = requested_limit
212        .unwrap_or(settings.completion.max_candidates)
213        .min(settings.completion.max_candidates);
214    let mut seen = HashSet::new();
215    let mut pool = Vec::new();
216
217    for history in
218        store.history_inventory(cwd, FUZZY_HISTORY_LIMIT, settings.history.successful_first)?
219    {
220        if seen.insert(history.command.clone()) {
221            pool.push(Candidate {
222                display: sanitize_display(&history.command),
223                description: history_description(history.uses, history.same_cwd),
224                description_pending: false,
225                kind: CandidateKind::History,
226                insert_text: history.command.clone(),
227                accept_text: history.command,
228                source: CandidateSource::History,
229            });
230        }
231    }
232    for command in commands.inventory() {
233        if seen.insert(command.name.clone()) {
234            pool.push(Candidate {
235                display: command.name.clone(),
236                description: command.description,
237                description_pending: false,
238                kind: CandidateKind::Command,
239                insert_text: command.name.clone(),
240                accept_text: command.name,
241                source: CandidateSource::Command,
242            });
243        }
244    }
245
246    let indexes = fzf_indexes(&pool, query, limit)?;
247    let mut candidates = Vec::with_capacity(indexes.len());
248    for index in indexes {
249        let mut candidate = pool[index].clone();
250        if candidate.source == CandidateSource::Command
251            && let Some(command) = commands
252                .matching(&candidate.display, 1)
253                .into_iter()
254                .find(|command| command.name == candidate.display)
255        {
256            candidate.description = command.description;
257            candidate.description_pending = command.description_pending;
258        }
259        candidates.push(candidate);
260    }
261    Ok(CompletionResponse {
262        replace_start_byte: 0,
263        replace_end_byte: 0,
264        candidates,
265        enrichment_pending: false,
266    })
267}
268
269pub fn filesystem_candidates(
270    buffer: &str,
271    cursor_byte: usize,
272    cwd: &str,
273    limit: usize,
274) -> Result<Vec<Candidate>> {
275    if limit == 0 || cursor_byte != buffer.len() || !buffer.is_char_boundary(cursor_byte) {
276        return Ok(Vec::new());
277    }
278    let Some(argument_start) = current_argument_start(buffer) else {
279        return Ok(Vec::new());
280    };
281    let Some(token) = unescape_path_token(&buffer[argument_start..]) else {
282        return Ok(Vec::new());
283    };
284    if token.starts_with('-') {
285        return Ok(Vec::new());
286    }
287
288    let (directory_text, name_prefix) = token
289        .rfind('/')
290        .map_or(("", token.as_str()), |slash| token.split_at(slash + 1));
291    let directory = resolve_directory(directory_text, cwd);
292    let Ok(children) = fs::read_dir(&directory) else {
293        return Ok(Vec::new());
294    };
295    let show_hidden = name_prefix.starts_with('.');
296    let mut matches = Vec::new();
297    for child in children.take(MAX_DIRECTORY_ENTRIES).flatten() {
298        let Some(name) = child.file_name().to_str().map(str::to_owned) else {
299            continue;
300        };
301        if name.is_empty()
302            || (!show_hidden && name.starts_with('.'))
303            || !name.starts_with(name_prefix)
304            || name.chars().any(char::is_control)
305        {
306            continue;
307        }
308        let Ok(file_type) = child.file_type() else {
309            continue;
310        };
311        if !(file_type.is_dir() || file_type.is_file() || file_type.is_symlink()) {
312            continue;
313        }
314        let is_directory = file_type.is_dir() || (file_type.is_symlink() && child.path().is_dir());
315        let suffix = &name[name_prefix.len()..];
316        let mut insert_text = escape_path_suffix(suffix);
317        if token.is_empty() && name.starts_with('-') {
318            insert_text.insert_str(0, "./");
319        }
320        if is_directory {
321            insert_text.push('/');
322        }
323        let exact_file = !is_directory && insert_text.is_empty();
324        if exact_file {
325            insert_text.push(' ');
326        }
327        matches.push((is_directory, name, insert_text, exact_file));
328    }
329    matches.sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
330    Ok(matches
331        .into_iter()
332        .take(limit)
333        .map(|(is_directory, _, insert_text, exact_file)| Candidate {
334            display: if exact_file {
335                buffer.to_owned()
336            } else {
337                format!("{buffer}{insert_text}")
338            },
339            description: if is_directory { "Directory" } else { "File" }.to_owned(),
340            description_pending: false,
341            kind: if is_directory {
342                CandidateKind::Directory
343            } else {
344                CandidateKind::File
345            },
346            accept_text: next_segment(&insert_text),
347            insert_text,
348            source: CandidateSource::Filesystem,
349        })
350        .collect())
351}
352
353pub fn merge_filesystem_candidates(
354    response: &mut CompletionResponse,
355    mut paths: Vec<Candidate>,
356    limit: usize,
357) {
358    if paths.is_empty() || limit == 0 {
359        return;
360    }
361    let history_count = response
362        .candidates
363        .iter()
364        .take_while(|candidate| candidate.source == CandidateSource::History)
365        .count();
366    let path_slots = paths.len().min((limit / 2).max(1));
367    if paths.len() > path_slots
368        && let Some(first) = paths.first_mut()
369    {
370        first.description.push_str(" (more matches)");
371    }
372    let history_keep = history_count.min(limit.saturating_sub(path_slots));
373    let mut original = std::mem::take(&mut response.candidates);
374    let trailing = original.split_off(history_count);
375    let history = original;
376    let mut seen = HashSet::new();
377    for candidate in history.into_iter().take(history_keep) {
378        if response.candidates.len() >= limit {
379            break;
380        }
381        if seen.insert(candidate.display.clone()) {
382            response.candidates.push(candidate);
383        }
384    }
385    let path_limit = response.candidates.len().saturating_add(path_slots);
386    for path in paths {
387        if response.candidates.len() >= path_limit || response.candidates.len() >= limit {
388            break;
389        }
390        if seen.insert(path.display.clone()) {
391            response.candidates.push(path);
392        }
393    }
394    for candidate in trailing {
395        if response.candidates.len() >= limit {
396            break;
397        }
398        if seen.insert(candidate.display.clone()) {
399            response.candidates.push(candidate);
400        }
401    }
402}
403
404fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
405    if directory_text == "~/" {
406        return std::env::var_os("HOME")
407            .map(PathBuf::from)
408            .unwrap_or_else(|| PathBuf::from(cwd));
409    }
410    if let Some(relative) = directory_text.strip_prefix("~/") {
411        return std::env::var_os("HOME")
412            .map(PathBuf::from)
413            .unwrap_or_else(|| PathBuf::from(cwd))
414            .join(relative);
415    }
416    let directory = Path::new(directory_text);
417    if directory.is_absolute() {
418        directory.to_owned()
419    } else {
420        Path::new(cwd).join(directory)
421    }
422}
423
424fn unescape_path_token(value: &str) -> Option<String> {
425    let mut unescaped = String::new();
426    let mut escaped = false;
427    for character in value.chars() {
428        if escaped {
429            unescaped.push(character);
430            escaped = false;
431        } else if character == '\\' {
432            escaped = true;
433        } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
434            return None;
435        } else {
436            unescaped.push(character);
437        }
438    }
439    (!escaped).then_some(unescaped)
440}
441
442fn current_argument_start(buffer: &str) -> Option<usize> {
443    let mut start = None;
444    let mut escaped = false;
445    for (index, character) in buffer.char_indices() {
446        if escaped {
447            escaped = false;
448        } else if character == '\\' {
449            escaped = true;
450        } else if character.is_whitespace() {
451            start = Some(index + character.len_utf8());
452        }
453    }
454    start
455}
456
457fn escape_path_suffix(value: &str) -> String {
458    let mut escaped = String::new();
459    for character in value.chars() {
460        if character.is_ascii()
461            && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
462        {
463            escaped.push('\\');
464        }
465        escaped.push(character);
466    }
467    escaped
468}
469
470fn option_context(buffer: &str) -> Option<(&str, &str)> {
471    if !safe_structured_buffer(buffer) {
472        return None;
473    }
474    let argument_start = buffer.rfind(char::is_whitespace)? + 1;
475    let prefix = &buffer[argument_start..];
476    if !prefix.starts_with('-') {
477        return None;
478    }
479    let mut words = buffer[..argument_start].split_ascii_whitespace();
480    let command = words.next()?;
481    if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
482        return None;
483    }
484    Some((command, prefix))
485}
486
487fn subcommand_context(buffer: &str) -> Option<(&str, &str)> {
488    if !safe_structured_buffer(buffer) {
489        return None;
490    }
491    let argument_start = buffer.rfind(char::is_whitespace)? + 1;
492    let prefix = &buffer[argument_start..];
493    if prefix.starts_with('-') || !valid_structured_prefix(prefix) {
494        return None;
495    }
496    let mut words = buffer[..argument_start].split_ascii_whitespace();
497    let command = words.next()?;
498    if !valid_command_prefix(command) || words.next().is_some() {
499        return None;
500    }
501    Some((command, prefix))
502}
503
504fn value_context(buffer: &str) -> Option<(&str, &str, &str)> {
505    if !safe_structured_buffer(buffer) {
506        return None;
507    }
508    let argument_start = buffer.rfind(char::is_whitespace)? + 1;
509    let token = &buffer[argument_start..];
510    let mut words = buffer[..argument_start].split_ascii_whitespace();
511    let command = words.next()?;
512    if !valid_command_prefix(command) {
513        return None;
514    }
515
516    if let Some((option, prefix)) = token.split_once('=') {
517        if words.next().is_none() && valid_option_context_spelling(option) {
518            return Some((command, option, prefix));
519        }
520        return None;
521    }
522
523    let option = words.next()?;
524    if words.next().is_some() || token.starts_with('-') || !valid_option_context_spelling(option) {
525        return None;
526    }
527    Some((command, option, token))
528}
529
530fn safe_structured_buffer(buffer: &str) -> bool {
531    buffer.is_ascii()
532        && !buffer
533            .chars()
534            .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
535}
536
537fn valid_structured_prefix(prefix: &str) -> bool {
538    prefix.is_empty()
539        || (prefix.as_bytes()[0].is_ascii_alphanumeric()
540            && prefix.bytes().all(|byte| {
541                byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+')
542            }))
543}
544
545fn valid_option_context_spelling(option: &str) -> bool {
546    option != "--"
547        && option.starts_with('-')
548        && option
549            .bytes()
550            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
551}
552
553fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
554    if candidates.is_empty() || limit == 0 {
555        return Ok(Vec::new());
556    }
557    let mut input = Vec::new();
558    for (index, candidate) in candidates.iter().enumerate() {
559        write!(input, "{index}\t{}\0", candidate.display)?;
560    }
561
562    let mut child = Command::new("fzf")
563        .args([
564            "--read0",
565            "--print0",
566            "--no-multi",
567            "--delimiter=\\t",
568            "--nth=2..",
569            "--tiebreak=index",
570            "--filter",
571            query,
572        ])
573        .env_remove("FZF_DEFAULT_OPTS")
574        .env_remove("FZF_DEFAULT_OPTS_FILE")
575        .stdin(Stdio::piped())
576        .stdout(Stdio::piped())
577        .stderr(Stdio::null())
578        .spawn()?;
579    let mut stdin = child.stdin.take().expect("fzf stdin is piped");
580    let writer = thread::spawn(move || stdin.write_all(&input));
581    let output = child.wait_with_output()?;
582    writer.join().expect("fzf input writer panicked")?;
583    if output.status.code() == Some(1) {
584        return Ok(Vec::new());
585    }
586    if !output.status.success() {
587        bail!("fzf fuzzy filter failed with {}", output.status);
588    }
589
590    let mut indexes = Vec::new();
591    for record in output.stdout.split(|byte| *byte == 0) {
592        if record.is_empty() || indexes.len() >= limit {
593            continue;
594        }
595        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
596            continue;
597        };
598        let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
599        if index < candidates.len() {
600            indexes.push(index);
601        }
602    }
603    Ok(indexes)
604}
605
606fn valid_command_prefix(buffer: &str) -> bool {
607    !buffer.is_empty()
608        && !buffer.starts_with('.')
609        && buffer
610            .bytes()
611            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
612}
613
614fn history_description(uses: usize, same_cwd: bool) -> String {
615    match (uses, same_cwd) {
616        (1, true) => "used here".to_owned(),
617        (1, false) => "used once".to_owned(),
618        (uses, true) => format!("used {uses}x, here"),
619        (uses, false) => format!("used {uses}x"),
620    }
621}
622
623fn sanitize_display(value: &str) -> String {
624    let mut display = String::with_capacity(value.len());
625    for character in value.chars() {
626        if character.is_control() {
627            display.extend(character.escape_default());
628        } else {
629            display.push(character);
630        }
631    }
632    display
633}
634
635pub fn next_segment(suffix: &str) -> String {
636    let mut saw_non_whitespace = false;
637    let mut escaped = false;
638    let mut quote = None;
639    let mut bracket_depth: usize = 0;
640    let mut characters = suffix.char_indices().peekable();
641    while let Some((index, character)) = characters.next() {
642        let end = index + character.len_utf8();
643        if escaped {
644            escaped = false;
645            saw_non_whitespace = true;
646            continue;
647        }
648        if character == '\\' && quote != Some('\'') {
649            escaped = true;
650            saw_non_whitespace = true;
651            continue;
652        }
653        if matches!(character, '\'' | '"') {
654            if quote == Some(character) {
655                quote = None;
656            } else if quote.is_none() {
657                quote = Some(character);
658            }
659            saw_non_whitespace = true;
660            continue;
661        }
662        if quote.is_some() {
663            saw_non_whitespace = true;
664            continue;
665        }
666        if character.is_whitespace() {
667            if saw_non_whitespace {
668                return suffix[..end].to_owned();
669            }
670            continue;
671        }
672        saw_non_whitespace = true;
673        match character {
674            '[' => bracket_depth += 1,
675            ']' => bracket_depth = bracket_depth.saturating_sub(1),
676            '@' | '=' | ',' if bracket_depth == 0 => return suffix[..end].to_owned(),
677            ':' if bracket_depth == 0 => {
678                let mut boundary = end;
679                while let Some((next_index, next)) = characters.peek().copied() {
680                    if !matches!(next, ':' | '/') {
681                        break;
682                    }
683                    characters.next();
684                    boundary = next_index + next.len_utf8();
685                }
686                return suffix[..boundary].to_owned();
687            }
688            '/' if bracket_depth == 0 => {
689                let mut boundary = end;
690                while let Some((next_index, '/')) = characters.peek().copied() {
691                    characters.next();
692                    boundary = next_index + 1;
693                }
694                return suffix[..boundary].to_owned();
695            }
696            _ => {}
697        }
698    }
699    suffix.to_owned()
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use crate::commands::{CommandCatalog, CommandEntry, OptionMatch, SubcommandMatch, ValueMatch};
706    use crate::config::Settings;
707    use crate::store::Store;
708    use tempfile::tempdir;
709
710    #[test]
711    fn accepts_one_path_segment() {
712        assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
713    }
714
715    #[test]
716    fn accepts_one_shell_word() {
717        assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
718    }
719
720    #[test]
721    fn accepts_remaining_text_without_boundary() {
722        assert_eq!(next_segment("status"), "status");
723    }
724
725    #[test]
726    fn accepts_ssh_destinations_in_semantic_segments() {
727        assert_eq!(next_segment("lice@example.com"), "lice@");
728        assert_eq!(next_segment("example.com:/srv/app/file"), "example.com:/");
729        assert_eq!(next_segment("srv/app/file"), "srv/");
730    }
731
732    #[test]
733    fn accepts_common_structured_values_in_semantic_segments() {
734        assert_eq!(next_segment("output=value"), "output=");
735        assert_eq!(next_segment("https://example.com/path"), "https://");
736        assert_eq!(next_segment("host::module/path"), "host::");
737        assert_eq!(next_segment("one,two"), "one,");
738    }
739
740    #[test]
741    fn preserves_quoted_escaped_and_ipv6_separators() {
742        assert_eq!(next_segment("'user@host'/path"), "'user@host'/");
743        assert_eq!(next_segment("user\\@host/path"), "user\\@host/");
744        assert_eq!(next_segment("user@[2001:db8::1]:/srv/app"), "user@");
745        assert_eq!(next_segment("[2001:db8::1]:/srv/app"), "[2001:db8::1]:/");
746    }
747
748    #[test]
749    fn escapes_control_characters_in_display_text() {
750        assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
751    }
752
753    #[test]
754    fn completes_filesystem_entries_at_argument_positions() {
755        let directory = tempdir().unwrap();
756        fs::create_dir(directory.path().join("alpha-dir")).unwrap();
757        fs::write(directory.path().join("alpha-file"), "file").unwrap();
758        fs::write(directory.path().join("alpha space"), "file").unwrap();
759        fs::write(directory.path().join(".hidden"), "file").unwrap();
760        fs::write(directory.path().join("-rf"), "file").unwrap();
761        fs::write(directory.path().join("=command"), "file").unwrap();
762        fs::create_dir(directory.path().join("space dir")).unwrap();
763        fs::write(directory.path().join("space dir/child"), "file").unwrap();
764
765        let buffer = "scp -r alpha";
766        let candidates =
767            filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
768                .unwrap();
769        assert_eq!(candidates[0].display, "scp -r alpha-dir/");
770        assert_eq!(candidates[0].kind, CandidateKind::Directory);
771        assert!(candidates.iter().any(|candidate| {
772            candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
773        }));
774        assert!(
775            candidates
776                .iter()
777                .any(|candidate| candidate.display == "scp -r alpha\\ space")
778        );
779        assert!(
780            !candidates
781                .iter()
782                .any(|candidate| candidate.display.contains(".hidden"))
783        );
784
785        let nested = "scp -r space\\ dir/ch";
786        let candidates =
787            filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
788                .unwrap();
789        assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
790
791        let exact = "scp -r alpha-file";
792        let candidates =
793            filesystem_candidates(exact, exact.len(), directory.path().to_str().unwrap(), 10)
794                .unwrap();
795        assert_eq!(candidates.len(), 1);
796        assert_eq!(candidates[0].display, exact);
797        assert_eq!(candidates[0].accept_text, " ");
798
799        let blank = "scp -r ";
800        let candidates =
801            filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
802                .unwrap();
803        assert!(
804            candidates
805                .iter()
806                .any(|candidate| candidate.display == "scp -r ./-rf")
807        );
808        assert!(
809            candidates
810                .iter()
811                .any(|candidate| candidate.display == "scp -r \\=command")
812        );
813    }
814
815    #[test]
816    fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
817        let directory = tempdir().unwrap();
818        fs::write(directory.path().join("visible"), "file").unwrap();
819
820        let buffer = "command ";
821        let candidates =
822            filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
823                .unwrap();
824        assert!(
825            candidates
826                .iter()
827                .any(|candidate| candidate.display == "command visible")
828        );
829        assert!(
830            filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
831                .unwrap()
832                .is_empty()
833        );
834    }
835
836    #[test]
837    fn history_stays_first_while_filesystem_candidates_reserve_capacity() {
838        let mut response = CompletionResponse {
839            replace_start_byte: 5,
840            replace_end_byte: 5,
841            candidates: (0..4)
842                .map(|index| Candidate {
843                    display: format!("cmd history-{index}"),
844                    description: String::new(),
845                    description_pending: false,
846                    kind: CandidateKind::History,
847                    insert_text: index.to_string(),
848                    accept_text: index.to_string(),
849                    source: CandidateSource::History,
850                })
851                .collect(),
852            enrichment_pending: false,
853        };
854        let paths = (0..2)
855            .map(|index| Candidate {
856                display: format!("cmd path-{index}"),
857                description: "File".to_owned(),
858                description_pending: false,
859                kind: CandidateKind::File,
860                insert_text: index.to_string(),
861                accept_text: index.to_string(),
862                source: CandidateSource::Filesystem,
863            })
864            .collect();
865        merge_filesystem_candidates(&mut response, paths, 4);
866        assert_eq!(response.candidates.len(), 4);
867        assert_eq!(response.candidates[0].source, CandidateSource::History);
868        assert_eq!(
869            response
870                .candidates
871                .iter()
872                .filter(|candidate| candidate.source == CandidateSource::Filesystem)
873                .count(),
874            2
875        );
876
877        let mut response = CompletionResponse::empty(0);
878        let paths = (0..2)
879            .map(|index| Candidate {
880                display: format!("cmd path-{index}"),
881                description: "File".to_owned(),
882                description_pending: false,
883                kind: CandidateKind::File,
884                insert_text: index.to_string(),
885                accept_text: index.to_string(),
886                source: CandidateSource::Filesystem,
887            })
888            .collect();
889        merge_filesystem_candidates(&mut response, paths, 1);
890        assert_eq!(response.candidates[0].description, "File (more matches)");
891    }
892
893    #[test]
894    fn recognizes_only_safe_root_option_contexts() {
895        assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
896        assert_eq!(option_context("git status --short"), None);
897        assert_eq!(
898            option_context("git --quiet --short"),
899            Some(("git", "--short"))
900        );
901        assert_eq!(option_context("git -- --literal"), None);
902        assert_eq!(option_context("git \"--ver"), None);
903        assert_eq!(option_context("--ver"), None);
904    }
905
906    #[test]
907    fn completes_cached_root_command_options() {
908        let store = Store::in_memory().unwrap();
909        let commands = CommandCatalog::from_options(
910            "tool",
911            vec![
912                OptionMatch {
913                    spelling: "--verbose".to_owned(),
914                    description: "Show verbose output".to_owned(),
915                },
916                OptionMatch {
917                    spelling: "--version".to_owned(),
918                    description: "Print version".to_owned(),
919                },
920            ],
921        );
922        let completion = complete(
923            &store,
924            &commands,
925            "tool --ver",
926            "tool --ver".len(),
927            "/repo",
928            None,
929            &Settings::default(),
930        )
931        .unwrap();
932        assert_eq!(completion.candidates.len(), 2);
933        assert_eq!(completion.candidates[0].display, "tool --verbose");
934        assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
935        assert_eq!(completion.candidates[0].source, CandidateSource::Help);
936        assert_eq!(completion.candidates[1].display, "tool --version");
937    }
938
939    #[test]
940    fn completes_cached_subcommands_and_documented_values() {
941        let store = Store::in_memory().unwrap();
942        let commands = CommandCatalog::from_structured(
943            "tool",
944            Vec::new(),
945            vec![
946                SubcommandMatch {
947                    name: "build".to_owned(),
948                    description: "Build the project".to_owned(),
949                },
950                SubcommandMatch {
951                    name: "inspect".to_owned(),
952                    description: "Inspect project state".to_owned(),
953                },
954            ],
955            vec![(
956                "--color".to_owned(),
957                vec![
958                    ValueMatch {
959                        value: "auto".to_owned(),
960                        description: String::new(),
961                    },
962                    ValueMatch {
963                        value: "always".to_owned(),
964                        description: "Always use color".to_owned(),
965                    },
966                ],
967            )],
968        );
969
970        let completion = complete(
971            &store,
972            &commands,
973            "tool bu",
974            "tool bu".len(),
975            "/repo",
976            None,
977            &Settings::default(),
978        )
979        .unwrap();
980        assert_eq!(completion.candidates.len(), 1);
981        assert_eq!(completion.candidates[0].display, "tool build");
982        assert_eq!(completion.candidates[0].insert_text, "ild");
983        assert_eq!(completion.candidates[0].kind, CandidateKind::Subcommand);
984
985        let completion = complete(
986            &store,
987            &commands,
988            "tool --color=a",
989            "tool --color=a".len(),
990            "/repo",
991            None,
992            &Settings::default(),
993        )
994        .unwrap();
995        assert_eq!(completion.candidates.len(), 2);
996        assert_eq!(completion.candidates[0].display, "tool --color=auto");
997        assert_eq!(completion.candidates[0].description, "Value for --color");
998        assert_eq!(completion.candidates[0].kind, CandidateKind::Value);
999        assert_eq!(completion.candidates[1].display, "tool --color=always");
1000        assert_eq!(completion.candidates[1].description, "Always use color");
1001
1002        let completion = complete(
1003            &store,
1004            &commands,
1005            "tool --color auto",
1006            "tool --color auto".len(),
1007            "/repo",
1008            None,
1009            &Settings::default(),
1010        )
1011        .unwrap();
1012        assert_eq!(completion.candidates[0].display, "tool --color auto");
1013        assert_eq!(completion.candidates[0].insert_text, " ");
1014    }
1015
1016    #[test]
1017    fn recognizes_only_unambiguous_structured_contexts() {
1018        assert_eq!(subcommand_context("tool bu"), Some(("tool", "bu")));
1019        assert_eq!(subcommand_context("tool "), Some(("tool", "")));
1020        assert_eq!(subcommand_context("tool other bu"), None);
1021        assert_eq!(subcommand_context("tool --flag "), None);
1022        assert_eq!(
1023            value_context("tool --color=a"),
1024            Some(("tool", "--color", "a"))
1025        );
1026        assert_eq!(
1027            value_context("tool --color a"),
1028            Some(("tool", "--color", "a"))
1029        );
1030        assert_eq!(value_context("tool other --color a"), None);
1031    }
1032
1033    #[test]
1034    fn describes_history_usage_and_directory() {
1035        assert_eq!(history_description(1, true), "used here");
1036        assert_eq!(history_description(3, true), "used 3x, here");
1037        assert_eq!(history_description(2, false), "used 2x");
1038    }
1039
1040    #[test]
1041    fn completes_history_with_a_single_segment() {
1042        let store = Store::in_memory().unwrap();
1043        let mut settings = Settings::default();
1044        settings.completion.accept = AcceptMode::Segment;
1045        store
1046            .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
1047            .unwrap();
1048
1049        let completion = complete(
1050            &store,
1051            &CommandCatalog::default(),
1052            "cd ~/d",
1053            "cd ~/d".len(),
1054            "/repo",
1055            None,
1056            &settings,
1057        )
1058        .unwrap();
1059
1060        assert_eq!(completion.candidates.len(), 1);
1061        assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
1062        assert_eq!(completion.candidates[0].accept_text, "ev/");
1063        assert_eq!(completion.candidates[0].description, "used here");
1064
1065        let completion = complete(
1066            &store,
1067            &CommandCatalog::default(),
1068            "cd ~/d",
1069            "cd ~/d".len(),
1070            "/repo",
1071            None,
1072            &Settings::default(),
1073        )
1074        .unwrap();
1075        assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
1076    }
1077
1078    #[test]
1079    fn fuzzy_searches_history_without_a_prefix() {
1080        if Command::new("fzf").arg("--version").output().is_err() {
1081            return;
1082        }
1083        let store = Store::in_memory().unwrap();
1084        store
1085            .record("cargo test --all", "/repo", 0, 100, "test", true)
1086            .unwrap();
1087        let completion = fuzzy(
1088            &store,
1089            &CommandCatalog::default(),
1090            "cgt",
1091            "/repo",
1092            None,
1093            &Settings::default(),
1094        )
1095        .unwrap();
1096        assert_eq!(completion.candidates[0].display, "cargo test --all");
1097    }
1098
1099    #[test]
1100    fn abstains_from_mid_line_completion() {
1101        let store = Store::in_memory().unwrap();
1102        let completion = complete(
1103            &store,
1104            &CommandCatalog::default(),
1105            "git status",
1106            3,
1107            "/repo",
1108            None,
1109            &Settings::default(),
1110        )
1111        .unwrap();
1112        assert!(completion.candidates.is_empty());
1113    }
1114
1115    #[test]
1116    fn discovers_commands_when_history_abstains() {
1117        let store = Store::in_memory().unwrap();
1118        let commands = CommandCatalog::from_entries([CommandEntry {
1119            name: "atlas".to_owned(),
1120            description: "CLI tool to manage MongoDB Atlas".to_owned(),
1121        }]);
1122
1123        let completion = complete(
1124            &store,
1125            &commands,
1126            "atl",
1127            3,
1128            "/repo",
1129            None,
1130            &Settings::default(),
1131        )
1132        .unwrap();
1133
1134        assert_eq!(completion.candidates[0].display, "atlas");
1135        assert_eq!(completion.candidates[0].accept_text, "as");
1136        assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
1137    }
1138
1139    #[test]
1140    fn history_precedes_command_inventory() {
1141        let store = Store::in_memory().unwrap();
1142        store
1143            .record("git status", "/repo", 0, 100, "test", true)
1144            .unwrap();
1145        let commands = CommandCatalog::from_entries([CommandEntry {
1146            name: "git-town".to_owned(),
1147            description: "Git workflow automation".to_owned(),
1148        }]);
1149
1150        let completion = complete(
1151            &store,
1152            &commands,
1153            "git",
1154            3,
1155            "/repo",
1156            None,
1157            &Settings::default(),
1158        )
1159        .unwrap();
1160
1161        assert_eq!(completion.candidates[0].source, CandidateSource::History);
1162        assert_eq!(completion.candidates[1].source, CandidateSource::Command);
1163    }
1164
1165    #[test]
1166    fn history_deduplicates_command_inventory() {
1167        let store = Store::in_memory().unwrap();
1168        store
1169            .record("atlas", "/repo", 0, 100, "test", true)
1170            .unwrap();
1171        let commands = CommandCatalog::from_entries([
1172            CommandEntry {
1173                name: "atlas".to_owned(),
1174                description: "CLI tool to manage MongoDB Atlas".to_owned(),
1175            },
1176            CommandEntry {
1177                name: "atlantis".to_owned(),
1178                description: "Terraform pull request automation".to_owned(),
1179            },
1180        ]);
1181
1182        let completion = complete(
1183            &store,
1184            &commands,
1185            "atl",
1186            3,
1187            "/repo",
1188            None,
1189            &Settings::default(),
1190        )
1191        .unwrap();
1192
1193        assert_eq!(completion.candidates.len(), 2);
1194        assert_eq!(completion.candidates[0].display, "atlas");
1195        assert_eq!(completion.candidates[0].source, CandidateSource::History);
1196        assert_eq!(completion.candidates[1].display, "atlantis");
1197    }
1198}