Skip to main content

mq_repl/
repl.rs

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