1use crate::check::Outcome;
14use crate::git;
15use crate::ui::{error_sign, highlight, valid_sign};
16
17struct Term {
19 label: &'static str,
20 prefilter: &'static str,
21 matches: fn(&str) -> bool,
22}
23
24fn is_ident(c: char) -> bool {
25 c.is_alphanumeric() || c == '_' || c == '$'
26}
27
28fn preceded_ok(src: &str, at: usize) -> bool {
32 src[..at]
33 .chars()
34 .next_back()
35 .map(|c| !(is_ident(c) || c == '.'))
36 .unwrap_or(true)
37}
38
39fn call_of(src: &str, word: &str) -> bool {
41 let mut from = 0;
42 while let Some(i) = src[from..].find(word) {
43 let at = from + i;
44 let after = at + word.len();
45 if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
46 return true;
47 }
48 from = at + word.len();
49 }
50 false
51}
52
53fn bare_debugger(src: &str) -> bool {
56 let word = "debugger";
57 let mut from = 0;
58 while let Some(i) = src[from..].find(word) {
59 let at = from + i;
60 let after = at + word.len();
61 let next_ok = src[after..]
62 .chars()
63 .next()
64 .map(|c| !is_ident(c))
65 .unwrap_or(true);
66 if preceded_ok(src, at) && next_ok {
67 return true;
68 }
69 from = at + word.len();
70 }
71 false
72}
73
74fn focused_suite(src: &str) -> bool {
81 for head in ["describe", "context", "it"] {
82 for tail in ["skip", "only"] {
83 let needle = format!("{head}.{tail}");
84 let mut from = 0;
85 while let Some(i) = src[from..].find(&needle) {
86 let at = from + i;
87 let after = at + needle.len();
88 let before_ok = src[..at]
89 .chars()
90 .next_back()
91 .map(|c| !is_ident(c))
92 .unwrap_or(true);
93 let after_ok = src[after..]
94 .chars()
95 .next()
96 .map(|c| !is_ident(c))
97 .unwrap_or(true);
98 if before_ok && after_ok {
99 return true;
100 }
101 from = at + needle.len();
102 }
103 }
104 }
105 false
106}
107
108const TERMS: [Term; 4] = [
109 Term {
110 label: "fit",
111 prefilter: r"\s*fit\(",
112 matches: |s| call_of(s, "fit"),
113 },
114 Term {
115 label: "fdescribe",
116 prefilter: r"\s*fdescribe\(",
117 matches: |s| call_of(s, "fdescribe"),
118 },
119 Term {
120 label: "debugger",
121 prefilter: "debugger;?",
122 matches: bare_debugger,
123 },
124 Term {
125 label: "skipOnly",
126 prefilter: r"(describe|context|it)\.(skip|only)",
127 matches: focused_suite,
128 },
129];
130
131#[derive(Clone, Copy, PartialEq)]
132enum S {
133 Code,
134 Line,
135 Block,
136 Single,
137 Double,
138 Template,
139 Regex,
140}
141
142const REGEX_KEYWORDS: [&str; 13] = [
144 "return",
145 "typeof",
146 "case",
147 "in",
148 "of",
149 "delete",
150 "void",
151 "instanceof",
152 "new",
153 "do",
154 "else",
155 "yield",
156 "await",
157];
158
159fn regex_can_start(prev: Option<char>, word: &str) -> bool {
162 match prev {
163 None => true,
165 Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
166 Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
168 Some(_) => false,
171 }
172}
173
174pub fn blank_non_code(src: &str) -> String {
193 let b: Vec<char> = src.chars().collect();
194 let mut out = String::with_capacity(src.len());
195 let mut state = S::Code;
196 let mut i = 0;
197 let mut prev_significant: Option<char> = None;
199 let mut word = String::new();
200 let mut in_class = false;
201 let mut subst: Vec<u32> = Vec::new();
205 let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
206
207 while i < b.len() {
208 let ch = b[i];
209 let next = b.get(i + 1).copied();
210 match state {
211 S::Code => {
212 if ch == '/' && next == Some('/') {
213 state = S::Line;
214 out.push_str(" ");
215 i += 2;
216 } else if ch == '/' && next == Some('*') {
217 state = S::Block;
218 out.push_str(" ");
219 i += 2;
220 } else if ch == '/' && regex_can_start(prev_significant, &word) {
221 state = S::Regex;
222 in_class = false;
223 out.push(ch);
224 i += 1;
225 } else if ch == '\'' || ch == '"' || ch == '`' {
226 state = match ch {
227 '\'' => S::Single,
228 '"' => S::Double,
229 _ => S::Template,
230 };
231 out.push(ch);
232 i += 1;
233 } else {
234 if !subst.is_empty() {
235 if ch == '{' {
236 *subst.last_mut().expect("non-empty") += 1;
237 } else if ch == '}' {
238 let depth = subst.last_mut().expect("non-empty");
239 if *depth == 0 {
240 subst.pop();
241 state = S::Template;
242 out.push(ch);
243 i += 1;
244 continue;
245 }
246 *depth -= 1;
247 }
248 }
249 if !ch.is_whitespace() {
250 prev_significant = Some(ch);
251 if ch.is_alphanumeric() || ch == '_' || ch == '$' {
252 word.push(ch);
253 } else {
254 word.clear();
255 }
256 }
257 out.push(ch);
258 i += 1;
259 }
260 }
261 S::Regex => {
262 if ch == '\\' {
264 out.push_str(if next.is_none() { " " } else { " " });
265 i += 2;
266 continue;
267 }
268 if ch == '[' {
269 in_class = true;
270 } else if ch == ']' {
271 in_class = false;
272 } else if ch == '/' && !in_class {
273 state = S::Code;
274 prev_significant = Some('/');
275 word.clear();
276 out.push(ch);
277 i += 1;
278 continue;
279 } else if ch == '\n' {
280 state = S::Code;
283 }
284 out.push(keep(ch));
285 i += 1;
286 }
287 S::Line => {
288 if ch == '\n' {
289 state = S::Code;
290 out.push(ch);
291 } else {
292 out.push(' ');
293 }
294 i += 1;
295 }
296 S::Block => {
297 if ch == '*' && next == Some('/') {
298 state = S::Code;
299 out.push_str(" ");
300 i += 2;
301 } else {
302 out.push(keep(ch));
303 i += 1;
304 }
305 }
306 S::Template => {
307 if ch == '\\' {
308 out.push_str(if next.is_none() { " " } else { " " });
309 i += 2;
310 continue;
311 }
312 if ch == '$' && next == Some('{') {
317 subst.push(0);
318 state = S::Code;
319 prev_significant = Some('{');
320 word.clear();
321 out.push_str("${");
322 i += 2;
323 continue;
324 }
325 if ch == '`' {
326 state = S::Code;
327 prev_significant = Some('`');
328 word.clear();
329 out.push(ch);
330 i += 1;
331 continue;
332 }
333 out.push(keep(ch));
334 i += 1;
335 }
336 _ => {
337 if ch == '\\' {
338 out.push_str(if next.is_none() { " " } else { " " });
340 i += 2;
341 continue;
342 }
343 let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
344 if closes {
345 state = S::Code;
346 out.push(ch);
347 } else {
348 out.push(keep(ch));
349 }
350 i += 1;
351 }
352 }
353 }
354 out
355}
356
357fn is_searchable(file: &str) -> bool {
358 let f = file.rsplit('/').next().unwrap_or(file);
359 [".js", ".jsx", ".ts", ".tsx", ".vue"]
360 .iter()
361 .any(|e| f.ends_with(e))
362}
363
364pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
365 let stem_matches_self = |file: &str| {
376 let base = file.rsplit('/').next().unwrap_or(file);
377 let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
378 stem == hook_name
379 };
380
381 let mut found_any = false;
382 for term in &TERMS {
383 let arg = format!("-G{}", term.prefilter);
384 let Some(out) =
385 git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
386 else {
387 continue;
388 };
389 let matches: Vec<&str> = out
390 .iter()
391 .map(String::as_str)
392 .filter(|f| is_searchable(f))
393 .filter(|f| !stem_matches_self(f))
394 .filter(|file| {
395 match git::stdout(&["show", &format!(":{file}")]) {
396 None => true,
400 Some(content) => (term.matches)(&blank_non_code(&content)),
401 }
402 })
403 .collect();
404
405 if !matches.is_empty() {
406 if !found_any {
407 crate::say!(" {} Unwanted terms found", error_sign().trim());
408 }
409 found_any = true;
410 crate::say!(
411 " The following files contains '{}' in them:",
412 highlight(term.label)
413 );
414 for m in matches {
415 crate::say!(" - {}", highlight(m));
416 }
417 }
418 }
419 if found_any {
420 return Outcome::Failed;
421 }
422 crate::say!(" {} No unwanted terms were found", valid_sign().trim());
423 Outcome::Passed
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn catches_the_banned_forms() {
432 assert!(call_of("fit('x', () => {})", "fit"));
433 assert!(call_of(" fit (", "fit"));
434 assert!(call_of("fdescribe('x')", "fdescribe"));
435 assert!(bare_debugger(" debugger;"));
436 assert!(bare_debugger("debugger"));
437 assert!(focused_suite("describe.skip('x')"));
438 assert!(focused_suite("it.only('x')"));
439 assert!(focused_suite("context.skip('x')"));
440 }
441
442 #[test]
444 fn leaves_lookalikes_alone() {
445 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)"));
451 }
452
453 #[test]
454 fn blanks_comments_and_strings_keeping_layout() {
455 let src = "a\n// debugger;\nb";
456 let out = blank_non_code(src);
457 assert_eq!(out.len(), src.len(), "length must be preserved");
458 assert_eq!(out.lines().count(), src.lines().count());
459 assert!(!bare_debugger(&out), "a term in a comment is discussion");
460
461 assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
462 assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
463 assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
464 }
465
466 #[test]
467 fn an_escape_never_closes_a_string() {
468 let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
470 assert!(
471 bare_debugger(&out),
472 "real code after the string must survive"
473 );
474 assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
475 }
476
477 #[test]
488 fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
489 for src in [
490 r"const re = /a\//; debugger;",
491 r"const re = /\//; debugger;",
492 ] {
493 assert!(
494 bare_debugger(&blank_non_code(src)),
495 "code after the regex must still be scanned: {src}"
496 );
497 }
498 assert!(!bare_debugger(&blank_non_code(
500 r"const re = /a\//; const ok = 1;"
501 )));
502 }
503
504 #[test]
507 fn terms_inside_a_regex_literal_are_not_violations() {
508 for src in [
509 r"const re = /it\.only/;",
510 r"if (x) { const r = /debugger/; }",
511 r"foo(/fdescribe\(/);",
512 r"return /describe\.skip/;",
513 r"const r = /[/]debugger/;", ] {
515 let b = blank_non_code(src);
516 assert!(!bare_debugger(&b), "false alarm: {src}");
517 assert!(!focused_suite(&b), "false alarm: {src}");
518 assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
519 }
520 }
521
522 #[test]
524 fn division_is_not_treated_as_a_regex() {
525 let src = "const x = a / b; debugger;";
526 assert!(bare_debugger(&blank_non_code(src)));
527 let src2 = "const x = (a + b) / c; debugger;";
528 assert!(bare_debugger(&blank_non_code(src2)));
529 }
530
531 #[test]
532 fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
533 let src = "const r = /oops
534debugger;";
535 assert!(bare_debugger(&blank_non_code(src)));
536 }
537
538 #[test]
539 fn blanking_still_preserves_length_and_lines() {
540 let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
541 let out = blank_non_code(src);
542 assert_eq!(out.len(), src.len());
543 assert_eq!(out.lines().count(), src.lines().count());
544 }
545
546 #[test]
547 fn only_js_like_files_are_searched() {
548 for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
549 assert!(is_searchable(f), "{f}");
550 }
551 for f in ["a.rs", "a.md", "a.json", "README"] {
552 assert!(!is_searchable(f), "{f}");
553 }
554 }
555}
556
557#[cfg(test)]
558mod template_substitutions {
559 use super::*;
560
561 #[test]
565 fn a_substitution_is_code() {
566 let b = blank_non_code("const s = `${fit(1)}`;");
567 assert!(call_of(&b, "fit"), "blanked to {b:?}");
568 }
569
570 #[test]
574 fn substitutions_nest() {
575 let b = blank_non_code("const s = `${`${fit(1)}`}`;");
576 assert!(call_of(&b, "fit"), "blanked to {b:?}");
577 assert!(
578 b.contains("${`${fit(1)}`}"),
579 "nesting must be tracked, not merely survived: {b:?}"
580 );
581 }
582
583 #[test]
585 fn braces_inside_a_substitution_do_not_close_it() {
586 let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
587 assert!(
588 !call_of(&b, "fit"),
589 "the string literal must stay blanked: {b:?}"
590 );
591 let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
592 assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
593
594 let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
599 assert!(
600 call_of(&b3, "fit"),
601 "a `}}` closing a nested object must not end the substitution: {b3:?}"
602 );
603 }
604
605 #[test]
607 fn template_text_is_still_blanked() {
608 assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
609 assert!(
610 !call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
611 "an escaped dollar does not open a substitution"
612 );
613 }
614
615 #[test]
618 fn nested_constructs_inside_a_substitution() {
619 assert!(!call_of(
620 &blank_non_code("const s = `${/* fit(1) */ x}`;"),
621 "fit"
622 ));
623 assert!(!call_of(
624 &blank_non_code(r#"const s = `${"fit("}`;"#),
625 "fit"
626 ));
627 assert!(!call_of(
628 &blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
629 "fit"
630 ));
631 }
632
633 #[test]
636 fn a_stray_brace_in_code_is_harmless() {
637 let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
638 assert!(call_of(&b, "fit"), "blanked to {b:?}");
639 }
640}