Skip to main content

mq_repl/
repl.rs

1use colored::*;
2use miette::IntoDiagnostic;
3use rustyline::{
4    At, Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers, Movement, Word,
5    completion::{Completer, FilenameCompleter, Pair},
6    error::ReadlineError,
7    highlight::{CmdKind, Highlighter},
8    hint::Hinter,
9    validate::{ValidationContext, ValidationResult, Validator},
10};
11use std::{borrow::Cow, cell::RefCell, fs, rc::Rc};
12
13use crate::command_context::{Command, CommandContext, CommandOutput};
14
15/// Highlight mq syntax with keywords and commands
16fn highlight_mq_syntax(line: &str) -> Cow<'_, str> {
17    let mut result = line.to_string();
18
19    let commands_pattern = r"^(/copy|/edit|/env|/help|/quit|/load|/vars|/version)\b";
20    if let Ok(re) = regex_lite::Regex::new(commands_pattern) {
21        result = re
22            .replace_all(&result, |caps: &regex_lite::Captures| {
23                caps[0].bright_green().to_string()
24            })
25            .to_string();
26    }
27
28    let keywords_pattern = r"\b(def|let|if|elif|else|end|while|foreach|self|nodes|fn|break|continue|include|true|false|None|match|import|module|do|var|macro|quote|unquote)\b";
29    if let Ok(re) = regex_lite::Regex::new(keywords_pattern) {
30        result = re
31            .replace_all(&result, |caps: &regex_lite::Captures| caps[0].bright_blue().to_string())
32            .to_string();
33    }
34
35    // Highlight strings
36    if let Ok(re) = regex_lite::Regex::new(r#""([^"\\]|\\.)*""#) {
37        result = re
38            .replace_all(&result, |caps: &regex_lite::Captures| {
39                caps[0].bright_green().to_string()
40            })
41            .to_string();
42    }
43
44    // Highlight numbers
45    if let Ok(re) = regex_lite::Regex::new(r"\b\d+\b") {
46        result = re
47            .replace_all(&result, |caps: &regex_lite::Captures| {
48                caps[0].bright_magenta().to_string()
49            })
50            .to_string();
51    }
52
53    // Highlight operators (after other highlighting to avoid conflicts)
54    let operators_pattern =
55        r"(\/\/=|<<|>>|\|\||\?\?|<=|>=|==|!=|=~|&&|\+=|-=|\*=|\/=|\|=|=|\||:|;|\?|!|\+|-|\*|\/|%|<|>|@)";
56    if let Ok(re) = regex_lite::Regex::new(operators_pattern) {
57        result = re
58            .replace_all(&result, |caps: &regex_lite::Captures| {
59                caps[0].bright_yellow().to_string()
60            })
61            .to_string();
62    }
63
64    Cow::Owned(result)
65}
66
67/// Get the appropriate prompt symbol based on character availability
68fn get_prompt() -> &'static str {
69    if is_char_available() { "❯ " } else { "> " }
70}
71
72/// Check if a Unicode character is available in the current environment
73fn is_char_available() -> bool {
74    // Check environment variables that might indicate character support
75    if let Ok(term) = std::env::var("TERM") {
76        // Most modern terminals support Unicode
77        if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
78            return true;
79        }
80    }
81
82    // Check if we're in a UTF-8 locale
83    if let Ok(lang) = std::env::var("LANG")
84        && (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
85    {
86        return true;
87    }
88
89    // Check LC_ALL and LC_CTYPE for UTF-8 support
90    for var in ["LC_ALL", "LC_CTYPE"] {
91        if let Ok(locale) = std::env::var(var)
92            && (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
93        {
94            return true;
95        }
96    }
97
98    // Default to false for safety if we can't determine character support
99    false
100}
101
102pub struct MqLineHelper {
103    command_context: Rc<RefCell<CommandContext>>,
104    file_completer: FilenameCompleter,
105}
106
107impl MqLineHelper {
108    pub fn new(command_context: Rc<RefCell<CommandContext>>) -> Self {
109        Self {
110            command_context,
111            file_completer: FilenameCompleter::new(),
112        }
113    }
114}
115
116impl Hinter for MqLineHelper {
117    type Hint = String;
118}
119
120impl Highlighter for MqLineHelper {
121    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> {
122        prompt.cyan().to_string().into()
123    }
124
125    fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
126        true
127    }
128
129    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
130        highlight_mq_syntax(line)
131    }
132}
133
134impl Validator for MqLineHelper {
135    fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult, ReadlineError> {
136        let input = ctx.input();
137        if input.is_empty() || input.ends_with("\n") || input.starts_with("/") {
138            return Ok(ValidationResult::Valid(None));
139        }
140
141        if mq_lang::parse_recovery(input).1.has_errors() {
142            Ok(ValidationResult::Incomplete)
143        } else {
144            Ok(ValidationResult::Valid(None))
145        }
146    }
147
148    fn validate_while_typing(&self) -> bool {
149        false
150    }
151}
152
153impl Completer for MqLineHelper {
154    type Candidate = Pair;
155
156    fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>), ReadlineError> {
157        let mut completions = self
158            .command_context
159            .borrow()
160            .completions(line, pos)
161            .iter()
162            .map(|cmd| Pair {
163                display: cmd.clone(),
164                replacement: format!("{}{}", cmd, &line[pos..]),
165            })
166            .collect::<Vec<_>>();
167
168        if line.starts_with(Command::LoadFile("".to_string()).to_string().as_str()) {
169            let (_, file_completions) = self.file_completer.complete_path(line, pos)?;
170            completions.extend(file_completions);
171        }
172
173        Ok((0, completions))
174    }
175}
176
177impl Helper for MqLineHelper {}
178
179pub struct Repl {
180    command_context: Rc<RefCell<CommandContext>>,
181}
182
183pub fn config_dir() -> Option<std::path::PathBuf> {
184    std::env::var_os("MQ_CONFIG_DIR")
185        .map(std::path::PathBuf::from)
186        .or_else(|| dirs::config_dir().map(|d| d.join("mq")))
187}
188
189impl Repl {
190    pub fn new(input: Vec<mq_lang::RuntimeValue>) -> Self {
191        let mut engine = mq_lang::DefaultEngine::default();
192
193        engine.load_builtin_module();
194
195        Self {
196            command_context: Rc::new(RefCell::new(CommandContext::new(engine, input))),
197        }
198    }
199
200    fn print_welcome() {
201        println!();
202        println!(
203            "  {}",
204            "mq - A jq-like command-line tool for Markdown processing".bright_cyan()
205        );
206        println!();
207        println!("  Welcome to mq. Start by typing commands or expressions.");
208        println!("  Type {} to see available commands.", "/help".bright_cyan());
209        println!();
210    }
211
212    pub fn run(&self) -> miette::Result<()> {
213        let config = Config::builder()
214            .history_ignore_space(true)
215            .completion_type(CompletionType::List)
216            .edit_mode(EditMode::Emacs)
217            .color_mode(rustyline::ColorMode::Enabled)
218            .build();
219        let mut editor = Editor::with_config(config).into_diagnostic()?;
220        let helper = MqLineHelper::new(Rc::clone(&self.command_context));
221
222        editor.set_helper(Some(helper));
223        editor.bind_sequence(
224            KeyEvent(KeyCode::Left, Modifiers::CTRL),
225            Cmd::Move(Movement::BackwardWord(1, Word::Big)),
226        );
227        editor.bind_sequence(
228            KeyEvent(KeyCode::Right, Modifiers::CTRL),
229            Cmd::Move(Movement::ForwardWord(1, At::AfterEnd, Word::Big)),
230        );
231        // Bind Esc+C (Alt+C) to clear all input lines
232        editor.bind_sequence(
233            KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
234            Cmd::Kill(Movement::WholeBuffer),
235        );
236        // Bind Esc+O (Alt+O) to open editor
237        editor.bind_sequence(
238            KeyEvent(KeyCode::Char('o'), Modifiers::ALT),
239            Cmd::Insert(1, "/edit\n".to_string()),
240        );
241
242        let config_dir = config_dir();
243
244        if let Some(config_dir) = &config_dir {
245            let history = config_dir.join("history.txt");
246            fs::create_dir_all(config_dir).ok();
247            if editor.load_history(&history).is_err() {
248                println!("No previous history.");
249            }
250        }
251
252        Self::print_welcome();
253
254        loop {
255            let prompt = format!("{}", get_prompt().cyan());
256            let readline = editor.readline(&prompt);
257
258            match readline {
259                Ok(line) => {
260                    editor.add_history_entry(&line).unwrap();
261
262                    match self.command_context.borrow_mut().execute(&line) {
263                        Ok(CommandOutput::String(s)) => println!("{}", s.join("\n")),
264                        Ok(CommandOutput::Value(runtime_values)) => {
265                            let lines = runtime_values
266                                .iter()
267                                .filter_map(|runtime_value| {
268                                    if runtime_value.is_none() {
269                                        return Some("None".to_string());
270                                    }
271
272                                    let s = runtime_value.to_string();
273                                    if s.is_empty() { None } else { Some(s) }
274                                })
275                                .collect::<Vec<_>>();
276
277                            if !lines.is_empty() {
278                                println!("{}", lines.join("\n"))
279                            }
280                        }
281                        Ok(CommandOutput::None) => (),
282                        Err(e) => {
283                            eprintln!("{:?}", e)
284                        }
285                    }
286                }
287                Err(ReadlineError::Interrupted) => {
288                    continue;
289                }
290                Err(ReadlineError::Eof) => {
291                    break;
292                }
293                Err(err) => {
294                    eprintln!("Error: {:?}", err);
295                    break;
296                }
297            }
298
299            if let Some(config_dir) = &config_dir {
300                let history = config_dir.join("history.txt");
301                editor.save_history(&history.to_string_lossy().to_string()).unwrap();
302            }
303        }
304
305        Ok(())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_config_dir() {
315        unsafe { std::env::set_var("MQ_CONFIG_DIR", "/tmp/test_mq_config") };
316        assert_eq!(config_dir(), Some(std::path::PathBuf::from("/tmp/test_mq_config")));
317
318        unsafe { std::env::remove_var("MQ_CONFIG_DIR") };
319        let config_dir = config_dir();
320        assert!(config_dir.is_some());
321        if let Some(dir) = config_dir {
322            assert!(dir.ends_with("mq"));
323        }
324    }
325
326    #[test]
327    fn test_highlight_mq_syntax() {
328        // Test keyword highlighting
329        let result = highlight_mq_syntax("let x = 42");
330        assert!(result.contains("let"));
331
332        // Test command highlighting
333        let result = highlight_mq_syntax("/help");
334        assert!(result.contains("help"));
335
336        // Test operator highlighting
337        let result = highlight_mq_syntax("x = 1 + 2");
338        assert!(result.contains("="));
339        assert!(result.contains("+"));
340
341        // Test string highlighting
342        let result = highlight_mq_syntax(r#""hello world""#);
343        assert!(result.contains("hello world"));
344
345        // Test number highlighting
346        let result = highlight_mq_syntax("42");
347        assert!(result.contains("42"));
348    }
349
350    #[test]
351    fn test_is_char_available_utf8_env() {
352        // Save original env vars
353        let orig_term = std::env::var("TERM").ok();
354        let orig_lang = std::env::var("LANG").ok();
355        let orig_lc_all = std::env::var("LC_ALL").ok();
356        let orig_lc_ctype = std::env::var("LC_CTYPE").ok();
357
358        // TERM contains xterm
359        unsafe { std::env::set_var("TERM", "xterm-256color") };
360        assert!(is_char_available());
361
362        // LANG contains utf-8
363        unsafe { std::env::remove_var("TERM") };
364        unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
365        assert!(is_char_available());
366
367        // LC_ALL contains utf8
368        unsafe { std::env::remove_var("LANG") };
369        unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
370        assert!(is_char_available());
371
372        // LC_CTYPE contains utf-8
373        unsafe { std::env::remove_var("LC_ALL") };
374        unsafe { std::env::set_var("LC_CTYPE", "fr_FR.UTF-8") };
375        assert!(is_char_available());
376
377        // No relevant env vars
378        unsafe { std::env::remove_var("LC_CTYPE") };
379        assert!(!is_char_available());
380
381        // Restore original env vars
382        if let Some(val) = orig_term {
383            unsafe { std::env::set_var("TERM", val) };
384        } else {
385            unsafe { std::env::remove_var("TERM") };
386        }
387        if let Some(val) = orig_lang {
388            unsafe { std::env::set_var("LANG", val) };
389        } else {
390            unsafe { std::env::remove_var("LANG") };
391        }
392        if let Some(val) = orig_lc_all {
393            unsafe { std::env::set_var("LC_ALL", val) };
394        } else {
395            unsafe { std::env::remove_var("LC_ALL") };
396        }
397        if let Some(val) = orig_lc_ctype {
398            unsafe { std::env::set_var("LC_CTYPE", val) };
399        } else {
400            unsafe { std::env::remove_var("LC_CTYPE") };
401        }
402    }
403}