Skip to main content

ase_shell/
repl.rs

1use crate::commands::{complete_command, find_executable, is_builtin};
2
3use anyhow::Context;
4use rustyline::completion::{Completer, FilenameCompleter, Pair, extract_word};
5use rustyline::config::{BellStyle, CompletionType, Configurer};
6use rustyline::highlight::{CmdKind, Highlighter};
7use rustyline::hint::Hinter;
8use rustyline::history::FileHistory;
9use rustyline::validate::{ValidationContext, ValidationResult, Validator};
10use rustyline::{Config, Context as RlContext, Editor, Helper};
11use std::borrow::Cow;
12
13pub type ReplEditor = Editor<AseHelper, FileHistory>;
14
15pub fn create_editor() -> anyhow::Result<ReplEditor> {
16  let config = Config::default();
17  let history = FileHistory::new();
18  let mut editor = Editor::<AseHelper, FileHistory>::with_history(config, history)
19    .context("create readline editor")?;
20  editor.set_helper(Some(AseHelper));
21  editor.set_completion_type(CompletionType::List);
22  editor.set_bell_style(BellStyle::Audible);
23  Ok(editor)
24}
25
26pub struct AseHelper;
27
28impl Default for AseHelper {
29  fn default() -> Self {
30    Self
31  }
32}
33
34/// List local git branch names that start with `prefix`.
35fn git_branches(prefix: &str) -> Vec<String> {
36  let Ok(cwd) = std::env::current_dir() else {
37    return Vec::new();
38  };
39  let output = std::process::Command::new("git")
40    .args(["branch", "--format=%(refname:short)"])
41    .current_dir(&cwd)
42    .stdout(std::process::Stdio::piped())
43    .stderr(std::process::Stdio::null())
44    .output();
45  let Ok(output) = output else {
46    return Vec::new();
47  };
48  let stdout = String::from_utf8_lossy(&output.stdout);
49  stdout
50    .lines()
51    .map(|l| l.trim().to_string())
52    .filter(|b| b.starts_with(prefix))
53    .collect()
54}
55
56const GIT_SUBCOMMANDS: &[&str] = &[
57  "add",
58  "bisect",
59  "branch",
60  "checkout",
61  "cherry-pick",
62  "clone",
63  "commit",
64  "diff",
65  "fetch",
66  "grep",
67  "init",
68  "log",
69  "merge",
70  "mv",
71  "pull",
72  "push",
73  "rebase",
74  "reflog",
75  "remote",
76  "reset",
77  "restore",
78  "revert",
79  "rm",
80  "show",
81  "stash",
82  "status",
83  "switch",
84  "tag",
85  "worktree",
86];
87
88impl Completer for AseHelper {
89  type Candidate = Pair;
90
91  fn complete(
92    &self,
93    line: &str,
94    pos: usize,
95    ctx: &RlContext<'_>,
96  ) -> rustyline::Result<(usize, Vec<Pair>)> {
97    let (start, word) = extract_word(line, pos, None, |c| c == ' ' || c == '\t');
98    let before = &line[..start];
99    let mut parts = before.split_whitespace();
100    let first = parts.next();
101
102    // First token: complete command names
103    if first.is_none() {
104      let candidates = complete_command(word)
105        .into_iter()
106        .map(|s| Pair {
107          display: s.clone(),
108          replacement: s,
109        })
110        .collect();
111      return Ok((start, candidates));
112    }
113
114    let cmd_name = first.unwrap();
115
116    // `cd` and `ls`: complete file/directory names
117    if cmd_name == "cd" || cmd_name == "ls" {
118      let file_completer = FilenameCompleter::new();
119      return file_completer.complete(line, pos, ctx);
120    }
121
122    // `git`: complete subcommands, then branch names for branch-taking subcommands
123    if cmd_name == "git" {
124      let remaining: Vec<&str> = parts.collect();
125
126      if remaining.is_empty() {
127        // Completing the git subcommand itself
128        let candidates = GIT_SUBCOMMANDS
129          .iter()
130          .filter(|s| s.starts_with(word))
131          .map(|s| Pair {
132            display: s.to_string(),
133            replacement: s.to_string(),
134          })
135          .collect();
136        return Ok((start, candidates));
137      }
138
139      let sub = remaining[0];
140      let branch_subs = [
141        "checkout",
142        "switch",
143        "merge",
144        "rebase",
145        "branch",
146        "diff",
147        "log",
148        "cherry-pick",
149        "reset",
150      ];
151      if branch_subs.contains(&sub) {
152        let branches: Vec<Pair> = git_branches(word)
153          .into_iter()
154          .map(|b| Pair {
155            display: b.clone(),
156            replacement: b,
157          })
158          .collect();
159        return Ok((start, branches));
160      }
161
162      // For other git subcommands, fall through to file completion
163      let file_completer = FilenameCompleter::new();
164      return file_completer.complete(line, pos, ctx);
165    }
166
167    Ok((pos, Vec::new()))
168  }
169}
170
171pub struct EmptyHint;
172
173impl rustyline::hint::Hint for EmptyHint {
174  fn display(&self) -> &str {
175    ""
176  }
177  fn completion(&self) -> Option<&str> {
178    None
179  }
180}
181
182impl Hinter for AseHelper {
183  type Hint = EmptyHint;
184
185  fn hint(&self, _line: &str, _pos: usize, _ctx: &RlContext<'_>) -> Option<EmptyHint> {
186    None
187  }
188}
189
190/// Highlight the first token (command name) in brand color (#fa912a ≈ ANSI 208).
191impl Highlighter for AseHelper {
192  fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
193    let trimmed_start = line.len() - line.trim_start().len();
194    let trimmed = &line[trimmed_start..];
195    let cmd_end = trimmed.find(|c: char| c == ' ' || c == '\t');
196
197    match cmd_end {
198      Some(end) => {
199        let cmd = &trimmed[..end];
200        let rest = &line[trimmed_start + end..];
201        let is_valid = is_builtin(cmd) || find_executable(cmd).is_some();
202        let color = if is_valid { "38;5;208" } else { "38;5;196" };
203        Cow::Owned(format!(
204          "{}\x1b[{color}m{cmd}\x1b[0m{rest}",
205          &line[..trimmed_start]
206        ))
207      }
208      None if !trimmed.is_empty() => {
209        let cmd = trimmed;
210        let is_valid = is_builtin(cmd) || find_executable(cmd).is_some();
211        let color = if is_valid { "38;5;208" } else { "38;5;196" };
212        Cow::Owned(format!(
213          "{}\x1b[{color}m{cmd}\x1b[0m",
214          &line[..trimmed_start]
215        ))
216      }
217      _ => Cow::Borrowed(line),
218    }
219  }
220
221  fn highlight_char(&self, _line: &str, _pos: usize, _forced: CmdKind) -> bool {
222    true
223  }
224}
225
226impl Validator for AseHelper {
227  fn validate(&self, _ctx: &mut ValidationContext<'_>) -> rustyline::Result<ValidationResult> {
228    Ok(ValidationResult::Valid(None))
229  }
230}
231
232impl Helper for AseHelper {}