sqruff_lib/rules/convention/
cv10.rs1use std::ops::Range;
2
3use hashbrown::HashMap;
4use regex::Regex;
5use smol_str::SmolStr;
6use sqruff_lib_core::dialects::init::DialectKind;
7use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
8use sqruff_lib_core::lint_fix::LintFix;
9use sqruff_lib_core::parser::markers::PositionMarker;
10use sqruff_lib_core::parser::segments::SegmentBuilder;
11use sqruff_lib_core::parser::segments::fix::SourceFix;
12use sqruff_lib_core::templaters::TemplateSliceKind;
13use strum_macros::{AsRefStr, EnumString};
14
15use crate::core::config::Value;
16use crate::core::rules::context::RuleContext;
17use crate::core::rules::crawlers::{Crawler, SegmentSeekerCrawler};
18use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups, targets_templated};
19
20#[derive(Debug, Copy, Clone, AsRefStr, EnumString, PartialEq, Default)]
21#[strum(serialize_all = "snake_case")]
22enum PreferredQuotedLiteralStyle {
23 #[default]
24 Consistent,
25 SingleQuotes,
26 DoubleQuotes,
27}
28
29impl PreferredQuotedLiteralStyle {
30 fn info(&self) -> QuoteInfo {
31 match self {
32 PreferredQuotedLiteralStyle::Consistent => unimplemented!(),
33 PreferredQuotedLiteralStyle::SingleQuotes => QuoteInfo {
34 preferred_quote_char: '\'',
35 alternate_quote_char: '"',
36 },
37 PreferredQuotedLiteralStyle::DoubleQuotes => QuoteInfo {
38 preferred_quote_char: '"',
39 alternate_quote_char: '\'',
40 },
41 }
42 }
43}
44
45struct QuoteInfo {
46 preferred_quote_char: char,
47 alternate_quote_char: char,
48}
49
50#[derive(Clone, Debug, Default)]
51pub struct RuleCV10 {
52 preferred_quoted_literal_style: PreferredQuotedLiteralStyle,
53 force_enable: bool,
54}
55
56impl Rule for RuleCV10 {
57 targets_templated!();
58
59 fn load_from_config(&self, config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
60 Ok(RuleCV10 {
61 preferred_quoted_literal_style: config["preferred_quoted_literal_style"]
62 .as_string()
63 .unwrap()
64 .to_owned()
65 .parse()
66 .unwrap(),
67 force_enable: config["force_enable"].as_bool().unwrap(),
68 }
69 .erased())
70 }
71
72 fn name(&self) -> &'static str {
73 "convention.quoted_literals"
74 }
75
76 fn description(&self) -> &'static str {
77 "Consistent usage of preferred quotes for quoted literals."
78 }
79
80 fn long_description(&self) -> &'static str {
81 r#"
82**Anti-pattern**
83
84```sql
85select
86 "abc",
87 'abc',
88 "\"",
89 "abc" = 'abc'
90from foo
91```
92
93**Best practice**
94
95Ensure all quoted literals use preferred quotes, unless escaping can be reduced by using alternate quotes.
96
97```sql
98select
99 "abc",
100 "abc",
101 '"',
102 "abc" = "abc"
103from foo
104```P
105"#
106 }
107
108 fn groups(&self) -> &'static [RuleGroups] {
109 &[RuleGroups::All, RuleGroups::Convention]
110 }
111
112 fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
113 if !(self.force_enable
115 || matches!(
116 context.dialect.name,
117 DialectKind::Bigquery | DialectKind::Sparksql
118 ))
119 {
120 return Vec::new();
121 }
122
123 let Some(position_marker) = context.segment.get_position_marker() else {
124 return Vec::new();
125 };
126 let spans_template = segment_spans_templated_slice(position_marker);
127 if spans_template
128 && !quote_delimiters_are_in_literal_source(
129 context.segment.raw().as_ref(),
130 position_marker,
131 )
132 {
133 return Vec::new();
134 }
135
136 let preferred_quoted_literal_style =
137 if self.preferred_quoted_literal_style == PreferredQuotedLiteralStyle::Consistent {
138 let preferred_quoted_literal_style = context
139 .try_get::<PreferredQuotedLiteralStyle>()
140 .unwrap_or_else(|| {
141 if context.segment.raw().ends_with('"') {
142 PreferredQuotedLiteralStyle::DoubleQuotes
143 } else {
144 PreferredQuotedLiteralStyle::SingleQuotes
145 }
146 });
147
148 context.set(preferred_quoted_literal_style);
149 preferred_quoted_literal_style
150 } else {
151 self.preferred_quoted_literal_style
152 };
153
154 let info = preferred_quoted_literal_style.info();
155 let fixed_string = normalize_preferred_quoted_literal_style(
156 context.segment.raw().as_ref(),
157 info.preferred_quote_char,
158 info.alternate_quote_char,
159 );
160
161 if fixed_string != context.segment.raw().as_str() {
162 let fixes = if spans_template {
163 source_only_quote_fixes(context, &fixed_string)
164 .map(|source_fixes| {
165 let raw_token = SegmentBuilder::token(
166 context.tables.next_id(),
167 context.segment.raw().as_ref(),
168 context.segment.get_type(),
169 )
170 .finish();
171
172 let edit_segment = SegmentBuilder::node(
173 context.tables.next_id(),
174 context.segment.get_type(),
175 context.dialect.name,
176 vec![raw_token],
177 )
178 .with_source_fixes(source_fixes)
179 .finish();
180
181 vec![LintFix::replace(
182 context.segment.clone(),
183 vec![edit_segment],
184 None,
185 )]
186 })
187 .unwrap_or_default()
188 } else {
189 vec![LintFix::replace(
190 context.segment.clone(),
191 vec![
192 SegmentBuilder::token(
193 context.tables.next_id(),
194 &fixed_string,
195 SyntaxKind::QuotedLiteral,
196 )
197 .finish(),
198 ],
199 None,
200 )]
201 };
202
203 return vec![LintResult::new(
204 context.segment.clone().into(),
205 fixes,
206 Some("".into()),
207 None,
208 )];
209 }
210
211 Vec::new()
212 }
213
214 fn is_fix_compatible(&self) -> bool {
215 true
216 }
217
218 fn crawl_behaviour(&self) -> Crawler {
219 SegmentSeekerCrawler::new(const { SyntaxSet::new(&[SyntaxKind::QuotedLiteral]) }).into()
220 }
221}
222
223fn segment_spans_templated_slice(position_marker: &PositionMarker) -> bool {
224 position_marker
225 .templated_file
226 .raw_sliced()
227 .iter()
228 .any(|slice| {
229 slice.has_slice_kind(TemplateSliceKind::Templated)
230 && ranges_overlap(&slice.source_slice(), &position_marker.source_slice).is_some()
231 })
232}
233
234fn quote_delimiters_are_in_literal_source(raw: &str, position_marker: &PositionMarker) -> bool {
235 let Some((leading_offset, leading_quote)) = raw
236 .char_indices()
237 .find(|(_, ch)| !matches!(ch, 'r' | 'b' | 'R' | 'B'))
238 else {
239 return false;
240 };
241 let Some((trailing_offset, trailing_quote)) = raw.char_indices().next_back() else {
242 return false;
243 };
244
245 matches!(leading_quote, '\'' | '"')
246 && matches!(trailing_quote, '\'' | '"')
247 && templated_position_is_literal_source(
248 position_marker,
249 position_marker.templated_slice.start + leading_offset,
250 leading_quote,
251 )
252 && templated_position_is_literal_source(
253 position_marker,
254 position_marker.templated_slice.start + trailing_offset,
255 trailing_quote,
256 )
257}
258
259fn templated_position_is_literal_source(
260 position_marker: &PositionMarker,
261 templated_pos: usize,
262 expected: char,
263) -> bool {
264 position_marker
265 .templated_file
266 .sliced_file
267 .iter()
268 .find(|slice| {
269 slice.templated_slice.start <= templated_pos
270 && templated_pos < slice.templated_slice.end
271 })
272 .is_some_and(|slice| {
273 if !slice.has_slice_kind(TemplateSliceKind::Literal) {
274 return false;
275 }
276
277 let source_pos = slice.source_slice.start + templated_pos - slice.templated_slice.start;
278 position_marker.templated_file.source_str[source_pos..].starts_with(expected)
279 })
280}
281
282fn source_only_quote_fixes(context: &RuleContext, fixed_string: &str) -> Option<Vec<SourceFix>> {
283 let position_marker = context.segment.get_position_marker()?;
284 let raw = context.segment.raw();
285
286 if raw.len() != fixed_string.len() {
287 return None;
288 }
289
290 let mut source_fixes = Vec::new();
291 let templated_file = &position_marker.templated_file;
292
293 for slice in &templated_file.sliced_file {
294 let Some(overlap) =
295 ranges_overlap(&slice.templated_slice, &position_marker.templated_slice)
296 else {
297 continue;
298 };
299 if overlap.is_empty() {
300 continue;
301 }
302
303 let local_slice = overlap.start - position_marker.templated_slice.start
304 ..overlap.end - position_marker.templated_slice.start;
305 let raw_part = raw.get(local_slice.clone())?;
306 let fixed_part = fixed_string.get(local_slice.clone())?;
307
308 if !slice.has_slice_kind(TemplateSliceKind::Literal) {
309 if raw_part != fixed_part {
310 return None;
311 }
312 continue;
313 }
314
315 if raw_part == fixed_part {
316 continue;
317 }
318
319 let source_start = slice.source_slice.start + overlap.start - slice.templated_slice.start;
320 let source_end = source_start + overlap.end - overlap.start;
321 source_fixes.push(SourceFix::new(
322 SmolStr::new(fixed_part),
323 source_start..source_end,
324 overlap,
325 ));
326 }
327
328 if source_fixes.is_empty() {
329 None
330 } else {
331 Some(source_fixes)
332 }
333}
334
335fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> Option<Range<usize>> {
336 let start = left.start.max(right.start);
337 let end = left.end.min(right.end);
338 (start < end).then_some(start..end)
339}
340
341fn normalize_preferred_quoted_literal_style(
343 s: &str,
344 preferred_quote_char: char,
345 alternate_quote_char: char,
346) -> String {
347 let mut s = s.to_string();
348 let trimmed = s.trim_start_matches(['r', 'b', 'R', 'B']);
349
350 let (orig_quote, new_quote) = if trimmed
351 .chars()
352 .take(3)
353 .eq(std::iter::repeat_n(preferred_quote_char, 3))
354 {
355 return s.to_string();
356 } else if trimmed.starts_with(preferred_quote_char) {
357 (
358 preferred_quote_char.to_string(),
359 alternate_quote_char.to_string(),
360 )
361 } else if trimmed
362 .chars()
363 .take(3)
364 .eq(std::iter::repeat_n(alternate_quote_char, 3))
365 {
366 (
367 std::iter::repeat_n(alternate_quote_char, 3).collect(),
368 std::iter::repeat_n(preferred_quote_char, 3).collect(),
369 )
370 } else if trimmed.starts_with(alternate_quote_char) {
371 (
372 alternate_quote_char.to_string(),
373 preferred_quote_char.to_string(),
374 )
375 } else {
376 return s.to_string();
377 };
378
379 let first_quote_pos = s.find(&orig_quote).unwrap_or_default();
380 let prefix = s[..first_quote_pos].to_string();
381 let unescaped_new_quote = Regex::new(&format!(r"(([^\\]|^)(\\\\)*){new_quote}")).unwrap();
382 let escaped_new_quote = Regex::new(&format!(r"([^\\]|^)\\((?:\\\\)*){new_quote}")).unwrap();
383 let escaped_orig_quote = Regex::new(&format!(r"([^\\]|^)\\((?:\\\\)*){orig_quote}")).unwrap();
384
385 let body_start = first_quote_pos + orig_quote.len();
386 let body_end = s.len() - orig_quote.len();
387
388 let mut body = s[body_start..body_end].to_string();
389 let mut new_body = if prefix.to_lowercase().contains("r") {
390 if unescaped_new_quote.find(&body).is_some() {
391 return s.to_string();
392 }
393 body.clone()
394 } else {
395 let mut new_body =
396 regex_sub_with_overlap(&escaped_new_quote, &format!(r"$1$2{new_quote}"), &body);
397 if new_body != body {
398 body = new_body.clone();
399 s = format!("{prefix}{orig_quote}{body}{orig_quote}");
400 }
401 new_body = regex_sub_with_overlap(
402 &escaped_orig_quote,
403 &format!(r"$1$2{orig_quote}"),
404 &new_body,
405 );
406 new_body = regex_sub_with_overlap(
407 &unescaped_new_quote,
408 &format!(r"$1\\{new_quote}"),
409 &new_body,
410 );
411
412 new_body
413 };
414
415 if new_quote
416 .chars()
417 .eq(std::iter::repeat_n(preferred_quote_char, 3))
418 && new_body.ends_with(preferred_quote_char)
419 {
420 let truncated_body = &new_body[..new_body.len() - 1];
421 new_body = format!("{truncated_body}\\{preferred_quote_char}");
422 }
423
424 let orig_escape_count = body.matches("\\").count();
425 let new_escape_count = new_body.matches("\\").count();
426 if new_escape_count > orig_escape_count {
427 return s.to_string();
428 }
429
430 if new_escape_count == orig_escape_count && orig_quote.starts_with(preferred_quote_char) {
431 s.to_string()
432 } else {
433 format!("{prefix}{new_quote}{new_body}{new_quote}")
434 }
435}
436
437fn regex_sub_with_overlap(regex: &Regex, replacement: &str, original: &str) -> String {
438 let first_pass = regex.replace_all(original, replacement);
439 let second_pass = regex.replace_all(&first_pass, replacement);
440 second_pass.to_string()
441}