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