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
34fn 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 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 if cmd_name == "cd" || cmd_name == "ls" {
118 let file_completer = FilenameCompleter::new();
119 return file_completer.complete(line, pos, ctx);
120 }
121
122 if cmd_name == "git" {
124 let remaining: Vec<&str> = parts.collect();
125
126 if remaining.is_empty() {
127 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 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
190impl 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 {}