use regex::Regex;
pub fn normalize_command(command: &str) -> String {
let stripped = strip_heredoc_bodies(command);
if let Some(tokens) = shlex::split(&stripped) {
tokens.join(" ")
} else {
stripped
.split_whitespace()
.filter(|token| !token.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
fn strip_heredoc_bodies(command: &str) -> String {
if !command.contains("<<") {
return command.to_string();
}
const HERESTRING_PLACEHOLDER: &str = "\u{0001}HERESTRING\u{0001}";
let command_owned: String = command.replace("<<<", HERESTRING_PLACEHOLDER);
let command: &str = &command_owned;
static HEREDOC_RE_INIT: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = HEREDOC_RE_INIT.get_or_init(|| {
Regex::new(r#"<<-?\s*(?:['"]?)([A-Za-z_][A-Za-z0-9_]*)(?:['"]?)"#)
.expect("heredoc regex compiles")
});
let mut out = String::with_capacity(command.len());
let mut lines = command.lines();
while let Some(line) = lines.next() {
let mut delim: Option<String> = None;
let mut redacted = line.to_string();
for cap in re.captures_iter(line) {
let whole = cap.get(0).map_or("", |m| m.as_str());
redacted = redacted.replace(whole, "");
delim = cap.get(1).map(|m| m.as_str().to_string());
}
let cleaned = redacted
.split_whitespace()
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
out.push_str(&cleaned);
out.push('\n');
if let Some(d) = delim {
for body_line in lines.by_ref() {
if body_line.trim() == d {
break;
}
}
}
}
out.replace(HERESTRING_PLACEHOLDER, "<<<")
}
pub fn pattern_matches(pattern: &str, command: &str) -> bool {
let pattern = normalize_command(pattern);
let command = normalize_command(command);
if pattern == "*" {
return true;
}
let escaped = regex::escape(&pattern).replace("\\*", ".*");
let Ok(re) = Regex::new(&format!("^{escaped}$")) else {
return false;
};
re.is_match(&command)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_command() {
assert_eq!(normalize_command("git status"), "git status");
assert_eq!(
normalize_command("git \"log --oneline\""),
"git log --oneline"
);
}
#[test]
fn test_pattern_matches() {
assert!(pattern_matches("git status", "git status"));
assert!(pattern_matches("git log *", "git log --oneline"));
assert!(pattern_matches("cargo *", "cargo test --all"));
assert!(!pattern_matches("git push --force", "git push origin main"));
}
#[test]
fn strip_heredoc_strips_simple_body() {
let cmd = "cat <<EOF > file.txt\nhello\nworld\nEOF";
let stripped = super::strip_heredoc_bodies(cmd);
assert!(!stripped.contains("hello"));
assert!(!stripped.contains("world"));
assert!(stripped.contains("> file.txt"));
}
#[test]
fn strip_heredoc_handles_dash_form() {
let cmd = "cat <<-EOF > file.txt\n\tbody\nEOF";
let stripped = super::strip_heredoc_bodies(cmd);
assert!(!stripped.contains("body"));
assert!(stripped.contains("> file.txt"));
}
#[test]
fn strip_heredoc_handles_quoted_delimiter() {
let cmd = "cat <<'END_OF_FILE' > out\nliteral $vars\nEND_OF_FILE";
let stripped = super::strip_heredoc_bodies(cmd);
assert!(!stripped.contains("literal $vars"));
assert!(stripped.contains("> out"));
}
#[test]
fn strip_heredoc_leaves_non_heredoc_commands_intact() {
let cmd = "echo hello && ls";
assert_eq!(super::strip_heredoc_bodies(cmd), "echo hello && ls");
}
#[test]
fn strip_heredoc_does_not_touch_here_string_operator() {
let cmd = "grep foo <<< \"some text\"";
let stripped = super::strip_heredoc_bodies(cmd);
assert!(stripped.contains("<<<"));
assert!(stripped.contains("some text"));
}
#[test]
fn normalize_command_strips_heredoc_for_pattern_matching() {
let normalized = normalize_command("cat <<EOF > file.txt\nbody\nEOF");
assert!(pattern_matches("cat > file.txt", &normalized));
}
}