use colored::*;
use miette::IntoDiagnostic;
use rustyline::{
At, Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers, Movement, Word,
completion::{Completer, FilenameCompleter, Pair},
error::ReadlineError,
highlight::{CmdKind, Highlighter},
hint::Hinter,
validate::{ValidationContext, ValidationResult, Validator},
};
use std::{borrow::Cow, cell::RefCell, fs, rc::Rc};
use crate::command_context::{Command, CommandContext, CommandOutput};
fn highlight_mq_syntax(line: &str) -> Cow<'_, str> {
let mut result = line.to_string();
let commands_pattern = r"^(/clear|/copy|/edit|/env|/help|/history|/quit|/load|/reset|/vars|/version)\b";
if let Ok(re) = regex_lite::Regex::new(commands_pattern) {
result = re
.replace_all(&result, |caps: ®ex_lite::Captures| {
caps[0].bright_green().to_string()
})
.to_string();
}
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";
if let Ok(re) = regex_lite::Regex::new(keywords_pattern) {
result = re
.replace_all(&result, |caps: ®ex_lite::Captures| caps[0].bright_blue().to_string())
.to_string();
}
if let Ok(re) = regex_lite::Regex::new(r#""([^"\\]|\\.)*""#) {
result = re
.replace_all(&result, |caps: ®ex_lite::Captures| {
caps[0].bright_green().to_string()
})
.to_string();
}
if let Ok(re) = regex_lite::Regex::new(r"\b\d+\b") {
result = re
.replace_all(&result, |caps: ®ex_lite::Captures| {
caps[0].bright_magenta().to_string()
})
.to_string();
}
let operators_pattern =
r"(\/\/=|<<|>>|\|\||\?\?|<=|>=|==|!=|=~|&&|\+=|-=|\*=|\/=|\|=|=|\||:|;|\?|!|\+|-|\*|\/|%|<|>|@)";
if let Ok(re) = regex_lite::Regex::new(operators_pattern) {
result = re
.replace_all(&result, |caps: ®ex_lite::Captures| {
caps[0].bright_yellow().to_string()
})
.to_string();
}
Cow::Owned(result)
}
fn format_markdown_node(node: &mq_markdown::Node) -> String {
let s = node.to_string();
match node {
mq_markdown::Node::Heading(_) => s.bold().bright_cyan().to_string(),
mq_markdown::Node::Code(_) => s.bright_yellow().to_string(),
mq_markdown::Node::CodeInline(_) => s.yellow().to_string(),
mq_markdown::Node::Link(_) | mq_markdown::Node::LinkRef(_) => s.bright_blue().to_string(),
mq_markdown::Node::Strong(_) => s.bold().to_string(),
mq_markdown::Node::Emphasis(_) => s.italic().to_string(),
_ => s,
}
}
fn format_runtime_value(value: &mq_lang::RuntimeValue) -> Option<String> {
use mq_lang::RuntimeValue;
let s = match value {
RuntimeValue::None => return Some("None".dimmed().to_string()),
RuntimeValue::Number(n) => n.to_string().bright_magenta().to_string(),
RuntimeValue::Boolean(b) => b.to_string().bright_yellow().to_string(),
RuntimeValue::String(s) => format!("\"{}\"", s).bright_green().to_string(),
RuntimeValue::Markdown(node, _) => format_markdown_node(node),
_ => {
let s = value.to_string();
if s.is_empty() {
return None;
}
s
}
};
Some(s)
}
fn get_prompt() -> &'static str {
if is_char_available() { "❯ " } else { "> " }
}
fn is_truecolor_supported() -> bool {
matches!(std::env::var("COLORTERM").as_deref(), Ok("truecolor") | Ok("24bit"))
}
fn logo_primary(s: &str) -> ColoredString {
if is_truecolor_supported() {
s.truecolor(133, 212, 255)
} else {
s.bright_cyan()
}
}
fn text_muted(s: &str) -> ColoredString {
if is_truecolor_supported() {
s.truecolor(148, 163, 184)
} else {
s.white()
}
}
fn is_char_available() -> bool {
if let Ok(term) = std::env::var("TERM") {
if term.contains("xterm") || term.contains("screen") || term.contains("tmux") {
return true;
}
}
if let Ok(lang) = std::env::var("LANG")
&& (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
{
return true;
}
for var in ["LC_ALL", "LC_CTYPE"] {
if let Ok(locale) = std::env::var(var)
&& (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
{
return true;
}
}
false
}
pub struct MqLineHelper {
command_context: Rc<RefCell<CommandContext>>,
file_completer: FilenameCompleter,
is_continuation: Rc<RefCell<bool>>,
}
impl MqLineHelper {
pub fn new(command_context: Rc<RefCell<CommandContext>>) -> Self {
Self {
command_context,
file_completer: FilenameCompleter::new(),
is_continuation: Rc::new(RefCell::new(false)),
}
}
}
impl Hinter for MqLineHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option<String> {
*self.is_continuation.borrow_mut() = line.contains('\n');
if pos < line.len() || line.is_empty() || line.starts_with('/') {
return None;
}
let (start, completions) = self.command_context.borrow().completions(line, pos);
let word = &line[start..pos];
if !word.is_empty() && completions.len() == 1 && completions[0].name.len() > word.len() {
return Some(completions[0].name[word.len()..].to_string());
}
if word.is_empty() {
let closing = match line.chars().last() {
Some('(') => Some(")"),
Some('[') => Some("]"),
Some('{') => Some("}"),
_ => None,
};
if let Some(c) = closing {
return Some(c.to_string());
}
}
None
}
}
impl Highlighter for MqLineHelper {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> {
if *self.is_continuation.borrow() {
".. ".dimmed().to_string().into()
} else {
prompt.cyan().to_string().into()
}
}
fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> {
hint.dimmed().to_string().into()
}
fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
true
}
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
highlight_mq_syntax(line)
}
}
impl Validator for MqLineHelper {
fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult, ReadlineError> {
let input = ctx.input();
if input.is_empty() || input.ends_with("\n") || input.starts_with("/") {
return Ok(ValidationResult::Valid(None));
}
if mq_lang::parse_recovery(input).1.has_errors() {
Ok(ValidationResult::Incomplete)
} else {
Ok(ValidationResult::Valid(None))
}
}
fn validate_while_typing(&self) -> bool {
false
}
}
impl Completer for MqLineHelper {
type Candidate = Pair;
fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>), ReadlineError> {
let (start, matches) = self.command_context.borrow().completions(line, pos);
let mut completions = matches
.iter()
.map(|item| Pair {
display: item.display.clone(),
replacement: format!("{}{}", item.name, &line[pos..]),
})
.collect::<Vec<_>>();
if line.starts_with(Command::LoadFile("".to_string()).to_string().as_str()) {
let (_, file_completions) = self.file_completer.complete_path(line, pos)?;
completions.extend(file_completions);
}
Ok((start, completions))
}
}
impl Helper for MqLineHelper {}
pub struct Repl {
command_context: Rc<RefCell<CommandContext>>,
}
pub fn config_dir() -> Option<std::path::PathBuf> {
std::env::var_os("MQ_CONFIG_DIR")
.map(std::path::PathBuf::from)
.or_else(|| dirs::config_dir().map(|d| d.join("mq")))
}
impl Repl {
pub fn new(input: Vec<mq_lang::RuntimeValue>) -> Self {
let mut engine = mq_lang::DefaultEngine::default();
engine.load_builtin_module();
Self {
command_context: Rc::new(RefCell::new(CommandContext::new(engine, input))),
}
}
fn print_welcome() {
let version = mq_lang::DefaultEngine::version();
println!();
println!(" {} {}", logo_primary("mq").bold(), text_muted(&format!("v{version}")));
println!(" {}", text_muted("Query. Filter. Transform Markdown."));
println!();
println!(" Type {} to see available commands.", logo_primary("/help"));
println!();
}
pub fn run(&self) -> miette::Result<()> {
let config = Config::builder()
.history_ignore_space(true)
.completion_type(CompletionType::List)
.edit_mode(EditMode::Emacs)
.color_mode(rustyline::ColorMode::Enabled)
.build();
let mut editor = Editor::with_config(config).into_diagnostic()?;
let helper = MqLineHelper::new(Rc::clone(&self.command_context));
editor.set_helper(Some(helper));
editor.bind_sequence(
KeyEvent(KeyCode::Left, Modifiers::CTRL),
Cmd::Move(Movement::BackwardWord(1, Word::Big)),
);
editor.bind_sequence(
KeyEvent(KeyCode::Right, Modifiers::CTRL),
Cmd::Move(Movement::ForwardWord(1, At::AfterEnd, Word::Big)),
);
editor.bind_sequence(
KeyEvent(KeyCode::Char('c'), Modifiers::ALT),
Cmd::Kill(Movement::WholeBuffer),
);
editor.bind_sequence(
KeyEvent(KeyCode::Char('o'), Modifiers::ALT),
Cmd::Insert(1, "/edit\n".to_string()),
);
let config_dir = config_dir();
if let Some(config_dir) = &config_dir {
let history = config_dir.join("history.txt");
fs::create_dir_all(config_dir).ok();
if editor.load_history(&history).is_err() {
println!("No previous history.");
}
}
Self::print_welcome();
loop {
let prompt = format!("{}", get_prompt().cyan());
let readline = editor.readline(&prompt);
match readline {
Ok(line) => {
editor.add_history_entry(&line).unwrap();
match self.command_context.borrow_mut().execute(&line) {
Ok(CommandOutput::String(s)) => println!("{}", s.join("\n")),
Ok(CommandOutput::Value(runtime_values)) => {
let lines: Vec<String> = runtime_values.iter().filter_map(format_runtime_value).collect();
if !lines.is_empty() {
println!("{}", lines.join("\n"))
}
}
Ok(CommandOutput::History) => {
let entries: Vec<String> = editor
.history()
.iter()
.enumerate()
.map(|(i, entry)| format!(" {:>4} {}", i + 1, entry.dimmed()))
.collect();
if entries.is_empty() {
println!(" No history.");
} else {
println!("{}", entries.join("\n"));
}
}
Ok(CommandOutput::None) => (),
Err(e) => {
eprintln!("{:?}", e)
}
}
}
Err(ReadlineError::Interrupted) => {
continue;
}
Err(ReadlineError::Eof) => {
break;
}
Err(err) => {
eprintln!("Error: {:?}", err);
break;
}
}
if let Some(config_dir) = &config_dir {
let history = config_dir.join("history.txt");
editor.save_history(&history.to_string_lossy().to_string()).unwrap();
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_dir() {
unsafe { std::env::set_var("MQ_CONFIG_DIR", "/tmp/test_mq_config") };
assert_eq!(config_dir(), Some(std::path::PathBuf::from("/tmp/test_mq_config")));
unsafe { std::env::remove_var("MQ_CONFIG_DIR") };
let config_dir = config_dir();
assert!(config_dir.is_some());
if let Some(dir) = config_dir {
assert!(dir.ends_with("mq"));
}
}
#[test]
fn test_highlight_mq_syntax() {
let result = highlight_mq_syntax("let x = 42");
assert!(result.contains("let"));
let result = highlight_mq_syntax("/help");
assert!(result.contains("help"));
let result = highlight_mq_syntax("x = 1 + 2");
assert!(result.contains("="));
assert!(result.contains("+"));
let result = highlight_mq_syntax(r#""hello world""#);
assert!(result.contains("hello world"));
let result = highlight_mq_syntax("42");
assert!(result.contains("42"));
}
#[test]
fn test_format_runtime_value_number() {
let v = mq_lang::RuntimeValue::Number(42.into());
let s = format_runtime_value(&v).unwrap();
assert!(s.contains("42"));
}
#[test]
fn test_format_runtime_value_boolean() {
let v = mq_lang::RuntimeValue::Boolean(true);
let s = format_runtime_value(&v).unwrap();
assert!(s.contains("true"));
}
#[test]
fn test_format_runtime_value_string() {
let v = mq_lang::RuntimeValue::String("hello".to_string());
let s = format_runtime_value(&v).unwrap();
assert!(s.contains("hello"));
assert!(s.contains('"'));
}
#[test]
fn test_format_runtime_value_none() {
let v = mq_lang::RuntimeValue::None;
let s = format_runtime_value(&v).unwrap();
assert!(s.contains("None"));
}
#[test]
fn test_is_char_available_utf8_env() {
let orig_term = std::env::var("TERM").ok();
let orig_lang = std::env::var("LANG").ok();
let orig_lc_all = std::env::var("LC_ALL").ok();
let orig_lc_ctype = std::env::var("LC_CTYPE").ok();
unsafe { std::env::set_var("TERM", "xterm-256color") };
assert!(is_char_available());
unsafe { std::env::remove_var("TERM") };
unsafe { std::env::set_var("LANG", "en_US.UTF-8") };
assert!(is_char_available());
unsafe { std::env::remove_var("LANG") };
unsafe { std::env::set_var("LC_ALL", "ja_JP.utf8") };
assert!(is_char_available());
unsafe { std::env::remove_var("LC_ALL") };
unsafe { std::env::set_var("LC_CTYPE", "fr_FR.UTF-8") };
assert!(is_char_available());
unsafe { std::env::remove_var("LC_CTYPE") };
assert!(!is_char_available());
if let Some(val) = orig_term {
unsafe { std::env::set_var("TERM", val) };
} else {
unsafe { std::env::remove_var("TERM") };
}
if let Some(val) = orig_lang {
unsafe { std::env::set_var("LANG", val) };
} else {
unsafe { std::env::remove_var("LANG") };
}
if let Some(val) = orig_lc_all {
unsafe { std::env::set_var("LC_ALL", val) };
} else {
unsafe { std::env::remove_var("LC_ALL") };
}
if let Some(val) = orig_lc_ctype {
unsafe { std::env::set_var("LC_CTYPE", val) };
} else {
unsafe { std::env::remove_var("LC_CTYPE") };
}
}
}