1use 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#[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 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
73impl 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 let preferred = match self.preferred_style {
113 PreferredStyle::Consistent => {
114 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#[derive(Debug)]
182struct StringLiteral {
183 start: usize,
185 end: usize,
187 quote_char: char,
189 is_triple: bool,
191 prefix: Option<u8>,
193}
194
195fn 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 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 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 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 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 if (bytes[i] == b'\'' || bytes[i] == b'"') && is_preceded_by_type_keyword(sql, i) {
244 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; }
256 continue;
257 }
258
259 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 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 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 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; }
315 j += 1;
316 }
317 } else {
318 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 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 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; 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
447fn 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 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
469fn 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()..]; 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 return None;
496 }
497
498 if !value_part.starts_with(&alt_triple) {
499 return None;
501 }
502
503 let body = &value_part[3..value_part.len() - 3];
505
506 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 if value_part.starts_with(pref_char) {
524 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 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 if body.contains(pref_char) {
549 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 let body_cleaned = remove_unnecessary_escapes(body, alt_char, pref_char);
565
566 let new_body = convert_quotes_in_body(&body_cleaned, pref_char as u8, alt_char as u8);
569
570 let orig_escapes = body_cleaned.matches('\\').count();
572 let new_escapes = new_body.matches('\\').count();
573
574 if new_escapes > orig_escapes {
575 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
591fn 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 let preceding_backslashes = count_preceding_backslashes(&result);
605 if preceding_backslashes.is_multiple_of(2) {
606 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
624fn 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 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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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}