sqruff_lib/rules/layout/
lt02.rs1use 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}