use crate::tui::prompt_editor::{self, PromptEditResult, PromptEditor};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const MAX_AUTOCOMPLETE_MATCHES: usize = 50;
const MAX_ARGUMENT_HINT_DISPLAY_WIDTH: usize = 160;
const MAX_ARGUMENT_HINT_BYTES: usize = MAX_ARGUMENT_HINT_DISPLAY_WIDTH * 4;
const ELLIPSIS: &str = "…";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutocompleteKind {
SlashCommand,
FileTag,
SkillTag,
ContextInjection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AutocompleteCandidate {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) kind: AutocompleteKind,
}
impl AutocompleteCandidate {
pub(crate) fn slash_command(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
kind: AutocompleteKind::SlashCommand,
}
}
pub(crate) fn file_tag(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: "file".to_string(),
kind: AutocompleteKind::FileTag,
}
}
pub(crate) fn skill_tag(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: "skill".to_string(),
kind: AutocompleteKind::SkillTag,
}
}
pub(crate) fn skill_tag_with_argument_hint(
name: impl Into<String>,
argument_hint: Option<&str>,
) -> Self {
let name = name.into();
let Some(argument_hint) = argument_hint.and_then(sanitize_argument_hint) else {
return Self::skill_tag(name);
};
Self {
name,
description: argument_hint,
kind: AutocompleteKind::SkillTag,
}
}
pub(crate) fn context_injection(
name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
kind: AutocompleteKind::ContextInjection,
}
}
pub(crate) fn insertion(&self) -> String {
match self.kind {
AutocompleteKind::SlashCommand => format!("/{}", self.name),
AutocompleteKind::FileTag => format!("@{}", self.name),
AutocompleteKind::SkillTag => format!("${}", self.name),
AutocompleteKind::ContextInjection => format!("#{}", self.name),
}
}
pub(crate) fn display(&self) -> String {
self.insertion()
}
}
fn is_default_ignorable(character: char) -> bool {
matches!(
character,
'\u{00ad}'
| '\u{034f}'
| '\u{061c}'
| '\u{115f}'..='\u{1160}'
| '\u{17b4}'..='\u{17b5}'
| '\u{180b}'..='\u{180f}'
| '\u{200b}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}'
| '\u{3164}'
| '\u{fe00}'..='\u{fe0f}'
| '\u{feff}'
| '\u{ffa0}'
| '\u{fff9}'..='\u{fffb}'
| '\u{1bca0}'..='\u{1bca3}'
| '\u{1d173}'..='\u{1d17a}'
| '\u{e0001}'
| '\u{e0020}'..='\u{e007f}'
| '\u{e0100}'..='\u{e01ef}'
)
}
fn sanitize_argument_hint(argument_hint: &str) -> Option<String> {
let controls_sanitized = crate::output::sanitize_display_controls(argument_hint);
let scalar_normalized: String = controls_sanitized
.chars()
.filter(|character| !is_default_ignorable(*character))
.collect();
let visible: String = scalar_normalized
.graphemes(true)
.filter(|grapheme| {
let grapheme = *grapheme;
UnicodeWidthStr::width(grapheme) > 0 || grapheme.chars().any(char::is_whitespace)
})
.collect();
let normalized = visible.split_whitespace().collect::<Vec<_>>().join(" ");
let redacted = crate::output::redact_sensitive_text(&normalized);
let mut helper = String::with_capacity(MAX_ARGUMENT_HINT_BYTES.min(redacted.len()));
let mut graphemes = Vec::new();
let mut display_width = 0;
let mut byte_len = 0;
let mut needs_separator = false;
let mut truncated = false;
'words: for word in redacted.split_whitespace() {
if needs_separator
&& !append_argument_hint_grapheme(
&mut helper,
&mut graphemes,
" ",
&mut display_width,
&mut byte_len,
)
{
truncated = true;
break;
}
for grapheme in word.graphemes(true) {
if !append_argument_hint_grapheme(
&mut helper,
&mut graphemes,
grapheme,
&mut display_width,
&mut byte_len,
) {
truncated = true;
break 'words;
}
}
needs_separator = true;
}
if helper.is_empty() {
return None;
}
if truncated {
while (display_width + UnicodeWidthStr::width(ELLIPSIS) > MAX_ARGUMENT_HINT_DISPLAY_WIDTH
|| byte_len + ELLIPSIS.len() > MAX_ARGUMENT_HINT_BYTES)
&& let Some((grapheme, width)) = graphemes.pop()
{
helper.truncate(helper.len() - grapheme.len());
display_width -= width;
byte_len -= grapheme.len();
}
helper.push_str(ELLIPSIS);
}
Some(helper)
}
fn append_argument_hint_grapheme<'a>(
output: &mut String,
graphemes: &mut Vec<(&'a str, usize)>,
grapheme: &'a str,
display_width: &mut usize,
byte_len: &mut usize,
) -> bool {
let width = UnicodeWidthStr::width(grapheme);
if display_width.saturating_add(width) > MAX_ARGUMENT_HINT_DISPLAY_WIDTH
|| byte_len.saturating_add(grapheme.len()) > MAX_ARGUMENT_HINT_BYTES
{
return false;
}
output.push_str(grapheme);
graphemes.push((grapheme, width));
*display_width += width;
*byte_len += grapheme.len();
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AutocompleteState {
pub(crate) candidates: Vec<AutocompleteCandidate>,
pub(crate) selected: usize,
pub(crate) token_start: usize,
pub(crate) token_end: usize,
pub(crate) token: String,
}
pub(crate) fn visible(autocomplete: &Option<AutocompleteState>) -> bool {
autocomplete
.as_ref()
.is_some_and(|autocomplete| !autocomplete.candidates.is_empty())
}
pub(crate) fn clear(
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
) {
*autocomplete = None;
*dismissed_token = None;
}
pub(crate) fn hide(autocomplete: &mut Option<AutocompleteState>) {
*autocomplete = None;
}
pub(crate) fn recompute(
input: &str,
cursor: usize,
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &Option<String>,
candidates: &[AutocompleteCandidate],
) {
let Some(active) = active_token_range(input, cursor) else {
*autocomplete = None;
return;
};
let token = input[active.start..active.end].to_string();
if dismissed_token.as_deref() == Some(token.as_str()) {
*autocomplete = None;
return;
}
let query = &token[1..];
let previous_name = autocomplete
.as_ref()
.and_then(|autocomplete| autocomplete.candidates.get(autocomplete.selected))
.map(|candidate| (candidate.kind, candidate.name.as_str()));
let matches = match active.kind {
AutocompleteKind::SlashCommand => slash_matches(query, candidates),
AutocompleteKind::FileTag => fuzzy_matches(query, candidates, AutocompleteKind::FileTag),
AutocompleteKind::SkillTag => fuzzy_matches(query, candidates, AutocompleteKind::SkillTag),
AutocompleteKind::ContextInjection => context_injection_matches(query, candidates),
};
if matches.is_empty() {
*autocomplete = None;
return;
}
let selected = previous_name
.and_then(|(kind, name)| {
matches
.iter()
.position(|candidate| candidate.kind == kind && candidate.name == name)
})
.unwrap_or(0)
.min(matches.len().saturating_sub(1));
*autocomplete = Some(AutocompleteState {
candidates: matches,
selected,
token_start: active.start,
token_end: active.end,
token,
});
}
fn slash_matches(query: &str, candidates: &[AutocompleteCandidate]) -> Vec<AutocompleteCandidate> {
candidates
.iter()
.filter(|candidate| {
candidate.kind == AutocompleteKind::SlashCommand && candidate.name.starts_with(query)
})
.take(MAX_AUTOCOMPLETE_MATCHES)
.cloned()
.collect()
}
fn context_injection_matches(
query: &str,
candidates: &[AutocompleteCandidate],
) -> Vec<AutocompleteCandidate> {
candidates
.iter()
.filter(|candidate| {
candidate.kind == AutocompleteKind::ContextInjection
&& candidate.name.starts_with(query)
})
.take(MAX_AUTOCOMPLETE_MATCHES)
.cloned()
.collect()
}
fn fuzzy_matches(
query: &str,
candidates: &[AutocompleteCandidate],
kind: AutocompleteKind,
) -> Vec<AutocompleteCandidate> {
let query = query.to_ascii_lowercase();
let mut scored: Vec<_> = candidates
.iter()
.filter(|candidate| candidate.kind == kind)
.filter_map(|candidate| {
fuzzy_score(&query, &candidate.name).map(|score| (score, candidate.clone()))
})
.collect();
scored.sort_by(|(left_score, left), (right_score, right)| {
left_score
.cmp(right_score)
.then_with(|| left.name.cmp(&right.name))
});
scored
.into_iter()
.take(MAX_AUTOCOMPLETE_MATCHES)
.map(|(_, candidate)| candidate)
.collect()
}
fn fuzzy_score(query: &str, candidate: &str) -> Option<usize> {
if query.is_empty() {
return Some(0);
}
let candidate = candidate.to_ascii_lowercase();
if candidate.starts_with(query) {
return Some(candidate.len().saturating_sub(query.len()));
}
if let Some(index) = candidate.rfind('/') {
let basename = &candidate[index + 1..];
if basename.starts_with(query) {
return Some(100 + basename.len().saturating_sub(query.len()));
}
}
if let Some(index) = candidate.find(query) {
return Some(200 + index + candidate.len().saturating_sub(query.len()));
}
let mut score = 500usize;
let mut last_match: Option<usize> = None;
let mut search_from = 0usize;
for query_ch in query.chars() {
let haystack = &candidate[search_from..];
let (offset, _) = haystack
.char_indices()
.find(|(_, candidate_ch)| *candidate_ch == query_ch)?;
let index = search_from + offset;
score = score.saturating_add(index.saturating_sub(last_match.unwrap_or(index)));
last_match = Some(index);
search_from = index + query_ch.len_utf8();
}
Some(score + candidate.len())
}
pub(crate) fn dismiss_for_current_token(
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
) {
if let Some(autocomplete) = autocomplete.take() {
*dismissed_token = Some(autocomplete.token);
}
}
pub(crate) fn move_selection_up(autocomplete: &mut Option<AutocompleteState>) {
if let Some(autocomplete) = autocomplete {
autocomplete.selected = autocomplete.selected.saturating_sub(1);
}
}
pub(crate) fn move_selection_down(autocomplete: &mut Option<AutocompleteState>) {
if let Some(autocomplete) = autocomplete {
if autocomplete.candidates.is_empty() {
autocomplete.selected = 0;
} else {
let max = autocomplete.candidates.len().saturating_sub(1);
autocomplete.selected = (autocomplete.selected + 1).min(max);
}
}
}
pub(crate) fn accept(
editor: &mut PromptEditor,
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
visible_rows: u16,
wrap_width: u16,
) -> PromptEditResult {
let Some(autocomplete_state) = autocomplete.take() else {
return PromptEditResult::Unchanged;
};
let Some(candidate) = autocomplete_state
.candidates
.get(autocomplete_state.selected)
else {
return PromptEditResult::Unchanged;
};
let mut replacement = candidate.insertion();
let token_at_end = autocomplete_state.token_end == editor.visible_text().len();
if matches!(
candidate.kind,
AutocompleteKind::FileTag | AutocompleteKind::SkillTag
) && token_at_end
{
replacement.push(' ');
}
let result = editor.replace_visible_byte_range(
autocomplete_state.token_start,
autocomplete_state.token_end,
&replacement,
visible_rows,
wrap_width,
);
match result {
PromptEditResult::RejectedTooLarge | PromptEditResult::Failed => {
*autocomplete = Some(autocomplete_state);
}
PromptEditResult::Changed | PromptEditResult::Unchanged => {
*dismissed_token = None;
}
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ActiveToken {
start: usize,
end: usize,
kind: AutocompleteKind,
}
fn active_token_range(input: &str, cursor: usize) -> Option<ActiveToken> {
if let Some((start, end)) = active_slash_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::SlashCommand,
});
}
if let Some((start, end)) = active_file_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::FileTag,
});
}
if let Some((start, end)) = active_skill_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::SkillTag,
});
}
active_hash_token_range(input, cursor).map(|(start, end)| ActiveToken {
start,
end,
kind: AutocompleteKind::ContextInjection,
})
}
fn active_slash_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
if !input.starts_with('/') {
return None;
}
let cursor = prompt_editor::clamp_char_boundary(input, cursor);
let token_end = input
.char_indices()
.find_map(|(index, ch)| ch.is_whitespace().then_some(index))
.unwrap_or(input.len());
if cursor < token_end || (token_end == input.len() && cursor == token_end) {
Some((0, token_end))
} else {
None
}
}
fn active_file_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '@')
}
fn active_skill_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '$')
}
fn active_hash_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '#')
}
fn active_anywhere_token_range(input: &str, cursor: usize, marker: char) -> Option<(usize, usize)> {
let cursor = prompt_editor::clamp_char_boundary(input, cursor);
let start = input[..cursor]
.char_indices()
.rev()
.take_while(|(_, ch)| !is_tag_token_terminator(*ch))
.find_map(|(index, ch)| (ch == marker).then_some(index))?;
let end = input[start..]
.char_indices()
.skip(1)
.find_map(|(offset, ch)| is_tag_token_terminator(ch).then_some(start + offset))
.unwrap_or(input.len());
if cursor <= end {
Some((start, end))
} else {
None
}
}
fn is_tag_token_terminator(ch: char) -> bool {
ch.is_whitespace()
|| matches!(
ch,
'"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';' | ':'
)
}
#[cfg(test)]
mod tests {
use super::*;
fn names(candidates: &[AutocompleteCandidate]) -> Vec<&str> {
candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect()
}
fn skill_candidates() -> Vec<AutocompleteCandidate> {
vec![
AutocompleteCandidate::skill_tag("ratatui-rust-development"),
AutocompleteCandidate::skill_tag("rust-dev"),
AutocompleteCandidate::skill_tag("simplify-isomorphically-typescript"),
AutocompleteCandidate::context_injection("tree", "current directory tree"),
AutocompleteCandidate::context_injection("git-status", "current Git status"),
AutocompleteCandidate::file_tag("src/rust-dev.md"),
AutocompleteCandidate::slash_command("skills", "manage skills"),
]
}
#[test]
fn insertion_and_display_include_kind_prefixes() {
let slash = AutocompleteCandidate::slash_command("new", "session");
let file = AutocompleteCandidate::file_tag("src/lib.rs");
let skill = AutocompleteCandidate::skill_tag("rust-dev");
let context = AutocompleteCandidate::context_injection("tree", "current directory tree");
assert_eq!(slash.insertion(), "/new");
assert_eq!(file.insertion(), "@src/lib.rs");
assert_eq!(skill.insertion(), "$rust-dev");
assert_eq!(context.insertion(), "#tree");
assert_eq!(context.display(), "#tree");
assert_eq!(skill.display(), "$rust-dev");
assert_eq!(skill.description, "skill");
}
#[test]
fn invisible_argument_hint_falls_back_and_formatting_cannot_change_output() {
let invisible = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some("\u{200b}\u{202e}\u{2066}\u{2069}"),
);
let formatted = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some("path\u{200b}\u{202e} to"),
);
assert_eq!(invisible.description, "skill");
assert_eq!(formatted.description, "path to");
}
#[test]
fn skill_argument_hint_at_display_limit_is_not_truncated() {
let candidate = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some(&"界".repeat(MAX_ARGUMENT_HINT_DISPLAY_WIDTH / 2)),
);
assert_eq!(
candidate.description,
"界".repeat(MAX_ARGUMENT_HINT_DISPLAY_WIDTH / 2)
);
}
#[test]
fn skill_argument_hint_byte_limit_backtracks_graphemes() {
let combining = "a\u{0301}\u{0301}\u{0301}\u{0301}\u{0301}";
let candidate = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some(&combining.repeat(MAX_ARGUMENT_HINT_DISPLAY_WIDTH)),
);
let complete_graphemes = (MAX_ARGUMENT_HINT_BYTES - ELLIPSIS.len()) / combining.len();
assert_eq!(complete_graphemes, 57);
assert_eq!(
candidate.description,
format!("{}{}", combining.repeat(complete_graphemes), ELLIPSIS)
);
}
#[test]
fn skill_argument_hint_removes_zwj_before_redaction() {
let candidate = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some("api_\u{200d}key=secret-value"),
);
assert_eq!(candidate.description, "api_key=<redacted>");
}
#[test]
fn skill_argument_hint_sanitizes_controls_hidden_format_redacts_and_collapses_whitespace() {
let candidate = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some(" inspect\n\x1b[31mfiles\x1b[0m\tapi_\u{200b}key=secret-value "),
);
assert_eq!(candidate.description, "inspect files api_key=<redacted>");
}
#[test]
fn skill_argument_hint_is_bounded_by_display_width_without_splitting_unicode() {
let candidate = AutocompleteCandidate::skill_tag_with_argument_hint(
"rust-dev",
Some(&"界".repeat(200)),
);
assert!(
UnicodeWidthStr::width(candidate.description.as_str())
<= MAX_ARGUMENT_HINT_DISPLAY_WIDTH
);
assert!(candidate.description.ends_with('…'));
assert!(candidate.description.len() <= MAX_ARGUMENT_HINT_BYTES);
assert!(
candidate
.description
.chars()
.all(|character| character == '界' || character == '…')
);
}
#[test]
fn blank_argument_hint_uses_skill_helper_and_does_not_change_insertion() {
let blank = AutocompleteCandidate::skill_tag_with_argument_hint("rust-dev", Some(" \n\t "));
let hinted =
AutocompleteCandidate::skill_tag_with_argument_hint("rust-dev", Some("path to review"));
assert_eq!(blank.description, "skill");
assert_eq!(hinted.insertion(), "$rust-dev");
}
#[test]
fn skill_tags_trigger_anywhere_and_replace_at_end_with_space() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"please use $rust",
"please use $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
let state = autocomplete.as_ref().unwrap();
assert_eq!(state.token, "$rust");
assert_eq!(state.token_start, "please use ".len());
assert_eq!(state.candidates[0].kind, AutocompleteKind::SkillTag);
assert_eq!(state.candidates[0].name, "rust-dev");
let input = "please use $rust".to_string();
let cursor = input.len();
let mut editor = PromptEditor::new(&input, cursor);
let mut dismissed = None;
assert_eq!(
accept(&mut editor, &mut autocomplete, &mut dismissed, 1, 80,),
PromptEditResult::Changed
);
assert_eq!(editor.text(), "please use $rust-dev ");
assert_eq!(editor.cursor(), editor.text().len());
}
#[test]
fn context_injection_tags_trigger_anywhere_and_replace_without_submitting() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"please include #git",
"please include #git".len(),
&mut autocomplete,
&None,
&candidates,
);
let state = autocomplete.as_ref().unwrap();
assert_eq!(state.token, "#git");
assert_eq!(state.candidates.len(), 1);
assert_eq!(state.candidates[0].kind, AutocompleteKind::ContextInjection);
assert_eq!(state.candidates[0].name, "git-status");
let input = "please include #git now".to_string();
let cursor = "please include #git".len();
let mut editor = PromptEditor::new(&input, cursor);
let mut dismissed = None;
assert_eq!(
accept(&mut editor, &mut autocomplete, &mut dismissed, 1, 80,),
PromptEditResult::Changed
);
assert_eq!(editor.text(), "please include #git-status now");
assert_eq!(editor.cursor(), "please include #git-status".len());
}
#[test]
fn context_injection_hash_without_matches_hides_suggestions() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"ask #unknown",
"ask #unknown".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_none());
}
#[test]
fn skill_tag_accept_preserves_following_text() {
let candidates = skill_candidates();
let mut autocomplete = None;
let input = "use $rust, then continue";
recompute(
input,
"use $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_some());
let cursor = "use $rust".len();
let mut editor = PromptEditor::new(input, cursor);
let mut dismissed = None;
assert_eq!(
accept(&mut editor, &mut autocomplete, &mut dismissed, 1, 80,),
PromptEditResult::Changed
);
assert_eq!(editor.text(), "use $rust-dev, then continue");
assert_eq!(editor.cursor(), "use $rust-dev".len());
}
#[test]
fn slash_file_and_skill_tokens_do_not_cross_trigger() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute("/sk", 3, &mut autocomplete, &None, &candidates);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::SlashCommand
);
recompute(
"open @rust",
"open @rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::FileTag
);
recompute(
"open $rust",
"open $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::SkillTag
);
recompute(
"include #git",
"include #git".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::ContextInjection
);
}
#[test]
fn cursor_outside_skill_token_hides_suggestions() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"$rust done",
"$rust done".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_none());
}
#[test]
fn skill_fuzzy_ranking_is_deterministic_and_bounded() {
let candidates = (0..60)
.map(|index| AutocompleteCandidate::skill_tag(format!("skill-{index:02}")))
.chain([
AutocompleteCandidate::skill_tag("rust-dev"),
AutocompleteCandidate::skill_tag("ratatui-rust-development"),
AutocompleteCandidate::skill_tag("development-rust"),
])
.collect::<Vec<_>>();
let mut autocomplete = None;
recompute("$rust", 5, &mut autocomplete, &None, &candidates);
assert_eq!(
names(&autocomplete.as_ref().unwrap().candidates[..3]),
vec!["rust-dev", "development-rust", "ratatui-rust-development"]
);
recompute("$", 1, &mut autocomplete, &None, &candidates);
let matches = &autocomplete.as_ref().unwrap().candidates;
assert_eq!(matches.len(), MAX_AUTOCOMPLETE_MATCHES);
assert_eq!(matches[0].name, "development-rust");
assert_eq!(matches[1].name, "ratatui-rust-development");
}
}