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};
use rstest::rstest;
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()
}
#[rstest]
#[case::simple_command("git commit -m 'hi'", None, "cccaaaaaaaaffassss")]
#[case::pipe("cat foo | grep bar", None, "cccaaaaaoaccccaaaa")]
#[case::and_list("true && false", None, "ccccaooaccccc")]
#[case::env_assignment("FOO=bar make", None, "vvvvvvvacccc")]
#[case::variables("echo $HOME ${USER}x", None, "ccccavvvvvavvvvvvva")]
#[case::variable_in_string(r#"echo "hi $USER""#, None, "ccccassssvvvvvs")]
#[case::comment("ls # list", None, "cca######")]
#[case::comment_no_space("echo foo#bar", None, "ccccaaaaaaaa")]
#[case::fish_set("set -x PATH $PATH", Some("fish"), "cccaffaaaaaavvvvv")]
#[case::fish_subshell("echo (date) | grep foo", Some("fish"), "ccccaoccccoaoaccccaaaa")]
#[case::fish_string(r#"echo "hi $name""#, Some("fish"), "ccccassssvvvvvs")]
#[case::zsh_uses_bash("ls -la", Some("zsh"), "ccafff")]
#[case::nu_plain("ls -la", Some("nu"), "aaaaaa")]
#[case::powershell_plain("ls -la", Some("powershell"), "aaaaaa")]
fn classify_renders(#[case] cmd: &str, #[case] shell: Option<&str>, #[case] expected: &str) {
assert_eq!(render_shell(cmd, shell), expected);
}
#[rstest]
fn odd_inputs_do_not_panic(
#[values("echo 'oops", "if (= 1 2) { }", "", "echo héllo")] cmd: &str,
#[values(None, Some("fish"), Some("nu"))] shell: Option<&str>,
) {
assert_eq!(classify(cmd, shell).len(), cmd.len());
}
}