Skip to main content

sqruff_lib/rules/layout/
lt02.rs

1use hashbrown::HashMap;
2use sqruff_lib_core::dialects::syntax::SyntaxKind;
3use sqruff_lib_core::lint_fix::LintFix;
4use sqruff_lib_core::parser::segments::ErasedSegment;
5use sqruff_lib_core::templaters::TemplatedFile;
6
7use crate::core::config::Value;
8use crate::core::rules::context::RuleContext;
9use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
10use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
11use crate::utils::reflow::sequence::ReflowSequence;
12
13#[derive(Default, Debug, Clone)]
14pub struct RuleLT02;
15
16fn line_bounds(source: &str, pos: usize) -> (usize, usize) {
17    let pos = pos.min(source.len());
18    let start = source[..pos].rfind('\n').map_or(0, |idx| idx + 1);
19    let end = source[pos..]
20        .find('\n')
21        .map_or(source.len(), |idx| pos + idx);
22    (start, end)
23}
24
25fn skip_whitespace_forward(source: &str, mut pos: usize) -> usize {
26    while pos < source.len() {
27        let Some(ch) = source[pos..].chars().next() else {
28            break;
29        };
30        if !ch.is_whitespace() {
31            break;
32        }
33        pos += ch.len_utf8();
34    }
35    pos
36}
37
38fn skip_whitespace_backward(source: &str, mut pos: usize) -> usize {
39    while pos > 0 {
40        let Some(ch) = source[..pos].chars().next_back() else {
41            break;
42        };
43        if !ch.is_whitespace() {
44            break;
45        }
46        pos -= ch.len_utf8();
47    }
48    pos
49}
50
51fn source_only_slice_at(templated_file: &TemplatedFile, pos: usize) -> bool {
52    templated_file.raw_sliced().iter().any(|slice| {
53        slice.slice_kind().is_source_only()
54            && slice.source_slice().start <= pos
55            && pos < slice.source_slice().end
56    }) || templated_file.sliced_file.iter().any(|slice| {
57        slice.templated_slice.is_empty()
58            && slice.source_slice.start <= pos
59            && pos < slice.source_slice.end
60    })
61}
62
63fn line_is_adjacent_to_source_only_slice(templated_file: &TemplatedFile, pos: usize) -> bool {
64    let source = templated_file.source_str.as_str();
65    let (line_start, line_end) = line_bounds(source, pos);
66
67    let before = skip_whitespace_backward(source, line_start);
68    let after = skip_whitespace_forward(source, line_end);
69
70    source_only_slice_at(templated_file, line_start)
71        || source_only_slice_at(templated_file, pos)
72        || (before > 0 && source_only_slice_at(templated_file, before - 1))
73        || source_only_slice_at(templated_file, after)
74}
75
76fn source_line_has_non_source_only_non_whitespace(
77    templated_file: &TemplatedFile,
78    pos: usize,
79) -> bool {
80    let source = templated_file.source_str.as_str();
81    let (line_start, line_end) = line_bounds(source, pos);
82    source[line_start..line_end]
83        .char_indices()
84        .any(|(idx, ch)| {
85            !ch.is_whitespace() && !source_only_slice_at(templated_file, line_start + idx)
86        })
87}
88
89fn is_literal_whitespace_segment(segment: &ErasedSegment) -> bool {
90    (segment.is_type(SyntaxKind::Newline) || segment.is_type(SyntaxKind::Whitespace))
91        && segment
92            .get_position_marker()
93            .is_some_and(|marker| marker.is_literal())
94}
95
96fn is_whitespace_edit(segment: &ErasedSegment) -> bool {
97    segment.is_type(SyntaxKind::Newline) || segment.is_type(SyntaxKind::Whitespace)
98}
99
100fn is_literal_indentation_fix(fix: &LintFix) -> bool {
101    match fix {
102        LintFix::Replace { anchor, edit, .. } => {
103            is_literal_whitespace_segment(anchor) && edit.iter().all(is_whitespace_edit)
104        }
105        _ => false,
106    }
107}
108
109fn has_only_literal_indentation_fixes(result: &LintResult) -> bool {
110    !result.fixes.is_empty() && result.fixes.iter().all(is_literal_indentation_fix)
111}
112
113impl Rule for RuleLT02 {
114    fn load_from_config(&self, _config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
115        Ok(RuleLT02.erased())
116    }
117    fn name(&self) -> &'static str {
118        "layout.indent"
119    }
120
121    fn description(&self) -> &'static str {
122        "Incorrect Indentation."
123    }
124
125    fn long_description(&self) -> &'static str {
126        r#"
127**Anti-pattern**
128
129The ``•`` character represents a space and the ``→`` character represents a tab.
130In this example, the third line contains five spaces instead of four and
131the second line contains two spaces and one tab.
132
133```sql
134SELECT
135••→a,
136•••••b
137FROM foo
138```
139
140**Best practice**
141
142Change the indentation to use a multiple of four spaces. This example also assumes that the indent_unit config value is set to space. If it had instead been set to tab, then the indents would be tabs instead.
143
144```sql
145SELECT
146••••a,
147••••b
148FROM foo
149```
150"#
151    }
152
153    fn groups(&self) -> &'static [RuleGroups] {
154        &[RuleGroups::All, RuleGroups::Core, RuleGroups::Layout]
155    }
156
157    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
158        let results = ReflowSequence::from_root(&context.segment, context.config)
159            .reindent(context.tables)
160            .results();
161
162        let Some(templated_file) = &context.templated_file else {
163            return results;
164        };
165
166        results
167            .into_iter()
168            .filter(|result| {
169                !result.anchor.as_ref().is_some_and(|anchor| {
170                    anchor.get_position_marker().is_some_and(|marker| {
171                        let source_pos = marker.source_slice.start;
172                        line_is_adjacent_to_source_only_slice(templated_file, source_pos)
173                            && (!has_only_literal_indentation_fixes(result)
174                                || !source_line_has_non_source_only_non_whitespace(
175                                    templated_file,
176                                    source_pos,
177                                ))
178                    })
179                })
180            })
181            .collect()
182    }
183
184    fn is_fix_compatible(&self) -> bool {
185        true
186    }
187
188    fn crawl_behaviour(&self) -> Crawler {
189        RootOnlyCrawler.into()
190    }
191}