use crate::commands::registry::REGISTRY;
#[derive(Debug, Clone)]
pub struct Candidate {
pub display: String,
pub replacement: String,
pub score: i64,
pub summary: Option<String>,
pub category: Option<String>,
}
const DSL_KEYWORDS: &[(&str, &str)] = &[
("agent", "definition"),
("behavior", "definition"),
("function", "definition"),
("metadata", "block"),
("policy", "definition"),
("schedule", "definition"),
("channel", "definition"),
("memory", "definition"),
("webhook", "definition"),
("capabilities", "declaration"),
("with", "block"),
("let", "statement"),
("if", "statement"),
("else", "statement"),
("return", "statement"),
("for", "statement"),
("while", "statement"),
("match", "statement"),
("try", "statement"),
("catch", "statement"),
("emit", "statement"),
("require", "statement"),
("invoke", "statement"),
("String", "type"),
("int", "type"),
("float", "type"),
("bool", "type"),
("true", "literal"),
("false", "literal"),
("null", "literal"),
("allow", "policy"),
("deny", "policy"),
("audit", "policy"),
("sandbox", "config"),
("strict", "sandbox"),
("moderate", "sandbox"),
("permissive", "sandbox"),
("timeout", "config"),
("reason", "async fn"),
("llm_call", "async fn"),
("chain", "async fn"),
("debate", "async fn"),
("spawn_agent", "async fn"),
("ask", "async fn"),
("send_to", "async fn"),
("parallel", "async fn"),
("race", "async fn"),
("tool_call", "async fn"),
("print", "fn"),
("len", "fn"),
("format", "fn"),
("parse_json", "fn"),
];
pub fn complete(
input: &str,
cursor: usize,
entities: &[(String, String)],
dsl_mode: bool,
) -> (usize, Vec<Candidate>) {
let text = &input[..cursor];
if let Some(query) = text.strip_prefix('/') {
let mut candidates: Vec<Candidate> = REGISTRY
.iter()
.filter_map(|entry| {
let candidate_body = &entry.name[1..];
let score = fuzzy_score(candidate_body, query);
if score == 0 && !query.is_empty() {
return None;
}
Some(Candidate {
display: entry.name.to_string(),
replacement: entry.name.to_string(),
score,
summary: Some(entry.summary.to_string()),
category: Some(entry.category.to_string()),
})
})
.collect();
if query.is_empty() {
candidates.sort_by(|a, b| a.category.cmp(&b.category).then(a.display.cmp(&b.display)));
} else {
candidates.sort_by(|a, b| b.score.cmp(&a.score).then(a.display.cmp(&b.display)));
}
return (0, candidates);
}
if let Some(at_pos) = text.rfind('@') {
let query = &text[at_pos + 1..];
let mut candidates: Vec<Candidate> = entities
.iter()
.filter_map(|(name, kind)| {
let score = fuzzy_score(name, query);
if score > 0 {
Some(Candidate {
display: format!("{} ({})", name, kind),
replacement: name.clone(),
score,
summary: None,
category: None,
})
} else {
None
}
})
.collect();
let wants_files = query.contains('/') || candidates.is_empty();
if wants_files {
candidates.extend(file_path_candidates(query));
}
candidates.sort_by_key(|b| std::cmp::Reverse(b.score));
return (at_pos, candidates);
}
if dsl_mode {
let word_start = text
.rfind(|c: char| !c.is_alphanumeric() && c != '_')
.map(|i| i + 1)
.unwrap_or(0);
let prefix = &text[word_start..];
if !prefix.is_empty() {
let mut candidates: Vec<Candidate> = DSL_KEYWORDS
.iter()
.filter(|(kw, _)| kw.starts_with(prefix))
.map(|(kw, kind)| Candidate {
display: format!("{} ({})", kw, kind),
replacement: kw.to_string(),
score: 100,
summary: None,
category: None,
})
.collect();
for (name, kind) in entities {
if name.starts_with(prefix) {
candidates.push(Candidate {
display: format!("{} ({})", name, kind),
replacement: name.clone(),
score: 90,
summary: None,
category: None,
});
}
}
if !candidates.is_empty() {
return (word_start, candidates);
}
}
}
(cursor, vec![])
}
const FILE_SCAN_LIMIT: usize = 500;
const FILE_RESULT_LIMIT: usize = 20;
fn file_path_candidates(query: &str) -> Vec<Candidate> {
let (dir_part, fragment) = split_dir_fragment(query);
let dir = resolve_dir(&dir_part);
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut results: Vec<Candidate> = Vec::new();
for entry in entries.take(FILE_SCAN_LIMIT).flatten() {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.') && !fragment.starts_with('.') {
continue;
}
let score = fuzzy_score(&file_name, &fragment);
if score == 0 && !fragment.is_empty() {
continue;
}
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let suffix = if is_dir { "/" } else { "" };
let display_path = format!("{}{}{}", dir_part, file_name, suffix);
let kind_tag = if is_dir { "dir" } else { "file" };
results.push(Candidate {
display: format!("{} ({})", display_path, kind_tag),
replacement: display_path,
score: score.saturating_sub(1).max(1),
summary: None,
category: Some("files".to_string()),
});
}
results.sort_by(|a, b| b.score.cmp(&a.score).then(a.display.cmp(&b.display)));
results.truncate(FILE_RESULT_LIMIT);
results
}
fn split_dir_fragment(query: &str) -> (String, String) {
match query.rfind('/') {
Some(idx) => (query[..=idx].to_string(), query[idx + 1..].to_string()),
None => (String::new(), query.to_string()),
}
}
fn resolve_dir(dir_part: &str) -> std::path::PathBuf {
if dir_part.is_empty() || dir_part == "./" {
return std::path::PathBuf::from(".");
}
if let Some(rest) = dir_part.strip_prefix("~/") {
if let Some(mut home) = dirs::home_dir() {
home.push(rest);
return home;
}
}
std::path::PathBuf::from(dir_part)
}
fn fuzzy_score(candidate: &str, query: &str) -> i64 {
if query.is_empty() {
return 1;
}
let candidate_lower: Vec<char> = candidate.to_lowercase().chars().collect();
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
let mut score: i64 = 0;
let mut ci = 0;
let mut prev_matched = false;
for &qc in &query_lower {
let mut found = false;
while ci < candidate_lower.len() {
if candidate_lower[ci] == qc {
score += 1;
if prev_matched {
score += 2;
}
if ci == 0 || candidate_lower[ci - 1] == '_' {
score += 3;
}
prev_matched = true;
ci += 1;
found = true;
break;
}
prev_matched = false;
ci += 1;
}
if !found {
return 0;
}
}
if candidate_lower.starts_with(&query_lower) {
score += 5;
}
score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_completion_prefix() {
let (pos, candidates) = complete("/he", 3, &[], false);
assert_eq!(pos, 0);
assert!(!candidates.is_empty());
assert_eq!(candidates[0].replacement, "/help");
assert_eq!(candidates[0].category.as_deref(), Some("session"));
assert!(candidates[0].summary.is_some());
}
#[test]
fn test_command_completion_fuzzy_subsequence() {
let (_, candidates) = complete("/ex", 3, &[], false);
assert!(candidates.iter().any(|c| c.replacement == "/exit"));
assert!(candidates.iter().any(|c| c.replacement == "/exec"));
}
#[test]
fn test_command_completion_bare_slash_lists_everything() {
let (_, candidates) = complete("/", 1, &[], false);
assert!(candidates.len() >= 40);
}
#[test]
fn test_bare_slash_groups_by_category() {
let (_, candidates) = complete("/", 1, &[], false);
let cats: Vec<&str> = candidates
.iter()
.map(|c| c.category.as_deref().unwrap_or(""))
.collect();
let mut seen_and_ended: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut prev: Option<&str> = None;
for cat in &cats {
if let Some(p) = prev {
if p != *cat {
seen_and_ended.insert(p);
}
}
assert!(
!seen_and_ended.contains(cat),
"category {:?} reappeared after ending; candidates are not grouped: {:?}",
cat,
cats
);
prev = Some(cat);
}
}
#[test]
fn test_filtered_slash_prioritises_fuzzy_rank_over_category() {
let (_, candidates) = complete("/he", 3, &[], false);
assert_eq!(candidates[0].replacement, "/help");
}
#[test]
fn test_at_mention_completion() {
let entities = vec![
("reason".to_string(), "async fn".to_string()),
("spawn_agent".to_string(), "async fn".to_string()),
];
let (pos, candidates) = complete("@rea", 4, &entities, false);
assert_eq!(pos, 0);
assert_eq!(candidates[0].replacement, "reason");
}
#[test]
fn test_at_mention_mid_line() {
let entities = vec![("MyAgent".to_string(), "agent".to_string())];
let (pos, candidates) = complete("ask @My", 7, &entities, false);
assert_eq!(pos, 4);
assert_eq!(candidates[0].replacement, "MyAgent");
}
#[test]
fn test_no_completion_for_bare_text_orchestrator_mode() {
let (_, candidates) = complete("hello", 5, &[], false);
assert!(candidates.is_empty());
}
#[test]
fn test_fuzzy_subsequence() {
let entities = vec![("spawn_agent".to_string(), "fn".to_string())];
let (_, candidates) = complete("@sa", 3, &entities, false);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].replacement, "spawn_agent");
}
#[test]
fn test_dsl_keyword_completion() {
let (pos, candidates) = complete("ag", 2, &[], true);
assert_eq!(pos, 0);
assert!(candidates.iter().any(|c| c.replacement == "agent"));
}
#[test]
fn test_dsl_builtin_completion() {
let (pos, candidates) = complete("let x = rea", 11, &[], true);
assert_eq!(pos, 8);
assert!(candidates.iter().any(|c| c.replacement == "reason"));
}
#[test]
fn test_dsl_no_keyword_completion_in_orchestrator_mode() {
let (_, candidates) = complete("ag", 2, &[], false);
assert!(candidates.is_empty());
}
#[test]
fn test_dsl_type_completion() {
let (_, candidates) = complete("Str", 3, &[], true);
assert!(candidates.iter().any(|c| c.replacement == "String"));
}
#[test]
fn test_split_dir_fragment() {
assert_eq!(
split_dir_fragment("src/mod"),
("src/".to_string(), "mod".to_string())
);
assert_eq!(
split_dir_fragment("foo"),
("".to_string(), "foo".to_string())
);
assert_eq!(
split_dir_fragment("a/b/c"),
("a/b/".to_string(), "c".to_string())
);
}
#[test]
fn test_at_path_lists_cwd_subdir() {
let td = tempfile::tempdir().unwrap();
std::fs::create_dir(td.path().join("alpha")).unwrap();
std::fs::write(td.path().join("bravo.txt"), b"").unwrap();
let prefix = format!("{}/", td.path().display());
let results = file_path_candidates(&prefix);
let names: Vec<String> = results.iter().map(|c| c.display.clone()).collect();
assert!(
names
.iter()
.any(|n| n.contains("alpha") && n.contains("dir")),
"expected alpha/ in results: {:?}",
names
);
assert!(
names
.iter()
.any(|n| n.contains("bravo.txt") && n.contains("file")),
"expected bravo.txt in results: {:?}",
names
);
}
#[test]
fn test_at_path_hides_dotfiles_by_default() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".hidden"), b"").unwrap();
std::fs::write(td.path().join("visible"), b"").unwrap();
let results = file_path_candidates(&format!("{}/", td.path().display()));
let names: Vec<String> = results.iter().map(|c| c.display.clone()).collect();
assert!(names.iter().any(|n| n.contains("visible")));
assert!(!names.iter().any(|n| n.contains(".hidden")));
}
#[test]
fn test_at_path_shows_dotfiles_when_fragment_starts_with_dot() {
let td = tempfile::tempdir().unwrap();
std::fs::write(td.path().join(".hidden"), b"").unwrap();
std::fs::write(td.path().join("visible"), b"").unwrap();
let results = file_path_candidates(&format!("{}/.h", td.path().display()));
let names: Vec<String> = results.iter().map(|c| c.display.clone()).collect();
assert!(names.iter().any(|n| n.contains(".hidden")));
}
}