use regex::Regex;
use std::sync::LazyLock;
pub(crate) const PARSE_LIMIT: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Branch {
Structured,
Lexical(&'static str),
}
impl Branch {
#[must_use]
pub fn label(self) -> String {
match self {
Branch::Structured => "structured".to_string(),
Branch::Lexical(SHAPE_UNKNOWN) => "lexical (shape unknown)".to_string(),
Branch::Lexical(r) => format!("lexical (too-complex: {r})"),
}
}
}
pub(crate) const SHAPE_UNKNOWN: &str = "shape unknown";
static CONTROL_CHARS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[\x00-\x08\x0B-\x1F\x7F]").expect("Jbn"));
static UNICODE_WS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
"[\u{00A0}\u{1680}\u{2000}-\u{200B}\u{2028}\u{2029}\u{202F}\u{205F}\u{3000}\u{FEFF}]",
)
.expect("of")
});
static ESCAPED_WS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\\[ \t]|(?:^|[^ \t\\])(?:\\\\)*\\\n|[ \t](?:\\\\)+\\\n").expect("Zbn")
});
static ZSH_TILDE_BRACKET: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~\[").expect("OGt"));
static ZSH_EQUALS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|[\s;&|])=[a-zA-Z_]").expect("DGt"));
static ZSH_RANGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<\d*-\d*>").expect("eTn"));
static BRACE_QUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\{[^}]*['\"]").expect("sf"));
static BRACE_EXPRESSION: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{[^{}\s]*,[^{}\s]*\}").expect("brace_expression"));
static FUNCTION_DEF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:function\s+[A-Za-z_][A-Za-z0-9_]*|[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\))")
.expect("function_definition")
});
#[derive(Debug, Default)]
struct BraceMask {
single: bool,
double: bool,
backtick: bool,
word_start: bool,
}
fn push_masked(out: &mut String, w: char) {
out.push(if w == '{' { ' ' } else { w });
}
impl BraceMask {
fn step_backtick(&mut self, e: &[char], i: usize, out: &mut String) -> usize {
let w = e[i];
if w == '\\' && matches!(e.get(i + 1), Some('`' | '\\' | '$')) {
out.push(w);
out.push(e[i + 1]);
return i + 2;
}
if w == '`' {
self.backtick = false;
}
push_masked(out, w);
i + 1
}
fn step_single(&mut self, e: &[char], i: usize, out: &mut String) -> usize {
let w = e[i];
if w == '\'' {
self.single = false;
}
push_masked(out, w);
i + 1
}
fn step_double(&mut self, e: &[char], i: usize, out: &mut String) -> usize {
let w = e[i];
if w == '\\' && matches!(e.get(i + 1), Some('"' | '\\' | '`')) {
out.push(w);
out.push(e[i + 1]);
return i + 2;
}
if w == '`' {
self.backtick = true;
out.push(w);
return i + 1;
}
if w == '"' {
self.double = false;
}
push_masked(out, w);
i + 1
}
fn step_plain(&mut self, e: &[char], i: usize, out: &mut String) -> usize {
let w = e[i];
if w == '\\' && i + 1 < e.len() {
out.push(w);
out.push(e[i + 1]);
if e[i + 1] != '\n' {
self.word_start = false;
}
return i + 2;
}
if w == '#' && self.word_start {
let mut j = i;
while j < e.len() && e[j] != '\n' {
out.push(e[j]);
j += 1;
}
self.word_start = true;
return j;
}
if w == '`' {
self.backtick = true;
self.word_start = false;
out.push(w);
return i + 1;
}
if w == '\'' {
self.single = true;
} else if w == '"' {
self.double = true;
}
self.word_start = matches!(
w,
' ' | '\t' | '\n' | ';' | '|' | '&' | '(' | ')' | '<' | '>'
);
out.push(w);
i + 1
}
}
pub(crate) fn mask_quoted_braces(command: &str) -> String {
if !command.contains('{') {
return command.to_string();
}
let e: Vec<char> = command.chars().collect();
let mut out = String::with_capacity(command.len());
let mut st = BraceMask {
word_start: true,
..BraceMask::default()
};
let mut i = 0usize;
while i < e.len() {
i = if st.backtick {
st.step_backtick(&e, i, &mut out)
} else if st.single {
st.step_single(&e, i, &mut out)
} else if st.double {
st.step_double(&e, i, &mut out)
} else {
st.step_plain(&e, i, &mut out)
};
}
out
}
const KEYWORD_STATEMENTS: &[(&str, &str)] = &[
("for", "for_statement"),
("while", "while_statement"),
("until", "until_statement"),
("if", "if_statement"),
("case", "case_statement"),
("select", "for_statement"),
];
pub(crate) fn branch_of(command: &str) -> Branch {
if command.len() > PARSE_LIMIT {
return Branch::Lexical("PARSE_ABORT");
}
for (re, reason) in [
(&*CONTROL_CHARS, "Contains control characters"),
(&*UNICODE_WS, "Contains Unicode whitespace"),
(&*ESCAPED_WS, "Contains backslash-escaped whitespace"),
(
&*ZSH_TILDE_BRACKET,
"Contains zsh ~[ dynamic directory syntax",
),
(&*ZSH_EQUALS, "Contains zsh =cmd equals expansion"),
(&*ZSH_RANGE, "Contains zsh <N-M> numeric-range glob"),
] {
if re.is_match(command) {
return Branch::Lexical(reason);
}
}
if BRACE_QUOTE.is_match(&mask_quoted_braces(command)) {
return Branch::Lexical("Contains brace with quote character (expansion obfuscation)");
}
if command.trim().is_empty() {
return Branch::Structured;
}
for (needle, node) in [
("<<<", "herestring_redirect"),
("<<", "heredoc_redirect"),
("[[", "test_command"),
("$'", "ansi_c_string"),
("$\"", "translated_string"),
] {
if command.contains(needle) {
return Branch::Lexical(node);
}
}
if BRACE_EXPRESSION.is_match(command) && !command.contains("${") {
return Branch::Lexical("brace_expression");
}
let Some(statements) = split_statements(command) else {
return Branch::Lexical("Parse error");
};
for stmt in statements {
let s = stmt.trim();
if s.is_empty() {
continue;
}
if let Some(node) = statement_node(s) {
return Branch::Lexical(node);
}
}
Branch::Structured
}
fn statement_node(s: &str) -> Option<&'static str> {
if s.starts_with('(') {
return Some("subshell");
}
if s.starts_with('{') && s[1..].starts_with(|c: char| c.is_whitespace()) {
return Some("compound_statement");
}
if FUNCTION_DEF.is_match(s) {
return Some("function_definition");
}
let head = s.split_whitespace().next().unwrap_or_default();
for (kw, node) in KEYWORD_STATEMENTS {
if head == *kw {
return Some(node);
}
}
if matches!(
head,
"do" | "then" | "fi" | "done" | "esac" | "elif" | "else"
) {
return Some(SHAPE_UNKNOWN);
}
None
}
pub(crate) fn split_statements(command: &str) -> Option<Vec<&str>> {
let b = command.as_bytes();
let mut out = Vec::new();
let mut start = 0usize;
let mut quote: Option<u8> = None;
let mut depth: i32 = 0;
let mut i = 0usize;
while i < b.len() {
let c = b[i];
if let Some(q) = quote {
if c == b'\\' && q == b'"' {
i += 2;
continue;
}
if c == q {
quote = None;
}
i += 1;
continue;
}
match c {
b'\\' => {
i += 2;
continue;
}
b'\'' | b'"' => quote = Some(c),
b'(' | b'{' => depth += 1,
b')' | b'}' => {
depth -= 1;
if depth < 0 {
return None;
}
}
b';' | b'\n' | b'&' | b'|' if depth == 0 => {
out.push(&command[start..i]);
let two = i + 1 < b.len() && b[i + 1] == c;
i += if two { 2 } else { 1 };
start = i;
continue;
}
_ => {}
}
i += 1;
}
if quote.is_some() || depth != 0 {
return None;
}
out.push(&command[start.min(command.len())..]);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_brace_mask_blanks_a_brace_only_inside_a_quote() {
assert_eq!(mask_quoted_braces("echo plain"), "echo plain");
assert_eq!(mask_quoted_braces("echo \"a{b}\""), "echo \"a b}\"");
assert_eq!(mask_quoted_braces("echo 'a{b}'"), "echo 'a b}'");
assert_eq!(mask_quoted_braces("echo `a{b}`"), "echo `a b}`");
assert_eq!(mask_quoted_braces("rm -rf /{a,b}"), "rm -rf /{a,b}");
assert_eq!(mask_quoted_braces("echo \\\"{a}"), "echo \\\"{a}");
assert_eq!(mask_quoted_braces("ls # {a}"), "ls # {a}");
}
#[test]
fn the_brace_mask_carries_an_escape_through_each_quote_state() {
assert_eq!(mask_quoted_braces("echo `a\\`b{c}`"), "echo `a\\`b c}`");
assert_eq!(
mask_quoted_braces("echo \"a\\\"b{c}\""),
"echo \"a\\\"b c}\""
);
assert_eq!(mask_quoted_braces("echo \"a`b{c}`\""), "echo \"a`b c}`\"");
}
#[test]
fn the_prechecks_answer_before_any_node_walk() {
for (command, reason) in [
("rm -rf $D/*\u{1}", "Contains control characters"),
("rm -rf\u{a0}$D/*", "Contains Unicode whitespace"),
("rm -rf\\ x $D/*", "Contains backslash-escaped whitespace"),
("rm -rf ~[x]/*", "Contains zsh ~[ dynamic directory syntax"),
("=ls -rf $D/*", "Contains zsh =cmd equals expansion"),
("rm -rf <1-9>/*", "Contains zsh <N-M> numeric-range glob"),
] {
assert_eq!(branch_of(command), Branch::Lexical(reason), "{command:?}");
}
}
#[test]
fn an_argument_shape_forces_the_lexical_path_wherever_it_appears() {
assert_eq!(
branch_of("cat <<< x"),
Branch::Lexical("herestring_redirect")
);
assert_eq!(branch_of("cat << EOF"), Branch::Lexical("heredoc_redirect"));
assert_eq!(
branch_of("[[ -f x ]] && rm y"),
Branch::Lexical("test_command")
);
assert_eq!(branch_of("echo $'a'"), Branch::Lexical("ansi_c_string"));
assert_eq!(
branch_of("echo $\"a\""),
Branch::Lexical("translated_string")
);
assert_eq!(
branch_of("rm -rf /{a,b}"),
Branch::Lexical("brace_expression")
);
assert_eq!(branch_of("rm -rf ${D}/{a,b}"), Branch::Structured);
}
#[test]
fn a_statement_head_the_walk_cannot_decompose_names_its_node() {
assert_eq!(branch_of("(rm -rf x)"), Branch::Lexical("subshell"));
assert_eq!(
branch_of("{ rm -rf x; }"),
Branch::Lexical("compound_statement")
);
for def in ["f() { rm -rf x; }", "function f { rm -rf x; }"] {
assert_eq!(branch_of(def), Branch::Lexical("function_definition"));
}
for (head, node) in KEYWORD_STATEMENTS {
let command = format!("{head} x; do rm y; done");
assert_eq!(branch_of(&command), Branch::Lexical(node), "{command:?}");
}
assert_eq!(branch_of("then rm -rf x"), Branch::Lexical(SHAPE_UNKNOWN));
assert_eq!(
branch_of("then rm -rf x").label(),
"lexical (shape unknown)"
);
assert_eq!(branch_of("; rm -rf x"), Branch::Structured);
assert_eq!(branch_of(" "), Branch::Structured);
}
#[test]
fn the_statement_split_answers_none_where_tree_sitter_would_answer_error() {
assert!(split_statements("echo \"a").is_none());
assert!(split_statements("echo (a").is_none());
assert!(split_statements("echo )").is_none());
assert_eq!(branch_of("rm -rf \"$D/*"), Branch::Lexical("Parse error"));
assert_eq!(
split_statements("echo \"a\\\"b\"; ls"),
Some(vec!["echo \"a\\\"b\"", " ls"])
);
assert_eq!(split_statements("echo a\\;b"), Some(vec!["echo a\\;b"]));
}
#[test]
fn the_double_quote_span_ends_on_its_own_quote() {
assert_eq!(mask_quoted_braces("echo \"x\" {a}"), "echo \"x\" {a}");
}
#[test]
fn a_trailing_lone_backslash_is_not_an_escape_pair() {
assert_eq!(mask_quoted_braces("{a} \\"), "{a} \\");
}
#[test]
fn a_line_continuation_keeps_the_word_start_a_comment_needs() {
assert_eq!(
mask_quoted_braces("echo \\\n# \"{a}\""),
"echo \\\n# \"{a}\""
);
}
#[test]
fn the_mask_opens_at_a_word_so_a_leading_hash_is_a_comment() {
assert_eq!(mask_quoted_braces("# \"{a}\""), "# \"{a}\"");
}
#[test]
fn the_parse_ceiling_admits_a_command_of_exactly_the_limit() {
let head = "rm -rf /tmp/";
let at_limit = format!("{head}{}", "a".repeat(PARSE_LIMIT - head.len()));
assert_eq!(at_limit.len(), PARSE_LIMIT);
assert_eq!(branch_of(&at_limit), Branch::Structured);
assert_eq!(
branch_of(&format!("{at_limit}a")),
Branch::Lexical("PARSE_ABORT")
);
}
#[test]
fn a_brace_needs_a_blank_after_it_to_open_a_compound_statement() {
assert_eq!(branch_of("a b"), Branch::Structured);
}
#[test]
fn an_escaped_separator_advances_the_split_past_both_bytes() {
assert_eq!(split_statements("a\\;b"), Some(vec!["a\\;b"]));
}
#[test]
fn a_separator_inside_a_group_is_not_a_top_level_split() {
assert_eq!(split_statements("(a; b)"), Some(vec!["(a; b)"]));
}
#[test]
fn a_two_character_operator_is_consumed_whole() {
assert_eq!(split_statements("a && b"), Some(vec!["a ", " b"]));
assert_eq!(split_statements("a;"), Some(vec!["a", ""]));
}
#[test]
fn the_brace_quote_precheck_reads_the_masked_command() {
assert!(matches!(
branch_of("rm -rf /{'',}tmp"),
Branch::Lexical("Contains brace with quote character (expansion obfuscation)")
));
assert!(matches!(
branch_of("echo \"set {a: 'b'}\""),
Branch::Structured
));
}
}