1use rustyline::highlight::CmdKind;
2use rustyline::validate::ValidationResult;
3use rustyline::{
4 Changeset,
5 Editor,
6 Helper,
8 KeyEvent,
9 completion::{Completer, FilenameCompleter, Pair},
10 config::CompletionType,
11 error::ReadlineError,
12 highlight::Highlighter,
13 hint::{Hinter, HistoryHinter},
14 history::FileHistory,
15 line_buffer::LineBuffer,
16 validate::Validator,
17};
18use rustyline::{Cmd, EditMode, Modifiers, Movement};
19
20use common_macros::hash_set;
21use std::borrow::Cow;
22use std::collections::{HashMap, HashSet};
23use std::path::PathBuf;
24
25use crate::Expression;
26use crate::ai::{AIClient, MockAIClient, init_ai};
27use crate::cmdhelper::{
28 PATH_COMMANDS, should_trigger_cmd_completion, should_trigger_path_completion,
29};
30use crate::expression::alias::get_alias_tips;
31use crate::keyhandler::{LumeAbbrHandler, LumeKeyHandler, LumeMoveHandler};
32use crate::modules::get_builtin_tips;
33use crate::syntax::{get_ayu_dark_theme, get_dark_theme, get_light_theme, get_merged_theme};
34
35use crate::runtime::check;
36use crate::{Environment, highlight, parse_and_eval, prompt::get_prompt_engine};
37
38use std::sync::{Arc, Mutex};
41
42const DEFAULT: &str = "";
44const GREEN_BOLD: &str = "\x1b[1;32m";
45const RESET: &str = "\x1b[0m";
49pub fn run_repl(env: &mut Environment) {
52 match env.get("LUME_WELCOME") {
55 Some(wel) => {
56 println!("{wel}");
57 env.undefine("LUME_WELCOME");
58 }
59 _ => println!("Welcome to Lumesh {}", env!("CARGO_PKG_VERSION")),
60 }
61
62 let no_history = match env.get("LUME_NO_HISTORY") {
65 Some(Expression::Boolean(t)) => {
66 env.undefine("LUME_NO_HISTORY");
67 t
68 }
69 _ => false,
70 };
71 let history_file = match env.get("LUME_HISTORY_FILE") {
72 Some(hf) => hf.to_string(),
73 _ => {
74 let c_dir = match dirs::cache_dir() {
75 Some(c) => c,
76 _ => PathBuf::new(),
77 };
78 #[cfg(unix)]
79 let path = c_dir.join(".lume_history");
80 #[cfg(windows)]
81 let path = c_dir.join("lume_history.log");
82 if !path.exists() {
83 match std::fs::File::create(&path) {
84 Ok(_) => {}
85 Err(e) => eprint!("Failed to create cache directory: {e}"),
86 }
87 }
88 path.into_os_string().into_string().unwrap()
89 }
90 };
91 let ai_config = env.get("LUME_AI_CONFIG");
93 env.undefine("LUME_AI_CONFIG");
94 let vi_mode = match env.get("LUME_VI_MODE") {
95 Some(Expression::Boolean(true)) => {
96 env.undefine("LUME_AI_CONFIG");
97 true
98 }
99 _ => false,
100 };
101
102 let theme_base = env.get("LUME_THEME");
104 env.undefine("LUME_THEME");
105 let theme = match theme_base {
106 Some(Expression::String(t)) => match t.as_ref() {
107 "light" => get_light_theme(),
108 "ayu_dark" => get_ayu_dark_theme(),
109 _ => get_dark_theme(),
110 },
111 _ => get_dark_theme(),
112 };
113
114 let theme_config = env.get("LUME_THEME_CONFIG");
115 env.undefine("LUME_THEME_CONFIG");
116 let theme_merged = match theme_config {
117 Some(Expression::Map(m)) => get_merged_theme(theme, m.as_ref()),
118 _ => theme,
119 };
120
121 let rl = Arc::new(Mutex::new(new_editor(ai_config, vi_mode, theme_merged)));
123
124 match rl.lock().unwrap().load_history(&history_file) {
125 Ok(_) => {}
126 Err(e) => println!("No previous history {e}"),
127 }
128
129 let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
130
131 let rl_clone = Arc::clone(&rl);
135 let running_clone = Arc::clone(&running);
136 if no_history {
137 ctrlc::set_handler(move || {
138 running_clone.store(false, std::sync::atomic::Ordering::SeqCst);
139 std::process::exit(0);
140 })
141 .expect("Error setting Ctrl-C handler");
142 } else {
143 let hist = history_file.clone();
144 ctrlc::set_handler(move || {
145 running_clone.store(false, std::sync::atomic::Ordering::SeqCst);
146 let _ = rl_clone.lock().unwrap().save_history(&hist);
147 std::process::exit(0);
148 })
149 .expect("Error setting Ctrl-C handler");
150 }
151 rl.lock()
156 .unwrap()
157 .bind_sequence(KeyEvent::ctrl('j'), LumeMoveHandler::new(1));
158 rl.lock()
159 .unwrap()
160 .bind_sequence(KeyEvent::alt('j'), LumeMoveHandler::new(0));
161 rl.lock().unwrap().bind_sequence(
162 KeyEvent::ctrl('o'),
163 Cmd::Replace(Movement::WholeBuffer, Some(String::from(""))),
164 );
165 let hotkey_sudo = match env.get("LUME_SUDO_CMD") {
166 Some(s) => {
167 env.undefine("LUME_SUDO_CMD");
168 s.to_string()
169 }
170 _ => "sudo".to_string(),
171 };
172 rl.lock().unwrap().bind_sequence(
173 KeyEvent::alt('s'),
174 Cmd::Replace(Movement::BeginningOfLine, Some(hotkey_sudo)),
175 );
176
177 let hotkey_modifier = env.get("LUME_HOT_MODIFIER");
179 env.undefine("LUME_HOT_MODIFIER");
180 let modifier: u8 = match hotkey_modifier {
181 Some(Expression::Integer(bits)) => {
182 if (bits as u8 & (Modifiers::CTRL | Modifiers::ALT | Modifiers::SHIFT).bits()) == 0 {
186 eprintln!("invalid LUME_HOT_MODIFIER {bits}");
187 4
188 } else {
189 bits as u8
190 }
191 }
192 _ => 4,
193 };
194
195 let hotkey_config = env.get("LUME_HOT_KEYS");
196 env.undefine("LUME_HOT_KEYS");
197
198 if let Some(Expression::Map(keys)) = hotkey_config {
199 let mut rl_unlocked = rl.lock().unwrap();
200 for (k, cmd) in keys.iter() {
201 if let Some(c) = k.chars().next() {
202 rl_unlocked.bind_sequence(
203 KeyEvent::new(c, Modifiers::from_bits_retain(modifier)),
206 LumeKeyHandler::new(cmd.to_string()),
208 );
209 }
210 }
211 }
212 let abbr = env.get("LUME_ABBREVIATIONS");
214 env.undefine("LUME_ABBREVIATIONS");
215
216 if let Some(Expression::Map(ab)) = abbr {
217 let abmap = ab
218 .iter()
219 .map(|m| (m.0.to_string(), m.1.to_string()))
220 .collect::<HashMap<String, String>>();
221 rl.lock().unwrap().bind_sequence(
222 KeyEvent::new(' ', Modifiers::NONE),
223 LumeAbbrHandler::new(abmap),
224 );
225 }
226 let pe = get_prompt_engine(
230 env.get("LUME_PROMPT_SETTINGS"),
231 env.get("LUME_PROMPT_TEMPLATE"),
232 );
233 env.undefine("LUME_PROMPT_SETTINGS");
234 env.undefine("LUME_PROMPT_TEMPLATE");
235
236 while running.load(std::sync::atomic::Ordering::SeqCst) {
238 let prompt = pe.get_prompt();
239
240 let line = match rl.lock().unwrap().readline(prompt.as_str()) {
242 Ok(line) => line,
243 Err(ReadlineError::Interrupted) => {
244 println!("CTRL-C");
245 continue;
247 }
248 Err(ReadlineError::Eof) => {
256 println!("CTRL-D");
257 continue;
258 }
259 Err(err) => {
260 println!("Error: {err:?}");
261 break;
262 }
263 };
264
265 match line.trim() {
266 "" => {}
267 "exit" => break,
268 "history" => {
269 for (i, entry) in rl.lock().unwrap().history().iter().enumerate() {
270 println!("{}{}:{} {}", GREEN_BOLD, i + 1, RESET, entry);
271 }
272 }
273 _ => {
274 if parse_and_eval(&line, env)
275 {
277 match rl.lock().unwrap().add_history_entry(&line) {
278 Ok(_) => {}
279 Err(e) => eprintln!("add history err: {e}"),
280 };
281 }
282 }
283 }
284 }
285
286 if !no_history {
288 match rl.lock().unwrap().save_history(&history_file) {
289 Ok(_) => {}
290 Err(e) => eprintln!("save history err: {e}"),
291 };
292 }
293}
294
295#[derive(Clone)]
297struct LumeHelper {
298 completer: Arc<FilenameCompleter>,
299 hinter: Arc<HistoryHinter>,
300 highlighter: Arc<SyntaxHighlighter>,
301 ai_client: Option<Arc<MockAIClient>>,
302 cmds: HashSet<String>,
303}
304
305fn new_editor(
306 ai_config: Option<Expression>,
307 vi_mode: bool,
308 theme: HashMap<String, String>,
309) -> Editor<LumeHelper, FileHistory> {
310 let config = rustyline::Config::builder()
311 .history_ignore_space(true)
312 .completion_type(CompletionType::Circular)
313 .edit_mode(if vi_mode {
314 EditMode::Vi
315 } else {
316 EditMode::Emacs
317 })
318 .history_ignore_dups(true)
319 .unwrap()
320 .build();
321
322 let mut rl = Editor::with_config(config).unwrap_or_else(|_| Editor::new().unwrap());
323 let ai = ai_config.map(|ai_cfg| Arc::new(init_ai(ai_cfg)));
324 let mut cmds: HashSet<String> = hash_set! {
327 "cd ./".into(),
328 "ls -l --color ./".into(),
329 "clear".into(),
330 "rm ".into(),
331 "cp -r".into(),
332 "let ".into(),
333 "fn ".into(),
334 "if ".into(),
335 "else {".into(),
336 "match ".into(),
337 "while (".into(),
338 "for i in ".into(),
339 "loop {\n".into(),
340 "break".into(),
341 "return".into(),
342 "history".into(),
343 "del ".into(),
344 "use ".into(),
345 };
346 cmds.extend(get_builtin_tips());
347 cmds.extend(PATH_COMMANDS.lock().unwrap().iter().cloned());
348 cmds.extend(get_alias_tips());
349 let helper = LumeHelper {
350 completer: Arc::new(FilenameCompleter::new()),
351 hinter: Arc::new(HistoryHinter::new()),
352 highlighter: Arc::new(SyntaxHighlighter::new(theme)),
353 ai_client: ai,
354 cmds,
355 };
356 rl.set_helper(Some(helper));
357 rl
358}
359
360impl Helper for LumeHelper {}
361impl Completer for LumeHelper {
372 type Candidate = Pair;
373
374 fn complete(
375 &self,
376 line: &str,
377 pos: usize,
378 ctx: &rustyline::Context<'_>,
379 ) -> Result<(usize, Vec<Self::Candidate>), ReadlineError> {
380 match self.detect_completion_type(line, pos) {
381 LumeCompletionType::Path => self.path_completion(line, pos, ctx),
382 LumeCompletionType::Command => self.cmd_completion(line, pos),
383 LumeCompletionType::AI => self.ai_completion(line, pos),
384 LumeCompletionType::None => Ok((pos, Vec::new())),
385 }
386 }
387
388 fn update(&self, line: &mut LineBuffer, start: usize, elected: &str, cl: &mut Changeset) {
389 let end = line.pos();
391 line.replace(start..end, elected, cl);
392 }
393}
394
395impl LumeHelper {
397 fn detect_completion_type(&self, line: &str, pos: usize) -> LumeCompletionType {
399 if should_trigger_path_completion(line, pos) {
400 LumeCompletionType::Path
401 } else if should_trigger_cmd_completion(line, pos) {
402 LumeCompletionType::Command
403 } else if self.should_trigger_ai(line) {
404 LumeCompletionType::AI
405 } else {
406 LumeCompletionType::None
407 }
408 }
409
410 fn path_completion(
412 &self,
413 line: &str,
414 pos: usize,
415 ctx: &rustyline::Context<'_>,
416 ) -> Result<(usize, Vec<Pair>), ReadlineError> {
417 self.completer.complete(line, pos, ctx)
418 }
419
420 fn cmd_completion(&self, line: &str, pos: usize) -> Result<(usize, Vec<Pair>), ReadlineError> {
422 let input = &line[..pos];
424 let start = input.rfind(' ').map(|i| i + 1).unwrap_or(0);
425 let prefix = &input[start..];
426 let cpl_color = self
429 .highlighter
430 .theme
431 .get("completion_cmd")
432 .map_or(DEFAULT, |c| c.as_str());
433 let mut candidates: Vec<Pair> = self
434 .cmds
435 .iter()
436 .filter(|cmd| cmd.starts_with(prefix))
437 .map(|cmd| {
438 Pair {
440 display: format!("{cpl_color}{cmd}{RESET}"),
441 replacement: cmd.clone(),
442 }
443 })
444 .collect();
445 candidates.sort_by(|a, b| a.display.len().cmp(&b.display.len()));
447
448 Ok((start, candidates))
449 }
450
451 fn ai_completion(&self, line: &str, pos: usize) -> Result<(usize, Vec<Pair>), ReadlineError> {
453 let prompt = line.trim();
454 let suggestion = self
455 .ai_client
456 .as_ref()
457 .and_then(|ai| ai.complete(prompt).ok())
458 .unwrap_or_default();
459
460 let pair = Pair {
461 display: format!(
462 "{}{}{}",
463 self.highlighter
464 .theme
465 .get("completion_ai")
466 .map_or(DEFAULT, |c| c.as_str()),
467 suggestion,
468 RESET
469 ), replacement: suggestion,
471 };
472 Ok((pos, vec![pair]))
473 }
474
475 fn should_trigger_ai(&self, line: &str) -> bool {
477 self.ai_client.is_some() && line.split_whitespace().count() > 1
478 }
479}
480
481#[derive(Debug, PartialEq)]
483enum LumeCompletionType {
484 Path,
485 Command,
486 AI,
487 None,
488}
489
490impl Validator for LumeHelper {
491 fn validate(
492 &self,
493 ctx: &mut rustyline::validate::ValidationContext<'_>,
494 ) -> rustyline::Result<ValidationResult> {
495 if ctx.input().ends_with("\n\n") || check(ctx.input()) {
498 return Ok(ValidationResult::Valid(None));
499 };
500 Ok(ValidationResult::Incomplete)
501 }
502}
503
504impl Highlighter for LumeHelper {
505 fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
506 self.highlighter.highlight_char(line, pos, kind)
507 }
508 fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
509 self.highlighter.highlight(line, pos)
510 }
511 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
512 self.highlighter.highlight_hint(hint)
513 }
514 }
525
526impl Hinter for LumeHelper {
556 type Hint = String;
557
558 fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<String> {
559 let mut segment = String::new();
561 if !line.is_empty() {
562 for (i, ch) in line.chars().enumerate() {
563 if matches!(ch, ';' | '|' | '(' | '{' | '`' | '\n') {
565 segment.clear();
566 } else if segment.is_empty() && ch.is_ascii_whitespace() {
567 } else {
568 segment.push(ch);
569 }
570 if i == pos {
571 break;
572 }
573 }
574 }
575 if !segment.is_empty() {
577 let mut matches: Vec<_> = self
579 .cmds
580 .iter()
581 .filter(|cmd| cmd.starts_with(&segment))
582 .collect();
583
584 matches.sort_by(|a, b| a.len().cmp(&b.len()));
586 if let Some(matched) = matches.first() {
588 let suffix = &matched[segment.len()..];
589 if !suffix.is_empty() {
591 return Some(suffix.to_string());
592 }
593 }
594 }
595
596 self.hinter.hint(line, pos, ctx)
598 }
599}
600
601struct SyntaxHighlighter {
602 theme: HashMap<String, String>,
603}
604
605impl SyntaxHighlighter {
606 pub fn new(theme: HashMap<String, String>) -> Self {
607 Self { theme }
608 }
609}
610impl Highlighter for SyntaxHighlighter {
611 fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
612 let _s = (line, pos, kind);
613 kind != CmdKind::MoveCursor
614 }
615
616 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
617 let mut parts = line.splitn(2, |c: char| c.is_whitespace());
618 let cmd = parts.next().unwrap_or("");
619 let rest = parts.next().unwrap_or("");
620 if cmd.is_empty() {
621 return Cow::Borrowed(line);
622 }
623
624 let (color, is_valid) = if is_valid_command(cmd) {
625 (
626 self.theme
627 .get("command_valid")
628 .map_or(DEFAULT, |c| c.as_str()),
629 true,
630 )
631 } else {
634 return Cow::Owned(highlight(line, &self.theme));
636 };
637
638 let highlighted_rest = highlight(rest, &self.theme);
640 let colored_line = if is_valid {
641 format!("{color}{cmd}{RESET} {highlighted_rest}")
642 } else {
643 format!("{color}{cmd}{RESET} {highlighted_rest}")
644 };
645 Cow::Owned(colored_line)
646 }
647
648 fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
649 if hint.is_empty() || hint.contains('\x1b') {
652 return Cow::Borrowed(hint);
653 }
654 Cow::Owned(format!(
655 "{}{}{}",
656 self.theme.get("hint").map_or(DEFAULT, |c| c.as_str()),
657 hint,
658 RESET
659 ))
660 }
661
662 }
673fn is_valid_command(cmd: &str) -> bool {
721 PATH_COMMANDS.lock().unwrap().contains(cmd)
722}