Skip to main content

sqruff_lib/rules/convention/
cv10.rs

1use 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        // TODO: "databricks", "hive", "mysql"
114        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
341// FIXME: avoid memory allocations
342fn 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}