use crate::check::Outcome;
use crate::git;
use crate::ui::{error_sign, highlight, valid_sign};
struct Term {
label: &'static str,
prefilter: &'static str,
matches: fn(&str) -> bool,
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
fn preceded_ok(src: &str, at: usize) -> bool {
src[..at]
.chars()
.next_back()
.map(|c| !(is_ident(c) || c == '.'))
.unwrap_or(true)
}
fn call_of(src: &str, word: &str) -> bool {
let mut from = 0;
while let Some(i) = src[from..].find(word) {
let at = from + i;
let after = at + word.len();
if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
return true;
}
from = at + word.len();
}
false
}
fn bare_debugger(src: &str) -> bool {
let word = "debugger";
let mut from = 0;
while let Some(i) = src[from..].find(word) {
let at = from + i;
let after = at + word.len();
let next_ok = src[after..]
.chars()
.next()
.map(|c| !is_ident(c))
.unwrap_or(true);
if preceded_ok(src, at) && next_ok {
return true;
}
from = at + word.len();
}
false
}
fn focused_suite(src: &str) -> bool {
for head in ["describe", "context", "it"] {
for tail in ["skip", "only"] {
let needle = format!("{head}.{tail}");
let mut from = 0;
while let Some(i) = src[from..].find(&needle) {
let at = from + i;
let after = at + needle.len();
let before_ok = src[..at]
.chars()
.next_back()
.map(|c| !is_ident(c))
.unwrap_or(true);
let after_ok = src[after..]
.chars()
.next()
.map(|c| !is_ident(c))
.unwrap_or(true);
if before_ok && after_ok {
return true;
}
from = at + needle.len();
}
}
}
false
}
const TERMS: [Term; 4] = [
Term {
label: "fit",
prefilter: r"\s*fit\(",
matches: |s| call_of(s, "fit"),
},
Term {
label: "fdescribe",
prefilter: r"\s*fdescribe\(",
matches: |s| call_of(s, "fdescribe"),
},
Term {
label: "debugger",
prefilter: "debugger;?",
matches: bare_debugger,
},
Term {
label: "skipOnly",
prefilter: r"(describe|context|it)\.(skip|only)",
matches: focused_suite,
},
];
#[derive(Clone, Copy, PartialEq)]
enum S {
Code,
Line,
Block,
Single,
Double,
Template,
Regex,
}
const REGEX_KEYWORDS: [&str; 13] = [
"return",
"typeof",
"case",
"in",
"of",
"delete",
"void",
"instanceof",
"new",
"do",
"else",
"yield",
"await",
];
fn regex_can_start(prev: Option<char>, word: &str) -> bool {
match prev {
None => true,
Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
Some(_) => false,
}
}
pub fn blank_non_code(src: &str) -> String {
let b: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut state = S::Code;
let mut i = 0;
let mut prev_significant: Option<char> = None;
let mut word = String::new();
let mut in_class = false;
let mut subst: Vec<u32> = Vec::new();
let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
while i < b.len() {
let ch = b[i];
let next = b.get(i + 1).copied();
match state {
S::Code => {
if ch == '/' && next == Some('/') {
state = S::Line;
out.push_str(" ");
i += 2;
} else if ch == '/' && next == Some('*') {
state = S::Block;
out.push_str(" ");
i += 2;
} else if ch == '/' && regex_can_start(prev_significant, &word) {
state = S::Regex;
in_class = false;
out.push(ch);
i += 1;
} else if ch == '\'' || ch == '"' || ch == '`' {
state = match ch {
'\'' => S::Single,
'"' => S::Double,
_ => S::Template,
};
out.push(ch);
i += 1;
} else {
if !subst.is_empty() {
if ch == '{' {
*subst.last_mut().expect("non-empty") += 1;
} else if ch == '}' {
let depth = subst.last_mut().expect("non-empty");
if *depth == 0 {
subst.pop();
state = S::Template;
out.push(ch);
i += 1;
continue;
}
*depth -= 1;
}
}
if !ch.is_whitespace() {
prev_significant = Some(ch);
if ch.is_alphanumeric() || ch == '_' || ch == '$' {
word.push(ch);
} else {
word.clear();
}
}
out.push(ch);
i += 1;
}
}
S::Regex => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
if ch == '[' {
in_class = true;
} else if ch == ']' {
in_class = false;
} else if ch == '/' && !in_class {
state = S::Code;
prev_significant = Some('/');
word.clear();
out.push(ch);
i += 1;
continue;
} else if ch == '\n' {
state = S::Code;
}
out.push(keep(ch));
i += 1;
}
S::Line => {
if ch == '\n' {
state = S::Code;
out.push(ch);
} else {
out.push(' ');
}
i += 1;
}
S::Block => {
if ch == '*' && next == Some('/') {
state = S::Code;
out.push_str(" ");
i += 2;
} else {
out.push(keep(ch));
i += 1;
}
}
S::Template => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
if ch == '$' && next == Some('{') {
subst.push(0);
state = S::Code;
prev_significant = Some('{');
word.clear();
out.push_str("${");
i += 2;
continue;
}
if ch == '`' {
state = S::Code;
prev_significant = Some('`');
word.clear();
out.push(ch);
i += 1;
continue;
}
out.push(keep(ch));
i += 1;
}
_ => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
if closes {
state = S::Code;
out.push(ch);
} else {
out.push(keep(ch));
}
i += 1;
}
}
}
out
}
fn is_searchable(file: &str) -> bool {
let f = file.rsplit('/').next().unwrap_or(file);
[".js", ".jsx", ".ts", ".tsx", ".vue"]
.iter()
.any(|e| f.ends_with(e))
}
pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
let stem_matches_self = |file: &str| {
let base = file.rsplit('/').next().unwrap_or(file);
let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
stem == hook_name
};
let mut found_any = false;
for term in &TERMS {
let arg = format!("-G{}", term.prefilter);
let Some(out) =
git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
else {
continue;
};
let matches: Vec<&str> = out
.iter()
.map(String::as_str)
.filter(|f| is_searchable(f))
.filter(|f| !stem_matches_self(f))
.filter(|file| {
match git::stdout(&["show", &format!(":{file}")]) {
None => true,
Some(content) => (term.matches)(&blank_non_code(&content)),
}
})
.collect();
if !matches.is_empty() {
if !found_any {
eprintln!(" {} Unwanted terms found", error_sign().trim());
}
found_any = true;
println!(
" The following files contains '{}' in them:",
highlight(term.label)
);
for m in matches {
println!(" - {}", highlight(m));
}
}
}
if found_any {
return Outcome::Failed;
}
println!(" {} No unwanted terms where found", valid_sign().trim());
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catches_the_banned_forms() {
assert!(call_of("fit('x', () => {})", "fit"));
assert!(call_of(" fit (", "fit"));
assert!(call_of("fdescribe('x')", "fdescribe"));
assert!(bare_debugger(" debugger;"));
assert!(bare_debugger("debugger"));
assert!(focused_suite("describe.skip('x')"));
assert!(focused_suite("it.only('x')"));
assert!(focused_suite("context.skip('x')"));
}
#[test]
fn leaves_lookalikes_alone() {
assert!(!call_of("profit(", "fit")); assert!(!call_of("layout.fit(", "fit")); assert!(!bare_debugger("debuggerish")); assert!(!bare_debugger("x.debugger")); assert!(!focused_suite("describe.skipIf(cond)")); assert!(!focused_suite("it.onlyWhen(x)"));
}
#[test]
fn blanks_comments_and_strings_keeping_layout() {
let src = "a\n// debugger;\nb";
let out = blank_non_code(src);
assert_eq!(out.len(), src.len(), "length must be preserved");
assert_eq!(out.lines().count(), src.lines().count());
assert!(!bare_debugger(&out), "a term in a comment is discussion");
assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
}
#[test]
fn an_escape_never_closes_a_string() {
let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
assert!(
bare_debugger(&out),
"real code after the string must survive"
);
assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
}
#[test]
fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
for src in [
r"const re = /a\//; debugger;",
r"const re = /\//; debugger;",
] {
assert!(
bare_debugger(&blank_non_code(src)),
"code after the regex must still be scanned: {src}"
);
}
assert!(!bare_debugger(&blank_non_code(
r"const re = /a\//; const ok = 1;"
)));
}
#[test]
fn terms_inside_a_regex_literal_are_not_violations() {
for src in [
r"const re = /it\.only/;",
r"if (x) { const r = /debugger/; }",
r"foo(/fdescribe\(/);",
r"return /describe\.skip/;",
r"const r = /[/]debugger/;", ] {
let b = blank_non_code(src);
assert!(!bare_debugger(&b), "false alarm: {src}");
assert!(!focused_suite(&b), "false alarm: {src}");
assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
}
}
#[test]
fn division_is_not_treated_as_a_regex() {
let src = "const x = a / b; debugger;";
assert!(bare_debugger(&blank_non_code(src)));
let src2 = "const x = (a + b) / c; debugger;";
assert!(bare_debugger(&blank_non_code(src2)));
}
#[test]
fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
let src = "const r = /oops
debugger;";
assert!(bare_debugger(&blank_non_code(src)));
}
#[test]
fn blanking_still_preserves_length_and_lines() {
let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
let out = blank_non_code(src);
assert_eq!(out.len(), src.len());
assert_eq!(out.lines().count(), src.lines().count());
}
#[test]
fn only_js_like_files_are_searched() {
for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
assert!(is_searchable(f), "{f}");
}
for f in ["a.rs", "a.md", "a.json", "README"] {
assert!(!is_searchable(f), "{f}");
}
}
}
#[cfg(test)]
mod template_substitutions {
use super::*;
#[test]
fn a_substitution_is_code() {
let b = blank_non_code("const s = `${fit(1)}`;");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
}
#[test]
fn substitutions_nest() {
let b = blank_non_code("const s = `${`${fit(1)}`}`;");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
assert!(
b.contains("${`${fit(1)}`}"),
"nesting must be tracked, not merely survived: {b:?}"
);
}
#[test]
fn braces_inside_a_substitution_do_not_close_it() {
let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
assert!(
!call_of(&b, "fit"),
"the string literal must stay blanked: {b:?}"
);
let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
assert!(
call_of(&b3, "fit"),
"a `}}` closing a nested object must not end the substitution: {b3:?}"
);
}
#[test]
fn template_text_is_still_blanked() {
assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
assert!(
!call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
"an escaped dollar does not open a substitution"
);
}
#[test]
fn nested_constructs_inside_a_substitution() {
assert!(!call_of(
&blank_non_code("const s = `${/* fit(1) */ x}`;"),
"fit"
));
assert!(!call_of(
&blank_non_code(r#"const s = `${"fit("}`;"#),
"fit"
));
assert!(!call_of(
&blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
"fit"
));
}
#[test]
fn a_stray_brace_in_code_is_harmless() {
let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
}
}