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