use super::collect::{self, Value};
use super::fallback;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Language {
Python,
Rust,
Go,
Shell,
Php,
Ruby,
Perl,
CSharp,
JavaScript,
}
pub(crate) fn language(format: &str) -> Option<Language> {
match format {
"python" => Some(Language::Python),
"rust" => Some(Language::Rust),
"go" => Some(Language::Go),
"shellscript" => Some(Language::Shell),
"php" => Some(Language::Php),
"ruby" => Some(Language::Ruby),
"perl" => Some(Language::Perl),
"csharp" => Some(Language::CSharp),
"javascript" | "typescript" => Some(Language::JavaScript),
_ => None,
}
}
pub(crate) fn extract(text: &str, language: Language) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut scanner = Scanner {
chars: &chars,
at: 0,
language,
values: Vec::new(),
heredocs: Vec::new(),
unclosed: std::collections::HashSet::new(),
closable: None,
};
scanner.run();
collect::collect(&Value::Seq(scanner.values))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Escapes {
Backslash,
Doubled,
None,
}
struct Taken {
value: String,
end: usize,
}
struct Pending {
tag: String,
indented: bool,
}
struct Scanner<'a> {
chars: &'a [char],
at: usize,
language: Language,
values: Vec<Value>,
heredocs: Vec<Pending>,
unclosed: std::collections::HashSet<(String, bool)>,
closable: Option<std::collections::HashSet<String>>,
}
impl Scanner<'_> {
fn run(&mut self) {
while self.at < self.chars.len() {
if self.chars[self.at] == '\n' && !self.heredocs.is_empty() {
self.take_heredoc_bodies();
continue;
}
if self.take_comment()
|| self.take_string()
|| self.take_character()
|| self.take_heredoc_marker()
{
continue;
}
self.at += 1;
}
}
fn take_comment(&mut self) -> bool {
let Some(end) = self.comment_end() else {
return false;
};
let body: String = self.chars[self.at..end].iter().collect();
let runs: Vec<Value> = fallback::runs(&body, false)
.into_iter()
.map(|run| Value::Str(run.to_string()))
.collect();
self.values.extend(runs);
self.at = end;
true
}
fn take_string(&mut self) -> bool {
let Some(taken) = self.string_at() else {
return false;
};
self.values.push(Value::Str(taken.value));
self.at = taken.end;
true
}
fn take_character(&mut self) -> bool {
if !matches!(
self.language,
Language::Rust | Language::Go | Language::CSharp
) {
return false;
}
if self.chars.get(self.at) != Some(&'\'') {
return false;
}
let Some(end) = self.character_end() else {
return false;
};
self.at = end;
true
}
fn take_heredoc_marker(&mut self) -> bool {
let Some((pending, end)) = self.heredoc_marker() else {
return false;
};
self.heredocs.push(pending);
self.at = end;
true
}
fn string_at(&self) -> Option<Taken> {
match self.language {
Language::Python => self.python_string(),
Language::Rust => self.rust_string(),
Language::Go => self.go_string(),
Language::Shell => self.shell_string(),
Language::Php | Language::Ruby | Language::Perl => self.scripting_string(),
Language::CSharp => self.csharp_string(),
Language::JavaScript => self.javascript_string(),
}
}
fn python_string(&self) -> Option<Taken> {
const PREFIXES: [char; 8] = ['r', 'R', 'b', 'B', 'u', 'U', 'f', 'F'];
let start = self.at;
let mut at = start;
while at < start + 2 && self.chars.get(at).is_some_and(|c| PREFIXES.contains(c)) {
at += 1;
}
let quote = *self.chars.get(at)?;
if quote != '"' && quote != '\'' {
return None;
}
if at > start && self.preceded_by_identifier(start) {
return None;
}
let triple = [quote, quote, quote];
if self.matches_at(at, &triple) {
return self.delimited(at + 3, &triple, Escapes::Backslash, true);
}
self.delimited(at + 1, &[quote], Escapes::Backslash, false)
}
fn rust_string(&self) -> Option<Taken> {
let start = self.at;
let mut at = start;
if matches!(self.chars.get(at), Some('b' | 'c')) {
at += 1;
}
let raw = self.chars.get(at) == Some(&'r');
let mut hashes = 0;
if raw {
at += 1;
while self.chars.get(at) == Some(&'#') {
hashes += 1;
at += 1;
}
}
if self.chars.get(at) != Some(&'"') {
return None;
}
if at > start && self.preceded_by_identifier(start) {
return None;
}
if !raw {
return self.delimited(at + 1, &['"'], Escapes::Backslash, true);
}
let mut terminator = vec!['"'];
terminator.resize(hashes + 1, '#');
self.delimited(at + 1, &terminator, Escapes::None, true)
}
fn go_string(&self) -> Option<Taken> {
match self.chars.get(self.at)? {
'"' => self.delimited(self.at + 1, &['"'], Escapes::Backslash, false),
'`' => self.delimited(self.at + 1, &['`'], Escapes::None, true),
_ => None,
}
}
fn shell_string(&self) -> Option<Taken> {
match self.chars.get(self.at)? {
'"' => self.delimited(self.at + 1, &['"'], Escapes::Backslash, true),
'\'' => self.delimited(self.at + 1, &['\''], Escapes::None, true),
_ => None,
}
}
fn scripting_string(&self) -> Option<Taken> {
match self.chars.get(self.at)? {
quote @ ('"' | '\'') => {
self.delimited(self.at + 1, &[*quote], Escapes::Backslash, true)
}
_ => None,
}
}
fn csharp_string(&self) -> Option<Taken> {
let start = self.at;
let mut at = start;
let mut verbatim = false;
for _ in 0..2 {
match self.chars.get(at) {
Some('@') => {
verbatim = true;
at += 1;
}
Some('$') => at += 1,
_ => break,
}
}
if self.chars.get(at) != Some(&'"') {
return None;
}
if verbatim {
return self.delimited(at + 1, &['"'], Escapes::Doubled, true);
}
self.delimited(at + 1, &['"'], Escapes::Backslash, false)
}
fn javascript_string(&self) -> Option<Taken> {
match self.chars.get(self.at)? {
quote @ ('"' | '\'') => {
self.delimited(self.at + 1, &[*quote], Escapes::Backslash, false)
}
'`' => self.template(),
_ => None,
}
}
fn template(&self) -> Option<Taken> {
const MAX_NESTING: usize = 64;
let from = self.at + 1;
let mut at = from;
let mut open = vec![0usize];
while at < self.chars.len() {
let &depth = open.last()?;
let last = open.len() - 1;
let current = self.chars[at];
at = match current {
'\\' => at + 2,
'`' if depth == 0 && open.len() == 1 => {
return Some(Taken {
value: self.chars[from..at].iter().collect(),
end: at + 1,
});
}
'`' if depth == 0 => {
open.pop();
at + 1
}
'`' if open.len() >= MAX_NESTING => return None,
'`' => {
open.push(0);
at + 1
}
'$' if depth == 0 && self.chars.get(at + 1) == Some(&'{') => {
open[last] = 1;
at + 2
}
'{' if depth > 0 => {
open[last] = depth + 1;
at + 1
}
'}' if depth > 0 => {
open[last] = depth - 1;
at + 1
}
'"' | '\'' if depth > 0 => self
.delimited(at + 1, &[current], Escapes::Backslash, false)
.map_or(at + 1, |taken| taken.end),
_ => at + 1,
};
}
None
}
fn delimited(
&self,
from: usize,
terminator: &[char],
escapes: Escapes,
newlines: bool,
) -> Option<Taken> {
let mut at = from;
while at < self.chars.len() {
let current = self.chars[at];
if escapes == Escapes::Backslash && current == '\\' {
at += 2;
continue;
}
if !newlines && current == '\n' {
return None;
}
if !self.matches_at(at, terminator) {
at += 1;
continue;
}
if escapes == Escapes::Doubled && self.matches_at(at + terminator.len(), terminator) {
at += terminator.len() * 2;
continue;
}
return Some(Taken {
value: self.chars[from..at].iter().collect(),
end: at + terminator.len(),
});
}
None
}
fn character_end(&self) -> Option<usize> {
const LONGEST: usize = 14;
let mut at = self.at + 1;
let limit = (self.at + LONGEST).min(self.chars.len());
while at < limit {
match self.chars[at] {
'\\' => at += 2,
'\n' => return None,
'\'' => return Some(at + 1),
_ => at += 1,
}
}
None
}
fn comment_end(&self) -> Option<usize> {
match self.language {
Language::Python => self.hash_comment(true),
Language::Shell => self.hash_comment(false),
Language::Ruby => self.ruby_comment(),
Language::Perl => self.perl_comment(),
Language::Php => self
.slash_comment(false)
.or_else(|| self.hash_comment(true)),
Language::Rust => self.slash_comment(true),
Language::Go | Language::CSharp | Language::JavaScript => self.slash_comment(false),
}
}
fn hash_comment(&self, anywhere: bool) -> Option<usize> {
if self.chars.get(self.at) != Some(&'#') {
return None;
}
if !anywhere && !self.at_word_start() {
return None;
}
Some(self.line_end(self.at))
}
fn ruby_comment(&self) -> Option<usize> {
if let Some(end) = self.line_block("=begin", "=end") {
return Some(end);
}
self.hash_comment(true)
}
fn perl_comment(&self) -> Option<usize> {
if let Some(end) = self.pod_block() {
return Some(end);
}
if self
.at
.checked_sub(1)
.and_then(|index| self.chars.get(index))
.is_some_and(|c| matches!(c, '$' | '@' | '%'))
{
return None;
}
self.hash_comment(true)
}
fn slash_comment(&self, nested: bool) -> Option<usize> {
if self.matches_at(self.at, &['/', '/']) {
return Some(self.line_end(self.at));
}
if !self.matches_at(self.at, &['/', '*']) {
return None;
}
Some(self.block_comment_end(nested))
}
fn block_comment_end(&self, nested: bool) -> usize {
let mut at = self.at + 2;
let mut depth = 1usize;
while at < self.chars.len() {
if nested && self.matches_at(at, &['/', '*']) {
depth += 1;
at += 2;
continue;
}
if !self.matches_at(at, &['*', '/']) {
at += 1;
continue;
}
depth -= 1;
at += 2;
if depth == 0 {
return at;
}
}
self.chars.len()
}
fn line_block(&self, opener: &str, closer: &str) -> Option<usize> {
if !self.at_line_start() || !self.starts_with(self.at, opener) {
return None;
}
let mut at = self.line_end(self.at) + 1;
while at < self.chars.len() {
if self.starts_with(at, closer) {
return Some(self.line_end(at));
}
at = self.line_end(at) + 1;
}
Some(self.chars.len())
}
fn pod_block(&self) -> Option<usize> {
if !self.at_line_start() || self.chars.get(self.at) != Some(&'=') {
return None;
}
if !self
.chars
.get(self.at + 1)
.is_some_and(char::is_ascii_alphabetic)
{
return None;
}
let mut at = self.line_end(self.at) + 1;
while at < self.chars.len() {
if self.starts_with(at, "=cut") {
return Some(self.line_end(at));
}
at = self.line_end(at) + 1;
}
Some(self.chars.len())
}
fn heredoc_marker(&self) -> Option<(Pending, usize)> {
match self.language {
Language::Shell => self.shell_heredoc(),
Language::Php => self.php_heredoc(),
Language::Ruby | Language::Perl => self.script_heredoc(),
_ => None,
}
}
fn shell_heredoc(&self) -> Option<(Pending, usize)> {
if !self.matches_at(self.at, &['<', '<']) {
return None;
}
let mut at = self.at + 2;
let indented = self.chars.get(at) == Some(&'-');
if indented {
at += 1;
}
while self.chars.get(at) == Some(&' ') {
at += 1;
}
let (tag, end) = self.heredoc_tag(at)?;
Some((Pending { tag, indented }, end))
}
fn php_heredoc(&self) -> Option<(Pending, usize)> {
if !self.matches_at(self.at, &['<', '<', '<']) {
return None;
}
let mut at = self.at + 3;
while self.chars.get(at) == Some(&' ') {
at += 1;
}
let (tag, end) = self.heredoc_tag(at)?;
Some((
Pending {
tag,
indented: true,
},
end,
))
}
fn script_heredoc(&self) -> Option<(Pending, usize)> {
if !self.matches_at(self.at, &['<', '<']) {
return None;
}
let mut at = self.at + 2;
let squiggly = matches!(self.chars.get(at), Some('~' | '-'));
if squiggly {
at += 1;
}
let quoted = matches!(self.chars.get(at), Some('\'' | '"'));
let (tag, end) = self.heredoc_tag(at)?;
if !squiggly && !quoted && !tag.starts_with(|c: char| c.is_uppercase() || c == '_') {
return None;
}
Some((
Pending {
tag,
indented: true,
},
end,
))
}
fn heredoc_tag(&self, at: usize) -> Option<(String, usize)> {
let quote = match self.chars.get(at) {
Some(&found @ ('\'' | '"')) => Some(found),
_ => None,
};
let start = at + usize::from(quote.is_some());
if !self
.chars
.get(start)
.is_some_and(|c| c.is_alphabetic() || *c == '_')
{
return None;
}
let mut end = start;
while self
.chars
.get(end)
.is_some_and(|c| c.is_alphanumeric() || *c == '_')
{
end += 1;
}
let tag: String = self.chars[start..end].iter().collect();
let Some(quote) = quote else {
return Some((tag, end));
};
if self.chars.get(end) != Some("e) {
return None;
}
Some((tag, end + 1))
}
fn take_heredoc_bodies(&mut self) {
self.at += 1;
let pending = std::mem::take(&mut self.heredocs);
for heredoc in &pending {
let Some((body, end)) = self.heredoc_body(heredoc) else {
return;
};
self.values.push(Value::Str(body));
self.at = end;
}
}
fn heredoc_body(&mut self, heredoc: &Pending) -> Option<(String, usize)> {
if !self.closable().contains(&heredoc.tag) {
return None;
}
let key = (heredoc.tag.clone(), heredoc.indented);
if self.unclosed.contains(&key) {
return None;
}
let mut line = self.at;
while line <= self.chars.len() {
let end = self.line_end(line);
if self.terminates(line, end, heredoc) {
return Some((
self.chars[self.at..line].iter().collect(),
(end + 1).min(self.chars.len()),
));
}
line = end + 1;
}
self.unclosed.insert(key);
None
}
fn closable(&mut self) -> &std::collections::HashSet<String> {
if self.closable.is_none() {
let built = self.candidate_tags();
self.closable = Some(built);
}
self.closable.as_ref().expect("just built")
}
fn candidate_tags(&self) -> std::collections::HashSet<String> {
let mut tags = std::collections::HashSet::new();
let mut line = 0;
while line <= self.chars.len() {
let end = self.line_end(line);
let text: String = self
.chars
.get(line..end)
.unwrap_or_default()
.iter()
.collect();
tags.insert(self.candidate_tag(&text));
line = end + 1;
}
tags
}
fn candidate_tag(&self, line: &str) -> String {
if self.language != Language::Php {
return super::text::trim(line).to_string();
}
super::text::trim_start(line)
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
}
fn terminates(&self, start: usize, end: usize, heredoc: &Pending) -> bool {
let line: String = self
.chars
.get(start..end)
.unwrap_or_default()
.iter()
.collect();
let line = line.trim_end_matches('\r');
let candidate = if heredoc.indented {
super::text::trim_start(line)
} else {
line
};
if self.language == Language::Php {
let Some(rest) = candidate.strip_prefix(&heredoc.tag) else {
return false;
};
return rest
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_');
}
super::text::trim_end(candidate) == heredoc.tag
}
fn matches_at(&self, at: usize, needle: &[char]) -> bool {
self.chars.get(at..at + needle.len()) == Some(needle)
}
fn starts_with(&self, at: usize, text: &str) -> bool {
text.chars()
.enumerate()
.all(|(offset, expected)| self.chars.get(at + offset) == Some(&expected))
}
fn line_end(&self, from: usize) -> usize {
self.chars
.get(from..)
.and_then(|rest| rest.iter().position(|c| *c == '\n'))
.map_or(self.chars.len(), |offset| from + offset)
}
fn at_line_start(&self) -> bool {
self.at == 0 || self.chars.get(self.at - 1) == Some(&'\n')
}
fn at_word_start(&self) -> bool {
let Some(index) = self.at.checked_sub(1) else {
return true;
};
self.chars.get(index).is_some_and(|c| {
super::text::is_whitespace(*c) || matches!(c, ';' | '&' | '|' | '(' | ')')
})
}
fn preceded_by_identifier(&self, at: usize) -> bool {
at.checked_sub(1)
.and_then(|index| self.chars.get(index))
.is_some_and(|c| c.is_alphanumeric() || *c == '_')
}
}
#[cfg(test)]
mod tests {
use super::*;
fn read(text: &str, language: Language) -> Vec<String> {
extract(text, language)
}
#[test]
fn every_source_key_resolves_and_nothing_else_does() {
for key in [
"python",
"rust",
"go",
"shellscript",
"php",
"ruby",
"perl",
"csharp",
"javascript",
"typescript",
] {
assert!(language(key).is_some(), "{key}");
}
for key in ["json", "fallback", "markdown", ""] {
assert!(language(key).is_none(), "{key}");
}
}
#[test]
fn a_python_docstring_is_one_string() {
let source =
"def greet():\n \"\"\"Greet a user.\n\n Politely.\n \"\"\"\n return 1\n";
assert_eq!(
read(source, Language::Python),
["Greet a user.\n\n Politely."]
);
}
#[test]
fn python_reads_both_triple_quote_styles() {
assert_eq!(read("x = '''a\nb'''", Language::Python), ["a\nb"]);
assert_eq!(read("x = \"\"\"a\nb\"\"\"", Language::Python), ["a\nb"]);
}
#[test]
fn python_prefixes_are_part_of_the_syntax_not_the_value() {
assert_eq!(
read(
"a = f\"Hi {name}\"\nb = r\"\\d+\"\nc = b\"bytes\"\nd = rb'both'",
Language::Python
),
["Hi {name}", "\\d+", "bytes", "both"]
);
}
#[test]
fn an_identifier_ending_in_a_prefix_letter_is_not_a_prefix() {
assert_eq!(read("myf\"x\"", Language::Python), ["x"]);
assert_eq!(read("for x in 'items':", Language::Python), ["items"]);
}
#[test]
fn a_python_comment_yields_its_quoted_runs_and_nothing_else() {
assert_eq!(
read(
"# see \"the docs\"\nx = 'v' # don't worry\n",
Language::Python
),
["the docs", "v"]
);
}
#[test]
fn a_hash_inside_a_python_string_is_not_a_comment() {
assert_eq!(
read("x = 'a # b'\ny = 'c'", Language::Python),
["a # b", "c"]
);
}
#[test]
fn an_unterminated_python_string_is_not_a_string() {
assert_eq!(read("x = 'oops\ny = 'kept'", Language::Python), ["kept"]);
}
#[test]
fn a_rust_raw_string_is_one_string_with_its_quotes() {
assert_eq!(
read("let s = r#\"a raw \"quoted\" string\"#;", Language::Rust),
["a raw \"quoted\" string"]
);
}
#[test]
fn rust_raw_strings_take_any_number_of_hashes() {
assert_eq!(read("r\"plain\"", Language::Rust), ["plain"]);
assert_eq!(
read("r##\"has \"# inside\"##", Language::Rust),
["has \"# inside"]
);
assert_eq!(
read("b\"bytes\" br#\"raw bytes\"#", Language::Rust),
["bytes", "raw bytes"]
);
}
#[test]
fn a_rust_string_may_span_lines() {
assert_eq!(
read("let s = \"first\nsecond\";", Language::Rust),
["first\nsecond"]
);
}
#[test]
fn a_rust_character_literal_is_not_a_string() {
assert_eq!(
read("if c == '\"' { let s = \"kept\"; }", Language::Rust),
["kept"]
);
assert_eq!(
read("let c = 'a'; let s = \"kept\";", Language::Rust),
["kept"]
);
assert_eq!(
read("let c = '\\''; let s = \"kept\";", Language::Rust),
["kept"]
);
}
#[test]
fn a_rust_lifetime_is_not_a_character_literal() {
assert_eq!(
read(
"fn f<'a>(x: &'a str) -> &'a str { \"kept\" }",
Language::Rust
),
["kept"]
);
}
#[test]
fn rust_block_comments_nest() {
assert_eq!(
read("/* outer /* inner */ still */ \"kept\"", Language::Rust),
["kept"]
);
}
#[test]
fn a_raw_identifier_is_not_a_raw_string() {
assert_eq!(read("let r#type = \"kept\";", Language::Rust), ["kept"]);
}
#[test]
fn a_go_raw_string_is_one_string() {
assert_eq!(
read("const usage = `line one\nline two`", Language::Go),
["line one\nline two"]
);
}
#[test]
fn a_go_interpreted_string_stops_at_the_line() {
assert_eq!(
read("s := \"one\"\nt := \"two\"", Language::Go),
["one", "two"]
);
assert_eq!(read("s := \"oops\nt := \"kept\"", Language::Go), ["kept"]);
}
#[test]
fn a_go_rune_is_not_a_string() {
assert_eq!(
read("if c == '\"' { s := \"kept\" }", Language::Go),
["kept"]
);
}
#[test]
fn a_shell_heredoc_is_one_string() {
assert_eq!(
read("cat <<EOF\nThe body.\nSecond line.\nEOF\n", Language::Shell),
["The body.\nSecond line."]
);
}
#[test]
fn shell_heredocs_take_every_introducer() {
assert_eq!(
read("cat <<'EOF'\nliteral\nEOF\n", Language::Shell),
["literal"]
);
assert_eq!(
read("cat <<\"EOF\"\nexpanded\nEOF\n", Language::Shell),
["expanded"]
);
assert_eq!(
read("cat <<- EOF\n\tindented\n\tEOF\n", Language::Shell),
["indented"]
);
}
#[test]
fn two_heredocs_on_one_line_are_two_strings() {
assert_eq!(
read("diff <<A <<B\nfirst\nA\nsecond\nB\n", Language::Shell),
["first", "second"]
);
}
#[test]
fn a_heredoc_that_never_closes_is_not_a_string() {
assert!(read("cat <<EOF\nno terminator\n", Language::Shell).is_empty());
}
#[test]
fn a_heredoc_that_never_closes_ends_the_batch() {
assert!(read("diff <<A <<B\nfirst\nB\n", Language::Shell).is_empty());
assert_eq!(
read("diff <<A <<B\nfirst\nA\nsecond\nB\n", Language::Shell),
["first", "second"],
"a batch that closes is unaffected"
);
}
#[test]
fn a_tag_searched_for_once_is_not_searched_for_again() {
assert!(read("cat <<GONE\na\ncat <<GONE\nb\n", Language::Shell).is_empty());
}
#[test]
fn an_indented_tag_is_searched_for_even_after_the_bare_one_failed() {
assert_eq!(
read(
"cat <<EOF\nbody\n EOF\ncat <<- EOF\nkept\n EOF\n",
Language::Shell
),
["kept"]
);
}
#[test]
fn a_left_shift_is_not_a_heredoc() {
assert_eq!(read("x=$((1 << 2))\ny='kept'", Language::Shell), ["kept"]);
}
#[test]
fn a_hash_inside_a_shell_word_is_not_a_comment() {
assert_eq!(
read("file=report#1\necho 'kept'", Language::Shell),
["kept"]
);
assert_eq!(
read("# a \"noted\" thing\necho 'kept'", Language::Shell),
["noted", "kept"]
);
}
#[test]
fn a_php_heredoc_and_nowdoc_are_each_one_string() {
assert_eq!(read("$a = <<<EOT\nhello\nEOT;\n", Language::Php), ["hello"]);
assert_eq!(read("$a = <<<'NOW'\nraw\nNOW;\n", Language::Php), ["raw"]);
}
#[test]
fn php_reads_both_comment_styles() {
assert_eq!(
read(
"# one \"a\"\n// two \"b\"\n/* three \"c\" */\n$x = 'kept';",
Language::Php
),
["a", "b", "c", "kept"]
);
}
#[test]
fn a_ruby_heredoc_is_one_string_and_a_left_shift_is_not() {
assert_eq!(read("t = <<~EOS\n hi\nEOS\n", Language::Ruby), ["hi"]);
assert_eq!(read("list << item\nx = 'kept'", Language::Ruby), ["kept"]);
}
#[test]
fn a_ruby_block_comment_is_read_as_prose() {
assert_eq!(
read("=begin\nprose 'here'\n=end\nx = 'kept'", Language::Ruby),
["here", "kept"]
);
}
#[test]
fn a_perl_heredoc_is_one_string_and_a_last_index_is_not_a_comment() {
assert_eq!(
read("my $t = <<'END';\nbody\nEND\n", Language::Perl),
["body"]
);
assert_eq!(
read("my $n = $#list;\nmy $s = 'kept';", Language::Perl),
["kept"]
);
}
#[test]
fn perl_documentation_is_read_as_prose() {
assert_eq!(
read("=pod\nprose 'here'\n=cut\nmy $s = 'kept';", Language::Perl),
["here", "kept"]
);
}
#[test]
fn a_csharp_verbatim_string_keeps_its_doubled_quotes() {
assert_eq!(
read("var s = @\"He said \"\"hi\"\" loudly\";", Language::CSharp),
["He said \"\"hi\"\" loudly"]
);
}
#[test]
fn a_csharp_verbatim_string_spans_lines_and_keeps_backslashes() {
assert_eq!(
read("var p = @\"C:\\Users\\test\";", Language::CSharp),
["C:\\Users\\test"]
);
assert_eq!(
read("var q = @\"one\ntwo\";", Language::CSharp),
["one\ntwo"]
);
}
#[test]
fn csharp_interpolation_reads_either_order() {
assert_eq!(read("var a = $\"Hi {n}\";", Language::CSharp), ["Hi {n}"]);
assert_eq!(
read("var b = $@\"a \"\"b\"\";\";", Language::CSharp),
["a \"\"b\"\";"]
);
assert_eq!(read("var c = @$\"x\";", Language::CSharp), ["x"]);
}
#[test]
fn a_template_literal_is_one_string_across_lines() {
assert_eq!(
read(
"const body = `Dear reader,\n\nWelcome.`;",
Language::JavaScript
),
["Dear reader,\n\nWelcome."]
);
}
#[test]
fn a_nested_template_is_part_of_the_string_around_it() {
assert_eq!(
read("const s = `a ${x ? `b` : `c`} d`;", Language::JavaScript),
["a ${x ? `b` : `c`} d"]
);
}
#[test]
fn a_brace_inside_an_interpolation_does_not_end_it() {
assert_eq!(
read("const s = `a ${ {k: `v`}.k } b`;", Language::JavaScript),
["a ${ {k: `v`}.k } b"]
);
}
#[test]
fn a_quoted_brace_inside_an_interpolation_does_not_end_it() {
assert_eq!(
read("const s = `a ${f(\"}\")} b`;", Language::JavaScript),
["a ${f(\"}\")} b"]
);
}
#[test]
fn javascript_escapes_survive_into_the_value() {
assert_eq!(
read("const s = 'It\\'s fine';", Language::JavaScript),
["It\\'s fine"]
);
}
#[test]
fn a_javascript_comment_yields_its_quoted_runs() {
assert_eq!(
read(
"// A comment mentioning \"quoted text\".\nconst a = 'v';",
Language::JavaScript
),
["quoted text", "v"]
);
}
#[test]
fn empty_and_whitespace_only_literals_are_dropped_everywhere() {
assert_eq!(
read("a = ''\nb = ' '\nc = 'kept'", Language::Python),
["kept"]
);
assert_eq!(read("a := \"\"; b := \"kept\"", Language::Go), ["kept"]);
}
#[test]
fn values_are_trimmed_and_repeats_are_kept() {
assert_eq!(read("a = ' padded '", Language::Python), ["padded"]);
assert_eq!(
read("a = 'same'\nb = 'same'", Language::Python),
["same", "same"]
);
}
#[test]
fn an_empty_document_yields_nothing() {
assert!(read("", Language::Python).is_empty());
assert!(read("no literals at all", Language::Rust).is_empty());
}
#[test]
fn template_nesting_is_capped() {
let source = format!("`{}", "${`".repeat(200));
assert!(read(&source, Language::JavaScript).is_empty());
}
}