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
15fn 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: ®ex_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: ®ex_lite::Captures| caps[0].bright_blue().to_string())
32 .to_string();
33 }
34
35 if let Ok(re) = regex_lite::Regex::new(r#""([^"\\]|\\.)*""#) {
37 result = re
38 .replace_all(&result, |caps: ®ex_lite::Captures| {
39 caps[0].bright_green().to_string()
40 })
41 .to_string();
42 }
43
44 if let Ok(re) = regex_lite::Regex::new(r"\b\d+\b") {
46 result = re
47 .replace_all(&result, |caps: ®ex_lite::Captures| {
48 caps[0].bright_magenta().to_string()
49 })
50 .to_string();
51 }
52
53 let operators_pattern =
55 r"(\/\/=|<<|>>|\|\||\?\?|<=|>=|==|!=|=~|&&|\+=|-=|\*=|\/=|\|=|=|\||:|;|\?|!|\+|-|\*|\/|%|<|>|@)";
56 if let Ok(re) = regex_lite::Regex::new(operators_pattern) {
57 result = re
58 .replace_all(&result, |caps: ®ex_lite::Captures| {
59 caps[0].bright_yellow().to_string()
60 })
61 .to_string();
62 }
63
64 Cow::Owned(result)
65}
66
67fn 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
81fn 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
101fn 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
126fn is_char_available() -> bool {
128 if let Ok(term) = std::env::var("TERM") {
130 if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
132 return true;
133 }
134 }
135
136 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 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 false
154}
155
156pub struct MqLineHelper {
157 command_context: Rc<RefCell<CommandContext>>,
158 file_completer: FilenameCompleter,
159 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 *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 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 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 editor.bind_sequence(
328 KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
329 Cmd::Kill(Movement::WholeBuffer),
330 );
331 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 let result = highlight_mq_syntax("let x = 42");
427 assert!(result.contains("let"));
428
429 let result = highlight_mq_syntax("/help");
431 assert!(result.contains("help"));
432
433 let result = highlight_mq_syntax("x = 1 + 2");
435 assert!(result.contains("="));
436 assert!(result.contains("+"));
437
438 let result = highlight_mq_syntax(r#""hello world""#);
440 assert!(result.contains("hello world"));
441
442 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 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 unsafe { std::env::set_var("TERM", "xterm-256color") };
486 assert!(is_char_available());
487
488 unsafe { std::env::remove_var("TERM") };
490 unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
491 assert!(is_char_available());
492
493 unsafe { std::env::remove_var("LANG") };
495 unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
496 assert!(is_char_available());
497
498 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 unsafe { std::env::remove_var("LC_CTYPE") };
505 assert!(!is_char_available());
506
507 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}