Skip to main content

flowscope_core/linter/rules/
cv_010.rs

1//! LINT_CV_010: Consistent usage of preferred quotes for quoted literals.
2//!
3//! SQLFluff CV10 parity: detects inconsistent quoting of string literals in
4//! dialects where both single and double quotes denote strings (BigQuery,
5//! Databricks/SparkSQL, Hive, MySQL).  Applies Black-style quote
6//! normalization for autofixes.
7
8use crate::linter::config::LintConfig;
9use crate::linter::rule::{LintContext, LintRule};
10use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
11use sqlparser::ast::Statement;
12use std::ops::Range;
13
14// ---------------------------------------------------------------------------
15// Configuration
16// ---------------------------------------------------------------------------
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19enum PreferredStyle {
20    Consistent,
21    SingleQuotes,
22    DoubleQuotes,
23}
24
25impl PreferredStyle {
26    fn from_config(config: &LintConfig) -> Self {
27        match config
28            .rule_option_str(issue_codes::LINT_CV_010, "preferred_quoted_literal_style")
29            .unwrap_or("consistent")
30            .to_ascii_lowercase()
31            .as_str()
32        {
33            "single_quotes" | "single" => Self::SingleQuotes,
34            "double_quotes" | "double" => Self::DoubleQuotes,
35            _ => Self::Consistent,
36        }
37    }
38}
39
40pub struct ConventionQuotedLiterals {
41    preferred_style: PreferredStyle,
42    force_enable: bool,
43}
44
45impl ConventionQuotedLiterals {
46    pub fn from_config(config: &LintConfig) -> Self {
47        Self {
48            preferred_style: PreferredStyle::from_config(config),
49            force_enable: config
50                .rule_option_bool(issue_codes::LINT_CV_010, "force_enable")
51                .unwrap_or(false),
52        }
53    }
54
55    /// Dialects where both single and double quotes denote string literals.
56    fn is_double_quote_string_dialect(dialect: Dialect) -> bool {
57        matches!(
58            dialect,
59            Dialect::Bigquery | Dialect::Databricks | Dialect::Hive | Dialect::Mysql
60        )
61    }
62}
63
64impl Default for ConventionQuotedLiterals {
65    fn default() -> Self {
66        Self {
67            preferred_style: PreferredStyle::Consistent,
68            force_enable: false,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// LintRule impl
75// ---------------------------------------------------------------------------
76
77impl LintRule for ConventionQuotedLiterals {
78    fn code(&self) -> &'static str {
79        issue_codes::LINT_CV_010
80    }
81
82    fn name(&self) -> &'static str {
83        "Quoted literals style"
84    }
85
86    fn description(&self) -> &'static str {
87        "Consistent usage of preferred quotes for quoted literals."
88    }
89
90    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
91        let dialect = ctx.dialect();
92        if !self.force_enable && !Self::is_double_quote_string_dialect(dialect) {
93            return Vec::new();
94        }
95
96        let sql = ctx.statement_sql();
97        let masked_sql = contains_template_tags(sql).then(|| mask_templated_areas(sql));
98        let scan_sql = masked_sql.as_deref().unwrap_or(sql);
99        let literals = scan_string_literals(scan_sql);
100        let template_ranges = template_tag_ranges(ctx.sql);
101        if template_ranges.iter().any(|range| {
102            ctx.statement_range.start >= range.start && ctx.statement_range.end <= range.end
103        }) {
104            return Vec::new();
105        }
106
107        if literals.is_empty() {
108            return Vec::new();
109        }
110
111        // Determine effective preferred style.
112        let preferred = match self.preferred_style {
113            PreferredStyle::Consistent => {
114                // Derive from the first literal's quote character.
115                let first = &literals[0];
116                if first.quote_char == '"' {
117                    PreferredStyle::DoubleQuotes
118                } else {
119                    PreferredStyle::SingleQuotes
120                }
121            }
122            other => other,
123        };
124
125        let (pref_char, alt_char) = match preferred {
126            PreferredStyle::SingleQuotes => ('\'', '"'),
127            PreferredStyle::DoubleQuotes => ('"', '\''),
128            PreferredStyle::Consistent => unreachable!(),
129        };
130
131        let message = match preferred {
132            PreferredStyle::SingleQuotes => "Use single quotes for quoted literals.",
133            PreferredStyle::DoubleQuotes => "Use double quotes for quoted literals.",
134            PreferredStyle::Consistent => unreachable!(),
135        };
136
137        let mut issues = Vec::new();
138        for lit in &literals {
139            let absolute_start = ctx.statement_range.start + lit.start;
140            let absolute_end = ctx.statement_range.start + lit.end;
141            if template_ranges
142                .iter()
143                .any(|range| absolute_start >= range.start && absolute_end <= range.end)
144            {
145                continue;
146            }
147
148            let replacement = normalize_literal(sql, lit, pref_char, alt_char);
149            let mismatch = lit.quote_char != pref_char;
150            let template_mismatch = mismatch && literal_contains_template(sql, lit);
151            if replacement.is_none() && !template_mismatch {
152                continue;
153            }
154
155            let mut issue = Issue::info(issue_codes::LINT_CV_010, message)
156                .with_statement(ctx.statement_index)
157                .with_span(ctx.span_from_statement_offset(lit.start, lit.end));
158
159            if let Some(replacement) = replacement {
160                issue = issue.with_autofix_edits(
161                    IssueAutofixApplicability::Safe,
162                    vec![IssuePatchEdit::new(
163                        ctx.span_from_statement_offset(lit.start, lit.end),
164                        replacement,
165                    )],
166                );
167            }
168
169            issues.push(issue);
170        }
171
172        issues
173    }
174}
175
176// ---------------------------------------------------------------------------
177// String literal scanner
178// ---------------------------------------------------------------------------
179
180/// A string literal found in the raw SQL source.
181#[derive(Debug)]
182struct StringLiteral {
183    /// Byte offset of the start of the literal (including any prefix).
184    start: usize,
185    /// Byte offset one past the end of the literal.
186    end: usize,
187    /// The quote character used: `'` or `"`.
188    quote_char: char,
189    /// Whether this is a triple-quoted string.
190    is_triple: bool,
191    /// Whether the literal has a prefix (r, b, R, B).
192    prefix: Option<u8>,
193}
194
195/// Scan raw SQL for string literals (single-quoted, double-quoted, with
196/// optional BigQuery prefixes).  Skips comments, dollar-quoted strings,
197/// and date/time constructor strings (DATE'...', TIME'...', etc.).
198fn scan_string_literals(sql: &str) -> Vec<StringLiteral> {
199    let bytes = sql.as_bytes();
200    let len = bytes.len();
201    let mut result = Vec::new();
202    let mut i = 0;
203
204    while i < len {
205        // Skip Jinja templated tags.
206        if let Some(close_marker) = template_close_marker_at(bytes, i) {
207            i = skip_template_tag(bytes, i, close_marker);
208            continue;
209        }
210
211        // Skip line comments.
212        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
213            i += 2;
214            while i < len && bytes[i] != b'\n' {
215                i += 1;
216            }
217            continue;
218        }
219
220        // Skip block comments.
221        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
222            i += 2;
223            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
224                i += 1;
225            }
226            i += 2;
227            continue;
228        }
229
230        // Skip dollar-quoted strings ($$...$$).
231        if bytes[i] == b'$' && i + 1 < len && bytes[i + 1] == b'$' {
232            i += 2;
233            while i + 1 < len && !(bytes[i] == b'$' && bytes[i + 1] == b'$') {
234                i += 1;
235            }
236            i += 2;
237            continue;
238        }
239
240        // Check for date/time constructor keywords preceding a quote.
241        // E.g. DATE'...', TIME'...', TIMESTAMP'...', DATETIME'...'
242        // These are SQL typed literals, not normal string literals.
243        if (bytes[i] == b'\'' || bytes[i] == b'"') && is_preceded_by_type_keyword(sql, i) {
244            // Skip the typed literal.
245            let q = bytes[i];
246            i += 1;
247            while i < len && bytes[i] != q {
248                if bytes[i] == b'\\' && i + 1 < len {
249                    i += 1;
250                }
251                i += 1;
252            }
253            if i < len {
254                i += 1; // skip closing quote
255            }
256            continue;
257        }
258
259        // Check for prefix characters (r, b, R, B) before a quote.
260        let prefix: Option<u8>;
261        let quote_start: usize;
262        if (bytes[i] == b'r' || bytes[i] == b'R' || bytes[i] == b'b' || bytes[i] == b'B')
263            && i + 1 < len
264            && (bytes[i + 1] == b'\'' || bytes[i + 1] == b'"')
265        {
266            // Make sure this isn't part of an identifier.
267            if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
268                i += 1;
269                continue;
270            }
271            prefix = Some(bytes[i]);
272            quote_start = i + 1;
273        } else if bytes[i] == b'\'' || bytes[i] == b'"' {
274            prefix = None;
275            quote_start = i;
276        } else {
277            i += 1;
278            continue;
279        }
280
281        let q = bytes[quote_start];
282        let literal_start = if prefix.is_some() { i } else { quote_start };
283
284        // Check for triple quote.
285        let is_triple =
286            quote_start + 2 < len && bytes[quote_start + 1] == q && bytes[quote_start + 2] == q;
287
288        if is_triple {
289            let mut j = quote_start + 3;
290            loop {
291                if j + 2 >= len {
292                    // Unterminated triple quote -- skip.
293                    i = len;
294                    break;
295                }
296                if let Some(close_marker) = template_close_marker_at(bytes, j) {
297                    j = skip_template_tag(bytes, j, close_marker);
298                    continue;
299                }
300                if bytes[j] == q && bytes[j + 1] == q && bytes[j + 2] == q {
301                    let end = j + 3;
302                    result.push(StringLiteral {
303                        start: literal_start,
304                        end,
305                        quote_char: q as char,
306                        is_triple: true,
307                        prefix,
308                    });
309                    i = end;
310                    break;
311                }
312                if bytes[j] == b'\\' {
313                    j += 1; // skip escaped char
314                }
315                j += 1;
316            }
317        } else {
318            // Single-quoted literal.
319            let mut j = quote_start + 1;
320            while j < len {
321                if let Some(close_marker) = template_close_marker_at(bytes, j) {
322                    j = skip_template_tag(bytes, j, close_marker);
323                    continue;
324                }
325                if bytes[j] == b'\\' {
326                    j += 2;
327                    continue;
328                }
329                if bytes[j] == q {
330                    // Check for escaped quote ('' or "").
331                    if j + 1 < len && bytes[j + 1] == q {
332                        j += 2;
333                        continue;
334                    }
335                    break;
336                }
337                j += 1;
338            }
339            if j >= len {
340                // Unterminated string, skip.
341                i = len;
342                continue;
343            }
344            let end = j + 1;
345            result.push(StringLiteral {
346                start: literal_start,
347                end,
348                quote_char: q as char,
349                is_triple: false,
350                prefix,
351            });
352            i = end;
353        }
354    }
355
356    result
357}
358
359fn literal_contains_template(sql: &str, lit: &StringLiteral) -> bool {
360    let raw = &sql[lit.start..lit.end];
361    raw.contains("{{") || raw.contains("{%") || raw.contains("{#")
362}
363
364fn contains_template_tags(sql: &str) -> bool {
365    sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
366}
367
368fn mask_templated_areas(sql: &str) -> String {
369    let mut out = String::with_capacity(sql.len());
370    let mut index = 0usize;
371
372    while let Some((open_index, close_marker)) = find_next_template_open(sql, index) {
373        out.push_str(&sql[index..open_index]);
374        let marker_start = open_index + 2;
375        if let Some(close_offset) = sql[marker_start..].find(close_marker) {
376            let close_index = marker_start + close_offset + close_marker.len();
377            out.push_str(&mask_non_newlines(&sql[open_index..close_index]));
378            index = close_index;
379        } else {
380            out.push_str(&mask_non_newlines(&sql[open_index..]));
381            return out;
382        }
383    }
384
385    out.push_str(&sql[index..]);
386    out
387}
388
389fn find_next_template_open(sql: &str, from: usize) -> Option<(usize, &'static str)> {
390    let rest = sql.get(from..)?;
391    let candidates = [("{{", "}}"), ("{%", "%}"), ("{#", "#}")];
392
393    candidates
394        .into_iter()
395        .filter_map(|(open, close)| rest.find(open).map(|offset| (from + offset, close)))
396        .min_by_key(|(index, _)| *index)
397}
398
399fn mask_non_newlines(segment: &str) -> String {
400    segment
401        .chars()
402        .map(|ch| if ch == '\n' { '\n' } else { ' ' })
403        .collect()
404}
405
406fn template_tag_ranges(sql: &str) -> Vec<Range<usize>> {
407    let mut ranges = Vec::new();
408    let mut index = 0usize;
409
410    while let Some((open_index, close_marker)) = find_next_template_open(sql, index) {
411        let marker_start = open_index + 2;
412        let end = if let Some(close_offset) = sql[marker_start..].find(close_marker) {
413            marker_start + close_offset + close_marker.len()
414        } else {
415            sql.len()
416        };
417        ranges.push(open_index..end);
418        index = end;
419    }
420
421    ranges
422}
423
424fn template_close_marker_at(bytes: &[u8], index: usize) -> Option<&'static [u8]> {
425    if index + 1 >= bytes.len() || bytes[index] != b'{' {
426        return None;
427    }
428    match bytes[index + 1] {
429        b'{' => Some(b"}}"),
430        b'%' => Some(b"%}"),
431        b'#' => Some(b"#}"),
432        _ => None,
433    }
434}
435
436fn skip_template_tag(bytes: &[u8], start: usize, close_marker: &[u8]) -> usize {
437    let mut i = start + 2; // skip opening "{x"
438    while i + close_marker.len() <= bytes.len() {
439        if bytes[i..].starts_with(close_marker) {
440            return i + close_marker.len();
441        }
442        i += 1;
443    }
444    bytes.len()
445}
446
447/// Check if position `pos` is preceded by a type keyword like DATE, TIME,
448/// TIMESTAMP, DATETIME, INTERVAL (ignoring case and optional whitespace).
449fn is_preceded_by_type_keyword(sql: &str, pos: usize) -> bool {
450    let before = &sql[..pos];
451    let trimmed = before.trim_end();
452    let lower = trimmed.to_ascii_lowercase();
453    for kw in &["date", "time", "timestamp", "datetime", "interval"] {
454        if lower.ends_with(kw) {
455            // Make sure it's a complete keyword (not part of a longer identifier).
456            let prefix_len = trimmed.len() - kw.len();
457            if prefix_len == 0 {
458                return true;
459            }
460            let prev_byte = trimmed.as_bytes()[prefix_len - 1];
461            if !prev_byte.is_ascii_alphanumeric() && prev_byte != b'_' {
462                return true;
463            }
464        }
465    }
466    false
467}
468
469// ---------------------------------------------------------------------------
470// Quote normalization (Black-style)
471// ---------------------------------------------------------------------------
472
473/// Attempt to normalize a string literal to the preferred quote style.
474/// Returns `Some(replacement)` if the literal should be changed, `None` if
475/// it's already correct or conversion would increase escaping.
476fn normalize_literal(
477    sql: &str,
478    lit: &StringLiteral,
479    pref_char: char,
480    alt_char: char,
481) -> Option<String> {
482    let raw = &sql[lit.start..lit.end];
483    let prefix_str = match lit.prefix {
484        Some(_) => &raw[..1],
485        None => "",
486    };
487    let value_part = &raw[prefix_str.len()..]; // the part starting with quote(s)
488
489    if lit.is_triple {
490        let pref_triple = format!("{0}{0}{0}", pref_char);
491        let alt_triple = format!("{0}{0}{0}", alt_char);
492
493        if value_part.starts_with(&pref_triple) {
494            // Already preferred triple -- nothing to do.
495            return None;
496        }
497
498        if !value_part.starts_with(&alt_triple) {
499            // Neither preferred nor alternate triple -- skip.
500            return None;
501        }
502
503        // Body is between the triple quotes.
504        let body = &value_part[3..value_part.len() - 3];
505
506        // Converting triple quotes can require extra escaping; avoid fixes
507        // that introduce escapes compared to the original.
508        if body.ends_with(pref_char) {
509            return None;
510        }
511        if body.contains(&pref_triple) {
512            return None;
513        }
514
515        let result = format!("{}{}{}{}", prefix_str, pref_triple, body, pref_triple);
516        if result == raw {
517            return None;
518        }
519        return Some(result);
520    }
521
522    // Single-quoted literal.
523    if value_part.starts_with(pref_char) {
524        // Already preferred quote.  Check if we can remove unnecessary escapes.
525        let body = &value_part[1..value_part.len() - 1];
526        let new_body = remove_unnecessary_escapes(body, pref_char, alt_char);
527        if new_body == body {
528            return None;
529        }
530        let result = format!("{}{}{}{}", prefix_str, pref_char, new_body, pref_char);
531        if result == raw {
532            return None;
533        }
534        return Some(result);
535    }
536
537    if !value_part.starts_with(alt_char) {
538        return None;
539    }
540
541    // Currently using alternate quotes.
542    let body = &value_part[1..value_part.len() - 1];
543    let is_raw = lit.prefix.map(|p| p == b'r' || p == b'R').unwrap_or(false);
544
545    if is_raw {
546        // Raw strings: do not modify the body.  Can only convert if the
547        // body doesn't contain unescaped preferred quotes.
548        if body.contains(pref_char) {
549            // Check if ALL occurrences are escaped.
550            let has_unescaped = has_unescaped_char(body, pref_char as u8);
551            if has_unescaped {
552                return None;
553            }
554        }
555        let result = format!("{}{}{}{}", prefix_str, pref_char, body, pref_char);
556        if result == raw {
557            return None;
558        }
559        return Some(result);
560    }
561
562    // Non-raw: apply Black-style normalization.
563    // 1. Remove unnecessary escapes of the new (preferred) quote.
564    let body_cleaned = remove_unnecessary_escapes(body, alt_char, pref_char);
565
566    // 2. Add escapes for unescaped preferred quotes, remove escapes for
567    //    alternate quotes.
568    let new_body = convert_quotes_in_body(&body_cleaned, pref_char as u8, alt_char as u8);
569
570    // Compare escape counts.
571    let orig_escapes = body_cleaned.matches('\\').count();
572    let new_escapes = new_body.matches('\\').count();
573
574    if new_escapes > orig_escapes {
575        // Would introduce more escaping -- keep original but remove
576        // unnecessary escapes.
577        if body_cleaned != body {
578            let result = format!("{}{}{}{}", prefix_str, alt_char, body_cleaned, alt_char);
579            return Some(result);
580        }
581        return None;
582    }
583
584    let result = format!("{}{}{}{}", prefix_str, pref_char, new_body, pref_char);
585    if result == raw {
586        return None;
587    }
588    Some(result)
589}
590
591/// Remove unnecessary escapes of `other_char` in a body quoted with
592/// `quote_char`.  E.g. in a double-quoted string, `\'` is unnecessary.
593fn remove_unnecessary_escapes(body: &str, _quote_char: char, other_char: char) -> String {
594    let bytes = body.as_bytes();
595    let mut result = Vec::with_capacity(bytes.len());
596    let mut i = 0;
597
598    while i < bytes.len() {
599        if bytes[i] == b'\\' && i + 1 < bytes.len() {
600            let next = bytes[i + 1];
601            if next == other_char as u8 {
602                // Check if this backslash is itself escaped.
603                // Count preceding backslashes.
604                let preceding_backslashes = count_preceding_backslashes(&result);
605                if preceding_backslashes.is_multiple_of(2) {
606                    // This \other is unnecessary -- remove the backslash.
607                    result.push(next);
608                    i += 2;
609                    continue;
610                }
611            }
612            result.push(bytes[i]);
613            result.push(next);
614            i += 2;
615        } else {
616            result.push(bytes[i]);
617            i += 1;
618        }
619    }
620
621    String::from_utf8(result).unwrap_or_else(|_| body.to_string())
622}
623
624/// Convert body from alt_char quoting to pref_char quoting:
625/// - Escape unescaped pref_char occurrences
626/// - Unescape escaped alt_char occurrences
627fn convert_quotes_in_body(body: &str, pref: u8, alt: u8) -> String {
628    let bytes = body.as_bytes();
629    let mut result = Vec::with_capacity(bytes.len());
630    let mut i = 0;
631
632    while i < bytes.len() {
633        if bytes[i] == b'\\' && i + 1 < bytes.len() {
634            let next = bytes[i + 1];
635            if next == alt {
636                // Unescape: \alt -> alt (when we're switching to pref quoting).
637                let preceding = count_preceding_backslashes(&result);
638                if preceding.is_multiple_of(2) {
639                    result.push(alt);
640                    i += 2;
641                    continue;
642                }
643            }
644            result.push(bytes[i]);
645            result.push(next);
646            i += 2;
647        } else if bytes[i] == pref {
648            // Escape unescaped preferred quote.
649            let preceding = count_preceding_backslashes(&result);
650            if preceding.is_multiple_of(2) {
651                result.push(b'\\');
652            }
653            result.push(pref);
654            i += 1;
655        } else {
656            result.push(bytes[i]);
657            i += 1;
658        }
659    }
660
661    String::from_utf8(result).unwrap_or_else(|_| body.to_string())
662}
663
664fn count_preceding_backslashes(buf: &[u8]) -> usize {
665    buf.iter().rev().take_while(|&&b| b == b'\\').count()
666}
667
668fn has_unescaped_char(body: &str, ch: u8) -> bool {
669    let bytes = body.as_bytes();
670    let mut i = 0;
671    while i < bytes.len() {
672        if bytes[i] == b'\\' {
673            i += 2;
674            continue;
675        }
676        if bytes[i] == ch {
677            return true;
678        }
679        i += 1;
680    }
681    false
682}
683
684// ---------------------------------------------------------------------------
685// Tests
686// ---------------------------------------------------------------------------
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::linter::rule::with_active_dialect;
692    use crate::parser::parse_sql;
693
694    fn run_with_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
695        let statements = parse_sql(sql).expect("parse");
696        let rule = ConventionQuotedLiterals::default();
697        with_active_dialect(dialect, || {
698            statements
699                .iter()
700                .enumerate()
701                .flat_map(|(index, statement)| {
702                    rule.check(
703                        statement,
704                        &LintContext {
705                            sql,
706                            statement_range: 0..sql.len(),
707                            statement_index: index,
708                        },
709                    )
710                })
711                .collect()
712        })
713    }
714
715    fn run(sql: &str) -> Vec<Issue> {
716        run_with_dialect(sql, Dialect::Bigquery)
717    }
718
719    fn run_with_config(sql: &str, dialect: Dialect, config: &LintConfig) -> Vec<Issue> {
720        let statements = parse_sql(sql).expect("parse");
721        let rule = ConventionQuotedLiterals::from_config(config);
722        with_active_dialect(dialect, || {
723            statements
724                .iter()
725                .enumerate()
726                .flat_map(|(index, statement)| {
727                    rule.check(
728                        statement,
729                        &LintContext {
730                            sql,
731                            statement_range: 0..sql.len(),
732                            statement_index: index,
733                        },
734                    )
735                })
736                .collect()
737        })
738    }
739
740    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
741        let autofix = issue.autofix.as_ref()?;
742        let mut out = sql.to_string();
743        let mut edits = autofix.edits.clone();
744        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
745        for edit in edits.into_iter().rev() {
746            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
747        }
748        Some(out)
749    }
750
751    fn apply_all_issue_autofixes(sql: &str, issues: &[Issue]) -> String {
752        let mut out = sql.to_string();
753        let mut edits = Vec::new();
754        for issue in issues {
755            if let Some(autofix) = &issue.autofix {
756                edits.extend(autofix.edits.clone());
757            }
758        }
759        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
760        for edit in edits.into_iter().rev() {
761            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
762        }
763        out
764    }
765
766    fn make_config(style: &str) -> LintConfig {
767        LintConfig {
768            enabled: true,
769            disabled_rules: vec![],
770            rule_configs: std::collections::BTreeMap::from([(
771                "convention.quoted_literals".to_string(),
772                serde_json::json!({"preferred_quoted_literal_style": style}),
773            )]),
774        }
775    }
776
777    fn make_config_force(style: &str) -> LintConfig {
778        LintConfig {
779            enabled: true,
780            disabled_rules: vec![],
781            rule_configs: std::collections::BTreeMap::from([(
782                "convention.quoted_literals".to_string(),
783                serde_json::json!({
784                    "preferred_quoted_literal_style": style,
785                    "force_enable": true,
786                }),
787            )]),
788        }
789    }
790
791    // --- Dialect gating ---
792
793    #[test]
794    fn no_issue_for_ansi_dialect() {
795        let issues = run_with_dialect("SELECT 'abc', \"def\"", Dialect::Ansi);
796        assert!(issues.is_empty(), "CV10 should not fire for ANSI dialect");
797    }
798
799    #[test]
800    fn no_issue_for_postgres_dialect() {
801        let issues = run_with_dialect("SELECT 'abc'", Dialect::Postgres);
802        assert!(issues.is_empty());
803    }
804
805    #[test]
806    fn force_enable_works_for_postgres() {
807        let config = make_config_force("single_quotes");
808        let issues = run_with_config("SELECT 'abc'", Dialect::Postgres, &config);
809        assert!(issues.is_empty(), "single-quoted only should pass");
810    }
811
812    // --- BigQuery consistent mode ---
813
814    #[test]
815    fn consistent_mode_flags_mixed_quotes() {
816        let sql = "SELECT\n    \"some string\",\n    'some string'";
817        let issues = run(sql);
818        assert_eq!(issues.len(), 1);
819        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
820        assert_eq!(fixed, "SELECT\n    \"some string\",\n    \"some string\"");
821    }
822
823    #[test]
824    fn consistent_mode_no_issue_for_single_style() {
825        let issues = run("SELECT 'abc', 'def'");
826        assert!(issues.is_empty());
827    }
828
829    #[test]
830    fn consistent_mode_no_issue_for_double_style() {
831        let issues = run("SELECT \"abc\", \"def\"");
832        assert!(issues.is_empty());
833    }
834
835    // --- Explicit double_quotes preference ---
836
837    #[test]
838    fn double_pref_flags_single_quoted() {
839        let config = make_config("double_quotes");
840        let sql = "SELECT 'abc'";
841        let issues = run_with_config(sql, Dialect::Bigquery, &config);
842        assert_eq!(issues.len(), 1);
843        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
844        assert_eq!(fixed, "SELECT \"abc\"");
845    }
846
847    #[test]
848    fn double_pref_passes_double_quoted() {
849        let config = make_config("double_quotes");
850        let issues = run_with_config("SELECT \"abc\"", Dialect::Bigquery, &config);
851        assert!(issues.is_empty());
852    }
853
854    // --- Single quotes preference ---
855
856    #[test]
857    fn single_pref_flags_double_quoted() {
858        let config = make_config("single_quotes");
859        let sql = "SELECT \"abc\"";
860        let issues = run_with_config(sql, Dialect::Bigquery, &config);
861        assert_eq!(issues.len(), 1);
862        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
863        assert_eq!(fixed, "SELECT 'abc'");
864    }
865
866    // --- Empty strings ---
867
868    #[test]
869    fn double_pref_passes_empty_double() {
870        let config = make_config("double_quotes");
871        let issues = run_with_config("SELECT \"\"", Dialect::Bigquery, &config);
872        assert!(issues.is_empty());
873    }
874
875    #[test]
876    fn double_pref_flags_empty_single() {
877        let config = make_config("double_quotes");
878        let sql = "SELECT ''";
879        let issues = run_with_config(sql, Dialect::Bigquery, &config);
880        assert_eq!(issues.len(), 1);
881        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
882        assert_eq!(fixed, "SELECT \"\"");
883    }
884
885    // --- Date constructor strings are ignored ---
886
887    #[test]
888    fn date_constructor_ignored_consistent() {
889        let sql = "SELECT\n    \"quoted string\",\n    DATE'some string'";
890        let issues = run(sql);
891        assert!(
892            issues.is_empty(),
893            "DATE'...' should not count as single-quoted literal"
894        );
895    }
896
897    #[test]
898    fn date_constructor_ignored_double_pref() {
899        let config = make_config("double_quotes");
900        let issues = run_with_config("SELECT\n    DATE'some string'", Dialect::Bigquery, &config);
901        assert!(issues.is_empty());
902    }
903
904    // --- Dollar-quoted strings are ignored ---
905
906    #[test]
907    fn dollar_quoted_ignored() {
908        let config = make_config_force("single_quotes");
909        let sql = "SELECT\n    'some string',\n    $$some_other_string$$";
910        let issues = run_with_config(sql, Dialect::Postgres, &config);
911        assert!(issues.is_empty());
912    }
913
914    // --- String prefix handling (BigQuery r/b prefixes) ---
915
916    #[test]
917    fn bigquery_prefixes_double_pref() {
918        let config = make_config("double_quotes");
919        let sql = "SELECT\n    r'some_string',\n    b'some_string',\n    R'some_string',\n    B'some_string'";
920        let issues = run_with_config(sql, Dialect::Bigquery, &config);
921        assert_eq!(issues.len(), 4);
922        let fixed = apply_all_issue_autofixes(sql, &issues);
923        assert_eq!(
924            fixed,
925            "SELECT\n    r\"some_string\",\n    b\"some_string\",\n    R\"some_string\",\n    B\"some_string\""
926        );
927    }
928
929    #[test]
930    fn bigquery_prefixes_consistent_mode() {
931        // r'...' and b"..." -- consistent mode derives single from first literal.
932        let sql = "SELECT\n    r'some_string',\n    b\"some_string\"";
933        let issues = run(sql);
934        assert_eq!(issues.len(), 1);
935        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
936        assert_eq!(fixed, "SELECT\n    r'some_string',\n    b'some_string'");
937    }
938
939    // --- Escaping ---
940
941    #[test]
942    fn unnecessary_escaping_removed() {
943        let config = make_config("double_quotes");
944        let sql =
945            "SELECT\n    'unnecessary \\\"\\\"escaping',\n    \"unnecessary \\'\\' escaping\"";
946        let issues = run_with_config(sql, Dialect::Bigquery, &config);
947        assert_eq!(issues.len(), 2);
948    }
949
950    // --- Hive, MySQL, SparkSQL dialects ---
951
952    #[test]
953    fn hive_dialect_supported() {
954        let sql = "SELECT\n    \"some string\",\n    'some string'";
955        let issues = run_with_dialect(sql, Dialect::Hive);
956        assert_eq!(issues.len(), 1);
957    }
958
959    #[test]
960    fn mysql_dialect_supported() {
961        let sql = "SELECT\n    \"some string\",\n    'some string'";
962        let issues = run_with_dialect(sql, Dialect::Mysql);
963        assert_eq!(issues.len(), 1);
964    }
965
966    #[test]
967    fn sparksql_dialect_supported() {
968        // SparkSQL maps to Databricks.
969        let sql = "SELECT\n    \"some string\",\n    'some string'";
970        let issues = run_with_dialect(sql, Dialect::Databricks);
971        assert_eq!(issues.len(), 1);
972    }
973
974    // --- Triple-quoted strings ---
975
976    #[test]
977    fn triple_quotes_preferred_passes() {
978        let config = make_config("double_quotes");
979        let issues = run_with_config("SELECT \"\"\"some_string\"\"\"", Dialect::Bigquery, &config);
980        assert!(issues.is_empty());
981    }
982
983    #[test]
984    fn triple_quotes_alternate_fails_and_fixes() {
985        let config = make_config("double_quotes");
986        let sql = "SELECT '''some_string'''";
987        let issues = run_with_config(sql, Dialect::Bigquery, &config);
988        assert_eq!(issues.len(), 1);
989        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
990        assert_eq!(fixed, "SELECT \"\"\"some_string\"\"\"");
991    }
992
993    #[test]
994    fn scanner_ignores_quotes_inside_fully_templated_tags() {
995        let literals = scan_string_literals("SELECT {{ \"'a_non_lintable_string'\" }}");
996        assert!(literals.is_empty());
997    }
998
999    #[test]
1000    fn scanner_keeps_outer_literal_when_template_is_inside_literal() {
1001        let literals = scan_string_literals("SELECT '{{ \"a string\" }}'");
1002        assert_eq!(literals.len(), 1);
1003        assert_eq!(literals[0].quote_char, '\'');
1004    }
1005
1006    #[test]
1007    fn emits_per_literal_issues_for_partially_fixable_raw_literals() {
1008        let config = make_config("double_quotes");
1009        let sql = "SELECT\n    r'Tricky \"quote',\n    r'Not-so-tricky \\\"quote'";
1010        let issues = run_with_config(sql, Dialect::Bigquery, &config);
1011        assert_eq!(issues.len(), 1);
1012        let fixable: Vec<_> = issues
1013            .iter()
1014            .filter(|issue| issue.autofix.is_some())
1015            .collect();
1016        assert_eq!(fixable.len(), 1);
1017        let fixed = apply_issue_autofix(sql, fixable[0]).expect("autofix");
1018        assert_eq!(
1019            fixed,
1020            "SELECT\n    r'Tricky \"quote',\n    r\"Not-so-tricky \\\"quote\""
1021        );
1022    }
1023
1024    #[test]
1025    fn templated_mismatch_is_reported_even_when_unfixable() {
1026        let config = make_config("double_quotes");
1027        let sql = "SELECT '{{ \"a string\" }}'";
1028        let issues = run_with_config(sql, Dialect::Bigquery, &config);
1029        assert_eq!(issues.len(), 1);
1030        assert!(issues[0].autofix.is_none());
1031    }
1032
1033    #[test]
1034    fn triple_quote_fix_skips_literals_that_require_extra_escape() {
1035        let sql = "SELECT\n    '''abc\"''',\n    '''abc\" '''";
1036        let literals = scan_string_literals(sql);
1037        assert_eq!(literals.len(), 2);
1038
1039        let first = normalize_literal(sql, &literals[0], '"', '\'');
1040        let second = normalize_literal(sql, &literals[1], '"', '\'');
1041
1042        assert!(
1043            first.is_none(),
1044            "first triple literal should stay unfixable"
1045        );
1046        assert_eq!(second.as_deref(), Some("\"\"\"abc\" \"\"\""));
1047    }
1048}