Skip to main content

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}