1use std::collections::HashSet;
2use std::io::Write;
3use std::process::{Command, Stdio};
4use std::thread;
5
6use anyhow::{Result, bail};
7
8use crate::commands::CommandCatalog;
9use crate::config::{AcceptMode, Settings};
10use crate::protocol::{Candidate, CandidateKind, CandidateSource, CompletionResponse};
11use crate::store::Store;
12
13const MAX_BUFFER_BYTES: usize = 64 * 1024;
14const FUZZY_HISTORY_LIMIT: usize = 4096;
15
16pub fn complete(
17 store: &Store,
18 commands: &CommandCatalog,
19 buffer: &str,
20 cursor_byte: usize,
21 cwd: &str,
22 requested_limit: Option<usize>,
23 settings: &Settings,
24) -> Result<CompletionResponse> {
25 if buffer.len() > MAX_BUFFER_BYTES {
26 bail!("completion buffer exceeds {MAX_BUFFER_BYTES} bytes");
27 }
28 if cursor_byte > buffer.len() || !buffer.is_char_boundary(cursor_byte) {
29 bail!("cursor is not a valid UTF-8 byte offset");
30 }
31
32 if cursor_byte != buffer.len() || buffer.is_empty() {
35 return Ok(CompletionResponse::empty(cursor_byte));
36 }
37
38 let limit = requested_limit
39 .unwrap_or(settings.completion.max_candidates)
40 .min(settings.completion.max_candidates);
41 let history =
42 store.history_candidates(buffer, cwd, limit, settings.history.successful_first)?;
43
44 let mut candidates: Vec<_> = history
45 .into_iter()
46 .filter_map(|history| {
47 let command = history.command;
48 let suffix = command.strip_prefix(buffer)?;
49 if suffix.is_empty() {
50 return None;
51 }
52 let insert_text = suffix.to_owned();
53 let accept_text = match settings.completion.accept {
54 AcceptMode::Segment => next_segment(suffix),
55 AcceptMode::Full => insert_text.clone(),
56 };
57 Some(Candidate {
58 display: sanitize_display(&command),
59 description: history_description(history.uses, history.same_cwd),
60 description_pending: false,
61 kind: CandidateKind::History,
62 insert_text,
63 accept_text,
64 source: CandidateSource::History,
65 })
66 })
67 .collect();
68
69 if candidates.len() < limit && valid_command_prefix(buffer) {
70 let remaining = limit - candidates.len();
71 let command_candidates: Vec<_> = commands
72 .matching(buffer, limit)
73 .into_iter()
74 .filter(|entry| {
75 !candidates
76 .iter()
77 .any(|candidate| candidate.display == entry.name)
78 })
79 .take(remaining)
80 .map(|entry| {
81 let suffix = entry.name.strip_prefix(buffer).unwrap_or_default();
82 let insertion = if suffix.is_empty() { " " } else { suffix };
83 Candidate {
84 display: entry.name.clone(),
85 description: entry.description.clone(),
86 description_pending: entry.description_pending,
87 kind: CandidateKind::Command,
88 insert_text: insertion.to_owned(),
89 accept_text: insertion.to_owned(),
90 source: CandidateSource::Command,
91 }
92 })
93 .collect();
94 candidates.extend(command_candidates);
95 }
96
97 Ok(CompletionResponse {
98 replace_start_byte: cursor_byte,
99 replace_end_byte: cursor_byte,
100 candidates,
101 })
102}
103
104pub fn fuzzy(
105 store: &Store,
106 commands: &CommandCatalog,
107 query: &str,
108 cwd: &str,
109 requested_limit: Option<usize>,
110 settings: &Settings,
111) -> Result<CompletionResponse> {
112 if query.len() > MAX_BUFFER_BYTES {
113 bail!("fuzzy query exceeds {MAX_BUFFER_BYTES} bytes");
114 }
115 let limit = requested_limit
116 .unwrap_or(settings.completion.max_candidates)
117 .min(settings.completion.max_candidates);
118 let mut seen = HashSet::new();
119 let mut pool = Vec::new();
120
121 for history in
122 store.history_inventory(cwd, FUZZY_HISTORY_LIMIT, settings.history.successful_first)?
123 {
124 if seen.insert(history.command.clone()) {
125 pool.push(Candidate {
126 display: sanitize_display(&history.command),
127 description: history_description(history.uses, history.same_cwd),
128 description_pending: false,
129 kind: CandidateKind::History,
130 insert_text: history.command.clone(),
131 accept_text: history.command,
132 source: CandidateSource::History,
133 });
134 }
135 }
136 for command in commands.inventory() {
137 if seen.insert(command.name.clone()) {
138 pool.push(Candidate {
139 display: command.name.clone(),
140 description: command.description,
141 description_pending: false,
142 kind: CandidateKind::Command,
143 insert_text: command.name.clone(),
144 accept_text: command.name,
145 source: CandidateSource::Command,
146 });
147 }
148 }
149
150 let indexes = fzf_indexes(&pool, query, limit)?;
151 let mut candidates = Vec::with_capacity(indexes.len());
152 for index in indexes {
153 let mut candidate = pool[index].clone();
154 if candidate.source == CandidateSource::Command
155 && let Some(command) = commands
156 .matching(&candidate.display, 1)
157 .into_iter()
158 .find(|command| command.name == candidate.display)
159 {
160 candidate.description = command.description;
161 candidate.description_pending = command.description_pending;
162 }
163 candidates.push(candidate);
164 }
165 Ok(CompletionResponse {
166 replace_start_byte: 0,
167 replace_end_byte: 0,
168 candidates,
169 })
170}
171
172fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
173 if candidates.is_empty() || limit == 0 {
174 return Ok(Vec::new());
175 }
176 let mut input = Vec::new();
177 for (index, candidate) in candidates.iter().enumerate() {
178 write!(input, "{index}\t{}\0", candidate.display)?;
179 }
180
181 let mut child = Command::new("fzf")
182 .args([
183 "--read0",
184 "--print0",
185 "--no-multi",
186 "--delimiter=\\t",
187 "--nth=2..",
188 "--tiebreak=index",
189 "--filter",
190 query,
191 ])
192 .env_remove("FZF_DEFAULT_OPTS")
193 .env_remove("FZF_DEFAULT_OPTS_FILE")
194 .stdin(Stdio::piped())
195 .stdout(Stdio::piped())
196 .stderr(Stdio::null())
197 .spawn()?;
198 let mut stdin = child.stdin.take().expect("fzf stdin is piped");
199 let writer = thread::spawn(move || stdin.write_all(&input));
200 let output = child.wait_with_output()?;
201 writer.join().expect("fzf input writer panicked")?;
202 if output.status.code() == Some(1) {
203 return Ok(Vec::new());
204 }
205 if !output.status.success() {
206 bail!("fzf fuzzy filter failed with {}", output.status);
207 }
208
209 let mut indexes = Vec::new();
210 for record in output.stdout.split(|byte| *byte == 0) {
211 if record.is_empty() || indexes.len() >= limit {
212 continue;
213 }
214 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
215 continue;
216 };
217 let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
218 if index < candidates.len() {
219 indexes.push(index);
220 }
221 }
222 Ok(indexes)
223}
224
225fn valid_command_prefix(buffer: &str) -> bool {
226 !buffer.is_empty()
227 && !buffer.starts_with('.')
228 && buffer
229 .bytes()
230 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
231}
232
233fn history_description(uses: usize, same_cwd: bool) -> String {
234 match (uses, same_cwd) {
235 (1, true) => "used here".to_owned(),
236 (1, false) => "used once".to_owned(),
237 (uses, true) => format!("used {uses}x, here"),
238 (uses, false) => format!("used {uses}x"),
239 }
240}
241
242fn sanitize_display(value: &str) -> String {
243 let mut display = String::with_capacity(value.len());
244 for character in value.chars() {
245 if character.is_control() {
246 display.extend(character.escape_default());
247 } else {
248 display.push(character);
249 }
250 }
251 display
252}
253
254pub fn next_segment(suffix: &str) -> String {
255 let mut saw_non_whitespace = false;
256 for (index, character) in suffix.char_indices() {
257 let end = index + character.len_utf8();
258 if character.is_whitespace() {
259 if saw_non_whitespace {
260 return suffix[..end].to_owned();
261 }
262 continue;
263 }
264 saw_non_whitespace = true;
265 if matches!(character, '/' | '=' | ':' | ',') {
266 return suffix[..end].to_owned();
267 }
268 }
269 suffix.to_owned()
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::commands::{CommandCatalog, CommandEntry};
276 use crate::config::Settings;
277 use crate::store::Store;
278
279 #[test]
280 fn accepts_one_path_segment() {
281 assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
282 }
283
284 #[test]
285 fn accepts_one_shell_word() {
286 assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
287 }
288
289 #[test]
290 fn accepts_remaining_text_without_boundary() {
291 assert_eq!(next_segment("status"), "status");
292 }
293
294 #[test]
295 fn escapes_control_characters_in_display_text() {
296 assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
297 }
298
299 #[test]
300 fn describes_history_usage_and_directory() {
301 assert_eq!(history_description(1, true), "used here");
302 assert_eq!(history_description(3, true), "used 3x, here");
303 assert_eq!(history_description(2, false), "used 2x");
304 }
305
306 #[test]
307 fn completes_history_with_a_single_segment() {
308 let store = Store::in_memory().unwrap();
309 let mut settings = Settings::default();
310 settings.completion.accept = AcceptMode::Segment;
311 store
312 .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
313 .unwrap();
314
315 let completion = complete(
316 &store,
317 &CommandCatalog::default(),
318 "cd ~/d",
319 "cd ~/d".len(),
320 "/repo",
321 None,
322 &settings,
323 )
324 .unwrap();
325
326 assert_eq!(completion.candidates.len(), 1);
327 assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
328 assert_eq!(completion.candidates[0].accept_text, "ev/");
329 assert_eq!(completion.candidates[0].description, "used here");
330
331 let completion = complete(
332 &store,
333 &CommandCatalog::default(),
334 "cd ~/d",
335 "cd ~/d".len(),
336 "/repo",
337 None,
338 &Settings::default(),
339 )
340 .unwrap();
341 assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
342 }
343
344 #[test]
345 fn fuzzy_searches_history_without_a_prefix() {
346 if Command::new("fzf").arg("--version").output().is_err() {
347 return;
348 }
349 let store = Store::in_memory().unwrap();
350 store
351 .record("cargo test --all", "/repo", 0, 100, "test", true)
352 .unwrap();
353 let completion = fuzzy(
354 &store,
355 &CommandCatalog::default(),
356 "cgt",
357 "/repo",
358 None,
359 &Settings::default(),
360 )
361 .unwrap();
362 assert_eq!(completion.candidates[0].display, "cargo test --all");
363 }
364
365 #[test]
366 fn abstains_from_mid_line_completion() {
367 let store = Store::in_memory().unwrap();
368 let completion = complete(
369 &store,
370 &CommandCatalog::default(),
371 "git status",
372 3,
373 "/repo",
374 None,
375 &Settings::default(),
376 )
377 .unwrap();
378 assert!(completion.candidates.is_empty());
379 }
380
381 #[test]
382 fn discovers_commands_when_history_abstains() {
383 let store = Store::in_memory().unwrap();
384 let commands = CommandCatalog::from_entries([CommandEntry {
385 name: "atlas".to_owned(),
386 description: "CLI tool to manage MongoDB Atlas".to_owned(),
387 }]);
388
389 let completion = complete(
390 &store,
391 &commands,
392 "atl",
393 3,
394 "/repo",
395 None,
396 &Settings::default(),
397 )
398 .unwrap();
399
400 assert_eq!(completion.candidates[0].display, "atlas");
401 assert_eq!(completion.candidates[0].accept_text, "as");
402 assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
403 }
404
405 #[test]
406 fn history_precedes_command_inventory() {
407 let store = Store::in_memory().unwrap();
408 store
409 .record("git status", "/repo", 0, 100, "test", true)
410 .unwrap();
411 let commands = CommandCatalog::from_entries([CommandEntry {
412 name: "git-town".to_owned(),
413 description: "Git workflow automation".to_owned(),
414 }]);
415
416 let completion = complete(
417 &store,
418 &commands,
419 "git",
420 3,
421 "/repo",
422 None,
423 &Settings::default(),
424 )
425 .unwrap();
426
427 assert_eq!(completion.candidates[0].source, CandidateSource::History);
428 assert_eq!(completion.candidates[1].source, CandidateSource::Command);
429 }
430
431 #[test]
432 fn history_deduplicates_command_inventory() {
433 let store = Store::in_memory().unwrap();
434 store
435 .record("atlas", "/repo", 0, 100, "test", true)
436 .unwrap();
437 let commands = CommandCatalog::from_entries([
438 CommandEntry {
439 name: "atlas".to_owned(),
440 description: "CLI tool to manage MongoDB Atlas".to_owned(),
441 },
442 CommandEntry {
443 name: "atlantis".to_owned(),
444 description: "Terraform pull request automation".to_owned(),
445 },
446 ]);
447
448 let completion = complete(
449 &store,
450 &commands,
451 "atl",
452 3,
453 "/repo",
454 None,
455 &Settings::default(),
456 )
457 .unwrap();
458
459 assert_eq!(completion.candidates.len(), 2);
460 assert_eq!(completion.candidates[0].display, "atlas");
461 assert_eq!(completion.candidates[0].source, CandidateSource::History);
462 assert_eq!(completion.candidates[1].display, "atlantis");
463 }
464}