#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ctx {
Single,
Double,
Backtick,
Paren,
Brace,
Arith,
Ansi,
}
#[derive(Debug, Clone)]
struct Heredoc {
delim: String,
strip_tabs: bool,
quoted: bool,
body_start: usize,
}
#[derive(Debug, Default, Clone)]
pub struct QuotedRegions {
per_line: Vec<Vec<(usize, usize)>>,
any: bool,
quoted_heredoc: std::collections::HashSet<usize>,
}
impl QuotedRegions {
pub fn analyze(source: &str) -> Self {
let mut scanner = Scanner::default();
let mut per_line = Vec::new();
let mut any = false;
for (idx, line) in source.lines().enumerate() {
scanner.line_no = idx + 1;
let ranges = coalesce(&scanner.scan_line(line));
any |= !ranges.is_empty();
per_line.push(ranges);
}
let mut discard_at = scanner.quote_open_at;
if let Some(open) = scanner.body.as_ref() {
let from = (open.body_start, 1);
discard_at = Some(min_position(discard_at, from));
scanner
.quoted_heredoc
.retain(|line| *line < open.body_start);
}
if let Some((line, col)) = discard_at {
discard_from(&mut per_line, line, col);
any = per_line.iter().any(|ranges| !ranges.is_empty());
}
Self {
per_line,
any,
quoted_heredoc: scanner.quoted_heredoc,
}
}
pub fn is_literal(&self, line: usize, col: usize) -> bool {
let Some(ranges) = line.checked_sub(1).and_then(|i| self.per_line.get(i)) else {
return false;
};
ranges.iter().any(|&(s, e)| col >= s && col <= e)
}
pub fn is_empty(&self) -> bool {
!self.any
}
pub fn quoted_heredoc_lines(&self) -> &std::collections::HashSet<usize> {
&self.quoted_heredoc
}
fn ranges_for(&self, line: usize) -> &[(usize, usize)] {
line.checked_sub(1)
.and_then(|i| self.per_line.get(i))
.map_or(&[], Vec::as_slice)
}
}
fn min_position(a: Option<(usize, usize)>, b: (usize, usize)) -> (usize, usize) {
match a {
Some(a) if a <= b => a,
_ => b,
}
}
pub fn quoted_heredoc_lines(source: &str) -> std::collections::HashSet<usize> {
QuotedRegions::analyze(source).quoted_heredoc
}
pub const QUOTE_SENSITIVE_RULES: &[&str] = &[
"SC1020", "SC1026", "SC1140", "SC1035", "SC1044", "SC1045", "SC1065", "SC1007", "SC1014", "SC1036", "SC1037", "SC1041", "SC1100", ];
pub fn is_quote_sensitive(code: &str) -> bool {
QUOTE_SENSITIVE_RULES.contains(&code)
}
pub fn mask_literals(source: &str) -> String {
let regions = QuotedRegions::analyze(source);
if regions.is_empty() {
return source.to_string();
}
let mut out: Vec<u8> = Vec::with_capacity(source.len());
for (idx, line) in source.split('\n').enumerate() {
if idx > 0 {
out.push(b'\n');
}
mask_line(line, idx + 1, ®ions, &mut out);
}
String::from_utf8(out).unwrap_or_else(|_| source.to_string())
}
fn mask_line(line: &str, line_no: usize, regions: &QuotedRegions, out: &mut Vec<u8>) {
let ranges = regions.ranges_for(line_no);
let mut next = 0;
for (col, byte) in line.bytes().enumerate() {
let col = col + 1;
while next < ranges.len() && ranges[next].1 < col {
next += 1;
}
let literal = ranges
.get(next)
.is_some_and(|&(start, end)| col >= start && col <= end);
out.push(if literal && byte != b'\r' { b'x' } else { byte });
}
}
pub fn restore_masked_messages(source: &str, masked: &str, result: &mut crate::linter::LintResult) {
if result.diagnostics.is_empty() {
return;
}
let src_lines: Vec<&str> = source.lines().collect();
let masked_lines: Vec<&str> = masked.lines().collect();
for diag in result.diagnostics.iter_mut() {
if !is_quote_sensitive(&diag.code) {
continue;
}
let (line, lo, hi) = (diag.span.start_line, diag.span.start_col, diag.span.end_col);
let (Some(from), Some(to)) = (
span_text(&masked_lines, line, lo, hi),
span_text(&src_lines, line, lo, hi),
) else {
continue;
};
if from != to && !from.is_empty() && diag.message.contains(&from) {
diag.message = diag.message.replace(&from, &to);
}
}
}
fn span_text(lines: &[&str], line: usize, start: usize, end: usize) -> Option<String> {
if start == 0 || end <= start {
return None;
}
let bytes = lines.get(line.checked_sub(1)?)?.as_bytes();
let hi = end.saturating_sub(1).min(bytes.len());
let lo = start.saturating_sub(1).min(hi);
Some(String::from_utf8_lossy(&bytes[lo..hi]).into_owned())
}
fn discard_from(per_line: &mut [Vec<(usize, usize)>], line: usize, col: usize) {
for (idx, ranges) in per_line.iter_mut().enumerate() {
let ln = idx + 1;
if ln > line {
ranges.clear();
} else if ln == line {
ranges.retain_mut(|range| {
if range.0 >= col {
return false;
}
range.1 = range.1.min(col - 1);
true
});
}
}
}
fn coalesce(marks: &[bool]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (i, &m) in marks.iter().enumerate() {
match (m, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
out.push((s + 1, i));
start = None;
}
_ => {}
}
}
if let Some(s) = start {
out.push((s + 1, marks.len()));
}
out
}
#[derive(Debug, Default)]
struct Scanner {
stack: Vec<Ctx>,
quoted_heredoc: std::collections::HashSet<usize>,
line_no: usize,
quote_open_at: Option<(usize, usize)>,
pending: std::collections::VecDeque<Heredoc>,
body: Option<Heredoc>,
}
impl Scanner {
fn scan_line(&mut self, line: &str) -> Vec<bool> {
let bytes = line.as_bytes();
let mut marks = vec![false; bytes.len()];
if self.consume_heredoc_body(line, &mut marks) {
return marks;
}
let mut i = 0;
while i < bytes.len() {
i = match self.stack.last().copied() {
Some(Ctx::Single) => self.step_single(bytes, i, &mut marks),
Some(Ctx::Ansi) => self.step_ansi(bytes, i, &mut marks),
Some(Ctx::Double) => self.step_double(bytes, i, &mut marks),
_ => self.step_code(bytes, i, &mut marks),
};
}
if self.body.is_none() {
self.body = self.take_pending();
}
marks
}
fn consume_heredoc_body(&mut self, line: &str, marks: &mut [bool]) -> bool {
let Some(doc) = self.body.clone() else {
return false;
};
let candidate = if doc.strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
if candidate.trim_end() == doc.delim {
self.body = self.take_pending();
return true;
}
if doc.quoted {
self.quoted_heredoc.insert(self.line_no);
}
marks.iter_mut().for_each(|m| *m = true);
true
}
fn take_pending(&mut self) -> Option<Heredoc> {
let mut doc = self.pending.pop_front()?;
doc.body_start = self.line_no + 1;
Some(doc)
}
fn step_single(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
if bytes[i] == b'\'' {
self.pop_quote();
} else {
marks[i] = true;
}
i + 1
}
fn step_ansi(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
match bytes[i] {
b'\\' => {
marks[i] = true;
if let Some(m) = marks.get_mut(i + 1) {
*m = true;
}
i + 2
}
b'\'' => {
self.pop_quote();
i + 1
}
_ => {
marks[i] = true;
i + 1
}
}
}
fn push_quote(&mut self, ctx: Ctx, i: usize) {
if self.quote_open_at.is_none() {
self.quote_open_at = Some((self.line_no, i + 1));
}
self.stack.push(ctx);
}
fn pop_quote(&mut self) {
self.stack.pop();
if !self
.stack
.iter()
.any(|c| matches!(c, Ctx::Single | Ctx::Double | Ctx::Ansi))
{
self.quote_open_at = None;
}
}
fn step_double(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
match bytes[i] {
b'\\' => {
marks[i] = true;
if let Some(m) = marks.get_mut(i + 1) {
*m = true;
}
i + 2
}
b'"' => {
self.pop_quote();
i + 1
}
b'`' => {
self.stack.push(Ctx::Backtick);
i + 1
}
b'$' => self.open_expansion(bytes, i, marks),
_ => {
marks[i] = true;
i + 1
}
}
}
fn step_code(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
match bytes[i] {
b'\\' => i + 2,
b'\'' => {
self.push_quote(Ctx::Single, i);
i + 1
}
b'"' => {
self.push_quote(Ctx::Double, i);
i + 1
}
b'`' => {
self.toggle_backtick();
i + 1
}
b'$' => self.open_expansion(bytes, i, marks),
b'#' => self.maybe_comment(bytes, i, marks),
b'(' => self.open_paren(bytes, i),
b')' => self.close_paren(bytes, i),
b'}' => {
self.pop_if(Ctx::Brace);
i + 1
}
b'<' => self.maybe_heredoc(bytes, i),
_ => i + 1,
}
}
fn open_expansion(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
match (bytes.get(i + 1), bytes.get(i + 2)) {
(Some(b'('), Some(b'(')) => {
self.stack.push(Ctx::Arith);
i + 3
}
(Some(b'('), _) => {
self.stack.push(Ctx::Paren);
i + 2
}
(Some(b'{'), _) => {
self.stack.push(Ctx::Brace);
i + 2
}
(Some(b'\''), _) => {
self.push_quote(Ctx::Ansi, i);
i + 2
}
(Some(b'"'), _) => {
self.push_quote(Ctx::Double, i);
i + 2
}
_ => {
if let Some(next) = end_of_simple_expansion(bytes, i) {
return next;
}
if self.stack.last() == Some(&Ctx::Double) {
marks[i] = true;
}
i + 1
}
}
}
fn toggle_backtick(&mut self) {
if self.stack.last() == Some(&Ctx::Backtick) {
self.stack.pop();
} else {
self.stack.push(Ctx::Backtick);
}
}
fn open_paren(&mut self, bytes: &[u8], i: usize) -> usize {
if bytes.get(i + 1) == Some(&b'(') {
self.stack.push(Ctx::Arith);
return i + 2;
}
if matches!(self.stack.last(), Some(Ctx::Paren | Ctx::Arith)) {
self.stack.push(Ctx::Paren);
}
i + 1
}
fn close_paren(&mut self, bytes: &[u8], i: usize) -> usize {
if self.stack.last() == Some(&Ctx::Arith) {
if bytes.get(i + 1) == Some(&b')') {
self.stack.pop();
return i + 2;
}
return i + 1;
}
self.pop_if(Ctx::Paren);
i + 1
}
fn pop_if(&mut self, ctx: Ctx) {
if self.stack.last() == Some(&ctx) {
self.stack.pop();
}
}
fn maybe_comment(&mut self, bytes: &[u8], i: usize, marks: &mut [bool]) -> usize {
let starts_word = match i.checked_sub(1).map(|p| bytes[p]) {
None => true,
Some(b) => b.is_ascii_whitespace() || matches!(b, b';' | b'&' | b'|' | b'('),
};
if !starts_word {
return i + 1;
}
marks[i..].iter_mut().for_each(|m| *m = true);
bytes.len()
}
fn maybe_heredoc(&mut self, bytes: &[u8], i: usize) -> usize {
if bytes.get(i + 1) != Some(&b'<') {
return i + 1;
}
if bytes.get(i + 2) == Some(&b'<') {
return i + 3;
}
if self.stack.last() == Some(&Ctx::Arith) {
return i + 2;
}
let (doc, next) = parse_heredoc_opener(bytes, i + 2);
if let Some(doc) = doc {
self.pending.push_back(doc);
}
next
}
}
fn end_of_simple_expansion(bytes: &[u8], i: usize) -> Option<usize> {
let first = *bytes.get(i + 1)?;
if matches!(first, b'@' | b'*' | b'#' | b'?' | b'!' | b'$' | b'-') {
return Some(i + 2);
}
if !(first.is_ascii_alphanumeric() || first == b'_') {
return None;
}
let mut end = i + 1;
while bytes
.get(end)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
end += 1;
}
Some(end)
}
fn parse_heredoc_opener(bytes: &[u8], start: usize) -> (Option<Heredoc>, usize) {
let mut i = start;
let strip_tabs = bytes.get(i) == Some(&b'-');
if strip_tabs {
i += 1;
}
while bytes.get(i).is_some_and(|b| *b == b' ' || *b == b'\t') {
i += 1;
}
match bytes.get(i) {
Some(&q @ (b'\'' | b'"')) => parse_quoted_delim(bytes, i, q, strip_tabs),
Some(b) if b.is_ascii_alphabetic() || *b == b'_' => parse_bare_delim(bytes, i, strip_tabs),
_ => (None, i.max(start + 1)),
}
}
fn parse_quoted_delim(
bytes: &[u8],
open: usize,
quote: u8,
strip_tabs: bool,
) -> (Option<Heredoc>, usize) {
let mut end = open + 1;
while end < bytes.len() && bytes[end] != quote {
end += 1;
}
if end >= bytes.len() {
return (None, bytes.len());
}
let delim = String::from_utf8_lossy(&bytes[open + 1..end]).into_owned();
let doc = (!delim.is_empty()).then_some(Heredoc {
delim,
strip_tabs,
quoted: true,
body_start: 0,
});
(doc, end + 1)
}
fn parse_bare_delim(bytes: &[u8], start: usize, strip_tabs: bool) -> (Option<Heredoc>, usize) {
let mut end = start;
while bytes
.get(end)
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
{
end += 1;
}
let delim = String::from_utf8_lossy(&bytes[start..end]).into_owned();
(
Some(Heredoc {
delim,
strip_tabs,
quoted: false,
body_start: 0,
}),
end,
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn mask(source: &str) -> Vec<String> {
let regions = QuotedRegions::analyze(source);
source
.lines()
.enumerate()
.map(|(idx, line)| {
line.bytes()
.enumerate()
.map(|(col, b)| {
if regions.is_literal(idx + 1, col + 1) {
b as char
} else {
'.'
}
})
.collect()
})
.collect()
}
#[test]
fn test_GH226_quoting_single_quotes_are_literal() {
assert_eq!(mask("echo 'abc'"), vec![".....'abc'".replace('\'', ".")]);
}
#[test]
fn test_GH226_quoting_double_quotes_are_literal() {
assert_eq!(mask(r#"echo "abc""#), vec!["......abc."]);
}
#[test]
fn test_GH226_quoting_regex_class_in_double_quotes() {
let m = mask(r#"export PATTERN="PMAT-[0-9]{4}""#);
assert_eq!(m[0], "................PMAT-[0-9]{4}.");
}
#[test]
fn test_GH226_quoting_command_substitution_inside_double_quotes_is_code() {
let m = mask(r#"echo "[$(date '+%H:%M')] started""#);
assert_eq!(m[0], "......[........+%H:%M.._ started.".replace('_', "]"));
}
#[test]
fn test_GH226_quoting_nested_quotes_in_command_substitution() {
let m = mask(r#"out="$(curl -sSfL "$url")""#);
assert_eq!(m[0], "..........................");
}
#[test]
fn test_GH226_quoting_expansion_in_quotes_is_not_masked() {
let m = mask(r#"read -r -a HOST_ARR <<< "$HOSTS""#);
assert_eq!(m[0], "................................");
let m = mask(r#"echo "prefix ${x[0]} $1 $@ suffix""#);
assert_eq!(m[0], "......prefix ....... .. .. suffix.");
}
#[test]
fn test_GH226_quoting_escape_outside_quotes_is_code() {
let m = mask(r#"echo \" hi"#);
assert_eq!(m[0], "..........");
}
#[test]
fn test_GH226_quoting_escaped_quote_inside_double_quotes() {
let m = mask(r#"echo "a\"b""#);
assert_eq!(m[0], r#"......a\"b."#);
}
#[test]
fn test_GH226_quoting_multiline_single_quote() {
let m = mask("echo 'a\nb' done");
assert_eq!(m[0], "......a");
assert_eq!(m[1], "b......");
}
#[test]
fn test_GH226_quoting_trailing_comment_is_literal() {
let m = mask("cmd arg # [0-9]");
assert_eq!(m[0], ".........# [0-9]");
}
#[test]
fn test_GH226_quoting_hash_in_parameter_expansion_is_not_a_comment() {
let m = mask("echo ${#arr}");
assert_eq!(m[0], "............");
}
#[test]
fn test_GH226_quoting_heredoc_body_is_literal() {
let src = "cat <<EOF\nnot [shell] syntax\nEOF\necho ok";
let m = mask(src);
assert_eq!(m[0], ".........");
assert_eq!(m[1], "not [shell] syntax");
assert_eq!(m[2], "...");
assert_eq!(m[3], ".......");
}
#[test]
fn test_GH226_quoting_heredoc_apostrophe_does_not_leak() {
let src = "cat <<EOF\ndon't panic\nEOF\necho 'x'";
let m = mask(src);
assert_eq!(m[3], "......x.");
}
#[test]
fn test_GH226_quoting_here_string_has_no_body() {
let src = "cmd <<< 'word'\necho ok";
let m = mask(src);
assert_eq!(m[0], ".........word.");
assert_eq!(m[1], ".......");
}
#[test]
fn test_GH226_quoting_arith_left_shift_is_not_a_heredoc() {
let src = "x=$(( 1 << n ))\necho ok";
let m = mask(src);
assert_eq!(
m[1], ".......",
"the rest of the file must not be a heredoc body"
);
}
#[test]
fn test_GH226_quoting_backtick_substitution_is_code() {
let m = mask("x=`echo 'a'`");
assert_eq!(m[0], ".........a..");
}
#[test]
fn test_GH226_quoting_is_empty_for_pure_code() {
assert!(QuotedRegions::analyze("mkdir -p /tmp/x\nexit 0").is_empty());
assert!(!QuotedRegions::analyze("echo 'x'").is_empty());
}
#[test]
fn test_GH226_quoting_out_of_range_positions_are_not_literal() {
let r = QuotedRegions::analyze("echo 'x'");
assert!(!r.is_literal(0, 1), "line 0 does not exist (1-indexed)");
assert!(!r.is_literal(99, 1));
assert!(!r.is_literal(1, 999));
}
#[test]
fn test_GH226_quoting_allowlist_excludes_quote_rules() {
for code in ["SC1003", "SC1078", "SC1079", "SC1117", "SC2016", "SC2086"] {
assert!(
!is_quote_sensitive(code),
"{code} inspects quoting and must not be filtered"
);
}
for code in ["SC1020", "SC1035", "SC1140"] {
assert!(is_quote_sensitive(code), "{code} is shell syntax");
}
}
#[test]
fn test_GH226_quoting_unterminated_quote_does_not_blind_later_lines() {
let src = "echo start\necho 'unterminated\n[ -f x]\n";
let regions = QuotedRegions::analyze(src);
assert!(!regions.is_literal(3, 7), "line 3 must stay lintable");
assert!(
!regions.is_literal(2, 8),
"the guessed region is discarded too"
);
assert_eq!(mask_literals(src), src, "nothing may be masked");
}
#[test]
fn test_GH226_quoting_terminated_multiline_quote_is_still_masked() {
let src = "echo 'a\nb'\n[ -f x]\n";
let regions = QuotedRegions::analyze(src);
assert!(regions.is_literal(1, 7), "the quoted text is still literal");
assert!(!regions.is_literal(3, 7));
}
type LintCheck = fn(&str) -> crate::linter::LintResult;
fn allowlisted_checks() -> Vec<(&'static str, LintCheck)> {
use crate::linter::rules::*;
vec![
("SC1014", sc1014::check),
("SC1020", sc1020::check),
("SC1026", sc1026::check),
("SC1035", sc1035::check),
("SC1036", sc1036::check),
("SC1037", sc1037::check),
("SC1007", sc1007::check),
("SC1041", sc1041::check),
("SC1100", sc1100::check),
("SC1044", sc1044::check),
("SC1045", sc1045::check),
("SC1065", sc1065::check),
("SC1140", sc1140::check),
]
}
#[test]
fn test_GH226_quoting_allowlist_names_only_rules_that_exist() {
let known = allowlisted_checks();
for code in QUOTE_SENSITIVE_RULES {
assert!(
known.iter().any(|(c, _)| c == code),
"{code} is allowlisted but names no rule module"
);
}
assert_eq!(
known.len(),
QUOTE_SENSITIVE_RULES.len(),
"allowlist and module list must stay in step"
);
}
#[test]
fn test_GH226_quoting_allowlisted_rules_find_nothing_in_a_pure_literal() {
let sources = [
r#"echo "if [ x] ; then for i in done ] function(a,b) ( ) fi""#,
r#"echo 'case x in [0-9]) do done esac ] { } function f(a) [[ ]] $10'"#,
r#"printf ' x Found [[ ]] (bash-specific) and function keyword
'"#,
];
for src in sources {
let masked = mask_literals(src);
for (code, check) in allowlisted_checks() {
let found = check(&masked);
assert!(
found.diagnostics.is_empty(),
"{code} fired inside a string literal: {:?} on {src}",
found
.diagnostics
.iter()
.map(|d| &d.message)
.collect::<Vec<_>>()
);
}
}
}
#[test]
fn test_GH226_quoting_heredoc_marker_in_a_comment_opens_nothing() {
let src = "#!/bin/sh\n# embed python with <<'PY' ... PY\neval \"$USER_INPUT\"\n";
assert!(quoted_heredoc_lines(src).is_empty());
assert_eq!(mask_literals(src).lines().nth(2), src.lines().nth(2));
}
#[test]
fn test_GH226_quoting_heredoc_marker_in_a_string_opens_nothing() {
let src = "sed -i \"s/<<'EOF'/<<EOF/\" gen.sh\neval \"$X\"\n";
assert!(quoted_heredoc_lines(src).is_empty());
}
#[test]
fn test_GH226_quoting_unterminated_heredoc_does_not_blind_the_file() {
let src = "cat <<'EOF'\nreport\neval \"$USER_INPUT\"\n";
assert!(quoted_heredoc_lines(src).is_empty());
assert_eq!(mask_literals(src), src);
}
#[test]
fn test_GH226_quoting_terminated_heredoc_is_still_reported() {
let src = "cat <<'EOF'\nreport [0-9]\nEOF\necho ok\n";
assert_eq!(quoted_heredoc_lines(src), [2].into_iter().collect());
}
#[test]
fn test_GH226_quoting_unquoted_heredoc_is_not_a_quoted_region() {
let src = "cat <<EOF\nvalue $x\nEOF\n";
assert!(quoted_heredoc_lines(src).is_empty());
}
#[test]
fn test_GH226_quoting_bare_arithmetic_left_shift_is_not_a_heredoc() {
let src = "(( mask = one << shift ))\nif [ -f y]; then :; fi\n";
assert_eq!(mask_literals(src), src);
assert!(quoted_heredoc_lines(src).is_empty());
}
#[test]
fn test_GH226_quoting_ansi_c_escaped_apostrophe_keeps_parity() {
let src = concat!(r#"x=$'don\'t' ; echo 'ok'"#, "\n");
let regions = QuotedRegions::analyze(src);
assert!(
regions.is_literal(1, 7),
"the `don` inside $'...' is literal"
);
assert!(regions.is_literal(1, 21), "'ok' must still be a literal");
}
#[test]
fn test_GH226_quoting_ansi_c_does_not_leak_into_later_lines() {
let src = concat!(
r#"printf $'bad \'%s\'' "$c""#,
"\n",
"if [ -f y]; then :; fi\n"
);
let regions = QuotedRegions::analyze(src);
assert!(!regions.is_literal(2, 10), "line 2 must stay lintable");
}
#[test]
fn test_GH226_quoting_unterminated_quote_does_not_panic() {
for src in [
"echo 'unterminated",
"echo \"unterminated",
"x=$(",
"x=${",
"cat <<",
] {
let _ = QuotedRegions::analyze(src);
}
}
}