1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CompletionKind {
20 Command,
21 Path,
22 Other,
24}
25
26#[derive(Debug)]
28pub struct CompletionContext {
29 pub prefix: String,
30 pub kind: CompletionKind,
31 pub start: usize,
33}
34
35fn 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 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 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 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 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
90const 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#[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
120fn 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#[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
147const 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
163fn 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 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#[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 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
243pub 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#[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}