use crate::fallback_completion::{count_real_quotes_with, find_closing_quote};
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanSyntax {
Ruby,
Swift,
Toml,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StringLiteral {
pub content: Range<usize>,
pub end: usize,
pub quote: char,
}
#[derive(Debug, Clone, Copy)]
enum SpanKind {
Code,
Str,
LineComment,
BlockComment,
}
struct Span {
range: Range<usize>,
kind: SpanKind,
}
enum Marker {
Delim,
LineComment,
BlockComment,
}
fn is_delimiter(ch: char, syntax: ScanSyntax) -> bool {
match syntax {
ScanSyntax::Ruby | ScanSyntax::Toml => ch == '"' || ch == '\'',
ScanSyntax::Swift => ch == '"',
}
}
fn is_escaped(syntax: ScanSyntax, quote: char) -> bool {
match syntax {
ScanSyntax::Ruby | ScanSyntax::Swift => true,
ScanSyntax::Toml => quote == '"',
}
}
fn is_char_literal_quote(text: &str, at: usize) -> bool {
let Some('?') = text.get(..at).and_then(|s| s.chars().next_back()) else {
return false;
};
let question_at = at - '?'.len_utf8();
let before = text.get(..question_at).unwrap_or_default();
let trimmed = before.trim_end_matches([' ', '\t']);
trimmed.is_empty()
|| trimmed.ends_with([
',', '(', '[', '{', '=', '>', '<', '|', '&', '~', '+', '-', '*', '/', '%', '^', ':',
';', '\n',
])
}
fn find_next_marker(text: &str, from: usize, syntax: ScanSyntax) -> Option<(usize, Marker)> {
let rest = text.get(from..)?;
for (offset, ch) in rest.char_indices() {
if is_delimiter(ch, syntax) {
if syntax == ScanSyntax::Ruby && is_char_literal_quote(text, from + offset) {
continue;
}
return Some((from + offset, Marker::Delim));
}
match syntax {
ScanSyntax::Ruby | ScanSyntax::Toml => {
if ch == '#' {
return Some((from + offset, Marker::LineComment));
}
}
ScanSyntax::Swift => {
if ch == '/' {
match rest.as_bytes().get(offset + 1) {
Some(b'/') => return Some((from + offset, Marker::LineComment)),
Some(b'*') => return Some((from + offset, Marker::BlockComment)),
_ => {}
}
}
}
}
}
None
}
fn block_comment_end(text: &str, at: usize) -> usize {
text.get(at + 2..)
.and_then(|rest| rest.find("*/"))
.map_or(text.len(), |offset| at + 2 + offset + 2)
}
fn line_comment_end(text: &str, at: usize) -> usize {
text.get(at..)
.and_then(|rest| rest.find('\n'))
.map_or(text.len(), |offset| at + offset)
}
fn scan_spans(text: &str, syntax: ScanSyntax) -> Vec<Span> {
let mut spans = Vec::new();
let mut i = 0;
while i < text.len() {
let Some((marker_at, marker)) = find_next_marker(text, i, syntax) else {
spans.push(Span {
range: i..text.len(),
kind: SpanKind::Code,
});
break;
};
if marker_at > i {
spans.push(Span {
range: i..marker_at,
kind: SpanKind::Code,
});
}
let (end, kind) = match marker {
Marker::Delim => (
read_string_literal(text, marker_at, syntax)
.map_or(text.len(), |literal| literal.end),
SpanKind::Str,
),
Marker::LineComment => (line_comment_end(text, marker_at), SpanKind::LineComment),
Marker::BlockComment => (block_comment_end(text, marker_at), SpanKind::BlockComment),
};
spans.push(Span {
range: marker_at..end,
kind,
});
i = end;
}
spans
}
fn find_ruby_closing_quote(rest: &str, quote: char) -> Option<usize> {
find_ruby_closing_quote_primary(rest, quote)
.or_else(|| find_ruby_closing_quote_fallback(rest, quote))
.or_else(|| find_ruby_closing_quote_naive(rest, quote))
}
fn find_ruby_closing_quote_primary(rest: &str, quote: char) -> Option<usize> {
let mut chars = rest.char_indices().peekable();
let mut interpolation_depth: u32 = 0;
let mut nested_quote: Option<char> = None;
while let Some((idx, ch)) = chars.next() {
if interpolation_depth > 0 {
if let Some(nq) = nested_quote {
if ch == '\\' {
chars.next();
} else if ch == nq {
nested_quote = None;
} else if ch == quote {
return None;
}
continue;
}
if ch == '\'' || ch == '"' {
nested_quote = Some(ch);
continue;
}
match ch {
'{' => interpolation_depth += 1,
'}' => interpolation_depth -= 1,
_ => {}
}
continue;
}
if ch == '\\' {
chars.next();
continue;
}
if ch == '#' && chars.peek().is_some_and(|&(_, next)| next == '{') {
chars.next();
interpolation_depth = 1;
continue;
}
if ch == quote {
return Some(idx);
}
}
None
}
fn nested_span_len(rest: &str, start: usize, nq: char, quote: char) -> Option<usize> {
let close_len = find_closing_quote(rest.get(start..)?, nq)?;
let content = rest.get(start..start + close_len)?;
let (quote_count, _) = count_real_quotes_with(content, quote);
(!quote_count.is_multiple_of(2)).then_some(close_len + nq.len_utf8())
}
fn percent_literal_closing_delim(open: char) -> char {
match open {
'(' => ')',
'[' => ']',
'{' => '}',
'<' => '>',
other => other,
}
}
fn percent_literal_end(rest: &str, at: usize) -> Option<usize> {
let after_percent = at + '%'.len_utf8();
let mut chars = rest.get(after_percent..)?.char_indices();
let (_, first) = chars.next()?;
let (open_offset, open) =
if matches!(first, 'r' | 'q' | 'Q' | 'w' | 'W' | 'i' | 'I' | 's' | 'x') {
chars.next()?
} else {
(0, first)
};
if open.is_alphanumeric() || open.is_whitespace() {
return None;
}
let close = percent_literal_closing_delim(open);
let body_start = after_percent + open_offset + open.len_utf8();
let mut depth = 1u32;
let mut body_chars = rest.get(body_start..)?.char_indices();
while let Some((idx, ch)) = body_chars.next() {
if close != open && ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return Some(body_start + idx + close.len_utf8());
}
} else if ch == '\\' {
body_chars.next();
}
}
Some(rest.len())
}
fn find_ruby_closing_quote_fallback(rest: &str, quote: char) -> Option<usize> {
let mut interpolation_depth: u32 = 0;
let mut i = 0;
let mut prev: Option<char> = None;
while i < rest.len() {
let ch = rest.get(i..)?.chars().next()?;
let ch_len = ch.len_utf8();
if interpolation_depth > 0 {
if ch == '\\' {
let escaped = rest.get(i + ch_len..)?.chars().next();
i += ch_len + escaped.map_or(0, char::len_utf8);
prev = escaped.or(Some(ch));
continue;
}
let after_value = matches!(
prev,
Some(c) if c.is_alphanumeric() || matches!(c, '_' | ')' | ']' | '}' | '\'' | '"')
);
if ch == '#' {
i = line_comment_end(rest, i);
prev = None;
continue;
}
if ch == '%'
&& !after_value
&& let Some(end) = percent_literal_end(rest, i)
{
i = end;
prev = Some(')');
continue;
}
let is_regex_or_char_literal_quote = matches!(prev, Some('/' | '?'));
if (ch == '\'' || ch == '"')
&& !is_regex_or_char_literal_quote
&& let Some(span_len) = nested_span_len(rest, i + ch_len, ch, quote)
{
i += ch_len + span_len;
prev = Some(ch);
continue;
}
match ch {
'{' => interpolation_depth += 1,
'}' => interpolation_depth -= 1,
_ => {}
}
i += ch_len;
prev = Some(ch);
continue;
}
if ch == '\\' {
i += ch_len
+ rest
.get(i + ch_len..)?
.chars()
.next()
.map_or(0, char::len_utf8);
continue;
}
if ch == '#' && rest.get(i + ch_len..).is_some_and(|r| r.starts_with('{')) {
i += ch_len + 1;
interpolation_depth = 1;
prev = None;
continue;
}
if ch == quote {
return Some(i);
}
i += ch_len;
}
None
}
fn find_ruby_closing_quote_naive(rest: &str, quote: char) -> Option<usize> {
let mut chars = rest.char_indices().peekable();
let mut interpolation_depth: u32 = 0;
while let Some((idx, ch)) = chars.next() {
if interpolation_depth > 0 {
match ch {
'{' => interpolation_depth += 1,
'}' => interpolation_depth -= 1,
_ => {}
}
continue;
}
if ch == '\\' {
chars.next();
continue;
}
if ch == '#' && chars.peek().is_some_and(|&(_, next)| next == '{') {
chars.next();
interpolation_depth = 1;
continue;
}
if ch == quote {
return Some(idx);
}
}
None
}
#[must_use]
pub fn read_string_literal(text: &str, at: usize, syntax: ScanSyntax) -> Option<StringLiteral> {
let quote = text.get(at..)?.chars().next()?;
let content_start = at + quote.len_utf8();
let rest = text.get(content_start..)?;
let content_len = match syntax {
ScanSyntax::Ruby if quote == '"' => find_ruby_closing_quote(rest, quote)?,
_ if is_escaped(syntax, quote) => find_closing_quote(rest, quote)?,
_ => rest.find(quote)?,
};
let content_end = content_start + content_len;
Some(StringLiteral {
content: content_start..content_end,
end: content_end + quote.len_utf8(),
quote,
})
}
#[expect(
clippy::string_slice,
reason = "span.range.start is always a char boundary: every span built by scan_spans \
starts either at 0, at a char_indices() offset from find_next_marker, or just \
past a prior span's end (itself always a char boundary by the same argument)"
)]
#[must_use]
pub fn strip_line_comment(line: &str, syntax: ScanSyntax) -> &str {
scan_spans(line, syntax)
.into_iter()
.find(|span| matches!(span.kind, SpanKind::LineComment | SpanKind::BlockComment))
.map_or(line, |span| &line[..span.range.start])
}
#[expect(
clippy::indexing_slicing,
reason = "every span.range returned by scan_spans is a sub-range of 0..content.len(), so \
indexing bytes (the same length as content.as_bytes()) with it never panics"
)]
#[must_use]
pub fn blank_comments(content: &str, syntax: ScanSyntax) -> String {
let mut bytes = content.as_bytes().to_vec();
for span in scan_spans(content, syntax) {
if !matches!(span.kind, SpanKind::LineComment | SpanKind::BlockComment) {
continue;
}
for byte in &mut bytes[span.range] {
if *byte != b'\n' {
*byte = b' ';
}
}
}
String::from_utf8(bytes).unwrap_or_else(|_| content.to_string())
}
#[must_use]
pub fn is_code_byte(text: &str, byte_idx: usize, syntax: ScanSyntax) -> bool {
CodeSpans::new(text, syntax).is_code_byte(byte_idx)
}
pub struct CodeSpans<'a> {
text: &'a str,
spans: Vec<Span>,
}
impl<'a> CodeSpans<'a> {
#[must_use]
pub fn new(text: &'a str, syntax: ScanSyntax) -> Self {
Self {
text,
spans: scan_spans(text, syntax),
}
}
#[must_use]
pub fn is_code_byte(&self, byte_idx: usize) -> bool {
if byte_idx >= self.text.len() || !self.text.is_char_boundary(byte_idx) {
return false;
}
let idx = self
.spans
.partition_point(|span| span.range.end <= byte_idx);
self.spans.get(idx).is_some_and(|span| match span.kind {
SpanKind::Code => true,
SpanKind::Str => byte_idx == span.range.start,
SpanKind::LineComment | SpanKind::BlockComment => false,
})
}
}
#[cfg(test)]
#[expect(
clippy::string_slice,
reason = "every literal.content indexed below comes from read_string_literal, whose \
bounds are always char boundaries (see strip_line_comment's justification above)"
)]
mod tests {
use super::*;
#[test]
fn read_string_literal_ruby_double_quoted() {
let text = r#"source: "https://gems.corp""#;
let literal = read_string_literal(text, 8, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], "https://gems.corp");
assert_eq!(literal.end, text.len());
assert_eq!(literal.quote, '"');
}
#[test]
fn read_string_literal_ruby_single_quoted_ignores_double_quotes_inside() {
let text = r#"'vendor/git: "cache"'"#;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"vendor/git: "cache""#);
assert_eq!(literal.end, text.len());
}
#[test]
fn read_string_literal_unterminated_is_none() {
assert!(read_string_literal(r#""no closing quote"#, 0, ScanSyntax::Ruby).is_none());
}
#[test]
fn read_string_literal_ruby_backslash_escaped_quote() {
let text = r#""https://o\'brien.example""#;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"https://o\'brien.example");
}
#[test]
fn read_string_literal_toml_literal_string_has_no_escapes() {
let text = r"'C:\Users\x'";
let literal = read_string_literal(text, 0, ScanSyntax::Toml).unwrap();
assert_eq!(&text[literal.content], r"C:\Users\x");
}
#[test]
fn read_string_literal_ruby_interpolation_same_type_nested_quote_not_truncated() {
let text = r#""https://#{ENV["TOKEN"]}@gems.corp/""#;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(
&text[literal.content],
r#"https://#{ENV["TOKEN"]}@gems.corp/"#
);
}
#[test]
fn read_string_literal_ruby_nested_interpolation_closes_correctly() {
let text = r##""#{a["b#{c}"]}""##;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"#{a["b#{c}"]}"#);
}
#[test]
fn read_string_literal_ruby_interpolation_regex_apostrophe_falls_back_correctly() {
let text = r##""#{x =~ /'/}""##;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_interpolation_regex_apostrophe_does_not_swallow_later_value() {
let text = r##""#{x =~ /'/}", "https://a'b}c""##;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_leak_repro_with_escaped_outer_quote_resolves_via_primary() {
let text = r##"gem "p", require: "#{ ENV['}a\"b']}", source: "https://gems.corp/""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_some());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"#{ ENV['}a\"b']}"#);
}
#[test]
fn read_string_literal_ruby_fallback_nested_brace_in_cross_type_quote_not_truncated() {
let text = r##"gem "p", require: "#{ ENV['}a"b']}", source: "https://gems.corp/""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"#{ ENV['}a"b']}"#);
}
#[test]
fn read_string_literal_ruby_fallback_nested_brace_in_same_type_quote_not_truncated() {
let text = r#"source "https://#{ENV['}a"b']}@gems.corp/" do"#;
let quote_pos = text.find('"').unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(
&text[literal.content],
r#"https://#{ENV['}a"b']}@gems.corp/"#
);
}
#[test]
fn find_ruby_closing_quote_fallback_skips_brace_inside_nested_quote() {
let rest = r#"https://#{ENV['}a"b']}@gems.corp/" do"#;
let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
assert_eq!(&rest[..len], r#"https://#{ENV['}a"b']}@gems.corp/"#);
}
#[test]
fn find_ruby_closing_quote_fallback_rejects_unpaired_quote_far_match() {
let rest = r#"#{x =~ /'/}", "https://a'b}c""#;
let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
assert_eq!(&rest[..len], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_fallback_regex_apostrophe_before_later_odd_parity_value() {
let text = r##"gem "p", require: "#{x =~ /'/}", source: ENV['GEM_HOST']"##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_fallback_regex_apostrophe_before_later_odd_parity_value_git_option()
{
let text = r##"gem "p", require: "#{x =~ /'/}", source: "https://gems.corp/", git: 'https://git.corp/p.git'"##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_lexical_gate_w1_install_if_lambda() {
let text = r##"gem "p", require: "#{x =~ /'/}", install_if: -> { ENV['CI'] }, source: "https://gems.corp/""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_lexical_gate_w2_hash_literal_option() {
let text = r##"gem "p", require: "#{x =~ /'/}", git: { url: ENV['U'] }, source: "https://gems.corp/""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_lexical_gate_w3_brace_in_later_value() {
let text = r##"gem "p", require: "#{x =~ /'/}", source: ENV['A}B'], x: "q""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ /'/}");
}
#[test]
fn read_string_literal_ruby_lexical_gate_w4_char_literal() {
let text = r##"gem "p", require: "#{x == ?'}", install_if: -> { ENV['CI'] }, source: "https://gems.corp/""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x == ?'}");
}
#[test]
fn read_string_literal_ruby_percent_literal_regex_apostrophe_not_swallowed() {
let text = r##"gem "p", require: "#{x =~ %r|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
let quote_pos = text.find("\"#{").unwrap();
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ %r|'| }");
}
#[test]
fn read_string_literal_ruby_percent_literal_word_array_apostrophe_not_swallowed() {
let text = r##"gem "p", require: "#{ %w[a'b] }", install_if: -> { ENV['CI'] }, x: "q""##;
let quote_pos = text.find("\"#{").unwrap();
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{ %w[a'b] }");
}
#[test]
fn read_string_literal_ruby_hash_comment_apostrophe_not_swallowed() {
let text =
"gem \"p\", require: \"#{ # don't\n x }\", install_if: -> { ENV['CI'] }, x: \"q\"";
let quote_pos = text.find("\"#{").unwrap();
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], "#{ # don't\n x }");
}
#[test]
fn read_string_literal_ruby_percent_modulo_not_misread_as_literal() {
let text = r##""#{a % b}""##;
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], "#{a % b}");
}
#[test]
fn read_string_literal_ruby_fallback_percent_after_value_is_modulo_not_over_long_leak() {
let text = r##"gem "a", source: "#{ ENV['a"b'] + x%=2 }", install_if: -> { ENV['CI'] == "1" }, git: "https://TOKEN@evil""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"#{ ENV['a"b'] + x%=2 }"#);
}
#[test]
fn read_string_literal_ruby_fallback_percent_after_percent_literal_close_is_modulo() {
let text = r##"gem "a", source: "#{ ENV['a"b'] + %w[a]%=2 }", install_if: -> { ENV['CI'] == "1" }, git: "https://TOKEN@evil""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r#"#{ ENV['a"b'] + %w[a]%=2 }"#);
}
#[test]
fn find_ruby_closing_quote_fallback_percent_immediately_after_identifier_is_modulo() {
let rest = "#{x%y}\" tail";
let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
assert_eq!(&rest[..len], "#{x%y}");
}
#[test]
fn read_string_literal_ruby_fallback_hash_in_rejected_nested_string_is_a_documented_residual_gap()
{
let text = r##"gem "a", source: "#{ ENV['a"b'] + ENV["c#d"].map { |v|
v } + "z" }", git: "ok""##;
let quote_pos = text.find("\"#{").unwrap();
assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
let full_value = r#"#{ ENV['a"b'] + ENV["c#d"].map { |v|
v } + "z" }"#;
let resolved = &text[literal.content];
assert!(resolved.len() < full_value.len());
assert!(full_value.starts_with(resolved));
}
#[test]
fn read_string_literal_ruby_percent_literal_symbol_apostrophe_not_swallowed() {
let text = r##"gem "p", require: "#{x =~ %s|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
let quote_pos = text.find("\"#{").unwrap();
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ %s|'| }");
}
#[test]
fn read_string_literal_ruby_percent_literal_command_apostrophe_not_swallowed() {
let text = r##"gem "p", require: "#{x =~ %x|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
let quote_pos = text.find("\"#{").unwrap();
let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], r"#{x =~ %x|'| }");
}
#[test]
fn percent_literal_end_handles_nested_brackets() {
let rest = "%w[a[b]c] tail";
let end = percent_literal_end(rest, 0).unwrap();
assert_eq!(&rest[..end], "%w[a[b]c]");
}
#[test]
fn percent_literal_end_bare_delimiter_without_type_letter() {
let rest = "%(hello world) tail";
let end = percent_literal_end(rest, 0).unwrap();
assert_eq!(&rest[..end], "%(hello world)");
}
#[test]
fn percent_literal_end_backslash_delimiter_closes() {
let rest = r"%\a\ tail";
let end = percent_literal_end(rest, 0).unwrap();
assert_eq!(&rest[..end], r"%\a\");
}
#[test]
fn percent_literal_end_rejects_spaced_percent() {
assert!(percent_literal_end("% b", 0).is_none());
}
#[test]
fn find_ruby_closing_quote_naive_ignores_nested_quotes() {
let rest = r#"#{ENV["A"]}", source: "https://gems.corp/""#;
let len = find_ruby_closing_quote_naive(rest, '"').unwrap();
assert_eq!(&rest[..len], r#"#{ENV["A"]}"#);
}
#[test]
fn find_ruby_closing_quote_fallback_handles_long_escaped_quote_run_without_misparsing() {
let escaped_run = r"\'".repeat(2000);
let rest = format!("#{{ x.gsub(/{escaped_run}/, '') }}\"");
let len = find_ruby_closing_quote_fallback(&rest, '"').unwrap();
assert_eq!(&rest[..len], format!("#{{ x.gsub(/{escaped_run}/, '') }}"));
}
#[test]
fn read_string_literal_ruby_single_quoted_never_interpolates() {
let text = r"'#{'";
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], "#{");
}
#[test]
fn read_string_literal_ruby_single_quoted_ignores_hash_brace_content() {
let text = r"'a #{ b'";
let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
assert_eq!(&text[literal.content], "a #{ b");
}
#[test]
fn read_string_literal_ruby_unterminated_interpolation_is_none() {
let text = r##""#{""##;
assert!(read_string_literal(text, 0, ScanSyntax::Ruby).is_none());
}
#[test]
fn strip_line_comment_mismatched_delimiters_falls_through_unchanged() {
let line = r#"source: "foo' # not a comment"#;
assert_eq!(strip_line_comment(line, ScanSyntax::Ruby), line);
}
#[test]
fn strip_line_comment_toml_escape_aware() {
let line = "description = \"a\\\"# x\"";
assert_eq!(strip_line_comment(line, ScanSyntax::Toml), line);
}
#[test]
fn blank_comments_preserves_byte_length_for_non_ascii_comment() {
let content = "value = 1 # héllo wörld 日本語\nvalue2 = 2";
let blanked = blank_comments(content, ScanSyntax::Toml);
assert_eq!(blanked.len(), content.len());
assert!(blanked.starts_with("value = 1 "));
assert!(blanked.contains("\nvalue2 = 2"));
}
#[test]
fn blank_comments_preserves_newline_inside_block_comment() {
let content = "let x = 1 /* multi\nline */ let y = 2";
let blanked = blank_comments(content, ScanSyntax::Swift);
assert_eq!(blanked.len(), content.len());
assert!(blanked.contains('\n'));
assert!(blanked.contains("let y = 2"));
assert!(!blanked.contains("multi"));
}
#[test]
fn blank_comments_no_comment_returns_full_length_copy() {
let content = "no comments here";
assert_eq!(blank_comments(content, ScanSyntax::Ruby), content);
}
#[test]
fn is_code_byte_out_of_range_and_non_boundary_are_false() {
let text = "hé";
assert!(!is_code_byte(text, text.len(), ScanSyntax::Ruby));
assert!(!is_code_byte(text, text.len() + 5, ScanSyntax::Ruby));
assert!(!is_code_byte(text, 2, ScanSyntax::Ruby));
}
#[test]
fn is_code_byte_true_for_plain_code() {
assert!(is_code_byte("gem \"x\"", 0, ScanSyntax::Ruby));
}
#[test]
fn is_code_byte_false_inside_comment() {
let line = "gem \"x\" # comment";
let hash = line.find('#').unwrap();
assert!(!is_code_byte(line, hash, ScanSyntax::Ruby));
}
#[test]
fn code_spans_agrees_with_is_code_byte_at_every_byte() {
let line = r#"gem "x", path: 'vendor/git: "cache"', git: "https://real.example/r" # c"#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
for idx in 0..=line.len() {
assert_eq!(
code.is_code_byte(idx),
is_code_byte(line, idx, ScanSyntax::Ruby),
"byte {idx} disagreed"
);
}
}
#[test]
fn code_spans_out_of_range_and_non_boundary_are_false() {
let text = "hé";
let code = CodeSpans::new(text, ScanSyntax::Ruby);
assert!(!code.is_code_byte(text.len()));
assert!(!code.is_code_byte(text.len() + 5));
assert!(!code.is_code_byte(2));
}
#[test]
fn char_literal_quote_does_not_open_a_string() {
let line = r#"a: ?", install_if: { b: ?", source: "https://evil.example.com", c: ?" }"#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let brace = line.find('{').unwrap();
let source_key = line.find("source:").unwrap();
assert!(code.is_code_byte(brace));
assert!(code.is_code_byte(source_key));
let quote = line[source_key..].find('"').unwrap() + source_key;
let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
assert_eq!(&line[literal.content], "https://evil.example.com");
}
#[test]
fn char_literal_quote_single_quote_variant_also_recognized() {
let line = "a: ?', b: 1";
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let b_key = line.find('b').unwrap();
assert!(code.is_code_byte(b_key));
}
#[test]
fn ternary_with_space_still_opens_a_real_string() {
let line = r#"cond ? "a" : "b""#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let quote = line.find('"').unwrap();
assert!(code.is_code_byte(quote));
let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
assert_eq!(&line[literal.content], "a");
}
#[test]
fn unspaced_ternary_after_value_still_opens_a_real_string() {
let line = r#"flag?"a":"b""#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let quote = line.find('"').unwrap();
assert!(code.is_code_byte(quote));
}
#[test]
fn ternary_with_string_condition_and_no_space_still_opens_a_real_string() {
let line = r#""a"?"b":"c""#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let quote_after_question_mark = line.find('?').unwrap() + 1;
assert!(code.is_code_byte(quote_after_question_mark));
let literal =
read_string_literal(line, quote_after_question_mark, ScanSyntax::Ruby).unwrap();
assert_eq!(&line[literal.content], "b");
}
#[test]
fn ternary_with_space_but_value_before_it_still_opens_a_real_string() {
let line = r#"1 ?"b":"c""#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let quote = line.find('"').unwrap();
assert!(code.is_code_byte(quote));
let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
assert_eq!(&line[literal.content], "b");
}
#[test]
fn char_literal_quote_after_assignment_operator_still_recognized() {
let line = r#"x = ?", y = 1"#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let y_pos = line.find('y').unwrap();
assert!(code.is_code_byte(y_pos));
}
#[test]
fn bang_before_question_mark_does_not_swallow_source_option_into_bogus_string() {
let line = r#"save!?"a", source: "https://evil.example.com""#;
let code = CodeSpans::new(line, ScanSyntax::Ruby);
let url_start = line.find("https").unwrap();
assert!(
!code.is_code_byte(url_start),
"the source: value must stay recognized as string content, not be exposed as code by a bogus string span opened when '!' was (wrongly, defensively excluded now) treated as license for a char literal"
);
let url_quote = url_start - 1;
let literal = read_string_literal(line, url_quote, ScanSyntax::Ruby).unwrap();
assert_eq!(&line[literal.content], "https://evil.example.com");
}
}