1use crate::check::Outcome;
19use crate::git;
20use crate::ui::{error_sign, highlight, valid_sign};
21
22pub const EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue", ".rs", ".py"];
26
27const JS_LIKE: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
28const RUST: &[&str] = &[".rs"];
29const PYTHON: &[&str] = &[".py"];
30
31struct Term {
34 label: &'static str,
35 prefilter: &'static str,
36 exts: &'static [&'static str],
37 blank: fn(&str) -> String,
38 matches: fn(&str) -> bool,
39}
40
41fn is_ident(c: char) -> bool {
42 c.is_alphanumeric() || c == '_' || c == '$'
43}
44
45fn preceded_ok(src: &str, at: usize) -> bool {
49 src[..at]
50 .chars()
51 .next_back()
52 .map(|c| !(is_ident(c) || c == '.'))
53 .unwrap_or(true)
54}
55
56fn call_of(src: &str, word: &str) -> bool {
58 let mut from = 0;
59 while let Some(i) = src[from..].find(word) {
60 let at = from + i;
61 let after = at + word.len();
62 if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
63 return true;
64 }
65 from = at + word.len();
66 }
67 false
68}
69
70fn bare_debugger(src: &str) -> bool {
73 let word = "debugger";
74 let mut from = 0;
75 while let Some(i) = src[from..].find(word) {
76 let at = from + i;
77 let after = at + word.len();
78 let next_ok = src[after..]
79 .chars()
80 .next()
81 .map(|c| !is_ident(c))
82 .unwrap_or(true);
83 if preceded_ok(src, at) && next_ok {
84 return true;
85 }
86 from = at + word.len();
87 }
88 false
89}
90
91fn focused_suite(src: &str) -> bool {
98 for head in ["describe", "context", "it"] {
99 for tail in ["skip", "only"] {
100 let needle = format!("{head}.{tail}");
101 let mut from = 0;
102 while let Some(i) = src[from..].find(&needle) {
103 let at = from + i;
104 let after = at + needle.len();
105 let before_ok = src[..at]
106 .chars()
107 .next_back()
108 .map(|c| !is_ident(c))
109 .unwrap_or(true);
110 let after_ok = src[after..]
111 .chars()
112 .next()
113 .map(|c| !is_ident(c))
114 .unwrap_or(true);
115 if before_ok && after_ok {
116 return true;
117 }
118 from = at + needle.len();
119 }
120 }
121 }
122 false
123}
124
125fn rust_dbg(src: &str) -> bool {
129 let word = "dbg";
130 let mut from = 0;
131 while let Some(i) = src[from..].find(word) {
132 let at = from + i;
133 let after = at + word.len();
134 let before_ok = src[..at]
135 .chars()
136 .next_back()
137 .map(|c| !is_ident(c))
138 .unwrap_or(true);
139 let rest = &src[after..];
140 if before_ok
141 && rest.starts_with('!')
142 && matches!(rest[1..].trim_start().chars().next(), Some('(' | '[' | '{'))
143 {
144 return true;
145 }
146 from = after;
147 }
148 false
149}
150
151fn pdb_set_trace(src: &str) -> bool {
157 for needle in ["pdb.set_trace", "ipdb.set_trace"] {
158 let mut from = 0;
159 while let Some(i) = src[from..].find(needle) {
160 let at = from + i;
161 let after = at + needle.len();
162 let before_ok = src[..at]
163 .chars()
164 .next_back()
165 .map(|c| !is_ident(c))
166 .unwrap_or(true);
167 if before_ok && src[after..].trim_start().starts_with('(') {
168 return true;
169 }
170 from = at + needle.len();
171 }
172 }
173 false
174}
175
176const TERMS: [Term; 7] = [
177 Term {
178 label: "fit",
179 prefilter: r"\s*fit\(",
180 exts: JS_LIKE,
181 blank: blank_non_code,
182 matches: |s| call_of(s, "fit"),
183 },
184 Term {
185 label: "fdescribe",
186 prefilter: r"\s*fdescribe\(",
187 exts: JS_LIKE,
188 blank: blank_non_code,
189 matches: |s| call_of(s, "fdescribe"),
190 },
191 Term {
192 label: "debugger",
193 prefilter: "debugger;?",
194 exts: JS_LIKE,
195 blank: blank_non_code,
196 matches: bare_debugger,
197 },
198 Term {
199 label: "skipOnly",
200 prefilter: r"(describe|context|it)\.(skip|only)",
201 exts: JS_LIKE,
202 blank: blank_non_code,
203 matches: focused_suite,
204 },
205 Term {
206 label: "dbg!",
207 prefilter: "dbg!",
208 exts: RUST,
209 blank: blank_rust,
210 matches: rust_dbg,
211 },
212 Term {
213 label: "breakpoint",
214 prefilter: "breakpoint",
215 exts: PYTHON,
216 blank: blank_python,
217 matches: |s| call_of(s, "breakpoint"),
218 },
219 Term {
220 label: "set_trace",
221 prefilter: "set_trace",
222 exts: PYTHON,
223 blank: blank_python,
224 matches: pdb_set_trace,
225 },
226];
227
228#[derive(Clone, Copy, PartialEq)]
229enum S {
230 Code,
231 Line,
232 Block,
233 Single,
234 Double,
235 Template,
236 Regex,
237}
238
239const REGEX_KEYWORDS: [&str; 13] = [
241 "return",
242 "typeof",
243 "case",
244 "in",
245 "of",
246 "delete",
247 "void",
248 "instanceof",
249 "new",
250 "do",
251 "else",
252 "yield",
253 "await",
254];
255
256fn regex_can_start(prev: Option<char>, word: &str) -> bool {
259 match prev {
260 None => true,
262 Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
263 Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
265 Some(_) => false,
268 }
269}
270
271pub fn blank_non_code(src: &str) -> String {
290 let b: Vec<char> = src.chars().collect();
291 let mut out = String::with_capacity(src.len());
292 let mut state = S::Code;
293 let mut i = 0;
294 let mut prev_significant: Option<char> = None;
296 let mut word = String::new();
297 let mut in_class = false;
298 let mut subst: Vec<u32> = Vec::new();
302 let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
303
304 while i < b.len() {
305 let ch = b[i];
306 let next = b.get(i + 1).copied();
307 match state {
308 S::Code => {
309 if ch == '/' && next == Some('/') {
310 state = S::Line;
311 out.push_str(" ");
312 i += 2;
313 } else if ch == '/' && next == Some('*') {
314 state = S::Block;
315 out.push_str(" ");
316 i += 2;
317 } else if ch == '/' && regex_can_start(prev_significant, &word) {
318 state = S::Regex;
319 in_class = false;
320 out.push(ch);
321 i += 1;
322 } else if ch == '\'' || ch == '"' || ch == '`' {
323 state = match ch {
324 '\'' => S::Single,
325 '"' => S::Double,
326 _ => S::Template,
327 };
328 out.push(ch);
329 i += 1;
330 } else {
331 if !subst.is_empty() {
332 if ch == '{' {
333 *subst.last_mut().expect("non-empty") += 1;
334 } else if ch == '}' {
335 let depth = subst.last_mut().expect("non-empty");
336 if *depth == 0 {
337 subst.pop();
338 state = S::Template;
339 out.push(ch);
340 i += 1;
341 continue;
342 }
343 *depth -= 1;
344 }
345 }
346 if !ch.is_whitespace() {
347 prev_significant = Some(ch);
348 if ch.is_alphanumeric() || ch == '_' || ch == '$' {
349 word.push(ch);
350 } else {
351 word.clear();
352 }
353 }
354 out.push(ch);
355 i += 1;
356 }
357 }
358 S::Regex => {
359 if ch == '\\' {
361 out.push_str(if next.is_none() { " " } else { " " });
362 i += 2;
363 continue;
364 }
365 if ch == '[' {
366 in_class = true;
367 } else if ch == ']' {
368 in_class = false;
369 } else if ch == '/' && !in_class {
370 state = S::Code;
371 prev_significant = Some('/');
372 word.clear();
373 out.push(ch);
374 i += 1;
375 continue;
376 } else if ch == '\n' {
377 state = S::Code;
380 }
381 out.push(keep(ch));
382 i += 1;
383 }
384 S::Line => {
385 if ch == '\n' {
386 state = S::Code;
387 out.push(ch);
388 } else {
389 out.push(' ');
390 }
391 i += 1;
392 }
393 S::Block => {
394 if ch == '*' && next == Some('/') {
395 state = S::Code;
396 out.push_str(" ");
397 i += 2;
398 } else {
399 out.push(keep(ch));
400 i += 1;
401 }
402 }
403 S::Template => {
404 if ch == '\\' {
405 out.push_str(if next.is_none() { " " } else { " " });
406 i += 2;
407 continue;
408 }
409 if ch == '$' && next == Some('{') {
414 subst.push(0);
415 state = S::Code;
416 prev_significant = Some('{');
417 word.clear();
418 out.push_str("${");
419 i += 2;
420 continue;
421 }
422 if ch == '`' {
423 state = S::Code;
424 prev_significant = Some('`');
425 word.clear();
426 out.push(ch);
427 i += 1;
428 continue;
429 }
430 out.push(keep(ch));
431 i += 1;
432 }
433 _ => {
434 if ch == '\\' {
435 out.push_str(if next.is_none() { " " } else { " " });
437 i += 2;
438 continue;
439 }
440 let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
441 if closes {
442 state = S::Code;
443 out.push(ch);
444 } else {
445 out.push(keep(ch));
446 }
447 i += 1;
448 }
449 }
450 }
451 out
452}
453
454fn is_raw_prefix(word: &str) -> bool {
458 matches!(word, "r" | "br" | "cr")
459}
460
461#[derive(Clone, Copy, PartialEq)]
462enum R {
463 Code,
464 Line,
465 Block(u32),
466 Str,
467 Raw(u32),
468}
469
470pub fn blank_rust(src: &str) -> String {
486 let b: Vec<char> = src.chars().collect();
487 let mut out = String::with_capacity(src.len());
488 let mut state = R::Code;
489 let mut word = String::new();
490 let mut i = 0;
491 let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
492
493 while i < b.len() {
494 let ch = b[i];
495 let next = b.get(i + 1).copied();
496 match state {
497 R::Code => {
498 if ch == '/' && next == Some('/') {
499 state = R::Line;
500 out.push_str(" ");
501 i += 2;
502 } else if ch == '/' && next == Some('*') {
503 state = R::Block(1);
504 out.push_str(" ");
505 i += 2;
506 } else if ch == '"' {
507 state = if is_raw_prefix(&word) {
508 R::Raw(0)
509 } else {
510 R::Str
511 };
512 word.clear();
513 out.push(ch);
514 i += 1;
515 } else if ch == '#' && is_raw_prefix(&word) {
516 let mut n = 0;
520 while b.get(i + n) == Some(&'#') {
521 n += 1;
522 }
523 if b.get(i + n) == Some(&'"') {
524 for _ in 0..n {
525 out.push('#');
526 }
527 out.push('"');
528 state = R::Raw(n as u32);
529 i += n + 1;
530 } else {
531 out.push(ch);
532 i += 1;
533 }
534 word.clear();
535 } else if ch == '\'' {
536 let char_end = match next {
542 Some('\\') => (i + 3..(i + 14).min(b.len())).find(|&j| b[j] == '\''),
543 Some(c) if c != '\'' => {
544 if b.get(i + 2) == Some(&'\'') {
545 Some(i + 2)
546 } else {
547 None
548 }
549 }
550 _ => None,
551 };
552 match char_end {
553 Some(j) => {
554 out.push('\'');
555 for c in &b[i + 1..j] {
556 out.push(keep(*c));
557 }
558 out.push('\'');
559 i = j + 1;
560 }
561 None => {
562 out.push('\'');
564 i += 1;
565 }
566 }
567 word.clear();
568 } else {
569 if ch.is_alphanumeric() || ch == '_' {
570 word.push(ch);
571 } else {
572 word.clear();
573 }
574 out.push(ch);
575 i += 1;
576 }
577 }
578 R::Line => {
579 if ch == '\n' {
580 state = R::Code;
581 out.push(ch);
582 } else {
583 out.push(' ');
584 }
585 i += 1;
586 }
587 R::Block(depth) => {
588 if ch == '/' && next == Some('*') {
589 state = R::Block(depth + 1);
590 out.push_str(" ");
591 i += 2;
592 } else if ch == '*' && next == Some('/') {
593 state = if depth == 1 {
594 R::Code
595 } else {
596 R::Block(depth - 1)
597 };
598 out.push_str(" ");
599 i += 2;
600 } else {
601 out.push(keep(ch));
602 i += 1;
603 }
604 }
605 R::Str => {
606 if ch == '\\' {
607 out.push(' ');
611 if let Some(n) = next {
612 out.push(keep(n));
613 }
614 i += 2;
615 } else if ch == '"' {
616 state = R::Code;
617 out.push(ch);
618 i += 1;
619 } else {
620 out.push(keep(ch));
621 i += 1;
622 }
623 }
624 R::Raw(hashes) => {
625 let n = hashes as usize;
626 if ch == '"' && (1..=n).all(|k| b.get(i + k) == Some(&'#')) {
627 out.push('"');
628 for _ in 0..n {
629 out.push('#');
630 }
631 state = R::Code;
632 i += n + 1;
633 } else {
634 out.push(keep(ch));
635 i += 1;
636 }
637 }
638 }
639 }
640 out
641}
642
643#[derive(Clone, Copy)]
645struct PyLit {
646 quote: char,
647 triple: bool,
648 fstr: bool,
649}
650
651#[derive(Clone, Copy)]
652enum P {
653 Code,
654 Comment,
655 Lit(PyLit),
656}
657
658pub fn blank_python(src: &str) -> String {
675 let b: Vec<char> = src.chars().collect();
676 let mut out = String::with_capacity(src.len());
677 let mut state = P::Code;
678 let mut word = String::new();
679 let mut i = 0;
680 let mut subst: Vec<(u32, PyLit)> = Vec::new();
683 let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
684
685 while i < b.len() {
686 let ch = b[i];
687 let next = b.get(i + 1).copied();
688 match state {
689 P::Code => {
690 if ch == '#' {
691 state = P::Comment;
692 out.push(' ');
693 i += 1;
694 } else if ch == '\'' || ch == '"' {
695 let prefix = !word.is_empty()
696 && word.len() <= 2
697 && word.chars().all(|c| "rbfuRBFU".contains(c));
698 let fstr = prefix && word.to_ascii_lowercase().contains('f');
699 let triple = next == Some(ch) && b.get(i + 2) == Some(&ch);
700 state = P::Lit(PyLit {
701 quote: ch,
702 triple,
703 fstr,
704 });
705 word.clear();
706 if triple {
707 out.push(ch);
708 out.push(ch);
709 out.push(ch);
710 i += 3;
711 } else {
712 out.push(ch);
713 i += 1;
714 }
715 } else {
716 if !subst.is_empty() {
717 if ch == '{' {
718 subst.last_mut().expect("non-empty").0 += 1;
719 } else if ch == '}' {
720 let (depth, lit) = *subst.last().expect("non-empty");
721 if depth == 0 {
722 subst.pop();
723 state = P::Lit(lit);
724 out.push(ch);
725 i += 1;
726 continue;
727 }
728 subst.last_mut().expect("non-empty").0 -= 1;
729 }
730 }
731 if ch.is_alphanumeric() || ch == '_' {
732 word.push(ch);
733 } else {
734 word.clear();
735 }
736 out.push(ch);
737 i += 1;
738 }
739 }
740 P::Comment => {
741 if ch == '\n' {
742 state = P::Code;
743 out.push(ch);
744 } else {
745 out.push(' ');
746 }
747 i += 1;
748 }
749 P::Lit(lit) => {
750 if ch == '\\' {
751 out.push(' ');
752 if let Some(n) = next {
753 out.push(keep(n));
754 }
755 i += 2;
756 } else if lit.triple
757 && ch == lit.quote
758 && next == Some(lit.quote)
759 && b.get(i + 2) == Some(&lit.quote)
760 {
761 state = P::Code;
762 out.push(ch);
763 out.push(ch);
764 out.push(ch);
765 i += 3;
766 } else if !lit.triple && ch == lit.quote {
767 state = P::Code;
768 out.push(ch);
769 i += 1;
770 } else if !lit.triple && ch == '\n' {
771 state = P::Code;
773 out.push(ch);
774 i += 1;
775 } else if lit.fstr && ch == '{' && next == Some('{') {
776 out.push_str(" ");
777 i += 2;
778 } else if lit.fstr && ch == '{' {
779 subst.push((0, lit));
780 state = P::Code;
781 word.clear();
782 out.push(ch);
783 i += 1;
784 } else if lit.fstr && ch == '}' && next == Some('}') {
785 out.push_str(" ");
786 i += 2;
787 } else {
788 out.push(keep(ch));
789 i += 1;
790 }
791 }
792 }
793 }
794 out
795}
796
797fn is_searchable(file: &str, exts: &[&str]) -> bool {
798 let f = file.rsplit('/').next().unwrap_or(file);
799 exts.iter().any(|e| f.ends_with(e))
800}
801
802pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
803 let stem_matches_self = |file: &str| {
814 let base = file.rsplit('/').next().unwrap_or(file);
815 let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
816 stem == hook_name
817 };
818
819 let mut found_any = false;
820 for term in &TERMS {
821 let arg = format!("-G{}", term.prefilter);
822 let Some(out) =
823 git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
824 else {
825 continue;
826 };
827 let matches: Vec<&str> = out
828 .iter()
829 .map(String::as_str)
830 .filter(|f| is_searchable(f, term.exts))
831 .filter(|f| !stem_matches_self(f))
832 .filter(|file| {
833 match git::stdout(&["show", &format!(":{file}")]) {
834 None => true,
838 Some(content) => (term.matches)(&(term.blank)(&content)),
839 }
840 })
841 .collect();
842
843 if !matches.is_empty() {
844 if !found_any {
845 crate::say!(" {} Unwanted terms found", error_sign().trim());
846 }
847 found_any = true;
848 crate::say!(
849 " The following files contains '{}' in them:",
850 highlight(term.label)
851 );
852 for m in matches {
853 crate::say!(" - {}", highlight(m));
854 }
855 }
856 }
857 if found_any {
858 return Outcome::Failed;
859 }
860 crate::say!(" {} No unwanted terms were found", valid_sign().trim());
861 Outcome::Passed
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn catches_the_banned_forms() {
870 assert!(call_of("fit('x', () => {})", "fit"));
871 assert!(call_of(" fit (", "fit"));
872 assert!(call_of("fdescribe('x')", "fdescribe"));
873 assert!(bare_debugger(" debugger;"));
874 assert!(bare_debugger("debugger"));
875 assert!(focused_suite("describe.skip('x')"));
876 assert!(focused_suite("it.only('x')"));
877 assert!(focused_suite("context.skip('x')"));
878 }
879
880 #[test]
882 fn leaves_lookalikes_alone() {
883 assert!(!call_of("profit(", "fit")); assert!(!call_of("layout.fit(", "fit")); assert!(!bare_debugger("debuggerish")); assert!(!bare_debugger("x.debugger")); assert!(!focused_suite("describe.skipIf(cond)")); assert!(!focused_suite("it.onlyWhen(x)"));
889 }
890
891 #[test]
892 fn blanks_comments_and_strings_keeping_layout() {
893 let src = "a\n// debugger;\nb";
894 let out = blank_non_code(src);
895 assert_eq!(out.len(), src.len(), "length must be preserved");
896 assert_eq!(out.lines().count(), src.lines().count());
897 assert!(!bare_debugger(&out), "a term in a comment is discussion");
898
899 assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
900 assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
901 assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
902 }
903
904 #[test]
905 fn an_escape_never_closes_a_string() {
906 let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
908 assert!(
909 bare_debugger(&out),
910 "real code after the string must survive"
911 );
912 assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
913 }
914
915 #[test]
926 fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
927 for src in [
928 r"const re = /a\//; debugger;",
929 r"const re = /\//; debugger;",
930 ] {
931 assert!(
932 bare_debugger(&blank_non_code(src)),
933 "code after the regex must still be scanned: {src}"
934 );
935 }
936 assert!(!bare_debugger(&blank_non_code(
938 r"const re = /a\//; const ok = 1;"
939 )));
940 }
941
942 #[test]
945 fn terms_inside_a_regex_literal_are_not_violations() {
946 for src in [
947 r"const re = /it\.only/;",
948 r"if (x) { const r = /debugger/; }",
949 r"foo(/fdescribe\(/);",
950 r"return /describe\.skip/;",
951 r"const r = /[/]debugger/;", ] {
953 let b = blank_non_code(src);
954 assert!(!bare_debugger(&b), "false alarm: {src}");
955 assert!(!focused_suite(&b), "false alarm: {src}");
956 assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
957 }
958 }
959
960 #[test]
962 fn division_is_not_treated_as_a_regex() {
963 let src = "const x = a / b; debugger;";
964 assert!(bare_debugger(&blank_non_code(src)));
965 let src2 = "const x = (a + b) / c; debugger;";
966 assert!(bare_debugger(&blank_non_code(src2)));
967 }
968
969 #[test]
970 fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
971 let src = "const r = /oops
972debugger;";
973 assert!(bare_debugger(&blank_non_code(src)));
974 }
975
976 #[test]
977 fn blanking_still_preserves_length_and_lines() {
978 let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
979 let out = blank_non_code(src);
980 assert_eq!(out.len(), src.len());
981 assert_eq!(out.lines().count(), src.lines().count());
982 }
983
984 #[test]
985 fn each_term_searches_only_its_own_language() {
986 for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
987 assert!(is_searchable(f, JS_LIKE), "{f}");
988 }
989 for f in ["a.rs", "a.py", "a.md", "a.json", "README"] {
990 assert!(!is_searchable(f, JS_LIKE), "{f}");
991 }
992 assert!(is_searchable("src/lib.rs", RUST));
993 assert!(!is_searchable("a.ts", RUST));
994 assert!(is_searchable("app/main.py", PYTHON));
995 assert!(!is_searchable("a.pyi", PYTHON), "stubs never execute");
996 }
997
998 #[test]
1001 fn the_scope_is_the_union_of_the_terms() {
1002 let mut union: Vec<&str> = TERMS.iter().flat_map(|t| t.exts.iter().copied()).collect();
1003 union.sort_unstable();
1004 union.dedup();
1005 let mut declared: Vec<&str> = EXTS.to_vec();
1006 declared.sort_unstable();
1007 assert_eq!(union, declared);
1008 }
1009}
1010
1011#[cfg(test)]
1012mod rust_terms {
1013 use super::*;
1014
1015 #[test]
1016 fn catches_the_macro_call() {
1017 assert!(rust_dbg("dbg!(x)"));
1018 assert!(rust_dbg("let y = dbg! (x);"));
1019 assert!(rust_dbg("std::dbg!(x)"));
1020 assert!(rust_dbg("dbg![x]"));
1021 assert!(rust_dbg("dbg!{x}"));
1022 }
1023
1024 #[test]
1025 fn leaves_lookalikes_alone() {
1026 assert!(!rust_dbg("xdbg!(1)")); assert!(!rust_dbg("dbg(1)")); assert!(!rust_dbg("debug!(x)")); assert!(!rust_dbg("dbg")); }
1031
1032 #[test]
1033 fn comments_and_strings_are_discussion() {
1034 for src in [
1035 "// dbg!(x)\nlet a = 1;",
1036 "/* dbg!(x) */",
1037 "/// docs mentioning dbg!(x)\nfn f() {}",
1038 "let s = \"dbg!(x)\";",
1039 "let s = r\"dbg!(x)\";",
1040 "let s = r#\"dbg!(\"x\")\"#;",
1041 "let s = br\"dbg!(x)\";",
1042 ] {
1043 assert!(!rust_dbg(&blank_rust(src)), "false alarm: {src}");
1044 }
1045 }
1046
1047 #[test]
1050 fn block_comments_nest() {
1051 assert!(!rust_dbg(&blank_rust("/* /* x */ dbg!(1) */")));
1052 assert!(rust_dbg(&blank_rust("/* /* x */ */ dbg!(1)")));
1053 }
1054
1055 #[test]
1058 fn raw_string_hashes_are_honoured() {
1059 assert!(!rust_dbg(&blank_rust("let s = r#\"a\"b\"#;")));
1060 assert!(rust_dbg(&blank_rust("let s = r#\"a\"b\"#; dbg!(1);")));
1061 assert!(!rust_dbg(&blank_rust("let s = r##\"a\"# dbg!(1) \"##;")));
1062 }
1063
1064 #[test]
1067 fn a_char_literal_holding_a_quote_does_not_open_a_string() {
1068 assert!(rust_dbg(&blank_rust("let c = '\"'; dbg!(1);")));
1069 assert!(rust_dbg(&blank_rust("let c = '\\''; dbg!(1);")));
1070 assert!(rust_dbg(&blank_rust("let c = '\\\\'; dbg!(1);")));
1071 assert!(rust_dbg(&blank_rust("let c = '\\u{7f}'; dbg!(1);")));
1072 }
1073
1074 #[test]
1077 fn a_lifetime_does_not_swallow_the_line() {
1078 assert!(rust_dbg(&blank_rust("fn f<'a>(x: &'a str) { dbg!(x); }")));
1079 assert!(rust_dbg(&blank_rust("let x: &'static str = s; dbg!(x);")));
1080 }
1081
1082 #[test]
1083 fn blanking_preserves_length_and_lines() {
1084 let src = "let s = r#\"a\"b\"#;\n// dbg!(x)\nlet c = 'y';\n";
1085 let out = blank_rust(src);
1086 assert_eq!(out.len(), src.len());
1087 assert_eq!(out.lines().count(), src.lines().count());
1088 }
1089
1090 #[test]
1094 fn the_hooks_own_source_survives_its_own_scan() {
1095 assert!(!rust_dbg(&blank_rust(include_str!("ban_terms.rs"))));
1096 }
1097}
1098
1099#[cfg(test)]
1100mod python_terms {
1101 use super::*;
1102
1103 #[test]
1104 fn catches_the_debug_calls() {
1105 assert!(call_of("breakpoint()", "breakpoint"));
1106 assert!(call_of(" breakpoint ()", "breakpoint"));
1107 assert!(pdb_set_trace("pdb.set_trace()"));
1108 assert!(pdb_set_trace("ipdb.set_trace()"));
1109 assert!(pdb_set_trace("x.pdb.set_trace()"));
1110 }
1111
1112 #[test]
1113 fn leaves_lookalikes_alone() {
1114 assert!(!call_of("self.breakpoint()", "breakpoint")); assert!(!call_of("my_breakpoint()", "breakpoint"));
1116 assert!(!pdb_set_trace("xpdb.set_trace()")); assert!(!pdb_set_trace("set_trace()")); assert!(!pdb_set_trace("pdb.set_trace")); }
1120
1121 #[test]
1122 fn comments_and_strings_are_discussion() {
1123 for src in [
1124 "# breakpoint()\nx = 1\n",
1125 "s = 'breakpoint()'\n",
1126 "s = \"pdb.set_trace()\"\n",
1127 "def f():\n \"\"\"calls breakpoint() eventually\"\"\"\n",
1128 "s = '''ipdb.set_trace()'''\n",
1129 "s = f\"breakpoint( {x}\"\n", "s = f\"{{breakpoint()}}\"\n", ] {
1132 let b = blank_python(src);
1133 assert!(!call_of(&b, "breakpoint"), "false alarm: {src}");
1134 assert!(!pdb_set_trace(&b), "false alarm: {src}");
1135 }
1136 }
1137
1138 #[test]
1141 fn an_interpolation_is_code() {
1142 assert!(call_of(
1143 &blank_python("s = f\"{breakpoint()}\"\n"),
1144 "breakpoint"
1145 ));
1146 assert!(call_of(
1147 &blank_python("s = f\"{f'{breakpoint()}'}\"\n"),
1148 "breakpoint"
1149 ));
1150 assert!(call_of(
1152 &blank_python("s = f\"{x:{w}} {breakpoint()}\"\n"),
1153 "breakpoint"
1154 ));
1155 }
1156
1157 #[test]
1160 fn a_raw_string_backslash_does_not_close_early() {
1161 let b = blank_python("s = r\"\\\"; breakpoint()\"\n");
1162 assert!(!call_of(&b, "breakpoint"));
1163 }
1164
1165 #[test]
1168 fn an_unterminated_string_does_not_blank_the_next_line() {
1169 let b = blank_python("s = 'oops\nbreakpoint()\n");
1170 assert!(call_of(&b, "breakpoint"));
1171 }
1172
1173 #[test]
1174 fn triple_quotes_span_lines_and_close_only_on_three() {
1175 let b = blank_python("s = \"\"\"\ntext \" and \"\" inside\nbreakpoint()\n\"\"\"\nx = 1\n");
1176 assert!(!call_of(&b, "breakpoint"));
1177 let b2 = blank_python("s = \"\"\"doc\"\"\"\nbreakpoint()\n");
1178 assert!(call_of(&b2, "breakpoint"));
1179 }
1180
1181 #[test]
1182 fn blanking_preserves_length_and_lines() {
1183 let src = "# c\ns = f\"{x} y\"\nt = '''a\nb'''\n";
1184 let out = blank_python(src);
1185 assert_eq!(out.len(), src.len());
1186 assert_eq!(out.lines().count(), src.lines().count());
1187 }
1188}
1189
1190#[cfg(test)]
1191mod template_substitutions {
1192 use super::*;
1193
1194 #[test]
1198 fn a_substitution_is_code() {
1199 let b = blank_non_code("const s = `${fit(1)}`;");
1200 assert!(call_of(&b, "fit"), "blanked to {b:?}");
1201 }
1202
1203 #[test]
1207 fn substitutions_nest() {
1208 let b = blank_non_code("const s = `${`${fit(1)}`}`;");
1209 assert!(call_of(&b, "fit"), "blanked to {b:?}");
1210 assert!(
1211 b.contains("${`${fit(1)}`}"),
1212 "nesting must be tracked, not merely survived: {b:?}"
1213 );
1214 }
1215
1216 #[test]
1218 fn braces_inside_a_substitution_do_not_close_it() {
1219 let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
1220 assert!(
1221 !call_of(&b, "fit"),
1222 "the string literal must stay blanked: {b:?}"
1223 );
1224 let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
1225 assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
1226
1227 let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
1232 assert!(
1233 call_of(&b3, "fit"),
1234 "a `}}` closing a nested object must not end the substitution: {b3:?}"
1235 );
1236 }
1237
1238 #[test]
1240 fn template_text_is_still_blanked() {
1241 assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
1242 assert!(
1243 !call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
1244 "an escaped dollar does not open a substitution"
1245 );
1246 }
1247
1248 #[test]
1251 fn nested_constructs_inside_a_substitution() {
1252 assert!(!call_of(
1253 &blank_non_code("const s = `${/* fit(1) */ x}`;"),
1254 "fit"
1255 ));
1256 assert!(!call_of(
1257 &blank_non_code(r#"const s = `${"fit("}`;"#),
1258 "fit"
1259 ));
1260 assert!(!call_of(
1261 &blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
1262 "fit"
1263 ));
1264 }
1265
1266 #[test]
1269 fn a_stray_brace_in_code_is_harmless() {
1270 let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
1271 assert!(call_of(&b, "fit"), "blanked to {b:?}");
1272 }
1273}