sqruff_lib/rules/structure/
st04.rs

1use ahash::AHashMap;
2use itertools::Itertools;
3use smol_str::ToSmolStr;
4use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
5use sqruff_lib_core::lint_fix::LintFix;
6use sqruff_lib_core::parser::segments::{ErasedSegment, SegmentBuilder, Tables};
7use sqruff_lib_core::utils::functional::segments::Segments;
8
9use crate::core::config::Value;
10use crate::core::rules::context::RuleContext;
11use crate::core::rules::crawlers::{Crawler, SegmentSeekerCrawler};
12use crate::core::rules::{Erased as _, ErasedRule, LintResult, Rule, RuleGroups};
13use crate::utils::functional::context::FunctionalContext;
14use crate::utils::reflow::reindent::{IndentUnit, construct_single_indent};
15
16#[derive(Clone, Debug, Default)]
17pub struct RuleST04;
18
19impl Rule for RuleST04 {
20    fn load_from_config(&self, _config: &AHashMap<String, Value>) -> Result<ErasedRule, String> {
21        Ok(RuleST04.erased())
22    }
23
24    fn name(&self) -> &'static str {
25        "structure.nested_case"
26    }
27
28    fn description(&self) -> &'static str {
29        "Nested ``CASE`` statement in ``ELSE`` clause could be flattened."
30    }
31
32    fn long_description(&self) -> &'static str {
33        r"
34## Anti-pattern
35
36In this example, the outer `CASE`'s `ELSE` is an unnecessary, nested `CASE`.
37
38```sql
39SELECT
40  CASE
41    WHEN species = 'Cat' THEN 'Meow'
42    ELSE
43    CASE
44       WHEN species = 'Dog' THEN 'Woof'
45    END
46  END as sound
47FROM mytable
48```
49
50## Best practice
51
52Move the body of the inner `CASE` to the end of the outer one.
53
54```sql
55SELECT
56  CASE
57    WHEN species = 'Cat' THEN 'Meow'
58    WHEN species = 'Dog' THEN 'Woof'
59  END AS sound
60FROM mytable
61```
62"
63    }
64
65    fn groups(&self) -> &'static [RuleGroups] {
66        &[RuleGroups::All, RuleGroups::Structure]
67    }
68
69    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
70        let segment = FunctionalContext::new(context).segment();
71        let case1_children = segment.children(None);
72        let case1_keywords =
73            case1_children.find_first(Some(|it: &ErasedSegment| it.is_keyword("CASE")));
74        let case1_first_case = case1_keywords.first().unwrap();
75        let case1_when_list = case1_children.find_first(Some(|it: &ErasedSegment| {
76            matches!(
77                it.get_type(),
78                SyntaxKind::WhenClause | SyntaxKind::ElseClause
79            )
80        }));
81        let case1_first_when = case1_when_list.first().unwrap();
82        let when_clause_list =
83            case1_children.find_last(Some(|it| it.is_type(SyntaxKind::WhenClause)));
84        let case1_last_when = when_clause_list.first();
85        let case1_else_clause =
86            case1_children.find_last(Some(|it| it.is_type(SyntaxKind::ElseClause)));
87        let case1_else_expressions =
88            case1_else_clause.children(Some(|it| it.is_type(SyntaxKind::Expression)));
89        let expression_children = case1_else_expressions.children(None);
90        let case2 =
91            expression_children.select::<fn(&ErasedSegment) -> bool>(None, None, None, None);
92        let case2_children = case2.children(None);
93        let case2_case_list =
94            case2_children.find_first(Some(|it: &ErasedSegment| it.is_keyword("CASE")));
95        let case2_first_case = case2_case_list.first();
96        let case2_when_list = case2_children.find_first(Some(|it: &ErasedSegment| {
97            matches!(
98                it.get_type(),
99                SyntaxKind::WhenClause | SyntaxKind::ElseClause
100            )
101        }));
102        let case2_first_when = case2_when_list.first();
103
104        let Some(case1_last_when) = case1_last_when else {
105            return Vec::new();
106        };
107        if case1_else_expressions.len() > 1 || expression_children.len() > 1 || case2.is_empty() {
108            return Vec::new();
109        }
110
111        // Check if case2 actually contains a CASE expression
112        // If there's no nested CASE, we shouldn't proceed with flattening
113        let Some(case2_first_case) = case2_first_case else {
114            return Vec::new();
115        };
116
117        // Additionally check that case2 is actually a CASE expression
118        if !case2.any(Some(|seg: &ErasedSegment| {
119            seg.is_type(SyntaxKind::CaseExpression)
120        })) {
121            return Vec::new();
122        }
123
124        let x1 = segment
125            .children(Some(|it| it.is_code()))
126            .select::<fn(&ErasedSegment) -> bool>(
127                None,
128                None,
129                case1_first_case.into(),
130                case1_first_when.into(),
131            )
132            .into_iter()
133            .map(|it| it.raw().to_smolstr());
134
135        let x2 = case2
136            .children(Some(|it| it.is_code()))
137            .select::<fn(&ErasedSegment) -> bool>(
138                None,
139                None,
140                case2_first_case.into(),
141                case2_first_when,
142            )
143            .into_iter()
144            .map(|it| it.raw().to_smolstr());
145
146        if x1.ne(x2) {
147            return Vec::new();
148        }
149
150        let case1_else_clause_seg = case1_else_clause.first().unwrap();
151
152        let case1_to_delete = case1_children.select::<fn(&ErasedSegment) -> bool>(
153            None,
154            None,
155            case1_last_when.into(),
156            case1_else_clause_seg.into(),
157        );
158
159        let comments = case1_to_delete.find_last(Some(|it: &ErasedSegment| it.is_comment()));
160        let after_last_comment_index = comments
161            .first()
162            .and_then(|comment| case1_to_delete.iter().position(|it| it == comment))
163            .map_or(0, |n| n + 1);
164
165        let case1_comments_to_restore = case1_to_delete.select::<fn(&ErasedSegment) -> bool>(
166            None,
167            None,
168            None,
169            case1_to_delete.base.get(after_last_comment_index),
170        );
171        let after_else_comment = case1_else_clause.children(None).select(
172            Some(|it: &ErasedSegment| {
173                matches!(
174                    it.get_type(),
175                    SyntaxKind::Newline
176                        | SyntaxKind::InlineComment
177                        | SyntaxKind::BlockComment
178                        | SyntaxKind::Comment
179                        | SyntaxKind::Whitespace
180                )
181            }),
182            None,
183            None,
184            case1_else_expressions.first(),
185        );
186
187        let mut fixes = case1_to_delete
188            .into_iter()
189            .map(LintFix::delete)
190            .collect_vec();
191
192        let tab_space_size = context.config.raw["indentation"]["tab_space_size"]
193            .as_int()
194            .unwrap() as usize;
195        let indent_unit = context.config.raw["indentation"]["indent_unit"]
196            .as_string()
197            .unwrap();
198        let indent_unit = IndentUnit::from_type_and_size(indent_unit, tab_space_size);
199
200        let when_indent_str = indentation(&case1_children, case1_last_when, indent_unit);
201        let end_indent_str = indentation(&case1_children, case1_first_case, indent_unit);
202
203        let nested_clauses = case2.children(Some(|it: &ErasedSegment| {
204            matches!(
205                it.get_type(),
206                SyntaxKind::WhenClause
207                    | SyntaxKind::ElseClause
208                    | SyntaxKind::Newline
209                    | SyntaxKind::InlineComment
210                    | SyntaxKind::BlockComment
211                    | SyntaxKind::Comment
212                    | SyntaxKind::Whitespace
213            )
214        }));
215
216        let mut segments = case1_comments_to_restore.base;
217        segments.append(&mut rebuild_spacing(
218            context.tables,
219            &when_indent_str,
220            after_else_comment,
221        ));
222        segments.append(&mut rebuild_spacing(
223            context.tables,
224            &when_indent_str,
225            nested_clauses,
226        ));
227
228        fixes.push(LintFix::create_after(
229            case1_last_when.clone(),
230            segments,
231            None,
232        ));
233        fixes.push(LintFix::delete(case1_else_clause_seg.clone()));
234        fixes.append(&mut nested_end_trailing_comment(
235            context.tables,
236            case1_children,
237            case1_else_clause_seg,
238            &end_indent_str,
239        ));
240
241        vec![LintResult::new(case2.first().cloned(), fixes, None, None)]
242    }
243
244    fn is_fix_compatible(&self) -> bool {
245        true
246    }
247
248    fn crawl_behaviour(&self) -> Crawler {
249        SegmentSeekerCrawler::new(const { SyntaxSet::new(&[SyntaxKind::CaseExpression]) }).into()
250    }
251}
252
253fn indentation(
254    parent_segments: &Segments,
255    segment: &ErasedSegment,
256    indent_unit: IndentUnit,
257) -> String {
258    let leading_whitespace = parent_segments
259        .select::<fn(&ErasedSegment) -> bool>(None, None, None, segment.into())
260        .reversed()
261        .find_first(Some(|it: &ErasedSegment| {
262            it.is_type(SyntaxKind::Whitespace)
263        }));
264    let seg_indent = parent_segments
265        .select::<fn(&ErasedSegment) -> bool>(None, None, None, segment.into())
266        .find_last(Some(|it| it.is_type(SyntaxKind::Indent)));
267    let mut indent_level = 1;
268    if let Some(segment_indent) = seg_indent
269        .last()
270        .filter(|segment_indent| segment_indent.is_indent())
271    {
272        indent_level = segment_indent.indent_val() as usize + 1;
273    }
274
275    if let Some(whitespace_seg) = leading_whitespace.first() {
276        if !leading_whitespace.is_empty() && whitespace_seg.raw().len() > 1 {
277            leading_whitespace
278                .iter()
279                .map(|seg| seg.raw().to_string())
280                .collect::<String>()
281        } else {
282            construct_single_indent(indent_unit).repeat(indent_level)
283        }
284    } else {
285        construct_single_indent(indent_unit).repeat(indent_level)
286    }
287}
288
289fn rebuild_spacing(
290    tables: &Tables,
291    indent_str: &str,
292    nested_clauses: Segments,
293) -> Vec<ErasedSegment> {
294    let mut buff = Vec::new();
295
296    let mut prior_newline = nested_clauses
297        .find_last(Some(|it: &ErasedSegment| !it.is_whitespace()))
298        .any(Some(|it: &ErasedSegment| it.is_comment()));
299    let mut prior_whitespace = String::new();
300
301    for seg in nested_clauses {
302        if matches!(
303            seg.get_type(),
304            SyntaxKind::WhenClause | SyntaxKind::ElseClause
305        ) || (prior_newline && seg.is_comment())
306        {
307            buff.push(SegmentBuilder::newline(tables.next_id(), "\n"));
308            buff.push(SegmentBuilder::whitespace(tables.next_id(), indent_str));
309            buff.push(seg.clone());
310            prior_newline = false;
311            prior_whitespace.clear();
312        } else if seg.is_type(SyntaxKind::Newline) {
313            prior_newline = true;
314            prior_whitespace.clear();
315        } else if !prior_newline && seg.is_comment() {
316            buff.push(SegmentBuilder::whitespace(
317                tables.next_id(),
318                &prior_whitespace,
319            ));
320            buff.push(seg.clone());
321            prior_newline = false;
322            prior_whitespace.clear();
323        } else if seg.is_whitespace() {
324            prior_whitespace = seg.raw().to_string();
325        }
326    }
327
328    buff
329}
330
331fn nested_end_trailing_comment(
332    tables: &Tables,
333    case1_children: Segments,
334    case1_else_clause_seg: &ErasedSegment,
335    end_indent_str: &str,
336) -> Vec<LintFix> {
337    // Prepend newline spacing to comments on the final nested `END` line.
338    let trailing_end = case1_children.select::<fn(&ErasedSegment) -> bool>(
339        None,
340        Some(|seg: &ErasedSegment| !seg.is_type(SyntaxKind::Newline)),
341        Some(case1_else_clause_seg),
342        None,
343    );
344
345    let mut fixes = trailing_end
346        .select(
347            Some(|seg: &ErasedSegment| seg.is_whitespace()),
348            Some(|seg: &ErasedSegment| !seg.is_comment()),
349            None,
350            None,
351        )
352        .into_iter()
353        .map(LintFix::delete)
354        .collect_vec();
355
356    if let Some(first_comment) = trailing_end
357        .find_first(Some(|seg: &ErasedSegment| seg.is_comment()))
358        .first()
359    {
360        let segments = vec![
361            SegmentBuilder::newline(tables.next_id(), "\n"),
362            SegmentBuilder::whitespace(tables.next_id(), end_indent_str),
363        ];
364        fixes.push(LintFix::create_before(first_comment.clone(), segments));
365    }
366
367    fixes
368}