Skip to main content

agentic_codebase/cli/
repl_complete.rs

1//! Tab completion for the ACB interactive REPL.
2//!
3//! Provides context-aware completion for slash commands, query types,
4//! and .acb file paths.
5
6use rustyline::completion::{Completer, Pair};
7use rustyline::highlight::Highlighter;
8use rustyline::hint::Hinter;
9use rustyline::validate::Validator;
10use rustyline::{
11    Cmd, ConditionalEventHandler, Event, EventContext, EventHandler, Helper, KeyEvent, RepeatCount,
12};
13
14/// All available REPL slash commands.
15pub const COMMANDS: &[(&str, &str)] = &[
16    ("/compile", "Compile a directory into an .acb graph"),
17    ("/info", "Display summary of a loaded .acb file"),
18    ("/query", "Run a query (symbol, deps, impact, ...)"),
19    ("/get", "Get detailed info about a unit by ID"),
20    ("/load", "Load an .acb file for querying"),
21    ("/units", "List all units in the loaded graph"),
22    ("/clear", "Clear the screen"),
23    ("/help", "Show available commands"),
24    ("/exit", "Quit the REPL"),
25];
26
27/// Query type names for completion.
28const QUERY_TYPES: &[&str] = &[
29    "symbol",
30    "deps",
31    "rdeps",
32    "impact",
33    "calls",
34    "similar",
35    "prophecy",
36    "stability",
37    "coupling",
38];
39
40/// ACB REPL helper providing tab completion.
41pub struct AcbHelper;
42
43impl Default for AcbHelper {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl AcbHelper {
50    pub fn new() -> Self {
51        Self
52    }
53
54    /// Get list of .acb files in the current directory.
55    fn acb_files(&self) -> Vec<String> {
56        let mut files = Vec::new();
57        if let Ok(entries) = std::fs::read_dir(".") {
58            for entry in entries.flatten() {
59                let path = entry.path();
60                if path.extension().is_some_and(|e| e == "acb") {
61                    if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
62                        files.push(name.to_string());
63                    }
64                }
65            }
66        }
67        files.sort();
68        files
69    }
70}
71
72impl Completer for AcbHelper {
73    type Candidate = Pair;
74
75    fn complete(
76        &self,
77        line: &str,
78        pos: usize,
79        _ctx: &rustyline::Context<'_>,
80    ) -> rustyline::Result<(usize, Vec<Pair>)> {
81        let input = &line[..pos];
82
83        // Complete command names if input starts with /
84        if !input.contains(' ') {
85            let matches: Vec<Pair> = COMMANDS
86                .iter()
87                .filter(|(cmd, _)| cmd.starts_with(input))
88                .map(|(cmd, desc)| Pair {
89                    display: format!("{cmd:<16} {desc}"),
90                    replacement: format!("{cmd} "),
91                })
92                .collect();
93            return Ok((0, matches));
94        }
95
96        // Split into command and args
97        let parts: Vec<&str> = input.splitn(2, ' ').collect();
98        let cmd = parts[0];
99        let args = if parts.len() > 1 { parts[1] } else { "" };
100
101        match cmd {
102            // Query type completion
103            "/query" => {
104                if !args.contains(' ') {
105                    let prefix_start = input.len() - args.len();
106                    let matches: Vec<Pair> = QUERY_TYPES
107                        .iter()
108                        .filter(|t| t.starts_with(args.trim()))
109                        .map(|t| Pair {
110                            display: t.to_string(),
111                            replacement: format!("{t} "),
112                        })
113                        .collect();
114                    return Ok((prefix_start, matches));
115                }
116                Ok((pos, Vec::new()))
117            }
118
119            // .acb file completion for /load, /info
120            "/load" | "/info" => {
121                let files = self.acb_files();
122                let prefix_start = input.len() - args.len();
123                let matches: Vec<Pair> = files
124                    .iter()
125                    .filter(|f| f.starts_with(args.trim()))
126                    .map(|f| Pair {
127                        display: f.clone(),
128                        replacement: format!("{f} "),
129                    })
130                    .collect();
131                Ok((prefix_start, matches))
132            }
133
134            // Directory completion for /compile
135            "/compile" => {
136                let prefix_start = input.len() - args.len();
137                let query = args.trim();
138                let mut matches: Vec<Pair> = Vec::new();
139                let search_dir = if query.is_empty() { "." } else { query };
140                if let Ok(entries) = std::fs::read_dir(search_dir) {
141                    for entry in entries.flatten() {
142                        if entry.path().is_dir() {
143                            if let Some(name) = entry.file_name().to_str() {
144                                if name.starts_with(query) || query.is_empty() {
145                                    matches.push(Pair {
146                                        display: name.to_string(),
147                                        replacement: format!("{name} "),
148                                    });
149                                }
150                            }
151                        }
152                    }
153                }
154                Ok((prefix_start, matches))
155            }
156
157            _ => Ok((pos, Vec::new())),
158        }
159    }
160}
161
162impl Hinter for AcbHelper {
163    type Hint = String;
164
165    fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
166        if pos < line.len() || line.is_empty() {
167            return None;
168        }
169        // Show first matching command as ghost text
170        if line.starts_with('/') && !line.contains(' ') {
171            for (cmd, _) in COMMANDS {
172                if cmd.starts_with(line) && *cmd != line {
173                    return Some(cmd[line.len()..].to_string());
174                }
175            }
176        }
177        None
178    }
179}
180
181impl Highlighter for AcbHelper {}
182impl Validator for AcbHelper {}
183impl Helper for AcbHelper {}
184
185/// Event handler: Tab accepts hint if present, else triggers completion.
186pub struct TabCompleteOrAcceptHint;
187
188impl ConditionalEventHandler for TabCompleteOrAcceptHint {
189    fn handle(
190        &self,
191        _evt: &Event,
192        _n: RepeatCount,
193        _positive: bool,
194        ctx: &EventContext<'_>,
195    ) -> Option<Cmd> {
196        if ctx.has_hint() {
197            Some(Cmd::CompleteHint)
198        } else {
199            Some(Cmd::Complete)
200        }
201    }
202}
203
204/// Bind custom key sequences to the editor.
205pub fn bind_keys(rl: &mut rustyline::Editor<AcbHelper, rustyline::history::DefaultHistory>) {
206    rl.bind_sequence(
207        KeyEvent::from('\t'),
208        EventHandler::Conditional(Box::new(TabCompleteOrAcceptHint)),
209    );
210}
211
212/// Find the closest matching command for a misspelled input (Levenshtein distance).
213pub fn suggest_command(input: &str) -> Option<&'static str> {
214    let input_lower = input.to_lowercase();
215    let mut best: Option<(&str, usize)> = None;
216
217    for (cmd, _) in COMMANDS {
218        let cmd_name = &cmd[1..];
219        let dist = levenshtein(&input_lower, cmd_name);
220        if dist <= 3 && (best.is_none() || dist < best.unwrap().1) {
221            best = Some((cmd, dist));
222        }
223    }
224
225    best.map(|(cmd, _)| cmd)
226}
227
228/// Simple Levenshtein distance for fuzzy matching.
229fn levenshtein(a: &str, b: &str) -> usize {
230    let a_len = a.len();
231    let b_len = b.len();
232
233    if a_len == 0 {
234        return b_len;
235    }
236    if b_len == 0 {
237        return a_len;
238    }
239
240    let mut prev: Vec<usize> = (0..=b_len).collect();
241    let mut curr = vec![0; b_len + 1];
242
243    for (i, ca) in a.chars().enumerate() {
244        curr[0] = i + 1;
245        for (j, cb) in b.chars().enumerate() {
246            let cost = if ca == cb { 0 } else { 1 };
247            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
248        }
249        std::mem::swap(&mut prev, &mut curr);
250    }
251
252    prev[b_len]
253}