use atuin_client::theme::Meaning;
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn classify(cmd: &str, shell: Option<&str>) -> Vec<Meaning> {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static CACHE: RefCell<HashMap<String, Vec<Meaning>>> = RefCell::new(HashMap::new());
}
let key = format!("{}\x1f{}", shell.unwrap_or(""), cmd);
CACHE.with_borrow_mut(|cache| {
if cache.len() > 4096 {
cache.clear();
}
cache
.entry(key)
.or_insert_with(|| parse(cmd, shell))
.clone()
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn parse(cmd: &str, shell: Option<&str>) -> Vec<Meaning> {
let mut meanings = vec![Meaning::Base; cmd.len()];
let language: tree_sitter::Language = match shell {
Some("fish") => tree_sitter_fish::language(),
None | Some("bash" | "zsh" | "sh") => tree_sitter_bash::LANGUAGE.into(),
Some(_) => return meanings,
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&language).is_ok()
&& let Some(tree) = parser.parse(cmd, None)
{
walk(tree.root_node(), cmd.as_bytes(), &mut meanings);
}
meanings
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn classify(cmd: &str, _shell: Option<&str>) -> Vec<Meaning> {
vec![Meaning::Base; cmd.len()]
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn walk(node: tree_sitter::Node, src: &[u8], meanings: &mut [Meaning]) {
let meaning = match node.kind() {
"comment" => Some(Meaning::SyntaxComment),
"command_name" => Some(Meaning::SyntaxCommand),
"string"
| "raw_string"
| "ansi_c_string"
| "heredoc_body"
| "single_quote_string"
| "double_quote_string" => Some(Meaning::SyntaxString),
"simple_expansion" | "expansion" | "variable_assignment" | "variable_expansion" => {
Some(Meaning::SyntaxVariable)
}
"word" if src.get(node.start_byte()) == Some(&b'-') => Some(Meaning::SyntaxFlag),
k if !node.is_named()
&& !k.is_empty()
&& k.bytes().all(|b| b"|&;<>(){}$`".contains(&b)) =>
{
Some(Meaning::SyntaxOperator)
}
_ => None,
};
if let Some(meaning) = meaning
&& let Some(range) = meanings.get_mut(node.byte_range())
{
range.fill(meaning);
}
if node.kind() == "command"
&& let Some(name) = node.child_by_field_name("name")
&& let Some(range) = meanings.get_mut(name.byte_range())
{
range.fill(Meaning::SyntaxCommand);
}
if matches!(
node.kind(),
"simple_expansion" | "expansion" | "variable_expansion"
) {
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk(child, src, meanings);
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use super::{Meaning, classify};
fn render_shell(cmd: &str, shell: Option<&str>) -> String {
classify(cmd, shell)
.iter()
.map(|m| match m {
Meaning::SyntaxCommand => 'c',
Meaning::SyntaxFlag => 'f',
Meaning::SyntaxString => 's',
Meaning::SyntaxOperator => 'o',
Meaning::SyntaxVariable => 'v',
Meaning::SyntaxComment => '#',
_ => 'a',
})
.collect()
}
fn render(cmd: &str) -> String {
render_shell(cmd, None)
}
fn render_fish(cmd: &str) -> String {
render_shell(cmd, Some("fish"))
}
#[test]
fn simple_command() {
assert_eq!(render("git commit -m 'hi'"), "cccaaaaaaaaffassss");
}
#[test]
fn pipes_and_lists_start_new_commands() {
assert_eq!(render("cat foo | grep bar"), "cccaaaaaoaccccaaaa");
assert_eq!(render("true && false"), "ccccaooaccccc");
}
#[test]
fn env_assignment_prefix() {
assert_eq!(render("FOO=bar make"), "vvvvvvvacccc");
}
#[test]
fn variables() {
assert_eq!(render("echo $HOME ${USER}x"), "ccccavvvvvavvvvvvva");
}
#[test]
fn variable_inside_string_refines_string() {
assert_eq!(render(r#"echo "hi $USER""#), "ccccassssvvvvvs");
}
#[test]
fn comment() {
assert_eq!(render("ls # list"), "cca######");
assert_eq!(render("echo foo#bar"), "ccccaaaaaaaa");
}
#[test]
fn fish_uses_the_fish_grammar() {
assert_eq!(render_fish("set -x PATH $PATH"), "cccaffaaaaaavvvvv");
assert_eq!(
render_fish("echo (date) | grep foo"),
"ccccaoccccoaoaccccaaaa"
);
assert_eq!(render_fish(r#"echo "hi $name""#), "ccccassssvvvvvs");
}
#[test]
fn zsh_uses_the_bash_grammar() {
assert_eq!(render_shell("ls -la", Some("zsh")), "ccafff");
}
#[test]
fn shells_without_a_grammar_stay_plain() {
assert_eq!(render_shell("ls -la", Some("nu")), "aaaaaa");
assert_eq!(render_shell("ls -la", Some("powershell")), "aaaaaa");
}
#[test]
fn odd_inputs_do_not_panic() {
for cmd in ["echo 'oops", "if (= 1 2) { }", "", "echo héllo"] {
for shell in [None, Some("fish"), Some("nu")] {
assert_eq!(classify(cmd, shell).len(), cmd.len());
}
}
}
}