1use crate::check::{Outcome, Severity};
19use crate::finding::Finding;
20use crate::git;
21use crate::ui::{error_sign, highlight};
22
23pub 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
32struct Term {
35 label: &'static str,
36 prefilter: &'static str,
37 exts: &'static [&'static str],
38 blank: fn(&str) -> String,
39 locate: fn(&str) -> Option<usize>,
44}
45
46fn is_ident(c: char) -> bool {
47 c.is_alphanumeric() || c == '_' || c == '$'
48}
49
50fn 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
61fn 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
75fn 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
96fn 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
130fn 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
156fn 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
244const 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
261fn regex_can_start(prev: Option<char>, word: &str) -> bool {
264 match prev {
265 None => true,
267 Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
268 Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
270 Some(_) => false,
273 }
274}
275
276pub 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 let mut prev_significant: Option<char> = None;
301 let mut word = String::new();
302 let mut in_class = false;
303 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 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 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 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 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
459fn 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
475pub 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 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 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 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 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#[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
663pub 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 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 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
807pub const NAME: &str = "ban-terms";
809
810pub 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 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 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 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 #[test]
919 fn leaves_lookalikes_alone() {
920 assert!(call_of("profit(", "fit").is_none()); assert!(call_of("layout.fit(", "fit").is_none()); assert!(bare_debugger("debuggerish").is_none()); assert!(bare_debugger("x.debugger").is_none()); assert!(focused_suite("describe.skipIf(cond)").is_none()); 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 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 #[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 assert!(bare_debugger(&blank_non_code(r"const re = /a\//; const ok = 1;")).is_none());
978 }
979
980 #[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/;", ] {
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 #[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 #[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()); assert!(rust_dbg("dbg(1)").is_none()); assert!(rust_dbg("debug!(x)").is_none()); assert!(rust_dbg("dbg").is_none()); }
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 #[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 #[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 #[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 #[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 #[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()); assert!(call_of("my_breakpoint()", "breakpoint").is_none());
1154 assert!(pdb_set_trace("xpdb.set_trace()").is_none()); assert!(pdb_set_trace("set_trace()").is_none()); assert!(pdb_set_trace("pdb.set_trace").is_none()); }
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", "s = f\"{{breakpoint()}}\"\n", ] {
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 #[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 assert!(call_of(
1188 &blank_python("s = f\"{x:{w}} {breakpoint()}\"\n"),
1189 "breakpoint"
1190 )
1191 .is_some());
1192 }
1193
1194 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}