1use crate::check::{Outcome, Severity};
19use crate::finding::Finding;
20use crate::git;
21use crate::ui::{error_sign, highlight, valid_sign};
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(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
850 let mut candidates: Vec<String> = Vec::new();
856 for term in &TERMS {
857 let arg = format!("-G{}", term.prefilter);
858 let Some(out) =
859 git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
860 else {
861 continue;
862 };
863 for f in out {
864 if is_searchable(&f, term.exts) && !candidates.contains(&f) {
865 candidates.push(f);
866 }
867 }
868 }
869
870 let mut findings = Vec::new();
871 for file in &candidates {
872 match git::stdout(&["show", &format!(":{file}")]) {
873 None => findings.push(Finding::new(
877 NAME,
878 file,
879 Severity::Block,
880 "could not read the staged content to confirm a banned term",
881 )),
882 Some(content) => findings.extend(scan(file, &content, hook_name)),
883 }
884 }
885
886 if !findings.is_empty() {
887 crate::say!(" {} Unwanted terms found", error_sign().trim());
888 for f in &findings {
889 crate::say!(" {} — {}", highlight(&f.location()), f.message);
890 }
891 return Outcome::Failed;
892 }
893 crate::say!(" {} No unwanted terms were found", valid_sign().trim());
894 Outcome::Passed
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 #[test]
902 fn catches_the_banned_forms() {
903 assert!(call_of("fit('x', () => {})", "fit").is_some());
904 assert!(call_of(" fit (", "fit").is_some());
905 assert!(call_of("fdescribe('x')", "fdescribe").is_some());
906 assert!(bare_debugger(" debugger;").is_some());
907 assert!(bare_debugger("debugger").is_some());
908 assert!(focused_suite("describe.skip('x')").is_some());
909 assert!(focused_suite("it.only('x')").is_some());
910 assert!(focused_suite("context.skip('x')").is_some());
911 }
912
913 #[test]
915 fn leaves_lookalikes_alone() {
916 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());
922 }
923
924 #[test]
925 fn blanks_comments_and_strings_keeping_layout() {
926 let src = "a\n// debugger;\nb";
927 let out = blank_non_code(src);
928 assert_eq!(out.len(), src.len(), "length must be preserved");
929 assert_eq!(out.lines().count(), src.lines().count());
930 assert!(
931 bare_debugger(&out).is_none(),
932 "a term in a comment is discussion"
933 );
934
935 assert!(bare_debugger(&blank_non_code("const s = 'debugger';")).is_none());
936 assert!(bare_debugger(&blank_non_code("const s = `debugger`;")).is_none());
937 assert!(call_of(&blank_non_code("/* fit( */"), "fit").is_none());
938 }
939
940 #[test]
941 fn an_escape_never_closes_a_string() {
942 let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
944 assert!(
945 bare_debugger(&out).is_some(),
946 "real code after the string must survive"
947 );
948 assert!(call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit").is_none());
949 }
950
951 #[test]
962 fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
963 for src in [
964 r"const re = /a\//; debugger;",
965 r"const re = /\//; debugger;",
966 ] {
967 assert!(
968 bare_debugger(&blank_non_code(src)).is_some(),
969 "code after the regex must still be scanned: {src}"
970 );
971 }
972 assert!(bare_debugger(&blank_non_code(r"const re = /a\//; const ok = 1;")).is_none());
974 }
975
976 #[test]
979 fn terms_inside_a_regex_literal_are_not_violations() {
980 for src in [
981 r"const re = /it\.only/;",
982 r"if (x) { const r = /debugger/; }",
983 r"foo(/fdescribe\(/);",
984 r"return /describe\.skip/;",
985 r"const r = /[/]debugger/;", ] {
987 let b = blank_non_code(src);
988 assert!(bare_debugger(&b).is_none(), "false alarm: {src}");
989 assert!(focused_suite(&b).is_none(), "false alarm: {src}");
990 assert!(call_of(&b, "fdescribe").is_none(), "false alarm: {src}");
991 }
992 }
993
994 #[test]
996 fn division_is_not_treated_as_a_regex() {
997 let src = "const x = a / b; debugger;";
998 assert!(bare_debugger(&blank_non_code(src)).is_some());
999 let src2 = "const x = (a + b) / c; debugger;";
1000 assert!(bare_debugger(&blank_non_code(src2)).is_some());
1001 }
1002
1003 #[test]
1004 fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
1005 let src = "const r = /oops
1006debugger;";
1007 assert!(bare_debugger(&blank_non_code(src)).is_some());
1008 }
1009
1010 #[test]
1011 fn blanking_still_preserves_length_and_lines() {
1012 let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
1013 let out = blank_non_code(src);
1014 assert_eq!(out.len(), src.len());
1015 assert_eq!(out.lines().count(), src.lines().count());
1016 }
1017
1018 #[test]
1019 fn each_term_searches_only_its_own_language() {
1020 for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
1021 assert!(is_searchable(f, JS_LIKE), "{f}");
1022 }
1023 for f in ["a.rs", "a.py", "a.md", "a.json", "README"] {
1024 assert!(!is_searchable(f, JS_LIKE), "{f}");
1025 }
1026 assert!(is_searchable("src/lib.rs", RUST));
1027 assert!(!is_searchable("a.ts", RUST));
1028 assert!(is_searchable("app/main.py", PYTHON));
1029 assert!(!is_searchable("a.pyi", PYTHON), "stubs never execute");
1030 }
1031
1032 #[test]
1035 fn the_scope_is_the_union_of_the_terms() {
1036 let mut union: Vec<&str> = TERMS.iter().flat_map(|t| t.exts.iter().copied()).collect();
1037 union.sort_unstable();
1038 union.dedup();
1039 let mut declared: Vec<&str> = EXTS.to_vec();
1040 declared.sort_unstable();
1041 assert_eq!(union, declared);
1042 }
1043}
1044
1045#[cfg(test)]
1046mod rust_terms {
1047 use super::*;
1048
1049 #[test]
1050 fn catches_the_macro_call() {
1051 assert!(rust_dbg("dbg!(x)").is_some());
1052 assert!(rust_dbg("let y = dbg! (x);").is_some());
1053 assert!(rust_dbg("std::dbg!(x)").is_some());
1054 assert!(rust_dbg("dbg![x]").is_some());
1055 assert!(rust_dbg("dbg!{x}").is_some());
1056 }
1057
1058 #[test]
1059 fn leaves_lookalikes_alone() {
1060 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()); }
1065
1066 #[test]
1067 fn comments_and_strings_are_discussion() {
1068 for src in [
1069 "// dbg!(x)\nlet a = 1;",
1070 "/* dbg!(x) */",
1071 "/// docs mentioning dbg!(x)\nfn f() {}",
1072 "let s = \"dbg!(x)\";",
1073 "let s = r\"dbg!(x)\";",
1074 "let s = r#\"dbg!(\"x\")\"#;",
1075 "let s = br\"dbg!(x)\";",
1076 ] {
1077 assert!(rust_dbg(&blank_rust(src)).is_none(), "false alarm: {src}");
1078 }
1079 }
1080
1081 #[test]
1084 fn block_comments_nest() {
1085 assert!(rust_dbg(&blank_rust("/* /* x */ dbg!(1) */")).is_none());
1086 assert!(rust_dbg(&blank_rust("/* /* x */ */ dbg!(1)")).is_some());
1087 }
1088
1089 #[test]
1092 fn raw_string_hashes_are_honoured() {
1093 assert!(rust_dbg(&blank_rust("let s = r#\"a\"b\"#;")).is_none());
1094 assert!(rust_dbg(&blank_rust("let s = r#\"a\"b\"#; dbg!(1);")).is_some());
1095 assert!(rust_dbg(&blank_rust("let s = r##\"a\"# dbg!(1) \"##;")).is_none());
1096 }
1097
1098 #[test]
1101 fn a_char_literal_holding_a_quote_does_not_open_a_string() {
1102 assert!(rust_dbg(&blank_rust("let c = '\"'; dbg!(1);")).is_some());
1103 assert!(rust_dbg(&blank_rust("let c = '\\''; dbg!(1);")).is_some());
1104 assert!(rust_dbg(&blank_rust("let c = '\\\\'; dbg!(1);")).is_some());
1105 assert!(rust_dbg(&blank_rust("let c = '\\u{7f}'; dbg!(1);")).is_some());
1106 }
1107
1108 #[test]
1111 fn a_lifetime_does_not_swallow_the_line() {
1112 assert!(rust_dbg(&blank_rust("fn f<'a>(x: &'a str) { dbg!(x); }")).is_some());
1113 assert!(rust_dbg(&blank_rust("let x: &'static str = s; dbg!(x);")).is_some());
1114 }
1115
1116 #[test]
1117 fn blanking_preserves_length_and_lines() {
1118 let src = "let s = r#\"a\"b\"#;\n// dbg!(x)\nlet c = 'y';\n";
1119 let out = blank_rust(src);
1120 assert_eq!(out.len(), src.len());
1121 assert_eq!(out.lines().count(), src.lines().count());
1122 }
1123
1124 #[test]
1128 fn the_hooks_own_source_survives_its_own_scan() {
1129 assert!(rust_dbg(&blank_rust(include_str!("ban_terms.rs"))).is_none());
1130 }
1131}
1132
1133#[cfg(test)]
1134mod python_terms {
1135 use super::*;
1136
1137 #[test]
1138 fn catches_the_debug_calls() {
1139 assert!(call_of("breakpoint()", "breakpoint").is_some());
1140 assert!(call_of(" breakpoint ()", "breakpoint").is_some());
1141 assert!(pdb_set_trace("pdb.set_trace()").is_some());
1142 assert!(pdb_set_trace("ipdb.set_trace()").is_some());
1143 assert!(pdb_set_trace("x.pdb.set_trace()").is_some());
1144 }
1145
1146 #[test]
1147 fn leaves_lookalikes_alone() {
1148 assert!(call_of("self.breakpoint()", "breakpoint").is_none()); assert!(call_of("my_breakpoint()", "breakpoint").is_none());
1150 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()); }
1154
1155 #[test]
1156 fn comments_and_strings_are_discussion() {
1157 for src in [
1158 "# breakpoint()\nx = 1\n",
1159 "s = 'breakpoint()'\n",
1160 "s = \"pdb.set_trace()\"\n",
1161 "def f():\n \"\"\"calls breakpoint() eventually\"\"\"\n",
1162 "s = '''ipdb.set_trace()'''\n",
1163 "s = f\"breakpoint( {x}\"\n", "s = f\"{{breakpoint()}}\"\n", ] {
1166 let b = blank_python(src);
1167 assert!(call_of(&b, "breakpoint").is_none(), "false alarm: {src}");
1168 assert!(pdb_set_trace(&b).is_none(), "false alarm: {src}");
1169 }
1170 }
1171
1172 #[test]
1175 fn an_interpolation_is_code() {
1176 assert!(call_of(&blank_python("s = f\"{breakpoint()}\"\n"), "breakpoint").is_some());
1177 assert!(call_of(
1178 &blank_python("s = f\"{f'{breakpoint()}'}\"\n"),
1179 "breakpoint"
1180 )
1181 .is_some());
1182 assert!(call_of(
1184 &blank_python("s = f\"{x:{w}} {breakpoint()}\"\n"),
1185 "breakpoint"
1186 )
1187 .is_some());
1188 }
1189
1190 #[test]
1193 fn a_raw_string_backslash_does_not_close_early() {
1194 let b = blank_python("s = r\"\\\"; breakpoint()\"\n");
1195 assert!(call_of(&b, "breakpoint").is_none());
1196 }
1197
1198 #[test]
1201 fn an_unterminated_string_does_not_blank_the_next_line() {
1202 let b = blank_python("s = 'oops\nbreakpoint()\n");
1203 assert!(call_of(&b, "breakpoint").is_some());
1204 }
1205
1206 #[test]
1207 fn triple_quotes_span_lines_and_close_only_on_three() {
1208 let b = blank_python("s = \"\"\"\ntext \" and \"\" inside\nbreakpoint()\n\"\"\"\nx = 1\n");
1209 assert!(call_of(&b, "breakpoint").is_none());
1210 let b2 = blank_python("s = \"\"\"doc\"\"\"\nbreakpoint()\n");
1211 assert!(call_of(&b2, "breakpoint").is_some());
1212 }
1213
1214 #[test]
1215 fn blanking_preserves_length_and_lines() {
1216 let src = "# c\ns = f\"{x} y\"\nt = '''a\nb'''\n";
1217 let out = blank_python(src);
1218 assert_eq!(out.len(), src.len());
1219 assert_eq!(out.lines().count(), src.lines().count());
1220 }
1221}
1222
1223#[cfg(test)]
1224mod template_substitutions {
1225 use super::*;
1226
1227 #[test]
1231 fn a_substitution_is_code() {
1232 let b = blank_non_code("const s = `${fit(1)}`;");
1233 assert!(call_of(&b, "fit").is_some(), "blanked to {b:?}");
1234 }
1235
1236 #[test]
1240 fn substitutions_nest() {
1241 let b = blank_non_code("const s = `${`${fit(1)}`}`;");
1242 assert!(call_of(&b, "fit").is_some(), "blanked to {b:?}");
1243 assert!(
1244 b.contains("${`${fit(1)}`}"),
1245 "nesting must be tracked, not merely survived: {b:?}"
1246 );
1247 }
1248
1249 #[test]
1251 fn braces_inside_a_substitution_do_not_close_it() {
1252 let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
1253 assert!(
1254 call_of(&b, "fit").is_none(),
1255 "the string literal must stay blanked: {b:?}"
1256 );
1257 let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
1258 assert!(call_of(&b2, "fit").is_some(), "blanked to {b2:?}");
1259
1260 let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
1265 assert!(
1266 call_of(&b3, "fit").is_some(),
1267 "a `}}` closing a nested object must not end the substitution: {b3:?}"
1268 );
1269 }
1270
1271 #[test]
1273 fn template_text_is_still_blanked() {
1274 assert!(call_of(&blank_non_code("const s = `fit(`;"), "fit").is_none());
1275 assert!(
1276 call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit").is_none(),
1277 "an escaped dollar does not open a substitution"
1278 );
1279 }
1280
1281 #[test]
1284 fn nested_constructs_inside_a_substitution() {
1285 assert!(call_of(&blank_non_code("const s = `${/* fit(1) */ x}`;"), "fit").is_none());
1286 assert!(call_of(&blank_non_code(r#"const s = `${"fit("}`;"#), "fit").is_none());
1287 assert!(call_of(&blank_non_code(r"const s = `${/fit\(/.test(y)}`;"), "fit").is_none());
1288 }
1289
1290 #[test]
1293 fn a_stray_brace_in_code_is_harmless() {
1294 let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
1295 assert!(call_of(&b, "fit").is_some(), "blanked to {b:?}");
1296 }
1297}