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