sqruff_lib/rules/layout/
lt12.rs1use hashbrown::{HashMap, HashSet};
2use sqruff_lib_core::dialects::syntax::SyntaxKind;
3use sqruff_lib_core::lint_fix::LintFix;
4use sqruff_lib_core::parser::segments::{BlockType, ErasedSegment, SegmentBuilder};
5use sqruff_lib_core::templaters::{RawFileSlice, TemplateSliceKind, TemplatedFile};
6use sqruff_lib_core::utils::functional::segments::Segments;
7
8use crate::core::config::Value;
9use crate::core::rules::context::RuleContext;
10use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
11use crate::core::rules::{
12 Erased, ErasedRule, LintPhase, LintResult, Rule, RuleGroups, targets_templated,
13};
14use crate::utils::functional::context::FunctionalContext;
15
16fn get_trailing_newlines(segment: &ErasedSegment) -> Vec<ErasedSegment> {
17 let mut result = Vec::new();
18
19 for seg in segment.recursive_crawl_all(true) {
20 if seg.is_type(SyntaxKind::Newline) {
21 result.push(seg.clone());
22 } else if !seg.is_whitespace()
23 && !seg.is_type(SyntaxKind::Dedent)
24 && !seg.is_type(SyntaxKind::EndOfFile)
25 && !is_source_only_template_placeholder(&seg)
26 {
27 break;
28 }
29 }
30
31 result
32}
33
34fn trailing_newline_count(segments: &Segments) -> usize {
35 segments
36 .iter()
37 .map(|segment| segment.raw().chars().filter(|&ch| ch == '\n').count())
38 .sum()
39}
40
41fn get_last_segment(mut segment: Segments) -> (Vec<ErasedSegment>, Segments) {
42 let mut parent_stack = Vec::new();
43
44 loop {
45 let children = segment.children_all();
46
47 if !children.is_empty() {
48 parent_stack.push(segment.first().unwrap().clone());
49 segment = children.find_last_where(|s| {
50 !s.is_type(SyntaxKind::EndOfFile) && !is_source_only_template_placeholder(s)
51 });
52 } else {
53 return (parent_stack, segment);
54 }
55 }
56}
57
58fn is_source_only_template_placeholder(segment: &ErasedSegment) -> bool {
59 segment.is_type(SyntaxKind::Placeholder)
60 && matches!(
61 segment.block_type(),
62 Some(
63 BlockType::Comment
64 | BlockType::BlockStart
65 | BlockType::BlockMid
66 | BlockType::BlockEnd
67 )
68 )
69}
70
71fn source_eof_anchor(segment: &ErasedSegment, source_len: usize) -> Option<ErasedSegment> {
72 let mut anchor = None;
73
74 for seg in segment.recursive_crawl_all(true) {
75 if seg.is_type(SyntaxKind::EndOfFile) {
76 continue;
77 }
78
79 if seg
80 .get_position_marker()
81 .is_some_and(|marker| marker.source_slice.end == source_len)
82 {
83 anchor = Some(seg.clone());
84 }
85 }
86
87 anchor
88}
89
90fn templated_source_missing_final_newline(context: &RuleContext) -> bool {
91 context
92 .templated_file
93 .as_ref()
94 .is_some_and(|templated_file| {
95 !templated_file.source_str.ends_with('\n')
96 && templated_file.templated().ends_with('\n')
97 && templated_file
98 .raw_sliced()
99 .iter()
100 .any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
101 })
102}
103
104fn raw_slice_index_at_source_pos(raw_sliced: &[RawFileSlice], source_pos: usize) -> Option<usize> {
105 raw_sliced.iter().position(|slice| {
106 slice.source_slice().start <= source_pos && source_pos < slice.source_slice().end
107 })
108}
109
110fn whitespace_only_literal_inside_template_block(
111 templated_file: &TemplatedFile,
112 source_pos: usize,
113) -> bool {
114 let raw_sliced = templated_file.raw_sliced();
115 let Some(raw_idx) = raw_slice_index_at_source_pos(raw_sliced, source_pos) else {
116 return false;
117 };
118 let raw_slice = &raw_sliced[raw_idx];
119
120 raw_slice.has_slice_kind(TemplateSliceKind::Literal)
121 && raw_slice.raw().chars().all(char::is_whitespace)
122 && raw_sliced[raw_idx + 1..].iter().any(|next_slice| {
123 next_slice.block_idx() == raw_slice.block_idx()
124 && matches!(
125 next_slice.slice_kind(),
126 TemplateSliceKind::BlockMid | TemplateSliceKind::BlockEnd
127 )
128 })
129}
130
131fn templated_file_has_extra_final_newline(context: &RuleContext) -> bool {
132 context
133 .templated_file
134 .as_ref()
135 .is_some_and(|templated_file| {
136 if !templated_file
137 .raw_sliced()
138 .iter()
139 .any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
140 {
141 return false;
142 }
143
144 let templated = templated_file.templated();
145 let mut literal_trailing_newline_source_positions = HashSet::new();
146
147 for idx in (0..templated.len()).rev() {
148 if templated.as_bytes()[idx] != b'\n' {
149 break;
150 }
151
152 let Some(slice) = templated_file.sliced_file.iter().find(|slice| {
153 slice.templated_slice.start <= idx && idx < slice.templated_slice.end
154 }) else {
155 continue;
156 };
157
158 if slice.has_slice_kind(TemplateSliceKind::Literal) {
159 let source_idx = slice.source_slice.start + (idx - slice.templated_slice.start);
160 if source_idx < slice.source_slice.end
161 && !whitespace_only_literal_inside_template_block(
162 templated_file,
163 source_idx,
164 )
165 {
166 literal_trailing_newline_source_positions.insert(source_idx);
167 }
168 }
169 }
170
171 literal_trailing_newline_source_positions.len() > 1
172 })
173}
174
175#[derive(Debug, Default, Clone)]
176pub struct RuleLT12;
177
178impl Rule for RuleLT12 {
179 fn load_from_config(&self, _config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
180 Ok(RuleLT12.erased())
181 }
182 fn lint_phase(&self) -> LintPhase {
183 LintPhase::Post
184 }
185
186 fn name(&self) -> &'static str {
187 "layout.end_of_file"
188 }
189
190 fn description(&self) -> &'static str {
191 "Files must end with a single trailing newline."
192 }
193
194 fn long_description(&self) -> &'static str {
195 r#"
196**Anti-pattern**
197
198The content in file does not end with a single trailing newline. The $ represents end of file.
199
200```sql
201 SELECT
202 a
203 FROM foo$
204
205 -- Ending on an indented line means there is no newline
206 -- at the end of the file, the • represents space.
207
208 SELECT
209 ••••a
210 FROM
211 ••••foo
212 ••••$
213
214 -- Ending on a semi-colon means the last line is not a
215 -- newline.
216
217 SELECT
218 a
219 FROM foo
220 ;$
221
222 -- Ending with multiple newlines.
223
224 SELECT
225 a
226 FROM foo
227
228 $
229```
230
231**Best practice**
232
233Add trailing newline to the end. The $ character represents end of file.
234
235```sql
236 SELECT
237 a
238 FROM foo
239 $
240
241 -- Ensuring the last line is not indented so is just a
242 -- newline.
243
244 SELECT
245 ••••a
246 FROM
247 ••••foo
248 $
249
250 -- Even when ending on a semi-colon, ensure there is a
251 -- newline after.
252
253 SELECT
254 a
255 FROM foo
256 ;
257 $
258```
259"#
260 }
261 fn groups(&self) -> &'static [RuleGroups] {
262 &[RuleGroups::All, RuleGroups::Core, RuleGroups::Layout]
263 }
264
265 targets_templated!();
266
267 fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
268 let source_missing_final_newline = templated_source_missing_final_newline(context);
269 let templated_extra_final_newline = templated_file_has_extra_final_newline(context);
270 let (parent_stack, segment) = get_last_segment(FunctionalContext::new(context).segment());
271
272 if segment.is_empty() {
273 return if source_missing_final_newline {
274 context
275 .templated_file
276 .as_ref()
277 .and_then(|templated_file| {
278 source_eof_anchor(&context.segment, templated_file.source_str.len())
279 })
280 .map(|anchor| LintResult::new(anchor.into(), Vec::new(), None, None))
281 .into_iter()
282 .collect()
283 } else {
284 Vec::new()
285 };
286 }
287
288 let trailing_newlines = Segments::from_vec(get_trailing_newlines(&context.segment), None);
289 let trailing_newline_count = trailing_newline_count(&trailing_newlines);
290 let has_non_literal_slices =
291 context
292 .templated_file
293 .as_ref()
294 .is_some_and(|templated_file| {
295 templated_file
296 .raw_sliced()
297 .iter()
298 .any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
299 });
300
301 if trailing_newlines.is_empty() || source_missing_final_newline {
302 let default_fix_anchor_segment = if parent_stack.len() == 1 {
303 segment.first().unwrap().clone()
304 } else {
305 parent_stack[1].clone()
306 };
307 let fix_anchor_segment = if source_missing_final_newline {
308 context
309 .templated_file
310 .as_ref()
311 .and_then(|templated_file| {
312 source_eof_anchor(&context.segment, templated_file.source_str.len())
313 })
314 .unwrap_or(default_fix_anchor_segment)
315 } else {
316 default_fix_anchor_segment
317 };
318
319 let fixes = if source_missing_final_newline {
320 Vec::new()
321 } else {
322 vec![LintFix::create_after(
323 fix_anchor_segment,
324 vec![SegmentBuilder::newline(context.tables.next_id(), "\n")],
325 None,
326 )]
327 };
328
329 vec![LintResult::new(
330 segment.first().unwrap().clone().into(),
331 fixes,
332 None,
333 None,
334 )]
335 } else if (!has_non_literal_slices && trailing_newline_count > 1)
336 || templated_extra_final_newline
337 {
338 let fixes = if templated_extra_final_newline
339 || has_non_literal_slices
340 || trailing_newlines
341 .iter()
342 .any(|segment| segment.raw().chars().filter(|&ch| ch == '\n').count() > 1)
343 {
344 Vec::new()
345 } else {
346 trailing_newlines
347 .into_iter()
348 .skip(1)
349 .map(|d| LintFix::delete(d.clone()))
350 .collect()
351 };
352
353 vec![LintResult::new(
354 segment.first().unwrap().clone().into(),
355 fixes,
356 None,
357 None,
358 )]
359 } else {
360 vec![]
361 }
362 }
363
364 fn is_fix_compatible(&self) -> bool {
365 true
366 }
367
368 fn crawl_behaviour(&self) -> Crawler {
369 RootOnlyCrawler.into()
370 }
371}