deps_core/quote_scan.rs
1//! Shared escape-aware string-literal and comment scanning for manifest raw-text parsers.
2//!
3//! Four ecosystem parsers scan manifest source text byte-by-byte, skipping over string
4//! literals and comments to find or classify code: `deps-bundler`'s Gemfile option
5//! extraction, `deps-swift`'s Package.swift comment stripping, `deps-pypi`'s TOML comment
6//! stripping, and `deps-gradle`'s DSL/version-catalog completion-context detection. Before
7//! this module each did so with its own hand-rolled scanner — one of which (`deps-pypi`'s)
8//! did not track backslash escapes at all, truncating a quoted value early at an escaped
9//! `"`. This module centralizes that skip-scan behind a `syntax`-parameterized surface
10//! ([`crate::quote_scan::ScanSyntax`]), implemented as a single skip loop that advances past
11//! whichever comes first: a string literal (read via
12//! [`crate::quote_scan::read_string_literal`]) or a comment.
13//!
14//! The skip-scan delegates all escape-aware closing-quote search to
15//! [`crate::fallback_completion::find_closing_quote`] rather than re-implementing it, so
16//! there is exactly one escape rule in the workspace, with two exceptions. First, a
17//! [`crate::quote_scan::ScanSyntax::Ruby`] `"..."` literal is scanned via a dedicated `#{...}`
18//! interpolation-aware helper instead, since Ruby interpolation can nest a string literal using
19//! the same quote type as the outer literal — see [`crate::quote_scan::read_string_literal`]'s
20//! doc for the full contract and [`crate::quote_scan::ScanSyntax::Ruby`]'s doc for which
21//! literals this applies to. Second,
22//! [`crate::quote_scan::find_closing_quote_before_comment`] inlines its own copy of the same
23//! `backslash_run`/parity check rather than delegating to `find_closing_quote`, since it needs
24//! to interleave escape-tracking with comment-marker detection in a single forward pass —
25//! a structural requirement `find_closing_quote` itself has no comment awareness to serve, not
26//! an accidental duplication. **Use `find_closing_quote` directly** when a string is already
27//! known to be open and only its closing quote is needed (e.g. completing inside a string the
28//! cursor sits in). **Use this module** when comments are also in play, or when the string's
29//! start position is not already known and must be found by scanning — including via
30//! [`crate::quote_scan::last_string_literal`], which finds the *last* literal in a text (open
31//! or closed) rather than reading one from an already-known position, and
32//! [`crate::quote_scan::find_closing_quote_before_comment`], a comment-aware variant of
33//! `find_closing_quote` for scanning a partially-typed line.
34
35use crate::fallback_completion::{count_real_quotes_with, find_closing_quote};
36use std::ops::Range;
37
38/// Which manifest syntax family a scan should follow: which characters open a string
39/// literal, whether that literal's contents are backslash-escaped, and which characters
40/// start a comment.
41///
42/// One variant per syntax family, not per quote-style/comment-style combination — a
43/// `ScanSyntax` value fully determines both, so a new manifest family adds one variant
44/// here (and an exhaustive `match` on it forces every scan function to handle it) instead
45/// of a cross product of independent style flags.
46///
47/// `#[non_exhaustive]`: this enum gains a variant every time deps-lsp adds support for a
48/// manifest family with its own quoting/comment syntax — it already grew `Groovy` once
49/// (#1186), as an undocumented break against a matching downstream `match`. Ecosystem support
50/// is expected to keep expanding, and exhaustiveness on this type buys callers nothing (no
51/// scan function is meant to special-case "any other syntax"), so a future addition should
52/// ship as a non-breaking patch instead of forcing a major version bump.
53///
54/// # Examples
55///
56/// ```
57/// use deps_core::quote_scan::{ScanSyntax, strip_line_comment};
58///
59/// assert_eq!(
60/// strip_line_comment(r#"gem "x", source: "https://h" # comment"#, ScanSyntax::Ruby),
61/// r#"gem "x", source: "https://h" "#,
62/// );
63/// ```
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ScanSyntax {
67 /// Ruby (Gemfile) syntax: `"` and `'` string literals, both backslash-escaped; `#` starts
68 /// a line comment. Only `"..."` literals interpolate (`#{...}`, tracked by
69 /// [`read_string_literal`] via a dedicated helper) — Ruby's `'...'` literals never
70 /// interpolate, so `#{` inside one is just literal text, scanned like any other
71 /// backslash-escaped content.
72 Ruby,
73 /// Swift (Package.swift) syntax: `"` string literals, backslash-escaped; `//` starts
74 /// a line comment and `/* ... */` a block comment.
75 Swift,
76 /// TOML syntax: `"` string literals are backslash-escaped; `'` literal strings are
77 /// not escaped at all (a `\` inside one is just a literal backslash). `#` starts a
78 /// line comment.
79 Toml,
80 /// Groovy/Kotlin DSL syntax (Gradle build scripts): `"` and `'` string literals, both
81 /// backslash-escaped ([`ScanSyntax::Ruby`]'s quote handling); `//` starts a line comment
82 /// and `/* ... */` a block comment ([`ScanSyntax::Swift`]'s comment handling). Unlike
83 /// Ruby, `?"`/`?'` is not a distinct token, so the `?`-predecessor char-literal check is
84 /// never consulted for this variant.
85 ///
86 /// Known, documented approximations (all pre-existing in this crate's Gradle-specific
87 /// scanners this variant replaces, not new regressions):
88 /// - Groovy GString interpolation (`"${...}"`) nesting a same-type quote
89 /// (`"${p.get("x")}"`) is not tracked — the Groovy analogue of the Ruby `#{}` problem
90 /// [`read_string_literal`]'s interpolation-aware path solves; a literal reads as
91 /// closing at the inner `"` instead.
92 /// - Triple-quoted strings (`'''...'''`, `"""..."""`) are not tokenized; each `'`/`"`
93 /// inside one is read as an ordinary delimiter.
94 /// - A Kotlin `'a'` `Char` literal is scanned as a one-character string literal, which is
95 /// harmless for skip-scanning (the content is opaque either way).
96 /// - Known, intentional divergence from the crate-local scanner this variant replaces
97 /// (#1174 impl-critic C2): the old scanner tracked one `backslash_run` across the
98 /// *entire* input, so a stray `\` in code position (outside any string) suppressed
99 /// the very next quote as a delimiter (e.g. `impl \"a:b:1.0` read as no open string at
100 /// all); this scanner's escape tracking is scoped to inside a literal, so the same
101 /// input opens a `"` literal there instead. Unreachable from valid Groovy/Kotlin
102 /// syntax (a bare `\` is not valid outside a string literal), and the new answer is
103 /// arguably the more correct one, so this is not treated as a regression.
104 Groovy,
105}
106
107/// A string literal read from source text: its raw content span, the position just past
108/// its closing delimiter, and which character delimited it.
109///
110/// `content` excludes both delimiters and is the **raw** source span — escape sequences
111/// inside it (e.g. `\'` in `o\'brien`) are left intact, not unescaped. Byte offsets in
112/// `content` are absolute within the `text` passed to [`read_string_literal`], so callers
113/// can feed them directly to LSP byte-to-position conversion; unescaping, where a caller
114/// needs it, is ecosystem-specific and out of scope here.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct StringLiteral {
117 /// Byte range of the literal's content, excluding both delimiters.
118 pub content: Range<usize>,
119 /// Byte offset just past the closing delimiter.
120 pub end: usize,
121 /// The delimiter character (`"` or `'`) that opened, and must close, this literal.
122 pub quote: char,
123}
124
125/// The *last* string literal found while scanning `text` left to right, open or closed —
126/// the result of [`last_string_literal`].
127///
128/// Unlike [`StringLiteral`] (returned by [`read_string_literal`] for a literal whose
129/// opening position is already known), this describes a literal found by scanning `text`
130/// from its start, and distinguishes a literal that is still open at the end of `text`
131/// (`close: None`) from one that closed exactly at `text`'s end (`close: Some(text.len())`)
132/// — the distinction a completion handler needs to tell "cursor is inside an unterminated
133/// literal" from "cursor sits right after a literal that just closed".
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct ScannedLiteral {
136 /// The delimiter character (`"` or `'`) that opened this literal.
137 pub quote: char,
138 /// Byte offset of the opening delimiter.
139 pub open: usize,
140 /// Byte offset just past the closing delimiter, or `None` if the literal runs
141 /// unterminated to the end of the scanned text.
142 pub close: Option<usize>,
143}
144
145/// Scans `text` left to right (per `syntax`) and returns its last string literal, open or
146/// closed.
147///
148/// This is the one a cursor at the end of `text` would be inside, if any, or (when `text`
149/// ends outside any string) the last one that closed.
150///
151/// A quote of the *other* style encountered while inside an open literal is content, not a
152/// delimiter — toggling between `'`/`"` as independent alternatives rather than assuming one
153/// quote style for the whole text, so a line mixing both styles doesn't have an unrelated
154/// quote desync which delimiter actually closes the literal a cursor sits in. A comment (per
155/// `syntax`) outside any open literal is skipped, not scanned, so a quote character inside
156/// comment text is never mistaken for a literal opener.
157///
158/// # Examples
159///
160/// ```
161/// use deps_core::quote_scan::{ScanSyntax, last_string_literal};
162///
163/// // Still open at the end of `text` — the cursor sits inside it.
164/// let open = last_string_literal(r#"implementation("a:b:1.0"); implementation("c:d:2"#, ScanSyntax::Groovy).unwrap();
165/// assert!(open.close.is_none());
166///
167/// // Closed exactly at `text`'s end.
168/// let closed = last_string_literal(r#"version = "1.0""#, ScanSyntax::Toml).unwrap();
169/// assert_eq!(closed.close, Some(r#"version = "1.0""#.len()));
170/// ```
171#[must_use]
172pub fn last_string_literal(text: &str, syntax: ScanSyntax) -> Option<ScannedLiteral> {
173 scan_spans(text, syntax)
174 .into_iter()
175 .rev()
176 .find_map(|span| match span.kind {
177 SpanKind::Str { quote, terminated } => Some(ScannedLiteral {
178 quote,
179 open: span.range.start,
180 close: terminated.then_some(span.range.end),
181 }),
182 _ => None,
183 })
184}
185
186/// Forward search for the byte offset of `rest`'s real closing `quote` character.
187///
188/// Follows `syntax`'s escaping rule, bailing out (returning `None`, same as "no closing
189/// quote on this line") as soon as a comment marker (per `syntax`: `//`/`/* */` for
190/// [`ScanSyntax::Groovy`]/[`ScanSyntax::Swift`], `#` for [`ScanSyntax::Ruby`] or
191/// [`ScanSyntax::Toml`] with `quote == '"'`) is reached, and skipping over a *closed* block
192/// comment rather than scanning its content for a coincidental match. A `#` is deliberately
193/// **not** a bail marker for [`ScanSyntax::Toml`] with `quote == '\''`: a TOML `'...'`
194/// literal string has no escaping at all and permits `#` as ordinary content (unlike a
195/// `"..."` basic string, where this heuristic still applies), so bailing there would
196/// truncate a legitimately `#`-containing value like `version = '1.0-build#5'`.
197///
198/// `rest` is the tail of a **partially typed** line already known to sit inside an open
199/// literal delimited by `quote` (e.g. from [`last_string_literal`] filtered to `close.is_none()`).
200/// A comment marker found there is a hard stop because the trailing text is mid-edit — not
201/// because a comment can legitimately appear inside a string (it cannot; a real comment
202/// marker inside a closed string is just string content, already handled by
203/// [`read_string_literal`]/[`last_string_literal`], which this function is not used for).
204/// Callers must only use this where the value being completed cannot legitimately contain
205/// the comment marker itself under the bail rule above — a Maven coordinate segment or a
206/// double-quoted TOML catalog version literal contains neither `//` nor `#`; a single-quoted
207/// TOML literal string is exempted from the `#` rule for the reason above instead of being
208/// excluded from this function's use entirely.
209///
210/// # Examples
211///
212/// ```
213/// use deps_core::quote_scan::{ScanSyntax, find_closing_quote_before_comment};
214///
215/// assert_eq!(
216/// find_closing_quote_before_comment(r#"1.0" // trailing"#, '"', ScanSyntax::Groovy),
217/// Some(3),
218/// );
219/// assert_eq!(
220/// find_closing_quote_before_comment(r#"1.0 // still typing"#, '"', ScanSyntax::Groovy),
221/// None,
222/// );
223/// ```
224#[must_use]
225pub fn find_closing_quote_before_comment(
226 rest: &str,
227 quote: char,
228 syntax: ScanSyntax,
229) -> Option<usize> {
230 let escaped = is_escaped(syntax, quote);
231 let mut backslash_run = 0usize;
232 let mut chars = rest.char_indices().peekable();
233 while let Some((idx, ch)) = chars.next() {
234 match ch {
235 '\\' if escaped => backslash_run += 1,
236 '#' if syntax == ScanSyntax::Ruby || (syntax == ScanSyntax::Toml && quote != '\'') => {
237 return None;
238 }
239 '/' if matches!(syntax, ScanSyntax::Groovy | ScanSyntax::Swift) => {
240 match chars.peek().copied() {
241 Some((_, '/')) => return None,
242 Some((star_idx, '*')) => {
243 chars.next();
244 // `?`-bail distinguishes "closed" from "unterminated" (the latter
245 // must return `None`, same as any other unresolved comment marker);
246 // `block_comment_end` alone can't make that distinction, so it's
247 // only called below, once a close is already confirmed, to compute
248 // the resume offset instead of re-deriving the same arithmetic here.
249 rest.get(star_idx + 1..).and_then(|s| s.find("*/"))?;
250 let resume_at = block_comment_end(rest, idx);
251 while chars.peek().is_some_and(|&(i, _)| i < resume_at) {
252 chars.next();
253 }
254 backslash_run = 0;
255 }
256 _ => backslash_run = 0,
257 }
258 }
259 _ if ch == quote => {
260 let is_real = !escaped || backslash_run.is_multiple_of(2);
261 backslash_run = 0;
262 if is_real {
263 return Some(idx);
264 }
265 }
266 _ => backslash_run = 0,
267 }
268 }
269 None
270}
271
272/// Byte-range classification produced by the internal skip-scan.
273#[derive(Debug, Clone, Copy)]
274enum SpanKind {
275 /// Plain source text, outside any string literal or comment.
276 Code,
277 /// A string literal, delimiters included.
278 Str {
279 /// The delimiter character (`"` or `'`) that opened this literal.
280 quote: char,
281 /// Whether the literal closed before `text` ended — `false` when it runs
282 /// unterminated to the end of the scanned text.
283 terminated: bool,
284 },
285 /// A `#`/`//`-style line comment, excluding a trailing newline, if any.
286 LineComment,
287 /// A Swift/Groovy `/* ... */` block comment, delimiters included.
288 BlockComment,
289}
290
291/// One classified byte range produced by [`scan_spans`], in source order and covering
292/// `text` without gaps or overlaps.
293struct Span {
294 range: Range<usize>,
295 kind: SpanKind,
296}
297
298/// The next syntactically significant marker found in `text` at or after `from`.
299enum Marker {
300 /// A string-literal delimiter (`"` or `'`); the delimiter itself is recovered from
301 /// `text` by [`read_string_literal`], so it is not carried here.
302 Delim,
303 /// The start of a line comment (`#` or `//`, per [`ScanSyntax`]).
304 LineComment,
305 /// The start of a Swift `/*` block comment.
306 BlockComment,
307}
308
309/// Whether `ch` opens a string literal under `syntax`.
310fn is_delimiter(ch: char, syntax: ScanSyntax) -> bool {
311 match syntax {
312 ScanSyntax::Ruby | ScanSyntax::Toml | ScanSyntax::Groovy => ch == '"' || ch == '\'',
313 ScanSyntax::Swift => ch == '"',
314 }
315}
316
317/// Whether a literal delimited by `quote` under `syntax` is backslash-escaped — false
318/// only for TOML's literal `'...'` strings, which read every byte up to the next `'`
319/// verbatim.
320fn is_escaped(syntax: ScanSyntax, quote: char) -> bool {
321 match syntax {
322 ScanSyntax::Ruby | ScanSyntax::Swift | ScanSyntax::Groovy => true,
323 ScanSyntax::Toml => quote == '"',
324 }
325}
326
327/// True when the `"`/`'` at `text[at]` is actually Ruby's `?"`/`?'` one-character literal — a
328/// self-contained token, not a string-opening delimiter — e.g. the `?"` in `a: ?"` (bug-ops/
329/// deps-lsp#1039: a naive quote-toggling scan misreads `?"` as *opening* a string, desyncing
330/// quote state for everything after and hiding real brackets/option keys from bracket-depth
331/// tracking that assumes it can trust that state).
332///
333/// Heuristic, matching Ruby's real "expression expected" lexer state closely enough for manifest
334/// scanning without a full Ruby lexer: fires only when `?` is immediately followed (no space) by
335/// the quote character, **and** the last non-whitespace character before `?` — skipping over any
336/// run of plain spaces/tabs, so a space right before `?` never hides what actually precedes it —
337/// is one that unambiguously starts a fresh Ruby expression (`,` `(` `[` `{` `=` `>` `<` `|` `&`
338/// `~` `+` `-` `*` `/` `%` `^` `:` `;` `\n`, or nothing at all — start of text). Any other
339/// preceding token means a *value* already sits there, putting `?` in ternary-operator position
340/// instead (`cond ? "a" : "b"`, the unspaced `cond?"a":"b"`, or a ternary whose condition is
341/// itself a string literal like `"a" ? "b" : "c"`), where the following quote is a genuine string
342/// delimiter, not a character literal.
343///
344/// Allowlist-based, not a blocklist (#1039 critic finding C4): an earlier blocklist version
345/// (reject only when preceded by an identifier char or a closing `)`/`]`/`}`) missed that a
346/// *closing quote* or a bare space can also immediately precede `?` in genuine ternary position
347/// — `"a"?"..."` (condition is a string literal, no space at all before `?`) and `1 ?"..."`
348/// (space before `?`, but the real predecessor past that space is the value `1`) both slipped
349/// through the blocklist and had their real string's opening quote misread as a character
350/// literal, silently exposing the string's contents as "code" — reopening the exact quote-desync
351/// leak class this function exists to close, in the opposite direction. An allowlist cannot make
352/// that mistake: it only fires where a value provably cannot already be sitting.
353///
354/// **`!` is deliberately excluded** (#1039, defense-in-depth — see the round-7 correctness-gate
355/// doc correction below; this is *not* fixing a demonstrated false positive against valid Ruby).
356/// An earlier version of this doc justified the exclusion by claiming a bang-suffixed value like
357/// `save!` puts a following `?` in ternary-operator position, not char-literal position — that
358/// claim is factually wrong. Verified with `ruby -c`: both `x.save!?"a"` and `x.save! ?"a"`
359/// produce a syntax error ("unexpected character literal"), meaning Ruby's own lexer treats `!`
360/// exactly like the other allowlisted punctuation here — it puts `?"`/`?'` in char-literal
361/// (expression-begin) position, not ternary — and simply rejects the *result* as invalid in that
362/// argument position. So `!` in the allowlist was never a reachable false positive against
363/// genuinely valid Ruby; the shape that originally motivated removing it was itself not valid
364/// Ruby to begin with.
365///
366/// `!` stays excluded anyway, but as a conservative simplification rather than a correctness fix:
367/// relying on "Ruby will always reject this shape anyway" to justify recognizing a `?"`/`?'` right
368/// after `!` as a char literal is fragile — it ties this heuristic's correctness to an assumption
369/// about what surrounding Ruby *rejects*, not just what a `?"`/`?'` itself means, which is a
370/// larger and more easily invalidated assumption than the other allowlisted characters need. The
371/// cost is narrow: a genuine `!?"..."` (logical-NOT applied to a character literal, e.g. `x =
372/// !?"a"`) is now a false negative — the quote falls back to being read as a real string
373/// delimiter — rather than a demonstrated regression on any currently-valid Ruby input.
374///
375/// **Scope of the fix — a narrow set of unambiguous punctuation predecessors only.** This closes
376/// the `?"`/`?'` desync class when the character before `?` is one of the allowlisted punctuation
377/// characters above. A Ruby *keyword* predecessor (`and`, `or`, `not`, `then`, `return`, `when`,
378/// `else`, etc. — e.g. `return ?"` or `x = flag ? ?a : ?b`, where `?"`/`?a` right after
379/// `return`/`?`/`:` still legitimately opens a character literal) is not in the allowlist and so
380/// is not recognized, meaning a genuine char-literal position right after one of those keywords
381/// still desyncs quote state today. Ruby's real `?X` disambiguation is a lexer-state decision
382/// (`EXPR_BEG`/`EXPR_ARG` vs `EXPR_END`) plus a spacing rule — no character-class test of a single
383/// preceding byte can fully decide it, keyword or punctuation; closing the keyword-predecessor
384/// case (along with `%q`/`%w` percent-literals, regex literals, and heredocs — the other Ruby
385/// literal forms [`ScanSyntax::Ruby`] does not tokenize) needs real token-level lexing, not
386/// another allowlist entry. Tracked as a known, documented gap rather than attempted here — see
387/// bug-ops/deps-lsp#1039's follow-up discussion; every such vector still requires the Gemfile to
388/// already contain unusual/invalid-for-this-scanner Ruby, so it grants no capability beyond what
389/// a plain, valid `source: "evil"` already would.
390///
391/// Only ever called for [`ScanSyntax::Ruby`] — `?"` is not a distinct token in Swift or TOML.
392fn is_char_literal_quote(text: &str, at: usize) -> bool {
393 let Some('?') = text.get(..at).and_then(|s| s.chars().next_back()) else {
394 return false;
395 };
396 let question_at = at - '?'.len_utf8();
397 let before = text.get(..question_at).unwrap_or_default();
398 let trimmed = before.trim_end_matches([' ', '\t']);
399 trimmed.is_empty()
400 || trimmed.ends_with([
401 ',', '(', '[', '{', '=', '>', '<', '|', '&', '~', '+', '-', '*', '/', '%', '^', ':',
402 ';', '\n',
403 ])
404}
405
406/// Finds the first delimiter or comment-start marker in `text` at or after `from`.
407fn find_next_marker(text: &str, from: usize, syntax: ScanSyntax) -> Option<(usize, Marker)> {
408 let rest = text.get(from..)?;
409 for (offset, ch) in rest.char_indices() {
410 if is_delimiter(ch, syntax) {
411 if syntax == ScanSyntax::Ruby && is_char_literal_quote(text, from + offset) {
412 continue;
413 }
414 return Some((from + offset, Marker::Delim));
415 }
416 match syntax {
417 ScanSyntax::Ruby | ScanSyntax::Toml => {
418 if ch == '#' {
419 return Some((from + offset, Marker::LineComment));
420 }
421 }
422 ScanSyntax::Swift | ScanSyntax::Groovy => {
423 if ch == '/' {
424 match rest.as_bytes().get(offset + 1) {
425 Some(b'/') => return Some((from + offset, Marker::LineComment)),
426 Some(b'*') => return Some((from + offset, Marker::BlockComment)),
427 _ => {}
428 }
429 }
430 }
431 }
432 }
433 None
434}
435
436/// Byte offset just past a Swift block comment's closing `*/`, given the opening `/*`'s
437/// start `at` — `text.len()` if unterminated, mirroring [`ScanSyntax::Swift`]'s existing
438/// "runs to end of input" behavior for an unterminated block comment.
439fn block_comment_end(text: &str, at: usize) -> usize {
440 text.get(at + 2..)
441 .and_then(|rest| rest.find("*/"))
442 .map_or(text.len(), |offset| at + 2 + offset + 2)
443}
444
445/// Byte offset of a line comment's end: the position of the next `\n`, or `text.len()` if
446/// the comment runs to the end of `text`. Excludes the newline itself, so
447/// [`SpanKind::LineComment`] never swallows it — matching [`ScanSyntax::Swift`]'s existing
448/// line-comment behavior, which both [`blank_comments`] and [`strip_line_comment`] now
449/// share across all three syntaxes.
450fn line_comment_end(text: &str, at: usize) -> usize {
451 text.get(at..)
452 .and_then(|rest| rest.find('\n'))
453 .map_or(text.len(), |offset| at + offset)
454}
455
456/// The core skip-scan: classifies every byte of `text` under `syntax` as code, a string
457/// literal, a line comment, or a block comment, in source order.
458///
459/// Semantics an unterminated construct leaves for callers:
460/// - An unterminated string literal (its delimiter never closes) absorbs every remaining
461/// byte of `text` into one [`SpanKind::Str`] span — nothing after it can be a comment,
462/// since Ruby/TOML/Swift would not parse past an unterminated literal either.
463/// - An unterminated Swift block comment absorbs every remaining byte into one
464/// [`SpanKind::BlockComment`] span.
465fn scan_spans(text: &str, syntax: ScanSyntax) -> Vec<Span> {
466 let mut spans = Vec::new();
467 let mut i = 0;
468 while i < text.len() {
469 let Some((marker_at, marker)) = find_next_marker(text, i, syntax) else {
470 spans.push(Span {
471 range: i..text.len(),
472 kind: SpanKind::Code,
473 });
474 break;
475 };
476 if marker_at > i {
477 spans.push(Span {
478 range: i..marker_at,
479 kind: SpanKind::Code,
480 });
481 }
482 let (end, kind) = match marker {
483 Marker::Delim => {
484 let literal = read_string_literal(text, marker_at, syntax);
485 let terminated = literal.is_some();
486 // `literal.quote` is reused on the closed path; the unterminated path (no
487 // `StringLiteral` to read it from) falls back to the same lookup directly.
488 let quote = literal.as_ref().map_or_else(
489 || {
490 text.get(marker_at..)
491 .and_then(|s| s.chars().next())
492 .unwrap_or('"')
493 },
494 |l| l.quote,
495 );
496 let end = literal.map_or(text.len(), |literal| literal.end);
497 (end, SpanKind::Str { quote, terminated })
498 }
499 Marker::LineComment => (line_comment_end(text, marker_at), SpanKind::LineComment),
500 Marker::BlockComment => (block_comment_end(text, marker_at), SpanKind::BlockComment),
501 };
502 spans.push(Span {
503 range: marker_at..end,
504 kind,
505 });
506 i = end;
507 }
508 spans
509}
510
511/// Finds the byte length of a Ruby string literal's content, given `rest` (the text
512/// immediately after the opening `quote`) — interpolation-aware, unlike the generic
513/// [`find_closing_quote`], since Ruby's `#{...}` interpolation can embed a nested string
514/// literal using the *same* quote type as the outer literal (`"https://#{ENV["CREDS"]}@..."`
515/// — closing on the first same-type quote, as a naive scan would, truncates the value right
516/// there (#1041)).
517///
518/// Three-tier chain: [`find_ruby_closing_quote_primary`], then
519/// [`find_ruby_closing_quote_fallback`] when the primary pass can't resolve a close, then
520/// [`find_ruby_closing_quote_naive`] as a final, quote-blind resort that never fails on input
521/// the pre-#1047 baseline could resolve — see each function's doc for what it covers and why
522/// the next is needed.
523fn find_ruby_closing_quote(rest: &str, quote: char) -> Option<usize> {
524 find_ruby_closing_quote_primary(rest, quote)
525 .or_else(|| find_ruby_closing_quote_fallback(rest, quote))
526 .or_else(|| find_ruby_closing_quote_naive(rest, quote))
527}
528
529/// Primary pass behind [`find_ruby_closing_quote`]: tracks `#{` … `}` interpolation-depth (as
530/// a counter, so nested interpolation like `#{a["b#{c}"]}` closes correctly) and suspends the
531/// outer-quote check for its extent. While inside an interpolation span, a `'`/`"` opens a
532/// *nested* string whose own `{`/`}` are then content, not depth markers (`#{ENV["A}B"]}`) —
533/// otherwise the nested string's `}` would drop `interpolation_depth` early and its own
534/// closing quote would be misread as the outer literal's terminator (the same #1041 leak class
535/// via a different construct).
536///
537/// Security regression (critic finding S2, #1041 follow-up): a cross-type nested "string"
538/// (`nested_quote` holding the *other* quote character from `quote`) that is actually a regex
539/// or character literal's lone quote-shaped byte (`/'/`, `?'`) does not reliably fail to close
540/// — if a *later, unrelated* occurrence of that same character happens to appear further down
541/// `rest` (e.g. inside a completely different option's value on the same line), the latch
542/// closes there instead, silently absorbing everything in between (including a real `}` that
543/// should have ended the interpolation) as swallowed "nested string content". The result was a
544/// `Some` too far past the literal's true end rather than a clean scan failure, so the
545/// `find_ruby_closing_quote` fallback (triggered only by `None`) never engaged. Guarded here by
546/// aborting — returning `None` so [`find_ruby_closing_quote_fallback`] takes over — the moment
547/// `quote` itself (the *outer* literal's own delimiter) is seen while a *cross-type* nested
548/// quote is still open: a well-formed cross-type nested string (`ENV['X']` inside a `"..."`
549/// literal) has no structural reason to contain the outer literal's own quote character before
550/// its own close, so seeing one there is treated as proof the nested-quote assumption doesn't
551/// hold here — even though it may still hold in general (the fallback pass resolves those
552/// cases, see its doc). This can never misfire for a *same-type* nested string (`nq == quote`):
553/// there, any occurrence of `quote` always satisfies the `ch == nq` arm first and closes the
554/// nested span normally instead of reaching this check.
555fn find_ruby_closing_quote_primary(rest: &str, quote: char) -> Option<usize> {
556 let mut chars = rest.char_indices().peekable();
557 let mut interpolation_depth: u32 = 0;
558 let mut nested_quote: Option<char> = None;
559 while let Some((idx, ch)) = chars.next() {
560 if interpolation_depth > 0 {
561 if let Some(nq) = nested_quote {
562 if ch == '\\' {
563 chars.next();
564 } else if ch == nq {
565 nested_quote = None;
566 } else if ch == quote {
567 return None;
568 }
569 continue;
570 }
571 if ch == '\'' || ch == '"' {
572 nested_quote = Some(ch);
573 continue;
574 }
575 match ch {
576 '{' => interpolation_depth += 1,
577 '}' => interpolation_depth -= 1,
578 _ => {}
579 }
580 continue;
581 }
582 if ch == '\\' {
583 chars.next();
584 continue;
585 }
586 if ch == '#' && chars.peek().is_some_and(|&(_, next)| next == '{') {
587 chars.next();
588 interpolation_depth = 1;
589 continue;
590 }
591 if ch == quote {
592 return Some(idx);
593 }
594 }
595 None
596}
597
598/// Looks ahead from `start` (just past a candidate nested-quote opener `nq`, met while
599/// [`find_ruby_closing_quote_fallback`] is inside an open interpolation and already cleared by
600/// its lexical `/`/`?`-predecessor gate) for `nq`'s escape-aware matching close via
601/// [`find_closing_quote`], and decides whether to trust the span in between as genuine
602/// nested-string content (so its `{`/`}` are skipped as content, not counted as depth markers).
603///
604/// This is a *second* gate, not the sole trust signal (#1047 follow-up round 2: neither
605/// direction of this parity check is a proof on its own — see the caller's doc for the lexical
606/// gate that runs first). The signal here is the parity of how many *real* (non-escaped, via
607/// [`count_real_quotes_with`] — matching [`find_closing_quote`]'s own escape rule) times the
608/// *outer* literal's own `quote` character appears inside the candidate span: an odd count
609/// (including exactly one) is what genuine nested content containing the outer delimiter
610/// verbatim looks like (`'}a"b'` inside a `"..."` literal — the outer `"` appears once, as
611/// plain content, #1047); an even count means a complete `"..."`/`'...'` literal fully opened
612/// and closed inside the supposed span — one signature (among others the lexical gate catches
613/// instead) of `nq` being a stray non-string quote whose "close" is really some later,
614/// unrelated value's own delimiter (the #1041-follow-up S2 latch bug the primary pass's abort
615/// exists to dodge). Returns `None` — untrusted — when no close is found at all, or the parity
616/// check fails.
617///
618/// Parity alone is not infallible in *either* direction: a stray quote's candidate span can
619/// land on odd parity by chance and get wrongly trusted (#1047 follow-up round 2), or on even
620/// parity and get wrongly rejected when genuine (#1047 follow-up round 1, caught by
621/// [`find_ruby_closing_quote_naive`]).
622///
623/// The wrongly-trusted (odd-parity) direction is caught by the caller for a stray quote
624/// shaped like a regex or character literal (`/'/`, `?'`) via its lexical gate, and — since
625/// #1060 — for one embedded in a `%`-literal (`%r|'|`, `%w[a'b]`) or a `#` line comment via a
626/// span-skip that never lets `nested_span_len` see that quote at all.
627/// [`find_ruby_closing_quote_naive`] can't help with any of these, since a wrongly-trusted
628/// span can still resolve to a `Some`, not just a `None`. Three residual gaps remain, all
629/// documented rather than fixed because closing them costs more (in leaked, over-long spans)
630/// than the truncation they prevent — see [`find_ruby_closing_quote_fallback`]'s doc for the
631/// `%`/`#` gate this reasoning applies to:
632/// - Ruby heredocs, a literal form this scanner does not tokenize at all.
633/// - String content that happens to look like a `#` comment once this function has already
634/// rejected it as a nested string (`ENV["c#d"]` inside `#{...}`) — the `#` there is
635/// unconditionally treated as a real comment (#1060 follow-up round 2, critic finding S3).
636/// - `%`-literal recognition is predecessor-approximated (an `after_value` heuristic, not real
637/// EXPR_BEG/EXPR_ARG lexer state), so a `%` in an ambiguous position can still be mis-skipped
638/// (#1060 follow-up round 2, critic finding M3).
639fn nested_span_len(rest: &str, start: usize, nq: char, quote: char) -> Option<usize> {
640 let close_len = find_closing_quote(rest.get(start..)?, nq)?;
641 let content = rest.get(start..start + close_len)?;
642 let (quote_count, _) = count_real_quotes_with(content, quote);
643 (!quote_count.is_multiple_of(2)).then_some(close_len + nq.len_utf8())
644}
645
646/// Maps a Ruby `%`-literal's opening delimiter to its closing delimiter: the four
647/// bracket-pair openers nest (`%w[a[b]c]`'s inner `[`/`]` don't close the literal early);
648/// every other delimiter (including `%r|...|`'s `|`) closes on its own next unescaped
649/// occurrence.
650fn percent_literal_closing_delim(open: char) -> char {
651 match open {
652 '(' => ')',
653 '[' => ']',
654 '{' => '}',
655 '<' => '>',
656 other => other,
657 }
658}
659
660/// Byte offset in `rest` just past a Ruby `%`-literal's closing delimiter, given
661/// `rest[at] == '%'` — `None` when `at` is not actually a `%`-literal opener, so the caller
662/// falls through to treating `%` as ordinary content (e.g. the modulo operator in `a % b`).
663///
664/// Recognizes an optional one-letter literal type — Ruby's full set, `%q`/`%Q` (string),
665/// `%w`/`%W` (word array), `%i`/`%I` (symbol array), `%r` (regex), `%s` (symbol), `%x`
666/// (command) — immediately followed by a delimiter, or a bare `%<delim>` (`%(...)`, `%{...}`,
667/// ...).
668/// Requires the delimiter to sit immediately after `%` (or the type letter) with no space —
669/// `a % b`'s `%` is followed by a space, which is neither a type letter nor a punctuation
670/// delimiter, so it is correctly rejected here. The delimiter itself must be neither
671/// alphanumeric nor whitespace, which also rules out `%` inside an identifier.
672///
673/// An unterminated `%`-literal (its delimiter never closes) absorbs the rest of `rest`,
674/// mirroring how an unterminated string literal is handled elsewhere in this module. Ruby
675/// heredocs are a distinct literal form this scanner does not tokenize and remain out of
676/// scope (see [`find_ruby_closing_quote_fallback`]'s doc).
677fn percent_literal_end(rest: &str, at: usize) -> Option<usize> {
678 let after_percent = at + '%'.len_utf8();
679 let mut chars = rest.get(after_percent..)?.char_indices();
680 let (_, first) = chars.next()?;
681 let (open_offset, open) =
682 if matches!(first, 'r' | 'q' | 'Q' | 'w' | 'W' | 'i' | 'I' | 's' | 'x') {
683 chars.next()?
684 } else {
685 (0, first)
686 };
687 if open.is_alphanumeric() || open.is_whitespace() {
688 return None;
689 }
690 let close = percent_literal_closing_delim(open);
691 let body_start = after_percent + open_offset + open.len_utf8();
692 let mut depth = 1u32;
693 let mut body_chars = rest.get(body_start..)?.char_indices();
694 while let Some((idx, ch)) = body_chars.next() {
695 // Delimiter recognition runs before escape-consumption, so a delimiter that happens
696 // to be `\` itself (Ruby permits any non-alphanumeric, non-whitespace delimiter) still
697 // closes the literal instead of always being swallowed as an escape.
698 if close != open && ch == open {
699 depth += 1;
700 } else if ch == close {
701 depth -= 1;
702 if depth == 0 {
703 return Some(body_start + idx + close.len_utf8());
704 }
705 } else if ch == '\\' {
706 body_chars.next();
707 }
708 }
709 Some(rest.len())
710}
711
712/// Middle tier behind [`find_ruby_closing_quote`], engaged once
713/// [`find_ruby_closing_quote_primary`] has already aborted (returned `None`) rather than risk
714/// a wrong answer.
715///
716/// Tracks `#{` … `}` interpolation-depth like the primary pass, but resolves each candidate
717/// nested-quote span via two gates instead of the primary pass's char-by-char latch that never
718/// finds a close and must abort to get here in the first place (#1047: without this, a `}`
719/// inside a genuine nested string — same-type-as-outer or cross-type — was misread as closing
720/// the interpolation early, leaking or truncating whatever followed depending on which quote
721/// the mis-termination landed on):
722///
723/// 1. **Lexical gate** (#1047 follow-up round 2): a `'`/`"` immediately preceded by `?` (a
724/// Ruby character literal, e.g. `?'`) is never a string opener — an absolute grammar fact.
725/// Preceded by `/` it is *usually* a regex literal, not always (`/` is also division), but
726/// treating it as never a string opener is still safe here: it only ever widens which
727/// candidates fall through to plain brace counting instead of being parity-checked, which
728/// the pre-#1047 baseline already did unconditionally for every quote — so this can only
729/// recover cases that baseline got right, never regress below it. Either way the quote is
730/// never passed to [`nested_span_len`] at all and always falls through to plain brace
731/// counting for that character. Without this, a stray regex/char-literal quote whose
732/// candidate span happened
733/// to land on odd parity (see below) was wrongly trusted, silently swallowing everything up
734/// to and past the interpolation's true close as far as some later, unrelated same-type
735/// quote — a `Some` too far, which (unlike the `None` failure mode round 1 covers) the
736/// [`find_ruby_closing_quote_naive`] tier chained after this one is structurally unable to
737/// catch, since `.or_else` only fires on `None`.
738/// 2. **Parity gate** ([`nested_span_len`]) for whatever the lexical gate doesn't already rule
739/// out.
740///
741/// The previous character is tracked in `prev` as the byte-by-byte scan proceeds — no extra
742/// pass or allocation.
743///
744/// A backslash at interpolation top level (not yet inside a trusted nested-quote span) is
745/// consumed together with the character it escapes, *before* that character is ever considered
746/// as a candidate quote opener — both because an escaped quote character is not a real
747/// delimiter, and because without this a long run of escaped quotes (`\'\'\'...`) would each
748/// independently trigger [`nested_span_len`]'s `O(n)` lookahead, an `O(n²)` blowup on
749/// attacker-controlled document text with no size cap (#1047 follow-up C2). The primary pass
750/// does not do this at interpolation top level (only inside an already-open nested quote) —
751/// harmless in practice (verified on `#{ x.gsub(/\}/, '') }"`), but an intentional divergence
752/// between the two passes' otherwise-matching depth tracking.
753///
754/// Even with both gates, this tier is not proven infallible, so it can still fail to find a
755/// close that [`find_ruby_closing_quote_naive`] — chained after it — would have found; unlike
756/// that final tier, this one is allowed to return `None`.
757///
758/// A third mechanism, added for #1060, runs *before* either gate: a `%`-literal
759/// (`%r|'|`, `%w[a'b]`, ...) or a `#` line comment met while `interpolation_depth > 0` is
760/// skipped as a whole span via [`percent_literal_end`] / [`line_comment_end`] before any
761/// quote-shaped byte inside it is ever considered a candidate at all — unlike the lexical
762/// gate, which only ever looks at the single character immediately preceding a candidate
763/// quote, these two constructs can put a stray apostrophe arbitrarily far past their own
764/// opener (`%w[a'b]`'s `'` is preceded by `a`, not `%`/`w`/`[`), so a predecessor-only check
765/// cannot catch them; span-skipping sidesteps the candidate check entirely instead of trying
766/// to extend it. The `%` half of this skip is additionally gated on `prev` not being a value
767/// token (`x%=2` is modulo, not a `%`-literal); the `#` half is deliberately left ungated —
768/// see the `after_value` comment at this function's `%`/`#` branches for why the two are not
769/// symmetric, verified against a Ruby/Prism oracle (#1060 follow-up round 2). Ruby heredocs
770/// are a distinct literal form this scanner does not tokenize and remain out of scope, per
771/// this module's top-level doc.
772fn find_ruby_closing_quote_fallback(rest: &str, quote: char) -> Option<usize> {
773 let mut interpolation_depth: u32 = 0;
774 let mut i = 0;
775 let mut prev: Option<char> = None;
776 while i < rest.len() {
777 let ch = rest.get(i..)?.chars().next()?;
778 let ch_len = ch.len_utf8();
779 if interpolation_depth > 0 {
780 if ch == '\\' {
781 let escaped = rest.get(i + ch_len..)?.chars().next();
782 i += ch_len + escaped.map_or(0, char::len_utf8);
783 prev = escaped.or(Some(ch));
784 continue;
785 }
786 // After a value token, `%` is always modulo, never a %-literal opener (critic S1,
787 // #1060): gate on `prev` or `x%=2` misreads as a %-literal and swallows text to a
788 // later `=`.
789 //
790 // `#` is deliberately NOT gated the same way (critic S3, #1060 round 2 — corrects
791 // this function's earlier doc): Ruby always starts a comment after any predecessor,
792 // so gating would wrongly re-admit an apostrophe inside a real comment as a quote
793 // candidate. Oracle-verified over 86k Ruby cases: ungated `#` only ever truncates
794 // early (safer — see `..._hash_in_rejected_nested_string_is_a_documented_residual_gap`),
795 // while gating it leaks text past the literal's true end (credential-retention path,
796 // S1/S2 handoff).
797 let after_value = matches!(
798 prev,
799 Some(c) if c.is_alphanumeric() || matches!(c, '_' | ')' | ']' | '}' | '\'' | '"')
800 );
801 if ch == '#' {
802 // `prev = None` is safe here: `line_comment_end` always stops at (never past)
803 // `\n`, so the next iteration reprocesses it and sets `prev` correctly before
804 // `after_value` is read again; with no trailing `\n`, the loop ends first.
805 i = line_comment_end(rest, i);
806 prev = None;
807 continue;
808 }
809 if ch == '%'
810 && !after_value
811 && let Some(end) = percent_literal_end(rest, i)
812 {
813 i = end;
814 // A %-literal always produces a value, so `prev = Some(')')` marks it as such
815 // for the next `after_value` check (code-review finding: `prev = None` here let
816 // a %-literal's own close be misread as a fresh EXPR_BEG, re-admitting
817 // `%w[a]%=2`'s trailing `%` as a literal opener again — reopening S1's leak via
818 // a new trigger).
819 prev = Some(')');
820 continue;
821 }
822 // Deliberately not `is_char_literal_quote` (#1062): that helper also treats a
823 // genuine ternary (`flag?'a'`) as non-opening, which would narrow this gate and
824 // re-admit the quote to `nested_span_len`, reopening the wrong-trust direction
825 // #1047 round 2 closed. Treating every unspaced `?`-predecessor as non-opening is
826 // strictly conservative instead — it only ever widens fallthrough to plain brace
827 // counting.
828 let is_regex_or_char_literal_quote = matches!(prev, Some('/' | '?'));
829 if (ch == '\'' || ch == '"')
830 && !is_regex_or_char_literal_quote
831 && let Some(span_len) = nested_span_len(rest, i + ch_len, ch, quote)
832 {
833 i += ch_len + span_len;
834 prev = Some(ch);
835 continue;
836 }
837 match ch {
838 '{' => interpolation_depth += 1,
839 '}' => interpolation_depth -= 1,
840 _ => {}
841 }
842 i += ch_len;
843 prev = Some(ch);
844 continue;
845 }
846 if ch == '\\' {
847 i += ch_len
848 + rest
849 .get(i + ch_len..)?
850 .chars()
851 .next()
852 .map_or(0, char::len_utf8);
853 continue;
854 }
855 if ch == '#' && rest.get(i + ch_len..).is_some_and(|r| r.starts_with('{')) {
856 i += ch_len + 1;
857 interpolation_depth = 1;
858 prev = None;
859 continue;
860 }
861 if ch == quote {
862 return Some(i);
863 }
864 i += ch_len;
865 }
866 None
867}
868
869/// Last-resort tier behind [`find_ruby_closing_quote`]: plain `#{` … `}` brace-depth counting
870/// with no quote-awareness at all, byte-for-byte the same scan this module used as its only
871/// fallback before #1047's [`nested_span_len`]/[`find_ruby_closing_quote_fallback`] heuristic
872/// was added.
873///
874/// Chained last specifically *because* that heuristic can trust a wrong span (a stray
875/// regex/char-literal quote whose candidate span happens to land on odd parity by chance) and
876/// then run off the end of `rest` without `interpolation_depth` ever returning to 0 — this tier
877/// guarantees [`find_ruby_closing_quote`] never regresses below this module's pre-#1047
878/// baseline: for any input that baseline could resolve, this tier resolves it the same way
879/// (#1047 follow-up C1).
880fn find_ruby_closing_quote_naive(rest: &str, quote: char) -> Option<usize> {
881 let mut chars = rest.char_indices().peekable();
882 let mut interpolation_depth: u32 = 0;
883 while let Some((idx, ch)) = chars.next() {
884 if interpolation_depth > 0 {
885 match ch {
886 '{' => interpolation_depth += 1,
887 '}' => interpolation_depth -= 1,
888 _ => {}
889 }
890 continue;
891 }
892 if ch == '\\' {
893 chars.next();
894 continue;
895 }
896 if ch == '#' && chars.peek().is_some_and(|&(_, next)| next == '{') {
897 chars.next();
898 interpolation_depth = 1;
899 continue;
900 }
901 if ch == quote {
902 return Some(idx);
903 }
904 }
905 None
906}
907
908/// Reads the string literal starting at `text[at]`, per `syntax`'s quoting rules.
909///
910/// `text[at]` must be a delimiter character for `syntax` — a precondition, not validated
911/// here; callers only reach this from a position already known to be an opening quote
912/// (a caller-known-open-quote position, or a delimiter [`is_code_byte`]/`scan_spans` found
913/// by scanning). Returns `None` if the literal never closes before `text` ends.
914///
915/// A `ScanSyntax::Ruby` literal is scanned via a dedicated interpolation-aware helper only
916/// when `quote == '"'` — Ruby's `'...'` literals never interpolate (`'#{x}'` is the literal
917/// four-character-plus text `#{x}`, not an expression), so treating `#{` specially inside one
918/// would misparse valid Ruby (critic finding S1, #1041 follow-up): an unbalanced `#{` in a
919/// single-quoted value (e.g. `require: '#{'`) has no matching `}` to close on, so the
920/// interpolation-aware scan never finds the literal's real (very next) closing `'`, and the
921/// resulting "unterminated literal" absorbs the rest of the line — including any later
922/// `source:` option — into one string span, the same public-registry-leak direction #1041
923/// itself fixes, via a different construct. Single-quoted Ruby literals, and Swift/TOML (which
924/// have no interpolation syntax at all), keep the plain escape-aware (Swift, Ruby's `'...'`,
925/// and TOML's `"..."`) or verbatim (TOML's `'...'`) scan via the generic [`find_closing_quote`].
926///
927/// # Examples
928///
929/// ```
930/// use deps_core::quote_scan::{ScanSyntax, read_string_literal};
931///
932/// let text = r#"path: 'vendor/git: "cache"', git: "https://real.example/r""#;
933/// let literal = read_string_literal(text, 6, ScanSyntax::Ruby).unwrap();
934/// assert_eq!(&text[literal.content], r#"vendor/git: "cache""#);
935///
936/// // Ruby interpolation nesting the outer literal's own quote type (#1041).
937/// let text = r#"source: "https://#{ENV["TOKEN"]}@gems.corp/""#;
938/// let literal = read_string_literal(text, 8, ScanSyntax::Ruby).unwrap();
939/// assert_eq!(&text[literal.content], r#"https://#{ENV["TOKEN"]}@gems.corp/"#);
940/// ```
941#[must_use]
942pub fn read_string_literal(text: &str, at: usize, syntax: ScanSyntax) -> Option<StringLiteral> {
943 let quote = text.get(at..)?.chars().next()?;
944 let content_start = at + quote.len_utf8();
945 let rest = text.get(content_start..)?;
946 let content_len = match syntax {
947 ScanSyntax::Ruby if quote == '"' => find_ruby_closing_quote(rest, quote)?,
948 _ if is_escaped(syntax, quote) => find_closing_quote(rest, quote)?,
949 _ => rest.find(quote)?,
950 };
951 let content_end = content_start + content_len;
952 Some(StringLiteral {
953 content: content_start..content_end,
954 end: content_end + quote.len_utf8(),
955 quote,
956 })
957}
958
959/// Returns `line` with its trailing comment (per `syntax`) removed, stopping the search
960/// inside any string literal so a comment marker inside a quoted value is never mistaken
961/// for a real comment.
962///
963/// Returns `line` unchanged if no comment is found on it — including when a string
964/// literal on the line never closes, since an unterminated literal's `SpanKind::Str` span
965/// (see `scan_spans`) absorbs the rest of the line, leaving nothing that could still be
966/// classified as a comment.
967///
968/// # Examples
969///
970/// ```
971/// use deps_core::quote_scan::{ScanSyntax, strip_line_comment};
972///
973/// assert_eq!(
974/// strip_line_comment(r#"source: "a#b" # real comment"#, ScanSyntax::Ruby),
975/// r#"source: "a#b" "#,
976/// );
977/// assert_eq!(strip_line_comment("no comment here", ScanSyntax::Ruby), "no comment here");
978/// ```
979#[expect(
980 clippy::string_slice,
981 reason = "span.range.start is always a char boundary: every span built by scan_spans \
982 starts either at 0, at a char_indices() offset from find_next_marker, or just \
983 past a prior span's end (itself always a char boundary by the same argument)"
984)]
985#[must_use]
986pub fn strip_line_comment(line: &str, syntax: ScanSyntax) -> &str {
987 scan_spans(line, syntax)
988 .into_iter()
989 .find(|span| matches!(span.kind, SpanKind::LineComment | SpanKind::BlockComment))
990 .map_or(line, |span| &line[..span.range.start])
991}
992
993/// Replaces every comment (per `syntax`) in `content` with spaces.
994///
995/// Preserves both byte length and every `\n` inside a comment — so downstream byte-offset
996/// math (LSP position tracking) stays valid whether or not a comment was present, and
997/// multi-line block comments do not collapse the lines after them.
998///
999/// Always returns a full-length buffer, even when `content` has no comments at all —
1000/// never the untouched original, so callers must not rely on pointer or allocation
1001/// identity to detect "no comments found".
1002///
1003/// # Examples
1004///
1005/// ```
1006/// use deps_core::quote_scan::{ScanSyntax, blank_comments};
1007///
1008/// let blanked = blank_comments("let x = 1 // comment\nlet y = 2", ScanSyntax::Swift);
1009/// assert_eq!(blanked, "let x = 1 \nlet y = 2");
1010/// ```
1011#[expect(
1012 clippy::indexing_slicing,
1013 reason = "every span.range returned by scan_spans is a sub-range of 0..content.len(), so \
1014 indexing bytes (the same length as content.as_bytes()) with it never panics"
1015)]
1016#[must_use]
1017pub fn blank_comments(content: &str, syntax: ScanSyntax) -> String {
1018 let mut bytes = content.as_bytes().to_vec();
1019 for span in scan_spans(content, syntax) {
1020 if !matches!(span.kind, SpanKind::LineComment | SpanKind::BlockComment) {
1021 continue;
1022 }
1023 for byte in &mut bytes[span.range] {
1024 if *byte != b'\n' {
1025 *byte = b' ';
1026 }
1027 }
1028 }
1029 // Every replaced byte becomes an ASCII space at its original offset, so a
1030 // multi-byte UTF-8 sequence inside a comment is never left half-blanked into an
1031 // invalid encoding — `from_utf8` always succeeds; the fallback is defensive only.
1032 String::from_utf8(bytes).unwrap_or_else(|_| content.to_string())
1033}
1034
1035/// True iff the skip-scan (per `syntax`) visits `byte_idx` as source code — outside every
1036/// string literal's content and closing delimiter, and outside every comment.
1037///
1038/// A string literal's *opening* delimiter counts as code; its content and closing
1039/// delimiter do not. So a decoy option-like key sitting inside an already-open string
1040/// (e.g. `git:` inside `path: 'vendor/git: "cache"'`) is correctly reported as non-code,
1041/// while the same key outside any string is code — this is what lets a caller take the
1042/// first *code-positioned* regex match on a line instead of rejecting the line outright
1043/// whenever a decoy match exists anywhere on it.
1044///
1045/// Returns `false` for `byte_idx` out of range or not on a char boundary (unreachable
1046/// from the ASCII key regexes that call this).
1047///
1048/// Recomputes `text`'s full classification on every call — checking *one* candidate
1049/// position on `text` is the right tool. A caller checking many candidate positions on
1050/// the *same* `text` (e.g. every regex match on one manifest line) should build one
1051/// [`CodeSpans`] instead: repeatedly rebuilding this classification from scratch, once per
1052/// candidate, turns an O(text length) scan into O(candidates × text length) — quadratic
1053/// against adversarial input packed with decoy substrings (#1022 impl-critic C1).
1054///
1055/// # Examples
1056///
1057/// ```
1058/// use deps_core::quote_scan::{ScanSyntax, is_code_byte};
1059///
1060/// let line = r#"gem "x", path: 'vendor/git: "cache"', git: "https://real.example/r""#;
1061/// let decoy = line.find("git:").unwrap();
1062/// let real = line.rfind("git:").unwrap();
1063/// assert_ne!(decoy, real);
1064/// assert!(!is_code_byte(line, decoy, ScanSyntax::Ruby));
1065/// assert!(is_code_byte(line, real, ScanSyntax::Ruby));
1066/// ```
1067#[must_use]
1068pub fn is_code_byte(text: &str, byte_idx: usize, syntax: ScanSyntax) -> bool {
1069 CodeSpans::new(text, syntax).is_code_byte(byte_idx)
1070}
1071
1072/// A `text`'s code/string/comment classification (per `syntax`), built once and queried
1073/// many times.
1074///
1075/// The amortized alternative to [`is_code_byte`] for a caller that checks several
1076/// candidate positions on the *same* `text` — e.g. every regex match a `find_iter`/
1077/// `captures_iter` pass produces on one manifest line. Build one `CodeSpans` per `text` and
1078/// call [`CodeSpans::is_code_byte`] for each candidate: the underlying scan runs once
1079/// (O(text length)) instead of once per candidate, and each query is a binary search
1080/// (O(log spans)) rather than a full rescan — turning what was an O(candidates × text
1081/// length) — quadratic under adversarial input — into O(text length + candidates ×
1082/// log(spans)) (#1022 impl-critic C1).
1083///
1084/// # Examples
1085///
1086/// ```
1087/// use deps_core::quote_scan::{CodeSpans, ScanSyntax};
1088///
1089/// let line = r#"gem "x", path: 'vendor/git: "cache"', git: "https://real.example/r""#;
1090/// let code = CodeSpans::new(line, ScanSyntax::Ruby);
1091/// let decoy = line.find("git:").unwrap();
1092/// let real = line.rfind("git:").unwrap();
1093/// assert!(!code.is_code_byte(decoy));
1094/// assert!(code.is_code_byte(real));
1095/// ```
1096pub struct CodeSpans<'a> {
1097 text: &'a str,
1098 spans: Vec<Span>,
1099}
1100
1101impl<'a> CodeSpans<'a> {
1102 /// Classifies all of `text` (per `syntax`) once, up front.
1103 #[must_use]
1104 pub fn new(text: &'a str, syntax: ScanSyntax) -> Self {
1105 Self {
1106 text,
1107 spans: scan_spans(text, syntax),
1108 }
1109 }
1110
1111 /// Same contract as [`is_code_byte`], answered against the classification
1112 /// [`CodeSpans::new`] already built instead of recomputing it.
1113 ///
1114 /// `self.spans` covers `0..self.text.len()` contiguously and in order (by
1115 /// construction in `scan_spans`), so the span containing `byte_idx`, if any, is found
1116 /// by binary search rather than a linear scan.
1117 #[must_use]
1118 pub fn is_code_byte(&self, byte_idx: usize) -> bool {
1119 if byte_idx >= self.text.len() || !self.text.is_char_boundary(byte_idx) {
1120 return false;
1121 }
1122 let idx = self
1123 .spans
1124 .partition_point(|span| span.range.end <= byte_idx);
1125 self.spans.get(idx).is_some_and(|span| match span.kind {
1126 SpanKind::Code => true,
1127 SpanKind::Str { .. } => byte_idx == span.range.start,
1128 SpanKind::LineComment | SpanKind::BlockComment => false,
1129 })
1130 }
1131}
1132
1133#[cfg(test)]
1134#[expect(
1135 clippy::string_slice,
1136 reason = "every literal.content indexed below comes from read_string_literal, whose \
1137 bounds are always char boundaries (see strip_line_comment's justification above)"
1138)]
1139mod tests {
1140 use super::*;
1141
1142 #[test]
1143 fn read_string_literal_ruby_double_quoted() {
1144 let text = r#"source: "https://gems.corp""#;
1145 let literal = read_string_literal(text, 8, ScanSyntax::Ruby).unwrap();
1146 assert_eq!(&text[literal.content], "https://gems.corp");
1147 assert_eq!(literal.end, text.len());
1148 assert_eq!(literal.quote, '"');
1149 }
1150
1151 #[test]
1152 fn read_string_literal_ruby_single_quoted_ignores_double_quotes_inside() {
1153 let text = r#"'vendor/git: "cache"'"#;
1154 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1155 assert_eq!(&text[literal.content], r#"vendor/git: "cache""#);
1156 assert_eq!(literal.end, text.len());
1157 }
1158
1159 #[test]
1160 fn read_string_literal_unterminated_is_none() {
1161 assert!(read_string_literal(r#""no closing quote"#, 0, ScanSyntax::Ruby).is_none());
1162 }
1163
1164 #[test]
1165 fn read_string_literal_ruby_backslash_escaped_quote() {
1166 let text = r#""https://o\'brien.example""#;
1167 // Ruby double-quoted strings only need `"` escaped, but the shared scanner
1168 // tracks `\` generically, so an escaped `'` inside a `"`-delimited literal is
1169 // just inert content, not a delimiter at all (Ruby never treats `'` specially
1170 // inside `"..."`).
1171 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1172 assert_eq!(&text[literal.content], r"https://o\'brien.example");
1173 }
1174
1175 #[test]
1176 fn read_string_literal_toml_literal_string_has_no_escapes() {
1177 let text = r"'C:\Users\x'";
1178 let literal = read_string_literal(text, 0, ScanSyntax::Toml).unwrap();
1179 assert_eq!(&text[literal.content], r"C:\Users\x");
1180 }
1181
1182 #[test]
1183 fn read_string_literal_ruby_interpolation_same_type_nested_quote_not_truncated() {
1184 // #1041: a naive scan closes on the first same-type quote it meets, which sits
1185 // inside the `#{...}` interpolation span here, truncating the value.
1186 let text = r#""https://#{ENV["TOKEN"]}@gems.corp/""#;
1187 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1188 assert_eq!(
1189 &text[literal.content],
1190 r#"https://#{ENV["TOKEN"]}@gems.corp/"#
1191 );
1192 }
1193
1194 #[test]
1195 fn read_string_literal_ruby_nested_interpolation_closes_correctly() {
1196 // #1041 repro 2: interpolation-depth must be a counter, not a boolean, so a nested
1197 // `#{c}` inside the outer `#{a["b#{c}"]}` doesn't close the span one `}` early.
1198 let text = r##""#{a["b#{c}"]}""##;
1199 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1200 assert_eq!(&text[literal.content], r#"#{a["b#{c}"]}"#);
1201 }
1202
1203 #[test]
1204 fn read_string_literal_ruby_interpolation_regex_apostrophe_falls_back_correctly() {
1205 // A `'` inside `#{...}` that isn't a string delimiter (here, a regex literal) must not
1206 // latch the nested-quote tracker open forever — the fallback rescan recovers this.
1207 let text = r##""#{x =~ /'/}""##;
1208 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1209 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1210 }
1211
1212 /// Security regression (critic finding S1, #1041 follow-up): a regex/char-literal quote
1213 /// stranded inside `#{...}` can also close the nested-quote latch on a *later, unrelated*
1214 /// occurrence of the same character further down the text (here, another double-quoted
1215 /// value's own delimiter), rather than failing to close at all — in which case the
1216 /// nested-quote-aware pass returns a `Some` that reaches (and swallows) past the literal's
1217 /// true end, instead of the clean `None` the `.or_else` fallback in
1218 /// [`find_ruby_closing_quote`] is triggered by. Guarded in
1219 /// `find_ruby_closing_quote_primary` by aborting the moment the *outer* delimiter is seen
1220 /// while a *cross-type* nested quote is still open.
1221 #[test]
1222 fn read_string_literal_ruby_interpolation_regex_apostrophe_does_not_swallow_later_value() {
1223 let text = r##""#{x =~ /'/}", "https://a'b}c""##;
1224 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1225 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1226 }
1227
1228 /// #1047 (leak direction, as reported): with the outer `"` escaped (`\"`) inside the
1229 /// nested `'}a\"b'`, the primary pass's own backslash handling inside its nested-quote
1230 /// latch consumes that escaped quote before ever reaching its cross-type abort check, so
1231 /// this exact repro resolves on the primary pass alone — pinned here as a regression, but
1232 /// it does not exercise the fallback (see
1233 /// `read_string_literal_ruby_fallback_nested_brace_in_cross_type_quote_not_truncated` for
1234 /// the un-escaped variant that does).
1235 #[test]
1236 fn read_string_literal_ruby_leak_repro_with_escaped_outer_quote_resolves_via_primary() {
1237 let text = r##"gem "p", require: "#{ ENV['}a\"b']}", source: "https://gems.corp/""##;
1238 let quote_pos = text.find("\"#{").unwrap();
1239 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_some());
1240 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1241 assert_eq!(&text[literal.content], r#"#{ ENV['}a\"b']}"#);
1242 }
1243
1244 /// #1047 (leak direction): the fallback pass must be nested-quote-aware, not just
1245 /// brace-depth-aware — the `}` inside the cross-type nested `'}a"b'` (no escaping this
1246 /// time, so the primary pass genuinely aborts and the fallback must resolve it) must not be
1247 /// misread as closing the interpolation, which would truncate the `require:` value and
1248 /// leave `source:` misparsed (falling through to the public registry instead of resolving
1249 /// to `https://gems.corp/`).
1250 #[test]
1251 fn read_string_literal_ruby_fallback_nested_brace_in_cross_type_quote_not_truncated() {
1252 let text = r##"gem "p", require: "#{ ENV['}a"b']}", source: "https://gems.corp/""##;
1253 let quote_pos = text.find("\"#{").unwrap();
1254 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1255 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1256 assert_eq!(&text[literal.content], r#"#{ ENV['}a"b']}"#);
1257 }
1258
1259 /// #1047 (truncation direction): same root cause as the leak-direction repro above, but
1260 /// the mis-termination lands on the outer literal's own quote character instead — the
1261 /// nested `'}a"b'` string's `}` closes the interpolation early, and the very next `"a`
1262 /// content is then misread as the outer literal's closing quote.
1263 #[test]
1264 fn read_string_literal_ruby_fallback_nested_brace_in_same_type_quote_not_truncated() {
1265 let text = r#"source "https://#{ENV['}a"b']}@gems.corp/" do"#;
1266 let quote_pos = text.find('"').unwrap();
1267 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1268 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1269 assert_eq!(
1270 &text[literal.content],
1271 r#"https://#{ENV['}a"b']}@gems.corp/"#
1272 );
1273 }
1274
1275 /// Direct unit coverage of the fallback pass itself (rather than only through
1276 /// `read_string_literal`'s `.or_else`), pinning that it resolves the #1047 nested-brace
1277 /// case correctly even when reached in isolation.
1278 #[test]
1279 fn find_ruby_closing_quote_fallback_skips_brace_inside_nested_quote() {
1280 let rest = r#"https://#{ENV['}a"b']}@gems.corp/" do"#;
1281 let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
1282 assert_eq!(&rest[..len], r#"https://#{ENV['}a"b']}@gems.corp/"#);
1283 }
1284
1285 /// The fallback pass must still reject a stray regex/char-literal quote instead of
1286 /// latching onto a later, unrelated occurrence of the same character (the #1041-follow-up
1287 /// S2 bug `nested_span_len`'s parity check exists to avoid re-introducing). Must include
1288 /// the `#{` opener so the scan actually enters `interpolation_depth > 0` and reaches the
1289 /// parity check (#1047 follow-up S1: a prior version of this test omitted it and passed
1290 /// vacuously, exercising nothing).
1291 #[test]
1292 fn find_ruby_closing_quote_fallback_rejects_unpaired_quote_far_match() {
1293 let rest = r#"#{x =~ /'/}", "https://a'b}c""#;
1294 let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
1295 assert_eq!(&rest[..len], r"#{x =~ /'/}");
1296 }
1297
1298 /// #1047 follow-up C1 (round 1): before the lexical `/`/`?`-predecessor gate was added
1299 /// (round 2), the fallback pass's parity heuristic alone could land on *odd* parity by
1300 /// chance for a stray regex-literal apostrophe (one real quote inside `ENV['GEM_HOST']`'s
1301 /// own value) and wrongly trust it, jumping past the true interpolation close so
1302 /// `interpolation_depth` never returned to 0 — reintroducing #1047's own leak class one
1303 /// level up (an apparently-unterminated `require:` swallowing `source:`). The lexical gate
1304 /// now rules this quote out before parity is ever consulted (the apostrophe is directly
1305 /// preceded by `/`), so this now resolves within the fallback tier itself; kept as a
1306 /// regression pin for the original round-1 shape, and the naive tier remains in place as a
1307 /// backstop for whatever the two gates together still miss.
1308 #[test]
1309 fn read_string_literal_ruby_fallback_regex_apostrophe_before_later_odd_parity_value() {
1310 let text = r##"gem "p", require: "#{x =~ /'/}", source: ENV['GEM_HOST']"##;
1311 let quote_pos = text.find("\"#{").unwrap();
1312 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1313 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1314 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1315 }
1316
1317 /// #1047 follow-up C1 (round 1), second reproducer from the critic: same shape, this time
1318 /// landing on 3 real quotes (also odd) inside a later `git:` option's single-quoted value —
1319 /// also now resolved within the fallback tier by the lexical gate.
1320 #[test]
1321 fn read_string_literal_ruby_fallback_regex_apostrophe_before_later_odd_parity_value_git_option()
1322 {
1323 let text = r##"gem "p", require: "#{x =~ /'/}", source: "https://gems.corp/", git: 'https://git.corp/p.git'"##;
1324 let quote_pos = text.find("\"#{").unwrap();
1325 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1326 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1327 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1328 }
1329
1330 /// #1047 follow-up round 2 (W1-W4): the parity heuristic also fails in the *opposite*
1331 /// direction from round 1 — a wrongly-trusted span can resolve to a too-long `Some` instead
1332 /// of `None` whenever *any* later `}` in the rest of the literal brings
1333 /// `interpolation_depth` back to 0 before the damage is undone. Unlike round 1's `None`
1334 /// failure, `.or_else` never triggers the naive tier for a wrong `Some`, so these are
1335 /// structurally invisible to the three-tier chain without the lexical gate. All four are
1336 /// real Bundler/Ruby syntax with a stray regex/char-literal quote ahead of a later `}`.
1337 #[test]
1338 fn read_string_literal_ruby_lexical_gate_w1_install_if_lambda() {
1339 let text = r##"gem "p", require: "#{x =~ /'/}", install_if: -> { ENV['CI'] }, source: "https://gems.corp/""##;
1340 let quote_pos = text.find("\"#{").unwrap();
1341 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1342 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1343 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1344 }
1345
1346 #[test]
1347 fn read_string_literal_ruby_lexical_gate_w2_hash_literal_option() {
1348 let text = r##"gem "p", require: "#{x =~ /'/}", git: { url: ENV['U'] }, source: "https://gems.corp/""##;
1349 let quote_pos = text.find("\"#{").unwrap();
1350 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1351 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1352 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1353 }
1354
1355 #[test]
1356 fn read_string_literal_ruby_lexical_gate_w3_brace_in_later_value() {
1357 let text = r##"gem "p", require: "#{x =~ /'/}", source: ENV['A}B'], x: "q""##;
1358 let quote_pos = text.find("\"#{").unwrap();
1359 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1360 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1361 assert_eq!(&text[literal.content], r"#{x =~ /'/}");
1362 }
1363
1364 #[test]
1365 fn read_string_literal_ruby_lexical_gate_w4_char_literal() {
1366 let text = r##"gem "p", require: "#{x == ?'}", install_if: -> { ENV['CI'] }, source: "https://gems.corp/""##;
1367 let quote_pos = text.find("\"#{").unwrap();
1368 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1369 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1370 assert_eq!(&text[literal.content], r"#{x == ?'}");
1371 }
1372
1373 /// #1060 repro 1: a `%r|'|` percent-literal regex inside `#{...}` contains an apostrophe
1374 /// that is not a string-opening quote at all — the fallback's span-skip gate must consume
1375 /// the whole `%r|...|` construct so this apostrophe never reaches the nested-quote parity
1376 /// check, rather than only inspecting the single character before it.
1377 #[test]
1378 fn read_string_literal_ruby_percent_literal_regex_apostrophe_not_swallowed() {
1379 let text = r##"gem "p", require: "#{x =~ %r|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
1380 let quote_pos = text.find("\"#{").unwrap();
1381 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1382 assert_eq!(&text[literal.content], r"#{x =~ %r|'| }");
1383 }
1384
1385 /// #1060 repro 2: a `%w[a'b]` word-array percent literal, whose apostrophe sits inside the
1386 /// bracket delimiter body rather than right after the `%w` opener — the span-skip gate
1387 /// must still find and consume the whole construct.
1388 #[test]
1389 fn read_string_literal_ruby_percent_literal_word_array_apostrophe_not_swallowed() {
1390 let text = r##"gem "p", require: "#{ %w[a'b] }", install_if: -> { ENV['CI'] }, x: "q""##;
1391 let quote_pos = text.find("\"#{").unwrap();
1392 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1393 assert_eq!(&text[literal.content], r"#{ %w[a'b] }");
1394 }
1395
1396 /// #1060 repro 3: a `#` line comment inside `#{...}` containing an apostrophe must be
1397 /// skipped up to the next newline, never scanned for a quote.
1398 #[test]
1399 fn read_string_literal_ruby_hash_comment_apostrophe_not_swallowed() {
1400 let text =
1401 "gem \"p\", require: \"#{ # don't\n x }\", install_if: -> { ENV['CI'] }, x: \"q\"";
1402 let quote_pos = text.find("\"#{").unwrap();
1403 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1404 assert_eq!(&text[literal.content], "#{ # don't\n x }");
1405 }
1406
1407 /// #1060 non-regression: `a % b` (spaced modulo) inside `#{...}` must not be misread as a
1408 /// `%`-literal opener — the space right after `%` is neither a literal-type letter nor a
1409 /// punctuation delimiter, so `percent_literal_end` must reject it.
1410 #[test]
1411 fn read_string_literal_ruby_percent_modulo_not_misread_as_literal() {
1412 let text = r##""#{a % b}""##;
1413 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1414 assert_eq!(&text[literal.content], "#{a % b}");
1415 }
1416
1417 /// impl-critic S1 (#1060 follow-up): `%` immediately after a value token (the identifier
1418 /// `x`, here at EXPR_END) is always the modulo/format operator, never a `%`-literal opener
1419 /// — `%=` in particular is Ruby's modulo-assign operator, never a literal delimiter.
1420 /// Without the `after_value` gate, `percent_literal_end` read any non-alphanumeric byte
1421 /// after a bare `%` as a delimiter, so `x%=2` was misread as a `%`-literal delimited by
1422 /// `=` and swallowed everything up to the next unrelated `=` — reopening #1060's own
1423 /// over-long-span leak via a different trigger. `ENV['a"b']` forces the primary tier to
1424 /// abort so this exercises the fallback tier specifically.
1425 #[test]
1426 fn read_string_literal_ruby_fallback_percent_after_value_is_modulo_not_over_long_leak() {
1427 let text = r##"gem "a", source: "#{ ENV['a"b'] + x%=2 }", install_if: -> { ENV['CI'] == "1" }, git: "https://TOKEN@evil""##;
1428 let quote_pos = text.find("\"#{").unwrap();
1429 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1430 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1431 assert_eq!(&text[literal.content], r#"#{ ENV['a"b'] + x%=2 }"#);
1432 }
1433
1434 /// Code-review finding (#1060 follow-up, code-review round): a `%`-literal's own close was
1435 /// resetting `prev` to `None` instead of a value marker, so a `%` immediately following it
1436 /// (`%w[a]%=2`) was wrongly re-admitted to `percent_literal_end` as a fresh opener,
1437 /// reopening S1's leak via a new trigger — `%w[a]` produces a value just as much as an
1438 /// identifier does, so the `%` right after it must also resolve as modulo.
1439 #[test]
1440 fn read_string_literal_ruby_fallback_percent_after_percent_literal_close_is_modulo() {
1441 let text = r##"gem "a", source: "#{ ENV['a"b'] + %w[a]%=2 }", install_if: -> { ENV['CI'] == "1" }, git: "https://TOKEN@evil""##;
1442 let quote_pos = text.find("\"#{").unwrap();
1443 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1444 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1445 assert_eq!(&text[literal.content], r#"#{ ENV['a"b'] + %w[a]%=2 }"#);
1446 }
1447
1448 /// Direct unit coverage of the `after_value` gate itself: `%` right after an identifier
1449 /// character, with no space at all, must resolve as modulo rather than opening a
1450 /// `%`-literal delimited by `y`.
1451 #[test]
1452 fn find_ruby_closing_quote_fallback_percent_immediately_after_identifier_is_modulo() {
1453 let rest = "#{x%y}\" tail";
1454 let len = find_ruby_closing_quote_fallback(rest, '"').unwrap();
1455 assert_eq!(&rest[..len], "#{x%y}");
1456 }
1457
1458 /// impl-critic S2/S3 (#1060 follow-up, second round): pinned known-gap test, not a
1459 /// regression check — a `#` reached only because the parity gate already rejected the
1460 /// nested string `ENV["c#d"]` is string CONTENT, not a comment, but this scanner cannot
1461 /// tell code from string content at this point (no predecessor test separates `x# comment`
1462 /// from `ENV["c#d"]`, since both have a value predecessor). Verified against a Ruby/Prism
1463 /// oracle: gating `#` on `after_value` to fix this one case costs ~2193 new over-long
1464 /// (leak-direction) errors elsewhere across 86k valid-Ruby cases, against only 406 residual
1465 /// if `#` stays ungated — so `#` is deliberately left ungated, and this construct
1466 /// truncates the literal early (the safer failure direction) instead. See the `%`/`#`
1467 /// branch comment in `find_ruby_closing_quote_fallback` and this module's residual-gap doc
1468 /// on `nested_span_len`, which lists this beside heredocs.
1469 #[test]
1470 fn read_string_literal_ruby_fallback_hash_in_rejected_nested_string_is_a_documented_residual_gap()
1471 {
1472 let text = r##"gem "a", source: "#{ ENV['a"b'] + ENV["c#d"].map { |v|
1473 v } + "z" }", git: "ok""##;
1474 let quote_pos = text.find("\"#{").unwrap();
1475 assert!(find_ruby_closing_quote_primary(&text[quote_pos + 1..], '"').is_none());
1476 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1477 let full_value = r#"#{ ENV['a"b'] + ENV["c#d"].map { |v|
1478 v } + "z" }"#;
1479 let resolved = &text[literal.content];
1480 // Truncates before the true end (the safer, non-leaking direction) rather than
1481 // resolving the full interpolation.
1482 assert!(resolved.len() < full_value.len());
1483 assert!(full_value.starts_with(resolved));
1484 }
1485
1486 /// impl-critic M1 (#1060 follow-up): `%s` (symbol literal) is Ruby's own type-letter set
1487 /// but was missing from the allowlist — exact mirror of repro 1's `%r` case.
1488 #[test]
1489 fn read_string_literal_ruby_percent_literal_symbol_apostrophe_not_swallowed() {
1490 let text = r##"gem "p", require: "#{x =~ %s|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
1491 let quote_pos = text.find("\"#{").unwrap();
1492 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1493 assert_eq!(&text[literal.content], r"#{x =~ %s|'| }");
1494 }
1495
1496 /// impl-critic M1 (#1060 follow-up): `%x` (command literal) was also missing from the
1497 /// allowlist — exact mirror of repro 1's `%r` case.
1498 #[test]
1499 fn read_string_literal_ruby_percent_literal_command_apostrophe_not_swallowed() {
1500 let text = r##"gem "p", require: "#{x =~ %x|'| }", install_if: -> { ENV['CI'] }, x: "q""##;
1501 let quote_pos = text.find("\"#{").unwrap();
1502 let literal = read_string_literal(text, quote_pos, ScanSyntax::Ruby).unwrap();
1503 assert_eq!(&text[literal.content], r"#{x =~ %x|'| }");
1504 }
1505
1506 /// Direct unit coverage of [`percent_literal_end`]: a bracket-delimited `%`-literal nests
1507 /// its own delimiter type, so an inner `[`/`]` pair does not close the literal early.
1508 #[test]
1509 fn percent_literal_end_handles_nested_brackets() {
1510 let rest = "%w[a[b]c] tail";
1511 let end = percent_literal_end(rest, 0).unwrap();
1512 assert_eq!(&rest[..end], "%w[a[b]c]");
1513 }
1514
1515 /// Direct unit coverage of [`percent_literal_end`]: a bare `%<delim>` literal with no
1516 /// type-letter prefix is still recognized.
1517 #[test]
1518 fn percent_literal_end_bare_delimiter_without_type_letter() {
1519 let rest = "%(hello world) tail";
1520 let end = percent_literal_end(rest, 0).unwrap();
1521 assert_eq!(&rest[..end], "%(hello world)");
1522 }
1523
1524 /// Code-review finding, low severity (#1060 follow-up, code-review round): a `\`-delimited
1525 /// `%`-literal (`%\...\`, rare/unidiomatic but syntactically permitted — Ruby allows any
1526 /// non-alphanumeric, non-whitespace delimiter) must still close on its own delimiter rather
1527 /// than always having it consumed as an escape.
1528 #[test]
1529 fn percent_literal_end_backslash_delimiter_closes() {
1530 let rest = r"%\a\ tail";
1531 let end = percent_literal_end(rest, 0).unwrap();
1532 assert_eq!(&rest[..end], r"%\a\");
1533 }
1534
1535 /// Direct unit coverage of [`percent_literal_end`]'s modulo-operator guard.
1536 #[test]
1537 fn percent_literal_end_rejects_spaced_percent() {
1538 assert!(percent_literal_end("% b", 0).is_none());
1539 }
1540
1541 /// Direct unit coverage of the naive last-resort tier: pins that it resolves the same,
1542 /// quote-blind brace-counted close this module always used before #1047's nested-quote
1543 /// lookahead was added — kept as a backstop the fallback tier's two gates can still fall
1544 /// through to.
1545 #[test]
1546 fn find_ruby_closing_quote_naive_ignores_nested_quotes() {
1547 let rest = r#"#{ENV["A"]}", source: "https://gems.corp/""#;
1548 let len = find_ruby_closing_quote_naive(rest, '"').unwrap();
1549 assert_eq!(&rest[..len], r#"#{ENV["A"]}"#);
1550 }
1551
1552 /// #1047 follow-up C2: a long run of escaped quotes inside an interpolation must not
1553 /// trigger a separate `O(n)` [`nested_span_len`] lookahead per occurrence — each `\'` is
1554 /// consumed as one escaped unit before ever being considered a candidate quote opener. This
1555 /// does not assert on timing (flaky in CI); it pins the *correctness* of that escape
1556 /// handling, which is what makes the `O(n)` behavior possible.
1557 #[test]
1558 fn find_ruby_closing_quote_fallback_handles_long_escaped_quote_run_without_misparsing() {
1559 let escaped_run = r"\'".repeat(2000);
1560 let rest = format!("#{{ x.gsub(/{escaped_run}/, '') }}\"");
1561 let len = find_ruby_closing_quote_fallback(&rest, '"').unwrap();
1562 assert_eq!(&rest[..len], format!("#{{ x.gsub(/{escaped_run}/, '') }}"));
1563 }
1564
1565 /// Security regression (critic finding S1, #1041 follow-up): Ruby's `'...'` literals never
1566 /// interpolate (`'#{x}'` is the literal text `#{x}`, not an expression), so an unbalanced
1567 /// `#{` inside a single-quoted value (which has no matching `}` to close on) must not be
1568 /// treated as opening an interpolation span — that misreads the literal's own very next `'`
1569 /// as still being inside an unclosed interpolation, making the whole literal look
1570 /// unterminated.
1571 #[test]
1572 fn read_string_literal_ruby_single_quoted_never_interpolates() {
1573 let text = r"'#{'";
1574 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1575 assert_eq!(&text[literal.content], "#{");
1576 }
1577
1578 /// Regression (critic finding M3, #1041 follow-up): a single-quoted Ruby literal containing
1579 /// a same-type nested quote is not itself a case interpolation-awareness applies to (there
1580 /// is no interpolation to nest inside), so it must keep resolving via the plain
1581 /// escape-aware path exactly as before this fix.
1582 #[test]
1583 fn read_string_literal_ruby_single_quoted_ignores_hash_brace_content() {
1584 let text = r"'a #{ b'";
1585 let literal = read_string_literal(text, 0, ScanSyntax::Ruby).unwrap();
1586 assert_eq!(&text[literal.content], "a #{ b");
1587 }
1588
1589 /// Known limitation, pinned (critic finding M1, #1041 follow-up): an unterminated `#{`
1590 /// interpolation (invalid Ruby — `ruby -c` rejects it) has no matching `}`, so the
1591 /// interpolation-aware scan never finds a close and `read_string_literal` reports the
1592 /// literal as unterminated. Acceptable since the input isn't valid Ruby to begin with; pinned
1593 /// here so a future change to this area doesn't silently alter the (documented) behavior for
1594 /// malformed input.
1595 #[test]
1596 fn read_string_literal_ruby_unterminated_interpolation_is_none() {
1597 let text = r##""#{""##;
1598 assert!(read_string_literal(text, 0, ScanSyntax::Ruby).is_none());
1599 }
1600
1601 #[test]
1602 fn strip_line_comment_mismatched_delimiters_falls_through_unchanged() {
1603 // `read_string_literal` never closes a `"` with a `'` (or vice versa) — an
1604 // unterminated literal absorbs the rest of the line, so a trailing `#` inside
1605 // what looks like a comment position is not treated as a comment start.
1606 let line = r#"source: "foo' # not a comment"#;
1607 assert_eq!(strip_line_comment(line, ScanSyntax::Ruby), line);
1608 }
1609
1610 #[test]
1611 fn strip_line_comment_toml_escape_aware() {
1612 // The bug `strip_trailing_toml_comment` had before this migration: a naive,
1613 // non-escape-aware scan closed the string at the escaped `\"`, then truncated
1614 // at the `#` that followed inside the still-open literal.
1615 let line = "description = \"a\\\"# x\"";
1616 assert_eq!(strip_line_comment(line, ScanSyntax::Toml), line);
1617 }
1618
1619 #[test]
1620 fn blank_comments_preserves_byte_length_for_non_ascii_comment() {
1621 let content = "value = 1 # héllo wörld 日本語\nvalue2 = 2";
1622 let blanked = blank_comments(content, ScanSyntax::Toml);
1623 assert_eq!(blanked.len(), content.len());
1624 assert!(blanked.starts_with("value = 1 "));
1625 assert!(blanked.contains("\nvalue2 = 2"));
1626 }
1627
1628 #[test]
1629 fn blank_comments_preserves_newline_inside_block_comment() {
1630 let content = "let x = 1 /* multi\nline */ let y = 2";
1631 let blanked = blank_comments(content, ScanSyntax::Swift);
1632 assert_eq!(blanked.len(), content.len());
1633 assert!(blanked.contains('\n'));
1634 assert!(blanked.contains("let y = 2"));
1635 assert!(!blanked.contains("multi"));
1636 }
1637
1638 #[test]
1639 fn blank_comments_no_comment_returns_full_length_copy() {
1640 let content = "no comments here";
1641 assert_eq!(blank_comments(content, ScanSyntax::Ruby), content);
1642 }
1643
1644 #[test]
1645 fn is_code_byte_out_of_range_and_non_boundary_are_false() {
1646 let text = "hé";
1647 assert!(!is_code_byte(text, text.len(), ScanSyntax::Ruby));
1648 assert!(!is_code_byte(text, text.len() + 5, ScanSyntax::Ruby));
1649 // Byte 2 sits inside the 2-byte UTF-8 encoding of 'é' (which starts at byte 1),
1650 // not on a char boundary.
1651 assert!(!is_code_byte(text, 2, ScanSyntax::Ruby));
1652 }
1653
1654 #[test]
1655 fn is_code_byte_true_for_plain_code() {
1656 assert!(is_code_byte("gem \"x\"", 0, ScanSyntax::Ruby));
1657 }
1658
1659 #[test]
1660 fn is_code_byte_false_inside_comment() {
1661 let line = "gem \"x\" # comment";
1662 let hash = line.find('#').unwrap();
1663 assert!(!is_code_byte(line, hash, ScanSyntax::Ruby));
1664 }
1665
1666 #[test]
1667 fn code_spans_agrees_with_is_code_byte_at_every_byte() {
1668 // #1022 impl-critic C1: `CodeSpans` is an amortized reimplementation of the same
1669 // classification `is_code_byte` computes from scratch — every byte position must
1670 // agree between the two, including span boundaries (the char right after a string
1671 // literal's opening delimiter, and the char right after a comment/literal ends).
1672 let line = r#"gem "x", path: 'vendor/git: "cache"', git: "https://real.example/r" # c"#;
1673 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1674 for idx in 0..=line.len() {
1675 assert_eq!(
1676 code.is_code_byte(idx),
1677 is_code_byte(line, idx, ScanSyntax::Ruby),
1678 "byte {idx} disagreed"
1679 );
1680 }
1681 }
1682
1683 #[test]
1684 fn code_spans_out_of_range_and_non_boundary_are_false() {
1685 let text = "hé";
1686 let code = CodeSpans::new(text, ScanSyntax::Ruby);
1687 assert!(!code.is_code_byte(text.len()));
1688 assert!(!code.is_code_byte(text.len() + 5));
1689 assert!(!code.is_code_byte(2));
1690 }
1691
1692 /// Regression (bug-ops/deps-lsp#1039, vector 1): Ruby's `?"` one-character literal must not
1693 /// be misread as opening a string — the exact critic PoC, confirmed with `ruby -c` and a stub
1694 /// `gem` method dumping parsed kwargs to show Ruby itself treats `source:` as nested inside
1695 /// `install_if`'s hash, not a top-level argument.
1696 #[test]
1697 fn char_literal_quote_does_not_open_a_string() {
1698 let line = r#"a: ?", install_if: { b: ?", source: "https://evil.example.com", c: ?" }"#;
1699 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1700 let brace = line.find('{').unwrap();
1701 let source_key = line.find("source:").unwrap();
1702 // The `{` must be seen as code (not swallowed into a bogus string opened by `?"`).
1703 assert!(code.is_code_byte(brace));
1704 // `source:` sits inside the (correctly recognized) `install_if` hash, not in a string.
1705 assert!(code.is_code_byte(source_key));
1706 // The genuine string value is still read correctly.
1707 let quote = line[source_key..].find('"').unwrap() + source_key;
1708 let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
1709 assert_eq!(&line[literal.content], "https://evil.example.com");
1710 }
1711
1712 #[test]
1713 fn char_literal_quote_single_quote_variant_also_recognized() {
1714 let line = "a: ?', b: 1";
1715 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1716 let b_key = line.find('b').unwrap();
1717 assert!(code.is_code_byte(b_key));
1718 }
1719
1720 #[test]
1721 fn ternary_with_space_still_opens_a_real_string() {
1722 // `cond ? "a" : "b"` — a space between `?` and `"` means this is the ternary operator
1723 // followed by a genuine string literal, not a character literal.
1724 let line = r#"cond ? "a" : "b""#;
1725 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1726 let quote = line.find('"').unwrap();
1727 assert!(code.is_code_byte(quote));
1728 let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
1729 assert_eq!(&line[literal.content], "a");
1730 }
1731
1732 #[test]
1733 fn unspaced_ternary_after_value_still_opens_a_real_string() {
1734 // `flag?"a":"b"` — `?` directly follows an identifier (a value), so this is still
1735 // ternary-operator position, not a character literal, even with no space.
1736 let line = r#"flag?"a":"b""#;
1737 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1738 let quote = line.find('"').unwrap();
1739 assert!(code.is_code_byte(quote));
1740 }
1741
1742 /// Regression (#1039 critic finding C4): a ternary whose *condition* is itself a string
1743 /// literal (`"a"?"..."`, no space at all between the condition's closing quote and `?`) must
1744 /// not be misread as a character literal — the closing quote right before `?` proves a value
1745 /// already sits there.
1746 #[test]
1747 fn ternary_with_string_condition_and_no_space_still_opens_a_real_string() {
1748 let line = r#""a"?"b":"c""#;
1749 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1750 let quote_after_question_mark = line.find('?').unwrap() + 1;
1751 assert!(code.is_code_byte(quote_after_question_mark));
1752 let literal =
1753 read_string_literal(line, quote_after_question_mark, ScanSyntax::Ruby).unwrap();
1754 assert_eq!(&line[literal.content], "b");
1755 }
1756
1757 /// Regression (#1039 critic finding C4): a space before `?` does not by itself mean
1758 /// character-literal position — `1 ?"..."` is still a ternary (condition `1`), since the
1759 /// real predecessor past the space is a value, not a fresh-expression punctuation character.
1760 #[test]
1761 fn ternary_with_space_but_value_before_it_still_opens_a_real_string() {
1762 let line = r#"1 ?"b":"c""#;
1763 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1764 let quote = line.find('"').unwrap();
1765 assert!(code.is_code_byte(quote));
1766 let literal = read_string_literal(line, quote, ScanSyntax::Ruby).unwrap();
1767 assert_eq!(&line[literal.content], "b");
1768 }
1769
1770 /// Regression (#1039 critic finding C4): a char literal right after an assignment `=` must
1771 /// still be recognized (part of the allowlist's operator set), distinct from the ternary
1772 /// cases above.
1773 #[test]
1774 fn char_literal_quote_after_assignment_operator_still_recognized() {
1775 let line = r#"x = ?", y = 1"#;
1776 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1777 let y_pos = line.find('y').unwrap();
1778 assert!(code.is_code_byte(y_pos));
1779 }
1780
1781 /// Regression (#1039, `!` excluded from `is_char_literal_quote`'s allowlist — see its doc for
1782 /// why this is defense-in-depth, not a fix for a reachable false positive on valid Ruby).
1783 /// Asserting `is_code_byte` on the *first* quote alone does not discriminate this: that
1784 /// position reads as code either way (an unrecognized-as-delimiter quote is still ordinary
1785 /// code, and a delimiter's own opening position also counts as code — see [`CodeSpans`]'s
1786 /// doc), so a prior version of this test passed unchanged whether `!` was allowlisted or not
1787 /// (tester finding, round-6 re-validation). The real discriminator is whether the SECOND
1788 /// string (`source:`'s value) is still correctly recognized as string content: if `!` were
1789 /// wrongly allowlisted, the first quote (right after `!?`) would be skipped as a non-delimiter,
1790 /// so the *next* quote (`"a"`'s closer) opens a bogus string that swallows `, source: ` and
1791 /// closes on the quote just before `https:`, leaving the real URL sitting in plain code —
1792 /// exactly the "content misread as code" failure this whole function exists to prevent.
1793 #[test]
1794 fn bang_before_question_mark_does_not_swallow_source_option_into_bogus_string() {
1795 let line = r#"save!?"a", source: "https://evil.example.com""#;
1796 let code = CodeSpans::new(line, ScanSyntax::Ruby);
1797 let url_start = line.find("https").unwrap();
1798 assert!(
1799 !code.is_code_byte(url_start),
1800 "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"
1801 );
1802 let url_quote = url_start - 1;
1803 let literal = read_string_literal(line, url_quote, ScanSyntax::Ruby).unwrap();
1804 assert_eq!(&line[literal.content], "https://evil.example.com");
1805 }
1806
1807 // #1174: `ScanSyntax::Groovy` and `last_string_literal`/`find_closing_quote_before_comment`.
1808
1809 #[test]
1810 fn last_string_literal_closed_at_text_end() {
1811 let text = r#"version = "1.0""#;
1812 let literal = last_string_literal(text, ScanSyntax::Toml).unwrap();
1813 assert_eq!(literal.quote, '"');
1814 assert_eq!(literal.close, Some(text.len()));
1815 }
1816
1817 #[test]
1818 fn last_string_literal_unterminated_is_open() {
1819 let text = r#"version = "1.0"#;
1820 let literal = last_string_literal(text, ScanSyntax::Toml).unwrap();
1821 assert_eq!(literal.quote, '"');
1822 assert_eq!(literal.open, text.find('"').unwrap());
1823 assert_eq!(literal.close, None);
1824 }
1825
1826 #[test]
1827 fn last_string_literal_groovy_single_quote_open() {
1828 let text = "implementation 'com.example:lib:1.0";
1829 let literal = last_string_literal(text, ScanSyntax::Groovy).unwrap();
1830 assert_eq!(literal.quote, '\'');
1831 assert_eq!(literal.close, None);
1832 }
1833
1834 #[test]
1835 fn last_string_literal_groovy_double_quote_open() {
1836 let text = r#"implementation "com.example:lib:1.0"#;
1837 let literal = last_string_literal(text, ScanSyntax::Groovy).unwrap();
1838 assert_eq!(literal.quote, '"');
1839 assert_eq!(literal.close, None);
1840 }
1841
1842 #[test]
1843 fn last_string_literal_toml_single_quote_no_escape() {
1844 let text = r"version = 'C:\Users\x";
1845 let literal = last_string_literal(text, ScanSyntax::Toml).unwrap();
1846 assert_eq!(literal.quote, '\'');
1847 assert_eq!(literal.close, None);
1848 }
1849
1850 #[test]
1851 fn last_string_literal_escaped_closing_quote_stays_open() {
1852 let text = r#"description = "a\""#;
1853 let literal = last_string_literal(text, ScanSyntax::Toml).unwrap();
1854 assert_eq!(literal.close, None);
1855 }
1856
1857 #[test]
1858 fn last_string_literal_quote_inside_comment_is_not_a_phantom_literal() {
1859 // The `'` inside the Groovy `//` comment must not be read as opening a literal.
1860 let text = "implementation(\"a:b:1.0\") // don't bump";
1861 let literal = last_string_literal(text, ScanSyntax::Groovy).unwrap();
1862 assert_eq!(literal.quote, '"');
1863 assert_eq!(literal.close, Some(text.find(')').unwrap()));
1864 }
1865
1866 #[test]
1867 fn last_string_literal_toml_hash_comment_not_a_phantom_literal() {
1868 let text = "version = \"1.0\" # a 'note'";
1869 let literal = last_string_literal(text, ScanSyntax::Toml).unwrap();
1870 assert_eq!(literal.quote, '"');
1871 assert_eq!(literal.close, Some(text.find(" #").unwrap()));
1872 }
1873
1874 #[test]
1875 fn last_string_literal_closed_block_comment_is_skipped() {
1876 let text = r#"implementation("a" /* "x" */ + "b"#;
1877 let literal = last_string_literal(text, ScanSyntax::Groovy).unwrap();
1878 assert_eq!(literal.close, None);
1879 assert_eq!(literal.open, text.rfind('"').unwrap());
1880 }
1881
1882 #[test]
1883 fn last_string_literal_unterminated_block_comment_ends_scan() {
1884 // An unterminated `/*` absorbs the rest of the text; no literal after it is found.
1885 let text = r#""a" /* unterminated"#;
1886 let literal = last_string_literal(text, ScanSyntax::Groovy).unwrap();
1887 assert_eq!(literal.close, Some(3));
1888 }
1889
1890 #[test]
1891 fn last_string_literal_empty_text_is_none() {
1892 assert!(last_string_literal("", ScanSyntax::Groovy).is_none());
1893 }
1894
1895 #[test]
1896 fn last_string_literal_no_literal_is_none() {
1897 assert!(last_string_literal("no strings here", ScanSyntax::Groovy).is_none());
1898 }
1899
1900 #[test]
1901 fn groovy_hash_is_not_a_comment() {
1902 // `#` has no comment meaning in Groovy — must stay code, unlike Ruby/TOML.
1903 let line = "implementation(\"a:b:1.0\") # not a comment";
1904 let code = CodeSpans::new(line, ScanSyntax::Groovy);
1905 let hash = line.find('#').unwrap();
1906 assert!(code.is_code_byte(hash));
1907 }
1908
1909 #[test]
1910 fn groovy_double_slash_inside_string_is_not_a_comment() {
1911 let line = r#"implementation("http://example.com:1.0")"#;
1912 let code = CodeSpans::new(line, ScanSyntax::Groovy);
1913 let slash = line.find("//").unwrap();
1914 // Inside the open string, so it stays non-code content, not a comment start.
1915 assert!(!code.is_code_byte(slash));
1916 assert_eq!(strip_line_comment(line, ScanSyntax::Groovy), line);
1917 }
1918
1919 #[test]
1920 fn groovy_line_comment_stripped() {
1921 let line = r#"implementation("a:b:1.0") // don't bump"#;
1922 assert_eq!(
1923 strip_line_comment(line, ScanSyntax::Groovy),
1924 r#"implementation("a:b:1.0") "#,
1925 );
1926 }
1927
1928 #[test]
1929 fn groovy_block_comment_blanked() {
1930 let content = "val x = 1 /* multi\nline */ val y = 2";
1931 let blanked = blank_comments(content, ScanSyntax::Groovy);
1932 assert_eq!(blanked.len(), content.len());
1933 assert!(blanked.contains('\n'));
1934 assert!(!blanked.contains("multi"));
1935 }
1936
1937 #[test]
1938 fn groovy_both_quote_styles_open_a_literal() {
1939 let single = last_string_literal("val a = 'x", ScanSyntax::Groovy).unwrap();
1940 assert_eq!(single.quote, '\'');
1941 let double = last_string_literal("val a = \"x", ScanSyntax::Groovy).unwrap();
1942 assert_eq!(double.quote, '"');
1943 }
1944
1945 #[test]
1946 fn find_closing_quote_before_comment_found() {
1947 assert_eq!(
1948 find_closing_quote_before_comment(r#"1.0" tail"#, '"', ScanSyntax::Groovy),
1949 Some(3),
1950 );
1951 }
1952
1953 #[test]
1954 fn find_closing_quote_before_comment_line_comment_bails() {
1955 assert_eq!(
1956 find_closing_quote_before_comment("1.0 // still typing", '"', ScanSyntax::Groovy),
1957 None,
1958 );
1959 }
1960
1961 #[test]
1962 fn find_closing_quote_before_comment_hash_bails_under_toml() {
1963 assert_eq!(
1964 find_closing_quote_before_comment("1.0 # still typing", '"', ScanSyntax::Toml),
1965 None,
1966 );
1967 }
1968
1969 #[test]
1970 fn find_closing_quote_before_comment_hash_is_not_a_comment_under_groovy() {
1971 // `#` has no comment meaning in Groovy, so it doesn't bail — it's just content, and the
1972 // scan continues past it to the real closing quote.
1973 assert_eq!(
1974 find_closing_quote_before_comment(
1975 "1.0 # not-a-comment\" tail",
1976 '"',
1977 ScanSyntax::Groovy
1978 ),
1979 Some(19),
1980 );
1981 }
1982
1983 #[test]
1984 fn find_closing_quote_before_comment_closed_block_comment_skipped() {
1985 let rest = r#"1.0" /* "x" */"#;
1986 assert_eq!(
1987 find_closing_quote_before_comment(rest, '"', ScanSyntax::Groovy),
1988 Some(3),
1989 );
1990 }
1991
1992 #[test]
1993 fn find_closing_quote_before_comment_unterminated_block_comment_bails() {
1994 assert_eq!(
1995 find_closing_quote_before_comment("1.0 /* unterminated", '"', ScanSyntax::Groovy),
1996 None,
1997 );
1998 }
1999
2000 #[test]
2001 fn find_closing_quote_before_comment_escaped_quote_not_taken() {
2002 let rest = r#"a\" real" tail"#;
2003 assert_eq!(
2004 find_closing_quote_before_comment(rest, '"', ScanSyntax::Groovy),
2005 Some(8),
2006 );
2007 }
2008
2009 #[test]
2010 fn find_closing_quote_before_comment_toml_literal_string_has_no_escapes() {
2011 let rest = r"C:\Users' tail";
2012 assert_eq!(
2013 find_closing_quote_before_comment(rest, '\'', ScanSyntax::Toml),
2014 Some(8),
2015 );
2016 }
2017
2018 /// Code-review finding (#1174/#1175 review round): a TOML `'...'` literal string has no
2019 /// escaping at all and permits `#` as ordinary content — unlike a `"..."` basic string,
2020 /// where `#` still bails as a mid-edit comment marker — so a legitimately `#`-containing
2021 /// single-quoted catalog value (e.g. a build-metadata suffix) must not be truncated.
2022 #[test]
2023 fn find_closing_quote_before_comment_toml_single_quoted_hash_is_content_not_comment() {
2024 let rest = r"1.0-build#5' tail";
2025 assert_eq!(
2026 find_closing_quote_before_comment(rest, '\'', ScanSyntax::Toml),
2027 Some(11),
2028 );
2029 }
2030}