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"^(/clear|/copy|/edit|/env|/help|/history|/quit|/load|/reset|/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/// Format a markdown node with type-specific colors.
68fn format_markdown_node(node: &mq_markdown::Node) -> String {
69    let s = node.to_string();
70    match node {
71        mq_markdown::Node::Heading(_) => s.bold().bright_cyan().to_string(),
72        mq_markdown::Node::Code(_) => s.bright_yellow().to_string(),
73        mq_markdown::Node::CodeInline(_) => s.yellow().to_string(),
74        mq_markdown::Node::Link(_) | mq_markdown::Node::LinkRef(_) => s.bright_blue().to_string(),
75        mq_markdown::Node::Strong(_) => s.bold().to_string(),
76        mq_markdown::Node::Emphasis(_) => s.italic().to_string(),
77        _ => s,
78    }
79}
80
81/// Format a runtime value with type-appropriate colors.
82fn format_runtime_value(value: &mq_lang::RuntimeValue) -> Option<String> {
83    use mq_lang::RuntimeValue;
84    let s = match value {
85        RuntimeValue::None => return Some("None".dimmed().to_string()),
86        RuntimeValue::Number(n) => n.to_string().bright_magenta().to_string(),
87        RuntimeValue::Boolean(b) => b.to_string().bright_yellow().to_string(),
88        RuntimeValue::String(s) => format!("\"{}\"", s).bright_green().to_string(),
89        RuntimeValue::Markdown(node, _) => format_markdown_node(node),
90        _ => {
91            let s = value.to_string();
92            if s.is_empty() {
93                return None;
94            }
95            s
96        }
97    };
98    Some(s)
99}
100
101/// Get the appropriate prompt symbol based on character availability
102fn get_prompt() -> &'static str {
103    if is_char_available() { "❯ " } else { "> " }
104}
105
106fn is_truecolor_supported() -> bool {
107    matches!(std::env::var("COLORTERM").as_deref(), Ok("truecolor") | Ok("24bit"))
108}
109
110fn logo_primary(s: &str) -> ColoredString {
111    if is_truecolor_supported() {
112        s.truecolor(133, 212, 255)
113    } else {
114        s.bright_cyan()
115    }
116}
117
118fn text_muted(s: &str) -> ColoredString {
119    if is_truecolor_supported() {
120        s.truecolor(148, 163, 184)
121    } else {
122        s.white()
123    }
124}
125
126/// Check if a Unicode character is available in the current environment
127fn is_char_available() -> bool {
128    // Check environment variables that might indicate character support
129    if let Ok(term) = std::env::var("TERM") {
130        // Most modern terminals support Unicode
131        if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
132            return true;
133        }
134    }
135
136    // Check if we're in a UTF-8 locale
137    if let Ok(lang) = std::env::var("LANG")
138        && (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
139    {
140        return true;
141    }
142
143    // Check LC_ALL and LC_CTYPE for UTF-8 support
144    for var in ["LC_ALL", "LC_CTYPE"] {
145        if let Ok(locale) = std::env::var(var)
146            && (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
147        {
148            return true;
149        }
150    }
151
152    // Default to false for safety if we can't determine character support
153    false
154}
155
156pub struct MqLineHelper {
157    command_context: Rc<RefCell<CommandContext>>,
158    file_completer: FilenameCompleter,
159    /// Tracks whether the current input spans multiple lines (continuation mode).
160    is_continuation: Rc<RefCell<bool>>,
161}
162
163impl MqLineHelper {
164    pub fn new(command_context: Rc<RefCell<CommandContext>>) -> Self {
165        Self {
166            command_context,
167            file_completer: FilenameCompleter::new(),
168            is_continuation: Rc::new(RefCell::new(false)),
169        }
170    }
171}
172
173impl Hinter for MqLineHelper {
174    type Hint = String;
175
176    fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option<String> {
177        // Update continuation state based on whether the buffer has newlines.
178        *self.is_continuation.borrow_mut() = line.contains('\n');
179
180        if pos < line.len() || line.is_empty() || line.starts_with('/') {
181            return None;
182        }
183
184        let (start, completions) = self.command_context.borrow().completions(line, pos);
185        let word = &line[start..pos];
186
187        // Completion hint takes priority when a single match extends the current word.
188        if !word.is_empty() && completions.len() == 1 && completions[0].name.len() > word.len() {
189            return Some(completions[0].name[word.len()..].to_string());
190        }
191
192        // Bracket closing hint: show the matching close bracket right after an open bracket.
193        if word.is_empty() {
194            let closing = match line.chars().last() {
195                Some('(') => Some(")"),
196                Some('[') => Some("]"),
197                Some('{') => Some("}"),
198                _ => None,
199            };
200            if let Some(c) = closing {
201                return Some(c.to_string());
202            }
203        }
204
205        None
206    }
207}
208
209impl Highlighter for MqLineHelper {
210    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> {
211        if *self.is_continuation.borrow() {
212            "..  ".dimmed().to_string().into()
213        } else {
214            prompt.cyan().to_string().into()
215        }
216    }
217
218    fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> {
219        hint.dimmed().to_string().into()
220    }
221
222    fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
223        true
224    }
225
226    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
227        highlight_mq_syntax(line)
228    }
229}
230
231impl Validator for MqLineHelper {
232    fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult, ReadlineError> {
233        let input = ctx.input();
234        if input.is_empty() || input.ends_with("\n") || input.starts_with("/") {
235            return Ok(ValidationResult::Valid(None));
236        }
237
238        if mq_lang::parse_recovery(input).1.has_errors() {
239            Ok(ValidationResult::Incomplete)
240        } else {
241            Ok(ValidationResult::Valid(None))
242        }
243    }
244
245    fn validate_while_typing(&self) -> bool {
246        false
247    }
248}
249
250impl Completer for MqLineHelper {
251    type Candidate = Pair;
252
253    fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>), ReadlineError> {
254        let (start, matches) = self.command_context.borrow().completions(line, pos);
255
256        let mut completions = matches
257            .iter()
258            .map(|item| Pair {
259                display: item.display.clone(),
260                replacement: format!("{}{}", item.name, &line[pos..]),
261            })
262            .collect::<Vec<_>>();
263
264        if line.starts_with(Command::LoadFile("".to_string()).to_string().as_str()) {
265            let (_, file_completions) = self.file_completer.complete_path(line, pos)?;
266            completions.extend(file_completions);
267        }
268
269        Ok((start, completions))
270    }
271}
272
273impl Helper for MqLineHelper {}
274
275pub struct Repl {
276    command_context: Rc<RefCell<CommandContext>>,
277}
278
279pub fn config_dir() -> Option<std::path::PathBuf> {
280    std::env::var_os("MQ_CONFIG_DIR")
281        .map(std::path::PathBuf::from)
282        .or_else(|| dirs::config_dir().map(|d| d.join("mq")))
283}
284
285impl Repl {
286    pub fn new(input: Vec<mq_lang::RuntimeValue>) -> Self {
287        let mut engine = mq_lang::DefaultEngine::default();
288
289        engine.load_builtin_module();
290
291        Self {
292            command_context: Rc::new(RefCell::new(CommandContext::new(engine, input))),
293        }
294    }
295
296    fn print_welcome() {
297        let version = mq_lang::DefaultEngine::version();
298
299        println!();
300        println!("  {} {}", logo_primary("mq").bold(), text_muted(&format!("v{version}")));
301        println!("  {}", text_muted("Query. Filter. Transform Markdown."));
302        println!();
303        println!("  Type {} to see available commands.", logo_primary("/help"));
304        println!();
305    }
306
307    pub fn run(&self) -> miette::Result<()> {
308        let config = Config::builder()
309            .history_ignore_space(true)
310            .completion_type(CompletionType::List)
311            .edit_mode(EditMode::Emacs)
312            .color_mode(rustyline::ColorMode::Enabled)
313            .build();
314        let mut editor = Editor::with_config(config).into_diagnostic()?;
315        let helper = MqLineHelper::new(Rc::clone(&self.command_context));
316
317        editor.set_helper(Some(helper));
318        editor.bind_sequence(
319            KeyEvent(KeyCode::Left, Modifiers::CTRL),
320            Cmd::Move(Movement::BackwardWord(1, Word::Big)),
321        );
322        editor.bind_sequence(
323            KeyEvent(KeyCode::Right, Modifiers::CTRL),
324            Cmd::Move(Movement::ForwardWord(1, At::AfterEnd, Word::Big)),
325        );
326        // Bind Esc+C (Alt+C) to clear all input lines
327        editor.bind_sequence(
328            KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
329            Cmd::Kill(Movement::WholeBuffer),
330        );
331        // Bind Esc+O (Alt+O) to open editor
332        editor.bind_sequence(
333            KeyEvent(KeyCode::Char('o'), Modifiers::ALT),
334            Cmd::Insert(1, "/edit\n".to_string()),
335        );
336
337        let config_dir = config_dir();
338
339        if let Some(config_dir) = &config_dir {
340            let history = config_dir.join("history.txt");
341            fs::create_dir_all(config_dir).ok();
342            if editor.load_history(&history).is_err() {
343                println!("No previous history.");
344            }
345        }
346
347        Self::print_welcome();
348
349        loop {
350            let prompt = format!("{}", get_prompt().cyan());
351            let readline = editor.readline(&prompt);
352
353            match readline {
354                Ok(line) => {
355                    editor.add_history_entry(&line).unwrap();
356
357                    match self.command_context.borrow_mut().execute(&line) {
358                        Ok(CommandOutput::String(s)) => println!("{}", s.join("\n")),
359                        Ok(CommandOutput::Value(runtime_values)) => {
360                            let lines: Vec<String> = runtime_values.iter().filter_map(format_runtime_value).collect();
361                            if !lines.is_empty() {
362                                println!("{}", lines.join("\n"))
363                            }
364                        }
365                        Ok(CommandOutput::History) => {
366                            let entries: Vec<String> = editor
367                                .history()
368                                .iter()
369                                .enumerate()
370                                .map(|(i, entry)| format!("  {:>4}  {}", i + 1, entry.dimmed()))
371                                .collect();
372                            if entries.is_empty() {
373                                println!("  No history.");
374                            } else {
375                                println!("{}", entries.join("\n"));
376                            }
377                        }
378                        Ok(CommandOutput::None) => (),
379                        Err(e) => {
380                            eprintln!("{:?}", e)
381                        }
382                    }
383                }
384                Err(ReadlineError::Interrupted) => {
385                    continue;
386                }
387                Err(ReadlineError::Eof) => {
388                    break;
389                }
390                Err(err) => {
391                    eprintln!("Error: {:?}", err);
392                    break;
393                }
394            }
395
396            if let Some(config_dir) = &config_dir {
397                let history = config_dir.join("history.txt");
398                editor.save_history(&history.to_string_lossy().to_string()).unwrap();
399            }
400        }
401
402        Ok(())
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_config_dir() {
412        unsafe { std::env::set_var("MQ_CONFIG_DIR", "/tmp/test_mq_config") };
413        assert_eq!(config_dir(), Some(std::path::PathBuf::from("/tmp/test_mq_config")));
414
415        unsafe { std::env::remove_var("MQ_CONFIG_DIR") };
416        let config_dir = config_dir();
417        assert!(config_dir.is_some());
418        if let Some(dir) = config_dir {
419            assert!(dir.ends_with("mq"));
420        }
421    }
422
423    #[test]
424    fn test_highlight_mq_syntax() {
425        // Test keyword highlighting
426        let result = highlight_mq_syntax("let x = 42");
427        assert!(result.contains("let"));
428
429        // Test command highlighting
430        let result = highlight_mq_syntax("/help");
431        assert!(result.contains("help"));
432
433        // Test operator highlighting
434        let result = highlight_mq_syntax("x = 1 + 2");
435        assert!(result.contains("="));
436        assert!(result.contains("+"));
437
438        // Test string highlighting
439        let result = highlight_mq_syntax(r#""hello world""#);
440        assert!(result.contains("hello world"));
441
442        // Test number highlighting
443        let result = highlight_mq_syntax("42");
444        assert!(result.contains("42"));
445    }
446
447    #[test]
448    fn test_format_runtime_value_number() {
449        let v = mq_lang::RuntimeValue::Number(42.into());
450        let s = format_runtime_value(&v).unwrap();
451        assert!(s.contains("42"));
452    }
453
454    #[test]
455    fn test_format_runtime_value_boolean() {
456        let v = mq_lang::RuntimeValue::Boolean(true);
457        let s = format_runtime_value(&v).unwrap();
458        assert!(s.contains("true"));
459    }
460
461    #[test]
462    fn test_format_runtime_value_string() {
463        let v = mq_lang::RuntimeValue::String("hello".to_string());
464        let s = format_runtime_value(&v).unwrap();
465        assert!(s.contains("hello"));
466        assert!(s.contains('"'));
467    }
468
469    #[test]
470    fn test_format_runtime_value_none() {
471        let v = mq_lang::RuntimeValue::None;
472        let s = format_runtime_value(&v).unwrap();
473        assert!(s.contains("None"));
474    }
475
476    #[test]
477    fn test_is_char_available_utf8_env() {
478        // Save original env vars
479        let orig_term = std::env::var("TERM").ok();
480        let orig_lang = std::env::var("LANG").ok();
481        let orig_lc_all = std::env::var("LC_ALL").ok();
482        let orig_lc_ctype = std::env::var("LC_CTYPE").ok();
483
484        // TERM contains xterm
485        unsafe { std::env::set_var("TERM", "xterm-256color") };
486        assert!(is_char_available());
487
488        // LANG contains utf-8
489        unsafe { std::env::remove_var("TERM") };
490        unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
491        assert!(is_char_available());
492
493        // LC_ALL contains utf8
494        unsafe { std::env::remove_var("LANG") };
495        unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
496        assert!(is_char_available());
497
498        // LC_CTYPE contains utf-8
499        unsafe { std::env::remove_var("LC_ALL") };
500        unsafe { std::env::set_var("LC_CTYPE", "fr_FR.UTF-8") };
501        assert!(is_char_available());
502
503        // No relevant env vars
504        unsafe { std::env::remove_var("LC_CTYPE") };
505        assert!(!is_char_available());
506
507        // Restore original env vars
508        if let Some(val) = orig_term {
509            unsafe { std::env::set_var("TERM", val) };
510        } else {
511            unsafe { std::env::remove_var("TERM") };
512        }
513        if let Some(val) = orig_lang {
514            unsafe { std::env::set_var("LANG", val) };
515        } else {
516            unsafe { std::env::remove_var("LANG") };
517        }
518        if let Some(val) = orig_lc_all {
519            unsafe { std::env::set_var("LC_ALL", val) };
520        } else {
521            unsafe { std::env::remove_var("LC_ALL") };
522        }
523        if let Some(val) = orig_lc_ctype {
524            unsafe { std::env::set_var("LC_CTYPE", val) };
525        } else {
526            unsafe { std::env::remove_var("LC_CTYPE") };
527        }
528    }
529}