use atuin_client::theme::Meaning;
use easy_cast::Conv;
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "illumos"))]
#[must_use]
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", target_os = "windows", target_os = "illumos"))]
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(),
Some("powershell" | "pwsh") => tree_sitter_powershell::LANGUAGE.into(),
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)
{
if language.name() == Some("powershell") {
highlight_powershell(tree.root_node(), cmd.as_bytes(), &mut meanings);
} else {
walk(tree.root_node(), cmd.as_bytes(), &mut meanings);
}
}
meanings
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "illumos"
)))]
pub fn classify(cmd: &str, _shell: Option<&str>) -> Vec<Meaning> {
vec![Meaning::Base; cmd.len()]
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "illumos"))]
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(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "illumos"))]
fn highlight_powershell(node: tree_sitter::Node, src: &[u8], meanings: &mut [Meaning]) {
static HIGHLIGHTS_QUERY: std::sync::LazyLock<tree_sitter::Query> =
std::sync::LazyLock::new(|| {
let language: tree_sitter::Language = tree_sitter_powershell::LANGUAGE.into();
tree_sitter::Query::new(&language, include_str!("highlights/powershell.scm"))
.expect("invalid PowerShell highlights query")
});
use tree_sitter::StreamingIterator;
let mut cursor = tree_sitter::QueryCursor::new();
let mut captures = cursor.captures(&HIGHLIGHTS_QUERY, node, src);
while let Some((m, capture_index)) = captures.next() {
let capture = m.captures()[*capture_index];
let capture_name = HIGHLIGHTS_QUERY.capture_names()[usize::conv(capture.index)];
let meaning = match capture_name {
"base" => Meaning::Base,
"command" => Meaning::SyntaxCommand,
"flag" => Meaning::SyntaxFlag,
"string" => Meaning::SyntaxString,
"variable" | "keyword" => Meaning::SyntaxVariable,
"operator" => Meaning::SyntaxOperator,
"comment" => Meaning::SyntaxComment,
_ => continue, };
if let Some(range) = meanings.get_mut(capture.node.byte_range()) {
range.fill(meaning);
}
}
}
#[cfg(all(
test,
any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "illumos")
))]
mod tests {
use rstest::rstest;
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()
}
#[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::powershell("$v = rg -i 'foo' $f", Some("powershell"), "vvaoaccaffasssssavv")]
#[case::zsh_uses_bash("ls -la", Some("zsh"), "ccafff")]
#[case::nu_plain("ls -la", Some("nu"), "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"), Some("powershell"))] shell: Option<&str>,
) {
assert_eq!(classify(cmd, shell).len(), cmd.len());
}
}