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