Skip to main content

amont_runtime/hooks/
ban_terms.rs

1//! pre-commit-ban-terms — refuse focused/debug leftovers in staged sources.
2//!
3//! Two stages, kept exactly as the JS had them:
4//!   1. `git diff --cached -G<loose>` picks candidate files cheaply, and keeps
5//!      the check scoped to what this commit touches — a pre-existing violation
6//!      in an untouched part of an edited file is not this commit's problem.
7//!   2. Each candidate is re-checked against its STAGED content with comments
8//!      and string literals blanked, which is where correctness lives.
9//!
10//! Stage 1 stays deliberately loose (POSIX regex, flavour varies by platform);
11//! a loose prefilter costs one extra file read, a strict one misses violations.
12//!
13//! Each term carries its own language: the file extensions it scans and the
14//! blanker that understands their comment and string syntax. JS/TS terms came
15//! first; Rust (`dbg!`) and Python (`breakpoint()`, `pdb.set_trace()`) get the
16//! same treatment — a debug leftover is a debug leftover in any language.
17
18use crate::check::Outcome;
19use crate::git;
20use crate::ui::{error_sign, highlight, valid_sign};
21
22/// Everything any term scans. The registry declares the check's scope from
23/// this constant, so a term cannot gain a language without the dashboard
24/// learning about it — pinned by `the_scope_is_the_union_of_the_terms`.
25pub const EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue", ".rs", ".py"];
26
27const JS_LIKE: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
28const RUST: &[&str] = &[".rs"];
29const PYTHON: &[&str] = &[".py"];
30
31/// A banned form: a loose `git diff -G` prefilter, the extensions it applies
32/// to, the blanker for that language, and the precise matcher.
33struct Term {
34    label: &'static str,
35    prefilter: &'static str,
36    exts: &'static [&'static str],
37    blank: fn(&str) -> String,
38    matches: fn(&str) -> bool,
39}
40
41fn is_ident(c: char) -> bool {
42    c.is_alphanumeric() || c == '_' || c == '$'
43}
44
45/// `(?<![\w.$])<word>` — not preceded by an identifier char, a dot or a `$`.
46/// The dot is what keeps `foo.fit(` out; without it `layout.fit(` is a false
47/// positive.
48fn preceded_ok(src: &str, at: usize) -> bool {
49    src[..at]
50        .chars()
51        .next_back()
52        .map(|c| !(is_ident(c) || c == '.'))
53        .unwrap_or(true)
54}
55
56/// `<word>\s*\(` — the call form.
57fn call_of(src: &str, word: &str) -> bool {
58    let mut from = 0;
59    while let Some(i) = src[from..].find(word) {
60        let at = from + i;
61        let after = at + word.len();
62        if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
63            return true;
64        }
65        from = at + word.len();
66    }
67    false
68}
69
70/// `(?<![\w.$])debugger(?![\w$])` — the bare statement, so `debuggerish` and
71/// `x.debugger` both pass.
72fn bare_debugger(src: &str) -> bool {
73    let word = "debugger";
74    let mut from = 0;
75    while let Some(i) = src[from..].find(word) {
76        let at = from + i;
77        let after = at + word.len();
78        let next_ok = src[after..]
79            .chars()
80            .next()
81            .map(|c| !is_ident(c))
82            .unwrap_or(true);
83        if preceded_ok(src, at) && next_ok {
84            return true;
85        }
86        from = at + word.len();
87    }
88    false
89}
90
91/// `(?<![\w$])(describe|context|it)\.(skip|only)(?![\w$])`
92///
93/// The trailing guard is the whole point: `describe.skipIf(...)` is vitest's
94/// legitimate conditional API and must pass, while `describe.skip` must not.
95/// Note the LEADING guard here excludes only identifier chars, not `.` — that
96/// matches the JS, so `foo.describe.skip` is still caught.
97fn focused_suite(src: &str) -> bool {
98    for head in ["describe", "context", "it"] {
99        for tail in ["skip", "only"] {
100            let needle = format!("{head}.{tail}");
101            let mut from = 0;
102            while let Some(i) = src[from..].find(&needle) {
103                let at = from + i;
104                let after = at + needle.len();
105                let before_ok = src[..at]
106                    .chars()
107                    .next_back()
108                    .map(|c| !is_ident(c))
109                    .unwrap_or(true);
110                let after_ok = src[after..]
111                    .chars()
112                    .next()
113                    .map(|c| !is_ident(c))
114                    .unwrap_or(true);
115                if before_ok && after_ok {
116                    return true;
117                }
118                from = at + needle.len();
119            }
120        }
121    }
122    false
123}
124
125/// `dbg!(…)` — the macro invocation, any delimiter. `xdbg!` and a function
126/// named `dbg` both pass; `std::dbg!(` is still the macro (`:` is not an
127/// identifier character, so the leading guard lets it through).
128fn rust_dbg(src: &str) -> bool {
129    let word = "dbg";
130    let mut from = 0;
131    while let Some(i) = src[from..].find(word) {
132        let at = from + i;
133        let after = at + word.len();
134        let before_ok = src[..at]
135            .chars()
136            .next_back()
137            .map(|c| !is_ident(c))
138            .unwrap_or(true);
139        let rest = &src[after..];
140        if before_ok
141            && rest.starts_with('!')
142            && matches!(rest[1..].trim_start().chars().next(), Some('(' | '[' | '{'))
143        {
144            return true;
145        }
146        from = after;
147    }
148    false
149}
150
151/// `pdb.set_trace(` / `ipdb.set_trace(` — the import-and-call form. The
152/// leading guard excludes identifier characters only, so `x.pdb.set_trace(`
153/// is still caught while `xpdb.set_trace(` matches neither needle (the `i`
154/// rejects the first, the `x` the second). Bare `set_trace(` is deliberately
155/// not matched: without its module it is just a method name.
156fn pdb_set_trace(src: &str) -> bool {
157    for needle in ["pdb.set_trace", "ipdb.set_trace"] {
158        let mut from = 0;
159        while let Some(i) = src[from..].find(needle) {
160            let at = from + i;
161            let after = at + needle.len();
162            let before_ok = src[..at]
163                .chars()
164                .next_back()
165                .map(|c| !is_ident(c))
166                .unwrap_or(true);
167            if before_ok && src[after..].trim_start().starts_with('(') {
168                return true;
169            }
170            from = at + needle.len();
171        }
172    }
173    false
174}
175
176const TERMS: [Term; 7] = [
177    Term {
178        label: "fit",
179        prefilter: r"\s*fit\(",
180        exts: JS_LIKE,
181        blank: blank_non_code,
182        matches: |s| call_of(s, "fit"),
183    },
184    Term {
185        label: "fdescribe",
186        prefilter: r"\s*fdescribe\(",
187        exts: JS_LIKE,
188        blank: blank_non_code,
189        matches: |s| call_of(s, "fdescribe"),
190    },
191    Term {
192        label: "debugger",
193        prefilter: "debugger;?",
194        exts: JS_LIKE,
195        blank: blank_non_code,
196        matches: bare_debugger,
197    },
198    Term {
199        label: "skipOnly",
200        prefilter: r"(describe|context|it)\.(skip|only)",
201        exts: JS_LIKE,
202        blank: blank_non_code,
203        matches: focused_suite,
204    },
205    Term {
206        label: "dbg!",
207        prefilter: "dbg!",
208        exts: RUST,
209        blank: blank_rust,
210        matches: rust_dbg,
211    },
212    Term {
213        label: "breakpoint",
214        prefilter: "breakpoint",
215        exts: PYTHON,
216        blank: blank_python,
217        matches: |s| call_of(s, "breakpoint"),
218    },
219    Term {
220        label: "set_trace",
221        prefilter: "set_trace",
222        exts: PYTHON,
223        blank: blank_python,
224        matches: pdb_set_trace,
225    },
226];
227
228#[derive(Clone, Copy, PartialEq)]
229enum S {
230    Code,
231    Line,
232    Block,
233    Single,
234    Double,
235    Template,
236    Regex,
237}
238
239/// Keywords after which a `/` begins a regex, not a division.
240const REGEX_KEYWORDS: [&str; 13] = [
241    "return",
242    "typeof",
243    "case",
244    "in",
245    "of",
246    "delete",
247    "void",
248    "instanceof",
249    "new",
250    "do",
251    "else",
252    "yield",
253    "await",
254];
255
256/// Can a `/` here start a regex? Decided from the last significant code
257/// character, and the word it belongs to when it is one.
258fn regex_can_start(prev: Option<char>, word: &str) -> bool {
259    match prev {
260        // start of input
261        None => true,
262        Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
263        // an identifier or number ends a value → `/` divides it …
264        Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
265        // … as do `)` and `]`; `}` is ambiguous and treated as allowing a
266        // regex, since a false alarm is the worse error (see the type doc).
267        Some(_) => false,
268    }
269}
270
271/// Blank comments and the insides of string/template/REGEX literals, preserving
272/// length and line count so offsets still line up — blanked, not deleted, for
273/// that reason.
274///
275/// Now handles regex literals. It previously did not, and `/a\\/b/` read as a
276/// line comment: everything after it on that line was blanked, so a real
277/// `debugger;` sharing the line went unreported. Missed warnings rather than
278/// false alarms, but missed all the same.
279///
280/// Telling a regex from division needs the preceding token, which is why this
281/// is a tokenizer and not a set of rules over characters. The two ways to be
282/// wrong are NOT symmetric:
283///   - division mistaken for a regex → blanks to the next `/`, so a violation
284///     there is MISSED. Safe direction.
285///   - a regex mistaken for division → its contents are scanned as code, so
286///     `/it\\.only/` would be reported as a violation that does not exist.
287///     A false alarm blocking a commit — the direction to avoid, and why the
288///     "regex allowed" set below is generous.
289pub fn blank_non_code(src: &str) -> String {
290    let b: Vec<char> = src.chars().collect();
291    let mut out = String::with_capacity(src.len());
292    let mut state = S::Code;
293    let mut i = 0;
294    // The token before the current position, for the regex-vs-division call.
295    let mut prev_significant: Option<char> = None;
296    let mut word = String::new();
297    let mut in_class = false;
298    // Open `${…}` substitutions, innermost last; each entry counts the `{` we
299    // are nested under, so the matching `}` returns us to template text.
300    // A stack, not a flag: substitutions nest — `` `${`${x}`}` ``.
301    let mut subst: Vec<u32> = Vec::new();
302    let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
303
304    while i < b.len() {
305        let ch = b[i];
306        let next = b.get(i + 1).copied();
307        match state {
308            S::Code => {
309                if ch == '/' && next == Some('/') {
310                    state = S::Line;
311                    out.push_str("  ");
312                    i += 2;
313                } else if ch == '/' && next == Some('*') {
314                    state = S::Block;
315                    out.push_str("  ");
316                    i += 2;
317                } else if ch == '/' && regex_can_start(prev_significant, &word) {
318                    state = S::Regex;
319                    in_class = false;
320                    out.push(ch);
321                    i += 1;
322                } else if ch == '\'' || ch == '"' || ch == '`' {
323                    state = match ch {
324                        '\'' => S::Single,
325                        '"' => S::Double,
326                        _ => S::Template,
327                    };
328                    out.push(ch);
329                    i += 1;
330                } else {
331                    if !subst.is_empty() {
332                        if ch == '{' {
333                            *subst.last_mut().expect("non-empty") += 1;
334                        } else if ch == '}' {
335                            let depth = subst.last_mut().expect("non-empty");
336                            if *depth == 0 {
337                                subst.pop();
338                                state = S::Template;
339                                out.push(ch);
340                                i += 1;
341                                continue;
342                            }
343                            *depth -= 1;
344                        }
345                    }
346                    if !ch.is_whitespace() {
347                        prev_significant = Some(ch);
348                        if ch.is_alphanumeric() || ch == '_' || ch == '$' {
349                            word.push(ch);
350                        } else {
351                            word.clear();
352                        }
353                    }
354                    out.push(ch);
355                    i += 1;
356                }
357            }
358            S::Regex => {
359                // `\` escapes anything; `[…]` is a class in which `/` is literal.
360                if ch == '\\' {
361                    out.push_str(if next.is_none() { " " } else { "  " });
362                    i += 2;
363                    continue;
364                }
365                if ch == '[' {
366                    in_class = true;
367                } else if ch == ']' {
368                    in_class = false;
369                } else if ch == '/' && !in_class {
370                    state = S::Code;
371                    prev_significant = Some('/');
372                    word.clear();
373                    out.push(ch);
374                    i += 1;
375                    continue;
376                } else if ch == '\n' {
377                    // An unterminated regex cannot span lines — bail back to
378                    // code rather than blanking the rest of the file.
379                    state = S::Code;
380                }
381                out.push(keep(ch));
382                i += 1;
383            }
384            S::Line => {
385                if ch == '\n' {
386                    state = S::Code;
387                    out.push(ch);
388                } else {
389                    out.push(' ');
390                }
391                i += 1;
392            }
393            S::Block => {
394                if ch == '*' && next == Some('/') {
395                    state = S::Code;
396                    out.push_str("  ");
397                    i += 2;
398                } else {
399                    out.push(keep(ch));
400                    i += 1;
401                }
402            }
403            S::Template => {
404                if ch == '\\' {
405                    out.push_str(if next.is_none() { " " } else { "  " });
406                    i += 2;
407                    continue;
408                }
409                // `${…}` is CODE. Blanking it hid every banned call written
410                // inside a substitution — `` `${it.only(x)}` `` read as string
411                // content. That is a MISSED warning, which is the direction
412                // this hook must never fail in.
413                if ch == '$' && next == Some('{') {
414                    subst.push(0);
415                    state = S::Code;
416                    prev_significant = Some('{');
417                    word.clear();
418                    out.push_str("${");
419                    i += 2;
420                    continue;
421                }
422                if ch == '`' {
423                    state = S::Code;
424                    prev_significant = Some('`');
425                    word.clear();
426                    out.push(ch);
427                    i += 1;
428                    continue;
429                }
430                out.push(keep(ch));
431                i += 1;
432            }
433            _ => {
434                if ch == '\\' {
435                    // Blank the escape AND what it escapes, so \" never closes.
436                    out.push_str(if next.is_none() { " " } else { "  " });
437                    i += 2;
438                    continue;
439                }
440                let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
441                if closes {
442                    state = S::Code;
443                    out.push(ch);
444                } else {
445                    out.push(keep(ch));
446                }
447                i += 1;
448            }
449        }
450    }
451    out
452}
453
454/// Is `word` exactly a raw-string prefix? `r"…"`, `br"…"`, `cr"…"` — an
455/// identifier that merely ENDS in `r` (`attr"…"` is not Rust anyway) stays an
456/// ordinary word because the tracker holds the whole contiguous run.
457fn is_raw_prefix(word: &str) -> bool {
458    matches!(word, "r" | "br" | "cr")
459}
460
461#[derive(Clone, Copy, PartialEq)]
462enum R {
463    Code,
464    Line,
465    Block(u32),
466    Str,
467    Raw(u32),
468}
469
470/// Blank comments and the insides of string/char literals in RUST source,
471/// preserving length and line count like `blank_non_code` does for JS.
472///
473/// The shapes that differ from JS and earn their handling here:
474///   - block comments NEST — `/* /* */ */` is one comment, and treating the
475///     first `*/` as the end would scan the tail of the comment as code
476///     (a false alarm, the direction to avoid);
477///   - raw strings `r"…"` / `r#"…"#` (and the `br`/`cr` variants), where `\`
478///     is literal and the terminator is `"` plus the opening's `#` count;
479///   - `'` is either a char literal or a lifetime. Bounded lookahead tells
480///     them apart: a char literal closes within a few characters, a lifetime
481///     never closes. Misreading a lifetime as a char start would blank real
482///     code (a missed warning); the case that must not be missed is `'"'`,
483///     which read as a lifetime opens a phantom string and blanks the rest of
484///     the file. The lookahead exists for that quote.
485pub fn blank_rust(src: &str) -> String {
486    let b: Vec<char> = src.chars().collect();
487    let mut out = String::with_capacity(src.len());
488    let mut state = R::Code;
489    let mut word = String::new();
490    let mut i = 0;
491    let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
492
493    while i < b.len() {
494        let ch = b[i];
495        let next = b.get(i + 1).copied();
496        match state {
497            R::Code => {
498                if ch == '/' && next == Some('/') {
499                    state = R::Line;
500                    out.push_str("  ");
501                    i += 2;
502                } else if ch == '/' && next == Some('*') {
503                    state = R::Block(1);
504                    out.push_str("  ");
505                    i += 2;
506                } else if ch == '"' {
507                    state = if is_raw_prefix(&word) {
508                        R::Raw(0)
509                    } else {
510                        R::Str
511                    };
512                    word.clear();
513                    out.push(ch);
514                    i += 1;
515                } else if ch == '#' && is_raw_prefix(&word) {
516                    // `r#"…"#`, possibly with more hashes. A `#` NOT followed
517                    // by hashes-then-quote is ordinary code (`r#ident` is a
518                    // raw identifier, `#[attr]` an attribute).
519                    let mut n = 0;
520                    while b.get(i + n) == Some(&'#') {
521                        n += 1;
522                    }
523                    if b.get(i + n) == Some(&'"') {
524                        for _ in 0..n {
525                            out.push('#');
526                        }
527                        out.push('"');
528                        state = R::Raw(n as u32);
529                        i += n + 1;
530                    } else {
531                        out.push(ch);
532                        i += 1;
533                    }
534                    word.clear();
535                } else if ch == '\'' {
536                    // Char literal or lifetime. After `'\` the literal closes
537                    // at the first quote (only `'\\'` puts a backslash before
538                    // it, and that IS the escaped char); after `'x` it closes
539                    // only as `'x'`. `'\u{10FFFF}'` is the longest escape, so
540                    // the lookahead is bounded.
541                    let char_end = match next {
542                        Some('\\') => (i + 3..(i + 14).min(b.len())).find(|&j| b[j] == '\''),
543                        Some(c) if c != '\'' => {
544                            if b.get(i + 2) == Some(&'\'') {
545                                Some(i + 2)
546                            } else {
547                                None
548                            }
549                        }
550                        _ => None,
551                    };
552                    match char_end {
553                        Some(j) => {
554                            out.push('\'');
555                            for c in &b[i + 1..j] {
556                                out.push(keep(*c));
557                            }
558                            out.push('\'');
559                            i = j + 1;
560                        }
561                        None => {
562                            // a lifetime — its name flows on as ordinary code
563                            out.push('\'');
564                            i += 1;
565                        }
566                    }
567                    word.clear();
568                } else {
569                    if ch.is_alphanumeric() || ch == '_' {
570                        word.push(ch);
571                    } else {
572                        word.clear();
573                    }
574                    out.push(ch);
575                    i += 1;
576                }
577            }
578            R::Line => {
579                if ch == '\n' {
580                    state = R::Code;
581                    out.push(ch);
582                } else {
583                    out.push(' ');
584                }
585                i += 1;
586            }
587            R::Block(depth) => {
588                if ch == '/' && next == Some('*') {
589                    state = R::Block(depth + 1);
590                    out.push_str("  ");
591                    i += 2;
592                } else if ch == '*' && next == Some('/') {
593                    state = if depth == 1 {
594                        R::Code
595                    } else {
596                        R::Block(depth - 1)
597                    };
598                    out.push_str("  ");
599                    i += 2;
600                } else {
601                    out.push(keep(ch));
602                    i += 1;
603                }
604            }
605            R::Str => {
606                if ch == '\\' {
607                    // Blank the escape AND what it escapes, so `\"` never
608                    // closes — but keep an escaped newline a newline, since
609                    // Rust strings span lines and the line count must hold.
610                    out.push(' ');
611                    if let Some(n) = next {
612                        out.push(keep(n));
613                    }
614                    i += 2;
615                } else if ch == '"' {
616                    state = R::Code;
617                    out.push(ch);
618                    i += 1;
619                } else {
620                    out.push(keep(ch));
621                    i += 1;
622                }
623            }
624            R::Raw(hashes) => {
625                let n = hashes as usize;
626                if ch == '"' && (1..=n).all(|k| b.get(i + k) == Some(&'#')) {
627                    out.push('"');
628                    for _ in 0..n {
629                        out.push('#');
630                    }
631                    state = R::Code;
632                    i += n + 1;
633                } else {
634                    out.push(keep(ch));
635                    i += 1;
636                }
637            }
638        }
639    }
640    out
641}
642
643/// One open Python string literal: what closes it and how its insides behave.
644#[derive(Clone, Copy)]
645struct PyLit {
646    quote: char,
647    triple: bool,
648    fstr: bool,
649}
650
651#[derive(Clone, Copy)]
652enum P {
653    Code,
654    Comment,
655    Lit(PyLit),
656}
657
658/// Blank comments and the insides of string literals in PYTHON source,
659/// preserving length and line count.
660///
661/// The shapes that earn their handling here:
662///   - `#` comments and the three quote forms: `'…'`, `"…"`, and the triples;
663///   - prefixes (`r`, `b`, `f`, `u` and their pairs) read from the word the
664///     tracker just saw, because `f"{breakpoint()}"` interpolates CODE: like
665///     the JS blanker's `${…}`, blanking it would hide a banned call — a
666///     missed warning, the direction this hook must never fail in. `{{` is
667///     text, and interpolations nest (`f"{f'{x}'}"`), so open ones are a
668///     stack of the literals to resume, not a flag;
669///   - a backslash always blanks the next character, raw or not: in Python a
670///     raw string still cannot end on a lone backslash, so `r"\"…` staying
671///     open mirrors the real parser;
672///   - an unterminated single-line string bails back to code at the newline
673///     rather than blanking the rest of the file.
674pub fn blank_python(src: &str) -> String {
675    let b: Vec<char> = src.chars().collect();
676    let mut out = String::with_capacity(src.len());
677    let mut state = P::Code;
678    let mut word = String::new();
679    let mut i = 0;
680    // Open f-string interpolations, innermost last: brace depth inside the
681    // interpolation, and the literal to resume when it closes.
682    let mut subst: Vec<(u32, PyLit)> = Vec::new();
683    let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
684
685    while i < b.len() {
686        let ch = b[i];
687        let next = b.get(i + 1).copied();
688        match state {
689            P::Code => {
690                if ch == '#' {
691                    state = P::Comment;
692                    out.push(' ');
693                    i += 1;
694                } else if ch == '\'' || ch == '"' {
695                    let prefix = !word.is_empty()
696                        && word.len() <= 2
697                        && word.chars().all(|c| "rbfuRBFU".contains(c));
698                    let fstr = prefix && word.to_ascii_lowercase().contains('f');
699                    let triple = next == Some(ch) && b.get(i + 2) == Some(&ch);
700                    state = P::Lit(PyLit {
701                        quote: ch,
702                        triple,
703                        fstr,
704                    });
705                    word.clear();
706                    if triple {
707                        out.push(ch);
708                        out.push(ch);
709                        out.push(ch);
710                        i += 3;
711                    } else {
712                        out.push(ch);
713                        i += 1;
714                    }
715                } else {
716                    if !subst.is_empty() {
717                        if ch == '{' {
718                            subst.last_mut().expect("non-empty").0 += 1;
719                        } else if ch == '}' {
720                            let (depth, lit) = *subst.last().expect("non-empty");
721                            if depth == 0 {
722                                subst.pop();
723                                state = P::Lit(lit);
724                                out.push(ch);
725                                i += 1;
726                                continue;
727                            }
728                            subst.last_mut().expect("non-empty").0 -= 1;
729                        }
730                    }
731                    if ch.is_alphanumeric() || ch == '_' {
732                        word.push(ch);
733                    } else {
734                        word.clear();
735                    }
736                    out.push(ch);
737                    i += 1;
738                }
739            }
740            P::Comment => {
741                if ch == '\n' {
742                    state = P::Code;
743                    out.push(ch);
744                } else {
745                    out.push(' ');
746                }
747                i += 1;
748            }
749            P::Lit(lit) => {
750                if ch == '\\' {
751                    out.push(' ');
752                    if let Some(n) = next {
753                        out.push(keep(n));
754                    }
755                    i += 2;
756                } else if lit.triple
757                    && ch == lit.quote
758                    && next == Some(lit.quote)
759                    && b.get(i + 2) == Some(&lit.quote)
760                {
761                    state = P::Code;
762                    out.push(ch);
763                    out.push(ch);
764                    out.push(ch);
765                    i += 3;
766                } else if !lit.triple && ch == lit.quote {
767                    state = P::Code;
768                    out.push(ch);
769                    i += 1;
770                } else if !lit.triple && ch == '\n' {
771                    // unterminated — bail so the rest of the file is scanned
772                    state = P::Code;
773                    out.push(ch);
774                    i += 1;
775                } else if lit.fstr && ch == '{' && next == Some('{') {
776                    out.push_str("  ");
777                    i += 2;
778                } else if lit.fstr && ch == '{' {
779                    subst.push((0, lit));
780                    state = P::Code;
781                    word.clear();
782                    out.push(ch);
783                    i += 1;
784                } else if lit.fstr && ch == '}' && next == Some('}') {
785                    out.push_str("  ");
786                    i += 2;
787                } else {
788                    out.push(keep(ch));
789                    i += 1;
790                }
791            }
792        }
793    }
794    out
795}
796
797fn is_searchable(file: &str, exts: &[&str]) -> bool {
798    let f = file.rsplit('/').next().unwrap_or(file);
799    exts.iter().any(|e| f.ends_with(e))
800}
801
802pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
803    // This file necessarily NAMES every term it bans, so it must never flag
804    // itself. Compare on the file STEM against the hook name we were invoked
805    // as: the hook is checked from two layouts — installed at .git/hooks/<name>
806    // and as source at templates/hooks/<name> — and a path-relative comparison
807    // never matched from the second, which once made this very file
808    // uncommittable.
809    //
810    // NOT argv[0]: that is now the `amont` binary, so deriving the name from
811    // it excluded nothing and the hook flagged its own source. Caught by the
812    // existing suite.
813    let stem_matches_self = |file: &str| {
814        let base = file.rsplit('/').next().unwrap_or(file);
815        let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
816        stem == hook_name
817    };
818
819    let mut found_any = false;
820    for term in &TERMS {
821        let arg = format!("-G{}", term.prefilter);
822        let Some(out) =
823            git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
824        else {
825            continue;
826        };
827        let matches: Vec<&str> = out
828            .iter()
829            .map(String::as_str)
830            .filter(|f| is_searchable(f, term.exts))
831            .filter(|f| !stem_matches_self(f))
832            .filter(|file| {
833                match git::stdout(&["show", &format!(":{file}")]) {
834                    // Unreadable (binary, or vanished between the two git
835                    // calls): keep the prefilter's verdict rather than
836                    // silently clearing it.
837                    None => true,
838                    Some(content) => (term.matches)(&(term.blank)(&content)),
839                }
840            })
841            .collect();
842
843        if !matches.is_empty() {
844            if !found_any {
845                crate::say!("  {} Unwanted terms found", error_sign().trim());
846            }
847            found_any = true;
848            crate::say!(
849                "    The following files contains '{}' in them:",
850                highlight(term.label)
851            );
852            for m in matches {
853                crate::say!("    - {}", highlight(m));
854            }
855        }
856    }
857    if found_any {
858        return Outcome::Failed;
859    }
860    crate::say!("  {} No unwanted terms were found", valid_sign().trim());
861    Outcome::Passed
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn catches_the_banned_forms() {
870        assert!(call_of("fit('x', () => {})", "fit"));
871        assert!(call_of("  fit (", "fit"));
872        assert!(call_of("fdescribe('x')", "fdescribe"));
873        assert!(bare_debugger("  debugger;"));
874        assert!(bare_debugger("debugger"));
875        assert!(focused_suite("describe.skip('x')"));
876        assert!(focused_suite("it.only('x')"));
877        assert!(focused_suite("context.skip('x')"));
878    }
879
880    /// The false positives that forced the two-stage design.
881    #[test]
882    fn leaves_lookalikes_alone() {
883        assert!(!call_of("profit(", "fit")); // preceded by a word char
884        assert!(!call_of("layout.fit(", "fit")); // preceded by a dot
885        assert!(!bare_debugger("debuggerish")); // trailing guard
886        assert!(!bare_debugger("x.debugger")); // preceded by a dot
887        assert!(!focused_suite("describe.skipIf(cond)")); // vitest's real API
888        assert!(!focused_suite("it.onlyWhen(x)"));
889    }
890
891    #[test]
892    fn blanks_comments_and_strings_keeping_layout() {
893        let src = "a\n// debugger;\nb";
894        let out = blank_non_code(src);
895        assert_eq!(out.len(), src.len(), "length must be preserved");
896        assert_eq!(out.lines().count(), src.lines().count());
897        assert!(!bare_debugger(&out), "a term in a comment is discussion");
898
899        assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
900        assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
901        assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
902    }
903
904    #[test]
905    fn an_escape_never_closes_a_string() {
906        // If \" closed the run, the trailing code would be scanned as code.
907        let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
908        assert!(
909            bare_debugger(&out),
910            "real code after the string must survive"
911        );
912        assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
913    }
914
915    /// The bug the tokenizer exists for, with the input that ACTUALLY triggers
916    /// it: an escaped slash immediately before the closing one puts two `/`
917    /// characters next to each other in the stream, which the old blanker read
918    /// as a line comment — blanking the rest of the line, so the real
919    /// `debugger;` after it went unreported.
920    ///
921    /// `/a\/b/` does NOT trigger it (the slashes are not adjacent); my first
922    /// version of this test used that and passed against both implementations,
923    /// proving nothing. Differentially confirmed: old exits 0 on these, new
924    /// exits 1.
925    #[test]
926    fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
927        for src in [
928            r"const re = /a\//; debugger;",
929            r"const re = /\//; debugger;",
930        ] {
931            assert!(
932                bare_debugger(&blank_non_code(src)),
933                "code after the regex must still be scanned: {src}"
934            );
935        }
936        // and the same shape with nothing to find must stay quiet
937        assert!(!bare_debugger(&blank_non_code(
938            r"const re = /a\//; const ok = 1;"
939        )));
940    }
941
942    /// The dangerous direction: a regex mistaken for division would have its
943    /// contents scanned, reporting a violation that does not exist.
944    #[test]
945    fn terms_inside_a_regex_literal_are_not_violations() {
946        for src in [
947            r"const re = /it\.only/;",
948            r"if (x) { const r = /debugger/; }",
949            r"foo(/fdescribe\(/);",
950            r"return /describe\.skip/;",
951            r"const r = /[/]debugger/;", // `/` inside a class does not end it
952        ] {
953            let b = blank_non_code(src);
954            assert!(!bare_debugger(&b), "false alarm: {src}");
955            assert!(!focused_suite(&b), "false alarm: {src}");
956            assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
957        }
958    }
959
960    /// Division must stay division, or the blanker eats real code.
961    #[test]
962    fn division_is_not_treated_as_a_regex() {
963        let src = "const x = a / b; debugger;";
964        assert!(bare_debugger(&blank_non_code(src)));
965        let src2 = "const x = (a + b) / c; debugger;";
966        assert!(bare_debugger(&blank_non_code(src2)));
967    }
968
969    #[test]
970    fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
971        let src = "const r = /oops
972debugger;";
973        assert!(bare_debugger(&blank_non_code(src)));
974    }
975
976    #[test]
977    fn blanking_still_preserves_length_and_lines() {
978        let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
979        let out = blank_non_code(src);
980        assert_eq!(out.len(), src.len());
981        assert_eq!(out.lines().count(), src.lines().count());
982    }
983
984    #[test]
985    fn each_term_searches_only_its_own_language() {
986        for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
987            assert!(is_searchable(f, JS_LIKE), "{f}");
988        }
989        for f in ["a.rs", "a.py", "a.md", "a.json", "README"] {
990            assert!(!is_searchable(f, JS_LIKE), "{f}");
991        }
992        assert!(is_searchable("src/lib.rs", RUST));
993        assert!(!is_searchable("a.ts", RUST));
994        assert!(is_searchable("app/main.py", PYTHON));
995        assert!(!is_searchable("a.pyi", PYTHON), "stubs never execute");
996    }
997
998    /// The registry declares the check's scope from `EXTS`; a term must not
999    /// scan a language the dashboard does not know about, nor the reverse.
1000    #[test]
1001    fn the_scope_is_the_union_of_the_terms() {
1002        let mut union: Vec<&str> = TERMS.iter().flat_map(|t| t.exts.iter().copied()).collect();
1003        union.sort_unstable();
1004        union.dedup();
1005        let mut declared: Vec<&str> = EXTS.to_vec();
1006        declared.sort_unstable();
1007        assert_eq!(union, declared);
1008    }
1009}
1010
1011#[cfg(test)]
1012mod rust_terms {
1013    use super::*;
1014
1015    #[test]
1016    fn catches_the_macro_call() {
1017        assert!(rust_dbg("dbg!(x)"));
1018        assert!(rust_dbg("let y = dbg! (x);"));
1019        assert!(rust_dbg("std::dbg!(x)"));
1020        assert!(rust_dbg("dbg![x]"));
1021        assert!(rust_dbg("dbg!{x}"));
1022    }
1023
1024    #[test]
1025    fn leaves_lookalikes_alone() {
1026        assert!(!rust_dbg("xdbg!(1)")); // preceded by a word char
1027        assert!(!rust_dbg("dbg(1)")); // a function, not the macro
1028        assert!(!rust_dbg("debug!(x)")); // a different macro
1029        assert!(!rust_dbg("dbg")); // the bare word
1030    }
1031
1032    #[test]
1033    fn comments_and_strings_are_discussion() {
1034        for src in [
1035            "// dbg!(x)\nlet a = 1;",
1036            "/* dbg!(x) */",
1037            "/// docs mentioning dbg!(x)\nfn f() {}",
1038            "let s = \"dbg!(x)\";",
1039            "let s = r\"dbg!(x)\";",
1040            "let s = r#\"dbg!(\"x\")\"#;",
1041            "let s = br\"dbg!(x)\";",
1042        ] {
1043            assert!(!rust_dbg(&blank_rust(src)), "false alarm: {src}");
1044        }
1045    }
1046
1047    /// Block comments NEST: the first `*/` must not end `/* /* … */`, or the
1048    /// tail of the comment is scanned as code — a false alarm.
1049    #[test]
1050    fn block_comments_nest() {
1051        assert!(!rust_dbg(&blank_rust("/* /* x */ dbg!(1) */")));
1052        assert!(rust_dbg(&blank_rust("/* /* x */ */ dbg!(1)")));
1053    }
1054
1055    /// A raw string's terminator is `"` plus the opening's `#` count — a mere
1056    /// `"` inside `r#"…"#` must not close it, and the real terminator must.
1057    #[test]
1058    fn raw_string_hashes_are_honoured() {
1059        assert!(!rust_dbg(&blank_rust("let s = r#\"a\"b\"#;")));
1060        assert!(rust_dbg(&blank_rust("let s = r#\"a\"b\"#; dbg!(1);")));
1061        assert!(!rust_dbg(&blank_rust("let s = r##\"a\"# dbg!(1) \"##;")));
1062    }
1063
1064    /// The quote-shaped char literals. Read as lifetimes they would open a
1065    /// phantom string and blank the rest of the file — a missed warning.
1066    #[test]
1067    fn a_char_literal_holding_a_quote_does_not_open_a_string() {
1068        assert!(rust_dbg(&blank_rust("let c = '\"'; dbg!(1);")));
1069        assert!(rust_dbg(&blank_rust("let c = '\\''; dbg!(1);")));
1070        assert!(rust_dbg(&blank_rust("let c = '\\\\'; dbg!(1);")));
1071        assert!(rust_dbg(&blank_rust("let c = '\\u{7f}'; dbg!(1);")));
1072    }
1073
1074    /// The other direction: a lifetime is code, not an open char literal, so
1075    /// what follows it must still be scanned.
1076    #[test]
1077    fn a_lifetime_does_not_swallow_the_line() {
1078        assert!(rust_dbg(&blank_rust("fn f<'a>(x: &'a str) { dbg!(x); }")));
1079        assert!(rust_dbg(&blank_rust("let x: &'static str = s; dbg!(x);")));
1080    }
1081
1082    #[test]
1083    fn blanking_preserves_length_and_lines() {
1084        let src = "let s = r#\"a\"b\"#;\n// dbg!(x)\nlet c = 'y';\n";
1085        let out = blank_rust(src);
1086        assert_eq!(out.len(), src.len());
1087        assert_eq!(out.lines().count(), src.lines().count());
1088    }
1089
1090    /// This file names the term it bans — in strings and comments only, which
1091    /// the blanker must keep blanked or the hook makes its own source
1092    /// uncommittable.
1093    #[test]
1094    fn the_hooks_own_source_survives_its_own_scan() {
1095        assert!(!rust_dbg(&blank_rust(include_str!("ban_terms.rs"))));
1096    }
1097}
1098
1099#[cfg(test)]
1100mod python_terms {
1101    use super::*;
1102
1103    #[test]
1104    fn catches_the_debug_calls() {
1105        assert!(call_of("breakpoint()", "breakpoint"));
1106        assert!(call_of("    breakpoint ()", "breakpoint"));
1107        assert!(pdb_set_trace("pdb.set_trace()"));
1108        assert!(pdb_set_trace("ipdb.set_trace()"));
1109        assert!(pdb_set_trace("x.pdb.set_trace()"));
1110    }
1111
1112    #[test]
1113    fn leaves_lookalikes_alone() {
1114        assert!(!call_of("self.breakpoint()", "breakpoint")); // somebody's API
1115        assert!(!call_of("my_breakpoint()", "breakpoint"));
1116        assert!(!pdb_set_trace("xpdb.set_trace()")); // neither pdb nor ipdb
1117        assert!(!pdb_set_trace("set_trace()")); // bare, no module
1118        assert!(!pdb_set_trace("pdb.set_trace")); // not the call form
1119    }
1120
1121    #[test]
1122    fn comments_and_strings_are_discussion() {
1123        for src in [
1124            "# breakpoint()\nx = 1\n",
1125            "s = 'breakpoint()'\n",
1126            "s = \"pdb.set_trace()\"\n",
1127            "def f():\n    \"\"\"calls breakpoint() eventually\"\"\"\n",
1128            "s = '''ipdb.set_trace()'''\n",
1129            "s = f\"breakpoint( {x}\"\n", // f-string TEXT is still a string
1130            "s = f\"{{breakpoint()}}\"\n", // escaped braces are text
1131        ] {
1132            let b = blank_python(src);
1133            assert!(!call_of(&b, "breakpoint"), "false alarm: {src}");
1134            assert!(!pdb_set_trace(&b), "false alarm: {src}");
1135        }
1136    }
1137
1138    /// An f-string interpolation is CODE — blanking it would hide a banned
1139    /// call, the direction this hook must never fail in. And they nest.
1140    #[test]
1141    fn an_interpolation_is_code() {
1142        assert!(call_of(
1143            &blank_python("s = f\"{breakpoint()}\"\n"),
1144            "breakpoint"
1145        ));
1146        assert!(call_of(
1147            &blank_python("s = f\"{f'{breakpoint()}'}\"\n"),
1148            "breakpoint"
1149        ));
1150        // a `}` closing a nested format spec must not end the interpolation
1151        assert!(call_of(
1152            &blank_python("s = f\"{x:{w}} {breakpoint()}\"\n"),
1153            "breakpoint"
1154        ));
1155    }
1156
1157    /// In Python a raw string still cannot end on a lone backslash: the `\"`
1158    /// keeps the literal open, so what looks like code after it is string.
1159    #[test]
1160    fn a_raw_string_backslash_does_not_close_early() {
1161        let b = blank_python("s = r\"\\\"; breakpoint()\"\n");
1162        assert!(!call_of(&b, "breakpoint"));
1163    }
1164
1165    /// An unterminated single-line string bails at the newline rather than
1166    /// blanking the rest of the file.
1167    #[test]
1168    fn an_unterminated_string_does_not_blank_the_next_line() {
1169        let b = blank_python("s = 'oops\nbreakpoint()\n");
1170        assert!(call_of(&b, "breakpoint"));
1171    }
1172
1173    #[test]
1174    fn triple_quotes_span_lines_and_close_only_on_three() {
1175        let b = blank_python("s = \"\"\"\ntext \" and \"\" inside\nbreakpoint()\n\"\"\"\nx = 1\n");
1176        assert!(!call_of(&b, "breakpoint"));
1177        let b2 = blank_python("s = \"\"\"doc\"\"\"\nbreakpoint()\n");
1178        assert!(call_of(&b2, "breakpoint"));
1179    }
1180
1181    #[test]
1182    fn blanking_preserves_length_and_lines() {
1183        let src = "# c\ns = f\"{x} y\"\nt = '''a\nb'''\n";
1184        let out = blank_python(src);
1185        assert_eq!(out.len(), src.len());
1186        assert_eq!(out.lines().count(), src.lines().count());
1187    }
1188}
1189
1190#[cfg(test)]
1191mod template_substitutions {
1192    use super::*;
1193
1194    /// `${…}` inside a template literal is CODE, not string content. Blanking
1195    /// it hid every banned call written in a substitution — a MISSED warning,
1196    /// which is the direction this hook must never fail in.
1197    #[test]
1198    fn a_substitution_is_code() {
1199        let b = blank_non_code("const s = `${fit(1)}`;");
1200        assert!(call_of(&b, "fit"), "blanked to {b:?}");
1201    }
1202
1203    /// A stack, not a flag: substitutions nest. Before the fix this case
1204    /// reported correctly by ACCIDENT — the scanner lost track and left the
1205    /// inner text unblanked, which happened to expose the call.
1206    #[test]
1207    fn substitutions_nest() {
1208        let b = blank_non_code("const s = `${`${fit(1)}`}`;");
1209        assert!(call_of(&b, "fit"), "blanked to {b:?}");
1210        assert!(
1211            b.contains("${`${fit(1)}`}"),
1212            "nesting must be tracked, not merely survived: {b:?}"
1213        );
1214    }
1215
1216    /// Braces INSIDE the substitution must not close it early.
1217    #[test]
1218    fn braces_inside_a_substitution_do_not_close_it() {
1219        let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
1220        assert!(
1221            !call_of(&b, "fit"),
1222            "the string literal must stay blanked: {b:?}"
1223        );
1224        let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
1225        assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
1226
1227        // The discriminating case for DEPTH COUNTING. Treating the first `}` as
1228        // the end of the substitution puts the rest of it back into template
1229        // text, so the call after the object literal is blanked and missed.
1230        // Popping unconditionally passes every other case here.
1231        let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
1232        assert!(
1233            call_of(&b3, "fit"),
1234            "a `}}` closing a nested object must not end the substitution: {b3:?}"
1235        );
1236    }
1237
1238    /// Template TEXT is still string content, and an escaped `\${` is text too.
1239    #[test]
1240    fn template_text_is_still_blanked() {
1241        assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
1242        assert!(
1243            !call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
1244            "an escaped dollar does not open a substitution"
1245        );
1246    }
1247
1248    /// Comments, strings and regexes inside a substitution are handled by the
1249    /// ordinary code path, so they must still be blanked there.
1250    #[test]
1251    fn nested_constructs_inside_a_substitution() {
1252        assert!(!call_of(
1253            &blank_non_code("const s = `${/* fit(1) */ x}`;"),
1254            "fit"
1255        ));
1256        assert!(!call_of(
1257            &blank_non_code(r#"const s = `${"fit("}`;"#),
1258            "fit"
1259        ));
1260        assert!(!call_of(
1261            &blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
1262            "fit"
1263        ));
1264    }
1265
1266    /// An unbalanced `}` in ordinary code must not be mistaken for the end of a
1267    /// substitution that was never opened.
1268    #[test]
1269    fn a_stray_brace_in_code_is_harmless() {
1270        let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
1271        assert!(call_of(&b, "fit"), "blanked to {b:?}");
1272    }
1273}