use crate::check::Outcome;
use crate::git;
use crate::ui::{error_sign, highlight, valid_sign};
pub const EXTS: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue", ".rs", ".py"];
const JS_LIKE: &[&str] = &[".js", ".jsx", ".ts", ".tsx", ".vue"];
const RUST: &[&str] = &[".rs"];
const PYTHON: &[&str] = &[".py"];
struct Term {
label: &'static str,
prefilter: &'static str,
exts: &'static [&'static str],
blank: fn(&str) -> String,
matches: fn(&str) -> bool,
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
fn preceded_ok(src: &str, at: usize) -> bool {
src[..at]
.chars()
.next_back()
.map(|c| !(is_ident(c) || c == '.'))
.unwrap_or(true)
}
fn call_of(src: &str, word: &str) -> bool {
let mut from = 0;
while let Some(i) = src[from..].find(word) {
let at = from + i;
let after = at + word.len();
if preceded_ok(src, at) && src[after..].trim_start().starts_with('(') {
return true;
}
from = at + word.len();
}
false
}
fn bare_debugger(src: &str) -> bool {
let word = "debugger";
let mut from = 0;
while let Some(i) = src[from..].find(word) {
let at = from + i;
let after = at + word.len();
let next_ok = src[after..]
.chars()
.next()
.map(|c| !is_ident(c))
.unwrap_or(true);
if preceded_ok(src, at) && next_ok {
return true;
}
from = at + word.len();
}
false
}
fn focused_suite(src: &str) -> bool {
for head in ["describe", "context", "it"] {
for tail in ["skip", "only"] {
let needle = format!("{head}.{tail}");
let mut from = 0;
while let Some(i) = src[from..].find(&needle) {
let at = from + i;
let after = at + needle.len();
let before_ok = src[..at]
.chars()
.next_back()
.map(|c| !is_ident(c))
.unwrap_or(true);
let after_ok = src[after..]
.chars()
.next()
.map(|c| !is_ident(c))
.unwrap_or(true);
if before_ok && after_ok {
return true;
}
from = at + needle.len();
}
}
}
false
}
fn rust_dbg(src: &str) -> bool {
let word = "dbg";
let mut from = 0;
while let Some(i) = src[from..].find(word) {
let at = from + i;
let after = at + word.len();
let before_ok = src[..at]
.chars()
.next_back()
.map(|c| !is_ident(c))
.unwrap_or(true);
let rest = &src[after..];
if before_ok
&& rest.starts_with('!')
&& matches!(rest[1..].trim_start().chars().next(), Some('(' | '[' | '{'))
{
return true;
}
from = after;
}
false
}
fn pdb_set_trace(src: &str) -> bool {
for needle in ["pdb.set_trace", "ipdb.set_trace"] {
let mut from = 0;
while let Some(i) = src[from..].find(needle) {
let at = from + i;
let after = at + needle.len();
let before_ok = src[..at]
.chars()
.next_back()
.map(|c| !is_ident(c))
.unwrap_or(true);
if before_ok && src[after..].trim_start().starts_with('(') {
return true;
}
from = at + needle.len();
}
}
false
}
const TERMS: [Term; 7] = [
Term {
label: "fit",
prefilter: r"\s*fit\(",
exts: JS_LIKE,
blank: blank_non_code,
matches: |s| call_of(s, "fit"),
},
Term {
label: "fdescribe",
prefilter: r"\s*fdescribe\(",
exts: JS_LIKE,
blank: blank_non_code,
matches: |s| call_of(s, "fdescribe"),
},
Term {
label: "debugger",
prefilter: "debugger;?",
exts: JS_LIKE,
blank: blank_non_code,
matches: bare_debugger,
},
Term {
label: "skipOnly",
prefilter: r"(describe|context|it)\.(skip|only)",
exts: JS_LIKE,
blank: blank_non_code,
matches: focused_suite,
},
Term {
label: "dbg!",
prefilter: "dbg!",
exts: RUST,
blank: blank_rust,
matches: rust_dbg,
},
Term {
label: "breakpoint",
prefilter: "breakpoint",
exts: PYTHON,
blank: blank_python,
matches: |s| call_of(s, "breakpoint"),
},
Term {
label: "set_trace",
prefilter: "set_trace",
exts: PYTHON,
blank: blank_python,
matches: pdb_set_trace,
},
];
#[derive(Clone, Copy, PartialEq)]
enum S {
Code,
Line,
Block,
Single,
Double,
Template,
Regex,
}
const REGEX_KEYWORDS: [&str; 13] = [
"return",
"typeof",
"case",
"in",
"of",
"delete",
"void",
"instanceof",
"new",
"do",
"else",
"yield",
"await",
];
fn regex_can_start(prev: Option<char>, word: &str) -> bool {
match prev {
None => true,
Some(c) if "(,=:[!&|?{};+-*%~^<>".contains(c) => true,
Some(c) if c.is_alphanumeric() || c == '_' || c == '$' => REGEX_KEYWORDS.contains(&word),
Some(_) => false,
}
}
pub fn blank_non_code(src: &str) -> String {
let b: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut state = S::Code;
let mut i = 0;
let mut prev_significant: Option<char> = None;
let mut word = String::new();
let mut in_class = false;
let mut subst: Vec<u32> = Vec::new();
let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
while i < b.len() {
let ch = b[i];
let next = b.get(i + 1).copied();
match state {
S::Code => {
if ch == '/' && next == Some('/') {
state = S::Line;
out.push_str(" ");
i += 2;
} else if ch == '/' && next == Some('*') {
state = S::Block;
out.push_str(" ");
i += 2;
} else if ch == '/' && regex_can_start(prev_significant, &word) {
state = S::Regex;
in_class = false;
out.push(ch);
i += 1;
} else if ch == '\'' || ch == '"' || ch == '`' {
state = match ch {
'\'' => S::Single,
'"' => S::Double,
_ => S::Template,
};
out.push(ch);
i += 1;
} else {
if !subst.is_empty() {
if ch == '{' {
*subst.last_mut().expect("non-empty") += 1;
} else if ch == '}' {
let depth = subst.last_mut().expect("non-empty");
if *depth == 0 {
subst.pop();
state = S::Template;
out.push(ch);
i += 1;
continue;
}
*depth -= 1;
}
}
if !ch.is_whitespace() {
prev_significant = Some(ch);
if ch.is_alphanumeric() || ch == '_' || ch == '$' {
word.push(ch);
} else {
word.clear();
}
}
out.push(ch);
i += 1;
}
}
S::Regex => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
if ch == '[' {
in_class = true;
} else if ch == ']' {
in_class = false;
} else if ch == '/' && !in_class {
state = S::Code;
prev_significant = Some('/');
word.clear();
out.push(ch);
i += 1;
continue;
} else if ch == '\n' {
state = S::Code;
}
out.push(keep(ch));
i += 1;
}
S::Line => {
if ch == '\n' {
state = S::Code;
out.push(ch);
} else {
out.push(' ');
}
i += 1;
}
S::Block => {
if ch == '*' && next == Some('/') {
state = S::Code;
out.push_str(" ");
i += 2;
} else {
out.push(keep(ch));
i += 1;
}
}
S::Template => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
if ch == '$' && next == Some('{') {
subst.push(0);
state = S::Code;
prev_significant = Some('{');
word.clear();
out.push_str("${");
i += 2;
continue;
}
if ch == '`' {
state = S::Code;
prev_significant = Some('`');
word.clear();
out.push(ch);
i += 1;
continue;
}
out.push(keep(ch));
i += 1;
}
_ => {
if ch == '\\' {
out.push_str(if next.is_none() { " " } else { " " });
i += 2;
continue;
}
let closes = matches!((state, ch), (S::Single, '\'') | (S::Double, '"'));
if closes {
state = S::Code;
out.push(ch);
} else {
out.push(keep(ch));
}
i += 1;
}
}
}
out
}
fn is_raw_prefix(word: &str) -> bool {
matches!(word, "r" | "br" | "cr")
}
#[derive(Clone, Copy, PartialEq)]
enum R {
Code,
Line,
Block(u32),
Str,
Raw(u32),
}
pub fn blank_rust(src: &str) -> String {
let b: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut state = R::Code;
let mut word = String::new();
let mut i = 0;
let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
while i < b.len() {
let ch = b[i];
let next = b.get(i + 1).copied();
match state {
R::Code => {
if ch == '/' && next == Some('/') {
state = R::Line;
out.push_str(" ");
i += 2;
} else if ch == '/' && next == Some('*') {
state = R::Block(1);
out.push_str(" ");
i += 2;
} else if ch == '"' {
state = if is_raw_prefix(&word) {
R::Raw(0)
} else {
R::Str
};
word.clear();
out.push(ch);
i += 1;
} else if ch == '#' && is_raw_prefix(&word) {
let mut n = 0;
while b.get(i + n) == Some(&'#') {
n += 1;
}
if b.get(i + n) == Some(&'"') {
for _ in 0..n {
out.push('#');
}
out.push('"');
state = R::Raw(n as u32);
i += n + 1;
} else {
out.push(ch);
i += 1;
}
word.clear();
} else if ch == '\'' {
let char_end = match next {
Some('\\') => (i + 3..(i + 14).min(b.len())).find(|&j| b[j] == '\''),
Some(c) if c != '\'' => {
if b.get(i + 2) == Some(&'\'') {
Some(i + 2)
} else {
None
}
}
_ => None,
};
match char_end {
Some(j) => {
out.push('\'');
for c in &b[i + 1..j] {
out.push(keep(*c));
}
out.push('\'');
i = j + 1;
}
None => {
out.push('\'');
i += 1;
}
}
word.clear();
} else {
if ch.is_alphanumeric() || ch == '_' {
word.push(ch);
} else {
word.clear();
}
out.push(ch);
i += 1;
}
}
R::Line => {
if ch == '\n' {
state = R::Code;
out.push(ch);
} else {
out.push(' ');
}
i += 1;
}
R::Block(depth) => {
if ch == '/' && next == Some('*') {
state = R::Block(depth + 1);
out.push_str(" ");
i += 2;
} else if ch == '*' && next == Some('/') {
state = if depth == 1 {
R::Code
} else {
R::Block(depth - 1)
};
out.push_str(" ");
i += 2;
} else {
out.push(keep(ch));
i += 1;
}
}
R::Str => {
if ch == '\\' {
out.push(' ');
if let Some(n) = next {
out.push(keep(n));
}
i += 2;
} else if ch == '"' {
state = R::Code;
out.push(ch);
i += 1;
} else {
out.push(keep(ch));
i += 1;
}
}
R::Raw(hashes) => {
let n = hashes as usize;
if ch == '"' && (1..=n).all(|k| b.get(i + k) == Some(&'#')) {
out.push('"');
for _ in 0..n {
out.push('#');
}
state = R::Code;
i += n + 1;
} else {
out.push(keep(ch));
i += 1;
}
}
}
}
out
}
#[derive(Clone, Copy)]
struct PyLit {
quote: char,
triple: bool,
fstr: bool,
}
#[derive(Clone, Copy)]
enum P {
Code,
Comment,
Lit(PyLit),
}
pub fn blank_python(src: &str) -> String {
let b: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut state = P::Code;
let mut word = String::new();
let mut i = 0;
let mut subst: Vec<(u32, PyLit)> = Vec::new();
let keep = |c: char| if c == '\n' { '\n' } else { ' ' };
while i < b.len() {
let ch = b[i];
let next = b.get(i + 1).copied();
match state {
P::Code => {
if ch == '#' {
state = P::Comment;
out.push(' ');
i += 1;
} else if ch == '\'' || ch == '"' {
let prefix = !word.is_empty()
&& word.len() <= 2
&& word.chars().all(|c| "rbfuRBFU".contains(c));
let fstr = prefix && word.to_ascii_lowercase().contains('f');
let triple = next == Some(ch) && b.get(i + 2) == Some(&ch);
state = P::Lit(PyLit {
quote: ch,
triple,
fstr,
});
word.clear();
if triple {
out.push(ch);
out.push(ch);
out.push(ch);
i += 3;
} else {
out.push(ch);
i += 1;
}
} else {
if !subst.is_empty() {
if ch == '{' {
subst.last_mut().expect("non-empty").0 += 1;
} else if ch == '}' {
let (depth, lit) = *subst.last().expect("non-empty");
if depth == 0 {
subst.pop();
state = P::Lit(lit);
out.push(ch);
i += 1;
continue;
}
subst.last_mut().expect("non-empty").0 -= 1;
}
}
if ch.is_alphanumeric() || ch == '_' {
word.push(ch);
} else {
word.clear();
}
out.push(ch);
i += 1;
}
}
P::Comment => {
if ch == '\n' {
state = P::Code;
out.push(ch);
} else {
out.push(' ');
}
i += 1;
}
P::Lit(lit) => {
if ch == '\\' {
out.push(' ');
if let Some(n) = next {
out.push(keep(n));
}
i += 2;
} else if lit.triple
&& ch == lit.quote
&& next == Some(lit.quote)
&& b.get(i + 2) == Some(&lit.quote)
{
state = P::Code;
out.push(ch);
out.push(ch);
out.push(ch);
i += 3;
} else if !lit.triple && ch == lit.quote {
state = P::Code;
out.push(ch);
i += 1;
} else if !lit.triple && ch == '\n' {
state = P::Code;
out.push(ch);
i += 1;
} else if lit.fstr && ch == '{' && next == Some('{') {
out.push_str(" ");
i += 2;
} else if lit.fstr && ch == '{' {
subst.push((0, lit));
state = P::Code;
word.clear();
out.push(ch);
i += 1;
} else if lit.fstr && ch == '}' && next == Some('}') {
out.push_str(" ");
i += 2;
} else {
out.push(keep(ch));
i += 1;
}
}
}
}
out
}
fn is_searchable(file: &str, exts: &[&str]) -> bool {
let f = file.rsplit('/').next().unwrap_or(file);
exts.iter().any(|e| f.ends_with(e))
}
pub fn run(hook_name: &str, _args: &[std::ffi::OsString]) -> Outcome {
let stem_matches_self = |file: &str| {
let base = file.rsplit('/').next().unwrap_or(file);
let stem = base.split_once('.').map(|(s, _)| s).unwrap_or(base);
stem == hook_name
};
let mut found_any = false;
for term in &TERMS {
let arg = format!("-G{}", term.prefilter);
let Some(out) =
git::stdout_paths(&["diff", "--cached", &arg, "--diff-filter=d", "--name-only"])
else {
continue;
};
let matches: Vec<&str> = out
.iter()
.map(String::as_str)
.filter(|f| is_searchable(f, term.exts))
.filter(|f| !stem_matches_self(f))
.filter(|file| {
match git::stdout(&["show", &format!(":{file}")]) {
None => true,
Some(content) => (term.matches)(&(term.blank)(&content)),
}
})
.collect();
if !matches.is_empty() {
if !found_any {
crate::say!(" {} Unwanted terms found", error_sign().trim());
}
found_any = true;
crate::say!(
" The following files contains '{}' in them:",
highlight(term.label)
);
for m in matches {
crate::say!(" - {}", highlight(m));
}
}
}
if found_any {
return Outcome::Failed;
}
crate::say!(" {} No unwanted terms were found", valid_sign().trim());
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catches_the_banned_forms() {
assert!(call_of("fit('x', () => {})", "fit"));
assert!(call_of(" fit (", "fit"));
assert!(call_of("fdescribe('x')", "fdescribe"));
assert!(bare_debugger(" debugger;"));
assert!(bare_debugger("debugger"));
assert!(focused_suite("describe.skip('x')"));
assert!(focused_suite("it.only('x')"));
assert!(focused_suite("context.skip('x')"));
}
#[test]
fn leaves_lookalikes_alone() {
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)"));
}
#[test]
fn blanks_comments_and_strings_keeping_layout() {
let src = "a\n// debugger;\nb";
let out = blank_non_code(src);
assert_eq!(out.len(), src.len(), "length must be preserved");
assert_eq!(out.lines().count(), src.lines().count());
assert!(!bare_debugger(&out), "a term in a comment is discussion");
assert!(!bare_debugger(&blank_non_code("const s = 'debugger';")));
assert!(!bare_debugger(&blank_non_code("const s = `debugger`;")));
assert!(!call_of(&blank_non_code("/* fit( */"), "fit"));
}
#[test]
fn an_escape_never_closes_a_string() {
let out = blank_non_code(r#"const s = "a\"b"; debugger;"#);
assert!(
bare_debugger(&out),
"real code after the string must survive"
);
assert!(!call_of(&blank_non_code(r#"const s = "a\"fit(";"#), "fit"));
}
#[test]
fn an_escaped_slash_before_the_terminator_no_longer_swallows_the_line() {
for src in [
r"const re = /a\//; debugger;",
r"const re = /\//; debugger;",
] {
assert!(
bare_debugger(&blank_non_code(src)),
"code after the regex must still be scanned: {src}"
);
}
assert!(!bare_debugger(&blank_non_code(
r"const re = /a\//; const ok = 1;"
)));
}
#[test]
fn terms_inside_a_regex_literal_are_not_violations() {
for src in [
r"const re = /it\.only/;",
r"if (x) { const r = /debugger/; }",
r"foo(/fdescribe\(/);",
r"return /describe\.skip/;",
r"const r = /[/]debugger/;", ] {
let b = blank_non_code(src);
assert!(!bare_debugger(&b), "false alarm: {src}");
assert!(!focused_suite(&b), "false alarm: {src}");
assert!(!call_of(&b, "fdescribe"), "false alarm: {src}");
}
}
#[test]
fn division_is_not_treated_as_a_regex() {
let src = "const x = a / b; debugger;";
assert!(bare_debugger(&blank_non_code(src)));
let src2 = "const x = (a + b) / c; debugger;";
assert!(bare_debugger(&blank_non_code(src2)));
}
#[test]
fn an_unterminated_regex_does_not_blank_the_rest_of_the_file() {
let src = "const r = /oops
debugger;";
assert!(bare_debugger(&blank_non_code(src)));
}
#[test]
fn blanking_still_preserves_length_and_lines() {
let src = "const re = /a\\/b/;\ndebugger;\n// x\n";
let out = blank_non_code(src);
assert_eq!(out.len(), src.len());
assert_eq!(out.lines().count(), src.lines().count());
}
#[test]
fn each_term_searches_only_its_own_language() {
for f in ["a.js", "a.jsx", "a.ts", "a.tsx", "a.vue", "dir/b.ts"] {
assert!(is_searchable(f, JS_LIKE), "{f}");
}
for f in ["a.rs", "a.py", "a.md", "a.json", "README"] {
assert!(!is_searchable(f, JS_LIKE), "{f}");
}
assert!(is_searchable("src/lib.rs", RUST));
assert!(!is_searchable("a.ts", RUST));
assert!(is_searchable("app/main.py", PYTHON));
assert!(!is_searchable("a.pyi", PYTHON), "stubs never execute");
}
#[test]
fn the_scope_is_the_union_of_the_terms() {
let mut union: Vec<&str> = TERMS.iter().flat_map(|t| t.exts.iter().copied()).collect();
union.sort_unstable();
union.dedup();
let mut declared: Vec<&str> = EXTS.to_vec();
declared.sort_unstable();
assert_eq!(union, declared);
}
}
#[cfg(test)]
mod rust_terms {
use super::*;
#[test]
fn catches_the_macro_call() {
assert!(rust_dbg("dbg!(x)"));
assert!(rust_dbg("let y = dbg! (x);"));
assert!(rust_dbg("std::dbg!(x)"));
assert!(rust_dbg("dbg![x]"));
assert!(rust_dbg("dbg!{x}"));
}
#[test]
fn leaves_lookalikes_alone() {
assert!(!rust_dbg("xdbg!(1)")); assert!(!rust_dbg("dbg(1)")); assert!(!rust_dbg("debug!(x)")); assert!(!rust_dbg("dbg")); }
#[test]
fn comments_and_strings_are_discussion() {
for src in [
"// dbg!(x)\nlet a = 1;",
"/* dbg!(x) */",
"/// docs mentioning dbg!(x)\nfn f() {}",
"let s = \"dbg!(x)\";",
"let s = r\"dbg!(x)\";",
"let s = r#\"dbg!(\"x\")\"#;",
"let s = br\"dbg!(x)\";",
] {
assert!(!rust_dbg(&blank_rust(src)), "false alarm: {src}");
}
}
#[test]
fn block_comments_nest() {
assert!(!rust_dbg(&blank_rust("/* /* x */ dbg!(1) */")));
assert!(rust_dbg(&blank_rust("/* /* x */ */ dbg!(1)")));
}
#[test]
fn raw_string_hashes_are_honoured() {
assert!(!rust_dbg(&blank_rust("let s = r#\"a\"b\"#;")));
assert!(rust_dbg(&blank_rust("let s = r#\"a\"b\"#; dbg!(1);")));
assert!(!rust_dbg(&blank_rust("let s = r##\"a\"# dbg!(1) \"##;")));
}
#[test]
fn a_char_literal_holding_a_quote_does_not_open_a_string() {
assert!(rust_dbg(&blank_rust("let c = '\"'; dbg!(1);")));
assert!(rust_dbg(&blank_rust("let c = '\\''; dbg!(1);")));
assert!(rust_dbg(&blank_rust("let c = '\\\\'; dbg!(1);")));
assert!(rust_dbg(&blank_rust("let c = '\\u{7f}'; dbg!(1);")));
}
#[test]
fn a_lifetime_does_not_swallow_the_line() {
assert!(rust_dbg(&blank_rust("fn f<'a>(x: &'a str) { dbg!(x); }")));
assert!(rust_dbg(&blank_rust("let x: &'static str = s; dbg!(x);")));
}
#[test]
fn blanking_preserves_length_and_lines() {
let src = "let s = r#\"a\"b\"#;\n// dbg!(x)\nlet c = 'y';\n";
let out = blank_rust(src);
assert_eq!(out.len(), src.len());
assert_eq!(out.lines().count(), src.lines().count());
}
#[test]
fn the_hooks_own_source_survives_its_own_scan() {
assert!(!rust_dbg(&blank_rust(include_str!("ban_terms.rs"))));
}
}
#[cfg(test)]
mod python_terms {
use super::*;
#[test]
fn catches_the_debug_calls() {
assert!(call_of("breakpoint()", "breakpoint"));
assert!(call_of(" breakpoint ()", "breakpoint"));
assert!(pdb_set_trace("pdb.set_trace()"));
assert!(pdb_set_trace("ipdb.set_trace()"));
assert!(pdb_set_trace("x.pdb.set_trace()"));
}
#[test]
fn leaves_lookalikes_alone() {
assert!(!call_of("self.breakpoint()", "breakpoint")); assert!(!call_of("my_breakpoint()", "breakpoint"));
assert!(!pdb_set_trace("xpdb.set_trace()")); assert!(!pdb_set_trace("set_trace()")); assert!(!pdb_set_trace("pdb.set_trace")); }
#[test]
fn comments_and_strings_are_discussion() {
for src in [
"# breakpoint()\nx = 1\n",
"s = 'breakpoint()'\n",
"s = \"pdb.set_trace()\"\n",
"def f():\n \"\"\"calls breakpoint() eventually\"\"\"\n",
"s = '''ipdb.set_trace()'''\n",
"s = f\"breakpoint( {x}\"\n", "s = f\"{{breakpoint()}}\"\n", ] {
let b = blank_python(src);
assert!(!call_of(&b, "breakpoint"), "false alarm: {src}");
assert!(!pdb_set_trace(&b), "false alarm: {src}");
}
}
#[test]
fn an_interpolation_is_code() {
assert!(call_of(
&blank_python("s = f\"{breakpoint()}\"\n"),
"breakpoint"
));
assert!(call_of(
&blank_python("s = f\"{f'{breakpoint()}'}\"\n"),
"breakpoint"
));
assert!(call_of(
&blank_python("s = f\"{x:{w}} {breakpoint()}\"\n"),
"breakpoint"
));
}
#[test]
fn a_raw_string_backslash_does_not_close_early() {
let b = blank_python("s = r\"\\\"; breakpoint()\"\n");
assert!(!call_of(&b, "breakpoint"));
}
#[test]
fn an_unterminated_string_does_not_blank_the_next_line() {
let b = blank_python("s = 'oops\nbreakpoint()\n");
assert!(call_of(&b, "breakpoint"));
}
#[test]
fn triple_quotes_span_lines_and_close_only_on_three() {
let b = blank_python("s = \"\"\"\ntext \" and \"\" inside\nbreakpoint()\n\"\"\"\nx = 1\n");
assert!(!call_of(&b, "breakpoint"));
let b2 = blank_python("s = \"\"\"doc\"\"\"\nbreakpoint()\n");
assert!(call_of(&b2, "breakpoint"));
}
#[test]
fn blanking_preserves_length_and_lines() {
let src = "# c\ns = f\"{x} y\"\nt = '''a\nb'''\n";
let out = blank_python(src);
assert_eq!(out.len(), src.len());
assert_eq!(out.lines().count(), src.lines().count());
}
}
#[cfg(test)]
mod template_substitutions {
use super::*;
#[test]
fn a_substitution_is_code() {
let b = blank_non_code("const s = `${fit(1)}`;");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
}
#[test]
fn substitutions_nest() {
let b = blank_non_code("const s = `${`${fit(1)}`}`;");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
assert!(
b.contains("${`${fit(1)}`}"),
"nesting must be tracked, not merely survived: {b:?}"
);
}
#[test]
fn braces_inside_a_substitution_do_not_close_it() {
let b = blank_non_code("const s = `${ {a: 1}.a }` + 'fit(';");
assert!(
!call_of(&b, "fit"),
"the string literal must stay blanked: {b:?}"
);
let b2 = blank_non_code("const s = `${ {a: 1}.a } ${fit(2)}`;");
assert!(call_of(&b2, "fit"), "blanked to {b2:?}");
let b3 = blank_non_code("const s = `${ {a: 1} && fit(2) }`;");
assert!(
call_of(&b3, "fit"),
"a `}}` closing a nested object must not end the substitution: {b3:?}"
);
}
#[test]
fn template_text_is_still_blanked() {
assert!(!call_of(&blank_non_code("const s = `fit(`;"), "fit"));
assert!(
!call_of(&blank_non_code(r#"const s = `\${fit(1)}`;"#), "fit"),
"an escaped dollar does not open a substitution"
);
}
#[test]
fn nested_constructs_inside_a_substitution() {
assert!(!call_of(
&blank_non_code("const s = `${/* fit(1) */ x}`;"),
"fit"
));
assert!(!call_of(
&blank_non_code(r#"const s = `${"fit("}`;"#),
"fit"
));
assert!(!call_of(
&blank_non_code(r"const s = `${/fit\(/.test(y)}`;"),
"fit"
));
}
#[test]
fn a_stray_brace_in_code_is_harmless() {
let b = blank_non_code("function f() { return 1; }\nfit(() => {});");
assert!(call_of(&b, "fit"), "blanked to {b:?}");
}
}