use super::format::Language;
type Span = (usize, usize);
pub(crate) fn prose_spans(text: &str, language: Option<Language>) -> Vec<Span> {
let Some(language) = language else {
return Vec::new();
};
match language {
Language::Python => scan(text, &Syntax::PYTHON),
Language::Ruby => scan(text, &Syntax::RUBY),
Language::Rust => scan(text, &Syntax::RUST),
Language::Go => scan(text, &Syntax::GO),
Language::CSharp => scan(text, &Syntax::CSHARP),
Language::Php => scan(text, &Syntax::PHP),
Language::JavaScript | Language::TypeScript | Language::Java => scan(text, &Syntax::C_LIKE),
}
}
pub(crate) fn is_prose(spans: &[Span], offset: usize) -> bool {
spans
.binary_search_by(|&(start, end)| {
if offset < start {
std::cmp::Ordering::Greater
} else if offset >= end {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.is_ok()
}
struct Syntax {
line_comments: &'static [&'static str],
block_comments: &'static [(&'static str, &'static str)],
escaped_quotes: &'static [char],
raw_strings: &'static [(&'static str, &'static str)],
rust_raw_strings: bool,
}
impl Syntax {
const C_LIKE: Self = Self {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
escaped_quotes: &['"', '\'', '`'],
raw_strings: &[],
rust_raw_strings: false,
};
const PYTHON: Self = Self {
line_comments: &["#"],
block_comments: &[],
escaped_quotes: &['"', '\''],
raw_strings: &[("\"\"\"", "\"\"\""), ("'''", "'''")],
rust_raw_strings: false,
};
const RUBY: Self = Self {
line_comments: &["#"],
block_comments: &[("=begin", "=end")],
escaped_quotes: &['"', '\''],
raw_strings: &[],
rust_raw_strings: false,
};
const RUST: Self = Self {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
escaped_quotes: &['"'],
raw_strings: &[],
rust_raw_strings: true,
};
const GO: Self = Self {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
escaped_quotes: &['"'],
raw_strings: &[("`", "`")],
rust_raw_strings: false,
};
const CSHARP: Self = Self {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
escaped_quotes: &['"', '\''],
raw_strings: &[("@\"", "\"")],
rust_raw_strings: false,
};
const PHP: Self = Self {
line_comments: &["//", "#"],
block_comments: &[("/*", "*/")],
escaped_quotes: &['"', '\''],
raw_strings: &[],
rust_raw_strings: false,
};
}
fn scan(text: &str, syntax: &Syntax) -> Vec<Span> {
let bytes = text.as_bytes();
let mut spans: Vec<Span> = Vec::new();
let mut at = 0usize;
while at < bytes.len() {
if let Some(end) = block_comment_end(text, at, syntax) {
spans.push((at, end));
at = end;
continue;
}
if let Some(end) = line_comment_end(text, at, syntax) {
spans.push((at, end));
at = end;
continue;
}
if let Some(end) = raw_string_end(text, at, syntax) {
spans.push((at, end));
at = end;
continue;
}
if let Some(end) = quoted_string_end(text, at, syntax) {
spans.push((at, end));
at = end;
continue;
}
at += 1;
while at < bytes.len() && !text.is_char_boundary(at) {
at += 1;
}
}
spans
}
fn line_comment_end(text: &str, at: usize, syntax: &Syntax) -> Option<usize> {
let opener = syntax
.line_comments
.iter()
.find(|opener| text[at..].starts_with(**opener))?;
let rest = at + opener.len();
Some(text[rest..].find('\n').map_or(text.len(), |at| rest + at))
}
fn block_comment_end(text: &str, at: usize, syntax: &Syntax) -> Option<usize> {
let (opener, closer) = syntax
.block_comments
.iter()
.find(|(opener, _)| text[at..].starts_with(*opener))?;
let rest = at + opener.len();
Some(
text[rest..]
.find(closer)
.map_or(text.len(), |found| rest + found + closer.len()),
)
}
fn raw_string_end(text: &str, at: usize, syntax: &Syntax) -> Option<usize> {
if syntax.rust_raw_strings
&& let Some(end) = rust_raw_string_end(text, at)
{
return Some(end);
}
let (opener, closer) = syntax
.raw_strings
.iter()
.find(|(opener, _)| text[at..].starts_with(*opener))?;
let rest = at + opener.len();
Some(
text[rest..]
.find(closer)
.map_or(text.len(), |found| rest + found + closer.len()),
)
}
fn rust_raw_string_end(text: &str, at: usize) -> Option<usize> {
let rest = text[at..].strip_prefix('r')?;
let hashes = rest.len() - rest.trim_start_matches('#').len();
let rest = &rest[hashes..];
let body = rest.strip_prefix('"')?;
let closer = format!("\"{}", "#".repeat(hashes));
let opened = at + 1 + hashes + 1;
Some(
body.find(&closer)
.map_or(text.len(), |found| opened + found + closer.len()),
)
}
fn quoted_string_end(text: &str, at: usize, syntax: &Syntax) -> Option<usize> {
let quote = text[at..].chars().next()?;
if !syntax.escaped_quotes.contains("e) {
return None;
}
let mut chars = text[at + quote.len_utf8()..].char_indices();
while let Some((offset, character)) = chars.next() {
if character == '\\' {
chars.next();
continue;
}
if character == quote {
return Some(at + quote.len_utf8() + offset + character.len_utf8());
}
if character == '\n' && quote != '`' {
return Some(at + quote.len_utf8() + offset);
}
}
Some(text.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn masked(text: &str, language: Language, needle: &str) -> bool {
let spans = prose_spans(text, Some(language));
let offset = text.find(needle).expect("the needle is in the text");
is_prose(&spans, offset)
}
#[test]
fn a_line_comment_is_prose_and_the_next_line_is_not() {
let text = "// const bad = /(a+)+b/;\nconst ok = /[a-z]+/;\n";
assert!(masked(text, Language::JavaScript, "/(a+)+b/"));
assert!(!masked(text, Language::JavaScript, "/[a-z]+/"));
}
#[test]
fn a_block_comment_is_prose_across_every_line_it_spans() {
let text = "/**\n * Example: /(a+)+b/ is dangerous.\n */\nconst ok = /[a-z]+/;\n";
assert!(masked(text, Language::JavaScript, "/(a+)+b/"));
assert!(!masked(text, Language::JavaScript, "/[a-z]+/"));
}
#[test]
fn an_unterminated_block_comment_runs_to_the_end() {
let text = "/* opened and never closed\nconst bad = /(a+)+b/;\n";
assert!(masked(text, Language::JavaScript, "/(a+)+b/"));
}
#[test]
fn a_call_outside_a_string_is_code_even_though_its_argument_is_not() {
let text = "GOOD = re.compile(r\"[a-z]+\")\n";
assert!(!masked(text, Language::Python, "re.compile"));
assert!(masked(text, Language::Python, "\"[a-z]+\""));
}
#[test]
fn a_python_docstring_is_prose_including_the_calls_inside_it() {
let text =
"\"\"\"\nExample: re.compile(r\"(a+)+b\")\n\"\"\"\nGOOD = re.compile(r\"[a-z]+\")\n";
assert!(masked(text, Language::Python, "re.compile(r\"(a+)+b\")"));
assert!(!masked(text, Language::Python, "re.compile(r\"[a-z]+\")"));
}
#[test]
fn a_hash_comment_is_prose_in_python_and_ruby() {
let text = "# re.compile(r\"(a+)+b\")\nGOOD = 1\n";
assert!(masked(text, Language::Python, "re.compile"));
assert!(masked("# /(a+)+b/\nOK = 1\n", Language::Ruby, "/(a+)+b/"));
}
#[test]
fn ruby_block_comments_are_prose() {
let text = "=begin\n/(a+)+b/\n=end\nok = /[a-z]+/\n";
assert!(masked(text, Language::Ruby, "/(a+)+b/"));
assert!(!masked(text, Language::Ruby, "/[a-z]+/"));
}
#[test]
fn a_rust_raw_string_closes_on_its_own_hashes() {
let text = "let a = r#\"say \"hi\" here\"#;\nlet b = Regex::new(r\"[a-z]+\");\n";
assert!(masked(text, Language::Rust, "\"hi\""));
assert!(!masked(text, Language::Rust, "Regex::new"));
}
#[test]
fn a_go_backtick_string_spans_lines() {
let text = "var a = `line one\nregexp.MustCompile(`\nvar b = 1\n";
assert!(masked(text, Language::Go, "regexp.MustCompile"));
}
#[test]
fn an_escaped_quote_does_not_close_a_string() {
let text = "const a = \"he said \\\" /(a+)+b/ \";\nconst ok = /[a-z]+/;\n";
assert!(masked(text, Language::JavaScript, "/(a+)+b/"));
assert!(!masked(text, Language::JavaScript, "/[a-z]+/"));
}
#[test]
fn an_unterminated_quote_ends_at_the_line() {
let text = "const a = \"oops;\nconst ok = /[a-z]+/;\n";
assert!(!masked(text, Language::JavaScript, "/[a-z]+/"));
}
#[test]
fn an_unknown_language_masks_nothing() {
let text = "// const bad = /(a+)+b/;\n";
assert!(prose_spans(text, None).is_empty());
}
#[test]
fn spans_are_sorted_and_do_not_overlap() {
let text = "// one\nconst a = \"two\";\n/* three */\nconst b = 'four';\n";
let spans = prose_spans(text, Some(Language::JavaScript));
assert!(spans.len() >= 4, "{spans:?}");
for pair in spans.windows(2) {
assert!(pair[0].1 <= pair[1].0, "{spans:?}");
}
}
#[test]
fn a_url_inside_a_comment_does_not_leak_a_pattern() {
let text = "// see https://example.com/docs/x\nconst ok = /[a-z]+/;\n";
assert!(masked(text, Language::JavaScript, "https://"));
assert!(!masked(text, Language::JavaScript, "/[a-z]+/"));
}
}