agentic_codebase/cli/
repl_complete.rs1use 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
14pub 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
27const QUERY_TYPES: &[&str] = &[
29 "symbol",
30 "deps",
31 "rdeps",
32 "impact",
33 "calls",
34 "similar",
35 "prophecy",
36 "stability",
37 "coupling",
38];
39
40pub 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 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 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 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" => {
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 "/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 "/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 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
185pub 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
204pub 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
212pub 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
228fn 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}