Skip to main content

aster/
engine.rs

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    // Mid-line replacement requires shell-aware token ranges. Abstain until that
33    // parser exists rather than risking corruption of the user's command.
34    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}