Skip to main content

xtask_todo_lib/devshell/
completion.rs

1//! Tab completion: context parsing (command vs path), command and path candidates.
2//! Rustyline Helper (Completer, Hinter, Highlighter, Validator) for command and path completion.
3
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::rc::Rc;
7
8use rustyline::completion::Completer;
9use rustyline::highlight::Highlighter;
10use rustyline::hint::{Hint, Hinter};
11use rustyline::validate::{ValidationContext, ValidationResult, Validator};
12use rustyline::Context;
13use rustyline::Helper;
14
15use super::vfs::Vfs;
16
17/// 当前输入位置是命令名、路径,或不需要补全(用于选择补全源)
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CompletionKind {
20    Command,
21    Path,
22    /// 前一 token 不是管道也不是路径型参数,不提供补全
23    Other,
24}
25
26/// 从 (line, pos) 解析出的补全上下文:当前词的前缀,以及是命令还是路径
27#[derive(Debug)]
28pub struct CompletionContext {
29    pub prefix: String,
30    pub kind: CompletionKind,
31    /// 当前词在 line 中的起始位置(用于 rustyline 的 replace start)
32    pub start: usize,
33}
34
35/// Tokenize line[..pos] by spaces and delimiters |, <, >, and "2>" as one token.
36/// Returns list of (`token_string`, `start_index`).
37fn tokenize(line: &str, pos: usize) -> Vec<(String, usize)> {
38    let slice = line.get(..pos).unwrap_or("");
39    let mut tokens = Vec::new();
40    let mut i = 0;
41    let bytes = slice.as_bytes();
42
43    while i < bytes.len() {
44        // Skip whitespace
45        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
46            i += 1;
47        }
48        if i >= bytes.len() {
49            break;
50        }
51        let token_start = i;
52
53        // Delimiter "2>" as one token
54        if i + 1 < bytes.len() && bytes[i] == b'2' && bytes[i + 1] == b'>' {
55            tokens.push(("2>".to_string(), token_start));
56            i += 2;
57            continue;
58        }
59        // Single-char delimiters
60        if bytes[i] == b'|' || bytes[i] == b'<' || bytes[i] == b'>' {
61            let ch = char::from(bytes[i]);
62            tokens.push((ch.to_string(), token_start));
63            i += 1;
64            continue;
65        }
66
67        // Collect run of non-delimiter, non-whitespace (stop before "2>", |, <, >, or space)
68        let start = i;
69        while i < bytes.len() {
70            if bytes[i].is_ascii_whitespace() {
71                break;
72            }
73            if bytes[i] == b'|' || bytes[i] == b'<' || bytes[i] == b'>' {
74                break;
75            }
76            if bytes[i] == b'2' && i + 1 < bytes.len() && bytes[i + 1] == b'>' {
77                break;
78            }
79            i += 1;
80        }
81        let token = slice[start..i].to_string();
82        if !token.is_empty() {
83            tokens.push((token, start));
84        }
85    }
86
87    tokens
88}
89
90/// Built-in command names for tab completion (must match command.rs).
91const BUILTIN_COMMANDS: &[&str] = &[
92    "pwd",
93    "cd",
94    "ls",
95    "mkdir",
96    "rustup",
97    "cargo",
98    "cat",
99    "touch",
100    "echo",
101    "save",
102    "export-readonly",
103    "export_readonly",
104    "exit",
105    "quit",
106    "help",
107];
108
109/// Command completion: case-insensitive prefix match. Returns matching command names.
110#[must_use]
111pub fn complete_commands(prefix: &str) -> Vec<String> {
112    let prefix_lower = prefix.to_lowercase();
113    BUILTIN_COMMANDS
114        .iter()
115        .filter(|c| c.to_lowercase().starts_with(prefix_lower.as_str()))
116        .map(|s| (*s).to_string())
117        .collect()
118}
119
120/// Split path being completed into `(dir_prefix, basename_prefix)`.
121///
122/// `dir_prefix` ends with `/` (or is empty); it is preserved in candidates so readline replaces
123/// the whole token (e.g. `src/` → `src/main.rs`, not `main.rs` alone).
124fn split_dir_and_basename_prefix(prefix: &str) -> (&str, &str) {
125    prefix.rfind('/').map_or(("", prefix), |idx| {
126        let dir = &prefix[..=idx];
127        let rest = &prefix[idx + 1..];
128        (dir, rest)
129    })
130}
131
132/// Path completion: prefix may contain slashes; only the basename segment is matched.
133///
134/// Returned strings are **full token replacements** (include any directory part before the last
135/// `/`), so rustyline's replace-from-`start` keeps paths like `src/main.rs` correct.
136/// `parent_names` are basenames in the resolved parent directory. Empty basename prefix returns all.
137#[must_use]
138pub fn complete_path(prefix: &str, parent_names: &[String]) -> Vec<String> {
139    let (dir_prefix, basename_prefix) = split_dir_and_basename_prefix(prefix);
140    parent_names
141        .iter()
142        .filter(|n| n.starts_with(basename_prefix))
143        .map(|n| format!("{dir_prefix}{n}"))
144        .collect()
145}
146
147/// Tokens after which the next word is completed as a path (command args or redirect target).
148const PATH_TRIGGER_TOKENS: &[&str] = &[
149    "cd",
150    "ls",
151    "cat",
152    "mkdir",
153    "touch",
154    "export-readonly",
155    "export_readonly",
156    "source",
157    ".",
158    ">",
159    "2>",
160    "<",
161];
162
163/// Returns the (prefix, start) for the token that contains the cursor at `pos`.
164/// Prefix is the part of the token from start up to pos (what the user has typed so far).
165fn token_at_cursor(line: &str, tokens: &[(String, usize)], pos: usize) -> Option<(String, usize)> {
166    if pos > line.len() {
167        return None;
168    }
169    for (token, start) in tokens {
170        let end = start + token.len();
171        if *start <= pos && end >= pos {
172            let prefix = line.get(*start..pos).unwrap_or("").to_string();
173            return Some((prefix, *start));
174        }
175    }
176    // Cursor in trailing whitespace: prefix empty, start at pos
177    if !tokens.is_empty() {
178        let (last_token, last_start) = tokens.last().unwrap();
179        let last_end = last_start + last_token.len();
180        if pos >= last_end {
181            return Some((String::new(), pos));
182        }
183    }
184    None
185}
186
187/// Parse completion context at (line, pos). Returns None if line empty or pos out of bounds.
188#[must_use]
189pub fn completion_context(line: &str, pos: usize) -> Option<CompletionContext> {
190    if line.is_empty() {
191        return None;
192    }
193    let line_len = line.len();
194    if pos > line_len {
195        return None;
196    }
197
198    let tokens = tokenize(line, pos);
199    let (prefix, start) = token_at_cursor(line, &tokens, pos)?;
200
201    // If we got empty prefix with start == pos, we're in trailing space; still return context with kind
202    let prefix = if prefix.is_empty() && start == pos && !tokens.is_empty() {
203        String::new()
204    } else if prefix.is_empty() && start == pos {
205        return None;
206    } else {
207        prefix
208    };
209
210    let token_index = tokens
211        .iter()
212        .position(|(t, s)| *s == start && t.as_str() == prefix.as_str())
213        .or({
214            if prefix.is_empty() {
215                Some(tokens.len())
216            } else {
217                None
218            }
219        });
220
221    let idx = token_index.unwrap_or_else(|| tokens.iter().take_while(|(_, s)| *s < start).count());
222
223    let kind = if idx == 0 {
224        CompletionKind::Command
225    } else {
226        let prev = tokens.get(idx.wrapping_sub(1)).map(|(t, _)| t.as_str());
227        if prev == Some("|") {
228            CompletionKind::Command
229        } else if prev.is_some_and(|p| PATH_TRIGGER_TOKENS.contains(&p)) {
230            CompletionKind::Path
231        } else {
232            CompletionKind::Other
233        }
234    };
235
236    Some(CompletionContext {
237        prefix,
238        kind,
239        start,
240    })
241}
242
243// ---------------------------------------------------------------------------
244// Rustyline Helper: command and path completion via Rc<RefCell<Vfs>>
245// ---------------------------------------------------------------------------
246
247/// Helper for rustyline: command and path tab-completion using shared Vfs.
248pub struct DevShellHelper {
249    pub vfs: Rc<RefCell<Vfs>>,
250}
251
252impl DevShellHelper {
253    pub const fn new(vfs: Rc<RefCell<Vfs>>) -> Self {
254        Self { vfs }
255    }
256}
257
258/// Dummy hint type for Hinter; we never return a hint (`hint()` returns None).
259#[derive(Debug)]
260pub struct NoHint;
261impl Hint for NoHint {
262    fn display(&self) -> &'static str {
263        ""
264    }
265    fn completion(&self) -> Option<&str> {
266        None
267    }
268}
269
270impl Completer for DevShellHelper {
271    type Candidate = String;
272
273    fn complete(
274        &self,
275        line: &str,
276        pos: usize,
277        _ctx: &Context<'_>,
278    ) -> Result<(usize, Vec<String>), rustyline::error::ReadlineError> {
279        let Some(ctx) = completion_context(line, pos) else {
280            return Ok((pos, vec![]));
281        };
282        let candidates = match ctx.kind {
283            CompletionKind::Command => complete_commands(&ctx.prefix),
284            CompletionKind::Other => vec![],
285            CompletionKind::Path => {
286                let parent = if ctx.prefix.contains('/') {
287                    let idx = ctx.prefix.rfind('/').unwrap();
288                    if idx == 0 {
289                        "/".to_string()
290                    } else {
291                        ctx.prefix[..idx].to_string()
292                    }
293                } else {
294                    ".".to_string()
295                };
296                let abs_parent = self.vfs.borrow().resolve_to_absolute(&parent);
297                let names = self.vfs.borrow().list_dir(&abs_parent).unwrap_or_default();
298                complete_path(&ctx.prefix, &names)
299            }
300        };
301        Ok((ctx.start, candidates))
302    }
303}
304
305impl Hinter for DevShellHelper {
306    type Hint = NoHint;
307
308    fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<NoHint> {
309        None
310    }
311}
312
313impl Highlighter for DevShellHelper {
314    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
315        Cow::Borrowed(line)
316    }
317}
318
319impl Validator for DevShellHelper {
320    fn validate(
321        &self,
322        _ctx: &mut ValidationContext<'_>,
323    ) -> Result<ValidationResult, rustyline::error::ReadlineError> {
324        Ok(ValidationResult::Valid(None))
325    }
326}
327
328impl Helper for DevShellHelper {}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::devshell::vfs::Vfs;
334
335    #[test]
336    fn complete_commands_prefix() {
337        let c = complete_commands("pw");
338        assert_eq!(c, vec!["pwd"]);
339        let c = complete_commands("ex");
340        assert!(c.iter().any(|s| s == "exit"));
341        let c = complete_commands("");
342        assert!(c.len() > 5);
343    }
344
345    #[test]
346    fn complete_path_empty_prefix() {
347        let names = vec!["a".into(), "b".into()];
348        assert_eq!(complete_path("", &names), vec!["a", "b"]);
349    }
350
351    #[test]
352    fn completion_context_first_token() {
353        let ctx = completion_context("pwd", 3).unwrap();
354        assert_eq!(ctx.prefix, "pwd");
355        assert_eq!(ctx.kind, CompletionKind::Command);
356    }
357
358    #[test]
359    fn completion_context_after_pipe_is_command() {
360        let ctx = completion_context("echo x | pw", 10).unwrap();
361        assert_eq!(ctx.kind, CompletionKind::Command);
362    }
363
364    #[test]
365    fn completion_context_path_token() {
366        let ctx = completion_context("cat /a/b", 8).unwrap();
367        assert_eq!(ctx.kind, CompletionKind::Path);
368    }
369
370    #[test]
371    fn completion_context_with_2_redirect() {
372        let ctx = completion_context("echo x 2> ", 10).unwrap();
373        assert_eq!(ctx.kind, CompletionKind::Path);
374    }
375
376    #[test]
377    fn completion_context_trailing_space_after_command() {
378        let ctx = completion_context("pwd ", 4).unwrap();
379        assert_eq!(ctx.prefix, "");
380    }
381
382    #[test]
383    fn completion_context_pos_past_line_len_returns_none() {
384        assert!(completion_context("pwd", 10).is_none());
385    }
386
387    #[test]
388    fn complete_path_with_prefix() {
389        let names = vec!["foo".into(), "bar".into(), "food".into()];
390        let c = complete_path("fo", &names);
391        assert_eq!(c, vec!["foo", "food"]);
392    }
393
394    #[test]
395    fn complete_path_trailing_slash_keeps_parent_in_candidate() {
396        let names = vec!["main.rs".into(), "lib.rs".into()];
397        let mut c = complete_path("src/", &names);
398        c.sort();
399        assert_eq!(c, vec!["src/lib.rs", "src/main.rs"]);
400    }
401
402    #[test]
403    fn complete_path_partial_under_subdir() {
404        let names = vec!["main.rs".into(), "mod.rs".into()];
405        let c = complete_path("src/ma", &names);
406        assert_eq!(c, vec!["src/main.rs"]);
407    }
408
409    #[test]
410    fn completer_complete_command() {
411        use std::cell::RefCell;
412        use std::rc::Rc;
413
414        let vfs = Rc::new(RefCell::new(Vfs::new()));
415        let helper = DevShellHelper::new(vfs);
416        let hist = rustyline::history::MemHistory::new();
417        let ctx = Context::new(&hist);
418        let (start, candidates) = helper.complete("pw", 2, &ctx).unwrap();
419        assert_eq!(start, 0);
420        assert_eq!(candidates, vec!["pwd"]);
421    }
422
423    #[test]
424    fn completer_complete_path() {
425        use std::cell::RefCell;
426        use std::rc::Rc;
427
428        let vfs = Rc::new(RefCell::new(Vfs::new()));
429        vfs.borrow_mut().mkdir("/a").unwrap();
430        vfs.borrow_mut().mkdir("/b").unwrap();
431        let helper = DevShellHelper::new(vfs);
432        let hist = rustyline::history::MemHistory::new();
433        let ctx = Context::new(&hist);
434        let (start, candidates) = helper.complete("ls /", 4, &ctx).unwrap();
435        assert!(start <= 4);
436        assert!(candidates.contains(&"/a".to_string()));
437        assert!(candidates.contains(&"/b".to_string()));
438    }
439
440    #[test]
441    fn completer_complete_when_context_none_returns_empty() {
442        use std::cell::RefCell;
443        use std::rc::Rc;
444
445        let vfs = Rc::new(RefCell::new(Vfs::new()));
446        let helper = DevShellHelper::new(vfs);
447        let hist = rustyline::history::MemHistory::new();
448        let ctx = Context::new(&hist);
449        let (pos, candidates) = helper.complete("pwd", 10, &ctx).unwrap();
450        assert_eq!(pos, 10);
451        assert!(candidates.is_empty());
452    }
453
454    #[test]
455    fn no_hint_display_and_completion() {
456        let h = NoHint;
457        assert_eq!(h.display(), "");
458        assert!(h.completion().is_none());
459    }
460
461    #[test]
462    fn hinter_returns_none_or_no_hint() {
463        use std::cell::RefCell;
464        use std::rc::Rc;
465
466        let vfs = Rc::new(RefCell::new(Vfs::new()));
467        let helper = DevShellHelper::new(vfs);
468        let history = rustyline::history::MemHistory::new();
469        let ctx = Context::new(&history);
470        let hint = helper.hint("pwd", 4, &ctx);
471        if let Some(h) = hint {
472            assert_eq!(h.display(), "");
473        }
474    }
475
476    #[test]
477    fn highlighter_returns_borrowed() {
478        use std::cell::RefCell;
479        use std::rc::Rc;
480
481        let vfs = Rc::new(RefCell::new(Vfs::new()));
482        let helper = DevShellHelper::new(vfs);
483        let out = helper.highlight("echo x", 6);
484        assert_eq!(out.as_ref(), "echo x");
485    }
486}