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"^(/copy|/edit|/env|/help|/quit|/load|/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 get_prompt() -> &'static str {
69 if is_char_available() { "❯ " } else { "> " }
70}
71
72fn is_char_available() -> bool {
74 if let Ok(term) = std::env::var("TERM") {
76 if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
78 return true;
79 }
80 }
81
82 if let Ok(lang) = std::env::var("LANG")
84 && (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
85 {
86 return true;
87 }
88
89 for var in ["LC_ALL", "LC_CTYPE"] {
91 if let Ok(locale) = std::env::var(var)
92 && (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
93 {
94 return true;
95 }
96 }
97
98 false
100}
101
102pub struct MqLineHelper {
103 command_context: Rc<RefCell<CommandContext>>,
104 file_completer: FilenameCompleter,
105}
106
107impl MqLineHelper {
108 pub fn new(command_context: Rc<RefCell<CommandContext>>) -> Self {
109 Self {
110 command_context,
111 file_completer: FilenameCompleter::new(),
112 }
113 }
114}
115
116impl Hinter for MqLineHelper {
117 type Hint = String;
118}
119
120impl Highlighter for MqLineHelper {
121 fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> {
122 prompt.cyan().to_string().into()
123 }
124
125 fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
126 true
127 }
128
129 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
130 highlight_mq_syntax(line)
131 }
132}
133
134impl Validator for MqLineHelper {
135 fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult, ReadlineError> {
136 let input = ctx.input();
137 if input.is_empty() || input.ends_with("\n") || input.starts_with("/") {
138 return Ok(ValidationResult::Valid(None));
139 }
140
141 if mq_lang::parse_recovery(input).1.has_errors() {
142 Ok(ValidationResult::Incomplete)
143 } else {
144 Ok(ValidationResult::Valid(None))
145 }
146 }
147
148 fn validate_while_typing(&self) -> bool {
149 false
150 }
151}
152
153impl Completer for MqLineHelper {
154 type Candidate = Pair;
155
156 fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>), ReadlineError> {
157 let mut completions = self
158 .command_context
159 .borrow()
160 .completions(line, pos)
161 .iter()
162 .map(|cmd| Pair {
163 display: cmd.clone(),
164 replacement: format!("{}{}", cmd, &line[pos..]),
165 })
166 .collect::<Vec<_>>();
167
168 if line.starts_with(Command::LoadFile("".to_string()).to_string().as_str()) {
169 let (_, file_completions) = self.file_completer.complete_path(line, pos)?;
170 completions.extend(file_completions);
171 }
172
173 Ok((0, completions))
174 }
175}
176
177impl Helper for MqLineHelper {}
178
179pub struct Repl {
180 command_context: Rc<RefCell<CommandContext>>,
181}
182
183pub fn config_dir() -> Option<std::path::PathBuf> {
184 std::env::var_os("MQ_CONFIG_DIR")
185 .map(std::path::PathBuf::from)
186 .or_else(|| dirs::config_dir().map(|d| d.join("mq")))
187}
188
189impl Repl {
190 pub fn new(input: Vec<mq_lang::RuntimeValue>) -> Self {
191 let mut engine = mq_lang::DefaultEngine::default();
192
193 engine.load_builtin_module();
194
195 Self {
196 command_context: Rc::new(RefCell::new(CommandContext::new(engine, input))),
197 }
198 }
199
200 fn print_welcome() {
201 println!();
202 println!(
203 " {}",
204 "mq - A jq-like command-line tool for Markdown processing".bright_cyan()
205 );
206 println!();
207 println!(" Welcome to mq. Start by typing commands or expressions.");
208 println!(" Type {} to see available commands.", "/help".bright_cyan());
209 println!();
210 }
211
212 pub fn run(&self) -> miette::Result<()> {
213 let config = Config::builder()
214 .history_ignore_space(true)
215 .completion_type(CompletionType::List)
216 .edit_mode(EditMode::Emacs)
217 .color_mode(rustyline::ColorMode::Enabled)
218 .build();
219 let mut editor = Editor::with_config(config).into_diagnostic()?;
220 let helper = MqLineHelper::new(Rc::clone(&self.command_context));
221
222 editor.set_helper(Some(helper));
223 editor.bind_sequence(
224 KeyEvent(KeyCode::Left, Modifiers::CTRL),
225 Cmd::Move(Movement::BackwardWord(1, Word::Big)),
226 );
227 editor.bind_sequence(
228 KeyEvent(KeyCode::Right, Modifiers::CTRL),
229 Cmd::Move(Movement::ForwardWord(1, At::AfterEnd, Word::Big)),
230 );
231 editor.bind_sequence(
233 KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
234 Cmd::Kill(Movement::WholeBuffer),
235 );
236 editor.bind_sequence(
238 KeyEvent(KeyCode::Char('o'), Modifiers::ALT),
239 Cmd::Insert(1, "/edit\n".to_string()),
240 );
241
242 let config_dir = config_dir();
243
244 if let Some(config_dir) = &config_dir {
245 let history = config_dir.join("history.txt");
246 fs::create_dir_all(config_dir).ok();
247 if editor.load_history(&history).is_err() {
248 println!("No previous history.");
249 }
250 }
251
252 Self::print_welcome();
253
254 loop {
255 let prompt = format!("{}", get_prompt().cyan());
256 let readline = editor.readline(&prompt);
257
258 match readline {
259 Ok(line) => {
260 editor.add_history_entry(&line).unwrap();
261
262 match self.command_context.borrow_mut().execute(&line) {
263 Ok(CommandOutput::String(s)) => println!("{}", s.join("\n")),
264 Ok(CommandOutput::Value(runtime_values)) => {
265 let lines = runtime_values
266 .iter()
267 .filter_map(|runtime_value| {
268 if runtime_value.is_none() {
269 return Some("None".to_string());
270 }
271
272 let s = runtime_value.to_string();
273 if s.is_empty() { None } else { Some(s) }
274 })
275 .collect::<Vec<_>>();
276
277 if !lines.is_empty() {
278 println!("{}", lines.join("\n"))
279 }
280 }
281 Ok(CommandOutput::None) => (),
282 Err(e) => {
283 eprintln!("{:?}", e)
284 }
285 }
286 }
287 Err(ReadlineError::Interrupted) => {
288 continue;
289 }
290 Err(ReadlineError::Eof) => {
291 break;
292 }
293 Err(err) => {
294 eprintln!("Error: {:?}", err);
295 break;
296 }
297 }
298
299 if let Some(config_dir) = &config_dir {
300 let history = config_dir.join("history.txt");
301 editor.save_history(&history.to_string_lossy().to_string()).unwrap();
302 }
303 }
304
305 Ok(())
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_config_dir() {
315 unsafe { std::env::set_var("MQ_CONFIG_DIR", "/tmp/test_mq_config") };
316 assert_eq!(config_dir(), Some(std::path::PathBuf::from("/tmp/test_mq_config")));
317
318 unsafe { std::env::remove_var("MQ_CONFIG_DIR") };
319 let config_dir = config_dir();
320 assert!(config_dir.is_some());
321 if let Some(dir) = config_dir {
322 assert!(dir.ends_with("mq"));
323 }
324 }
325
326 #[test]
327 fn test_highlight_mq_syntax() {
328 let result = highlight_mq_syntax("let x = 42");
330 assert!(result.contains("let"));
331
332 let result = highlight_mq_syntax("/help");
334 assert!(result.contains("help"));
335
336 let result = highlight_mq_syntax("x = 1 + 2");
338 assert!(result.contains("="));
339 assert!(result.contains("+"));
340
341 let result = highlight_mq_syntax(r#""hello world""#);
343 assert!(result.contains("hello world"));
344
345 let result = highlight_mq_syntax("42");
347 assert!(result.contains("42"));
348 }
349
350 #[test]
351 fn test_is_char_available_utf8_env() {
352 let orig_term = std::env::var("TERM").ok();
354 let orig_lang = std::env::var("LANG").ok();
355 let orig_lc_all = std::env::var("LC_ALL").ok();
356 let orig_lc_ctype = std::env::var("LC_CTYPE").ok();
357
358 unsafe { std::env::set_var("TERM", "xterm-256color") };
360 assert!(is_char_available());
361
362 unsafe { std::env::remove_var("TERM") };
364 unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
365 assert!(is_char_available());
366
367 unsafe { std::env::remove_var("LANG") };
369 unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
370 assert!(is_char_available());
371
372 unsafe { std::env::remove_var("LC_ALL") };
374 unsafe { std::env::set_var("LC_CTYPE", "fr_FR.UTF-8") };
375 assert!(is_char_available());
376
377 unsafe { std::env::remove_var("LC_CTYPE") };
379 assert!(!is_char_available());
380
381 if let Some(val) = orig_term {
383 unsafe { std::env::set_var("TERM", val) };
384 } else {
385 unsafe { std::env::remove_var("TERM") };
386 }
387 if let Some(val) = orig_lang {
388 unsafe { std::env::set_var("LANG", val) };
389 } else {
390 unsafe { std::env::remove_var("LANG") };
391 }
392 if let Some(val) = orig_lc_all {
393 unsafe { std::env::set_var("LC_ALL", val) };
394 } else {
395 unsafe { std::env::remove_var("LC_ALL") };
396 }
397 if let Some(val) = orig_lc_ctype {
398 unsafe { std::env::set_var("LC_CTYPE", val) };
399 } else {
400 unsafe { std::env::remove_var("LC_CTYPE") };
401 }
402 }
403}