Skip to main content

sqruff_lib/rules/layout/
lt12.rs

1use 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}