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
15fn highlight_mq_syntax(line: &str) -> Cow<'_, str> {
22 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 matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.cmp(&b.2)));
61
62 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
88fn 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
102fn 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
125fn 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
150fn is_char_available() -> bool {
152 if let Ok(term) = std::env::var("TERM") {
154 if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
156 return true;
157 }
158 }
159
160 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 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 false
178}
179
180pub struct MqLineHelper {
181 command_context: Rc<RefCell<CommandContext>>,
182 file_completer: FilenameCompleter,
183 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 *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 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 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 editor.bind_sequence(
348 KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
349 Cmd::Kill(Movement::WholeBuffer),
350 );
351 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 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 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 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 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 unsafe { std::env::set_var("TERM", "xterm-256color") };
536 assert!(is_char_available());
537
538 unsafe { std::env::remove_var("TERM") };
540 unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
541 assert!(is_char_available());
542
543 unsafe { std::env::remove_var("LANG") };
545 unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
546 assert!(is_char_available());
547
548 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 unsafe { std::env::remove_var("LC_CTYPE") };
555 assert!(!is_char_available());
556
557 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}