use hashbrown::{HashMap, HashSet};
use sqruff_lib_core::dialects::syntax::SyntaxKind;
use sqruff_lib_core::lint_fix::LintFix;
use sqruff_lib_core::parser::segments::{BlockType, ErasedSegment, SegmentBuilder};
use sqruff_lib_core::templaters::{RawFileSlice, TemplateSliceKind, TemplatedFile};
use sqruff_lib_core::utils::functional::segments::Segments;
use crate::core::config::Value;
use crate::core::rules::context::RuleContext;
use crate::core::rules::crawlers::{Crawler, RootOnlyCrawler};
use crate::core::rules::{
Erased, ErasedRule, LintPhase, LintResult, Rule, RuleGroups, targets_templated,
};
use crate::utils::functional::context::FunctionalContext;
fn get_trailing_newlines(segment: &ErasedSegment) -> Vec<ErasedSegment> {
let mut result = Vec::new();
for seg in segment.recursive_crawl_all(true) {
if seg.is_type(SyntaxKind::Newline) {
result.push(seg.clone());
} else if !seg.is_whitespace()
&& !seg.is_type(SyntaxKind::Dedent)
&& !seg.is_type(SyntaxKind::EndOfFile)
&& !is_source_only_template_placeholder(&seg)
{
break;
}
}
result
}
fn trailing_newline_count(segments: &Segments) -> usize {
segments
.iter()
.map(|segment| segment.raw().chars().filter(|&ch| ch == '\n').count())
.sum()
}
fn get_last_segment(mut segment: Segments) -> (Vec<ErasedSegment>, Segments) {
let mut parent_stack = Vec::new();
loop {
let children = segment.children_all();
if !children.is_empty() {
parent_stack.push(segment.first().unwrap().clone());
segment = children.find_last_where(|s| {
!s.is_type(SyntaxKind::EndOfFile) && !is_source_only_template_placeholder(s)
});
} else {
return (parent_stack, segment);
}
}
}
fn is_source_only_template_placeholder(segment: &ErasedSegment) -> bool {
segment.is_type(SyntaxKind::Placeholder)
&& matches!(
segment.block_type(),
Some(
BlockType::Comment
| BlockType::BlockStart
| BlockType::BlockMid
| BlockType::BlockEnd
)
)
}
fn source_eof_anchor(segment: &ErasedSegment, source_len: usize) -> Option<ErasedSegment> {
let mut anchor = None;
for seg in segment.recursive_crawl_all(true) {
if seg.is_type(SyntaxKind::EndOfFile) {
continue;
}
if seg
.get_position_marker()
.is_some_and(|marker| marker.source_slice.end == source_len)
{
anchor = Some(seg.clone());
}
}
anchor
}
fn templated_source_missing_final_newline(context: &RuleContext) -> bool {
context
.templated_file
.as_ref()
.is_some_and(|templated_file| {
!templated_file.source_str.ends_with('\n')
&& templated_file.templated().ends_with('\n')
&& templated_file
.raw_sliced()
.iter()
.any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
})
}
fn raw_slice_index_at_source_pos(raw_sliced: &[RawFileSlice], source_pos: usize) -> Option<usize> {
raw_sliced.iter().position(|slice| {
slice.source_slice().start <= source_pos && source_pos < slice.source_slice().end
})
}
fn whitespace_only_literal_inside_template_block(
templated_file: &TemplatedFile,
source_pos: usize,
) -> bool {
let raw_sliced = templated_file.raw_sliced();
let Some(raw_idx) = raw_slice_index_at_source_pos(raw_sliced, source_pos) else {
return false;
};
let raw_slice = &raw_sliced[raw_idx];
raw_slice.has_slice_kind(TemplateSliceKind::Literal)
&& raw_slice.raw().chars().all(char::is_whitespace)
&& raw_sliced[raw_idx + 1..].iter().any(|next_slice| {
next_slice.block_idx() == raw_slice.block_idx()
&& matches!(
next_slice.slice_kind(),
TemplateSliceKind::BlockMid | TemplateSliceKind::BlockEnd
)
})
}
fn templated_file_has_extra_final_newline(context: &RuleContext) -> bool {
context
.templated_file
.as_ref()
.is_some_and(|templated_file| {
if !templated_file
.raw_sliced()
.iter()
.any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
{
return false;
}
let templated = templated_file.templated();
let mut literal_trailing_newline_source_positions = HashSet::new();
for idx in (0..templated.len()).rev() {
if templated.as_bytes()[idx] != b'\n' {
break;
}
let Some(slice) = templated_file.sliced_file.iter().find(|slice| {
slice.templated_slice.start <= idx && idx < slice.templated_slice.end
}) else {
continue;
};
if slice.has_slice_kind(TemplateSliceKind::Literal) {
let source_idx = slice.source_slice.start + (idx - slice.templated_slice.start);
if source_idx < slice.source_slice.end
&& !whitespace_only_literal_inside_template_block(
templated_file,
source_idx,
)
{
literal_trailing_newline_source_positions.insert(source_idx);
}
}
}
literal_trailing_newline_source_positions.len() > 1
})
}
#[derive(Debug, Default, Clone)]
pub struct RuleLT12;
impl Rule for RuleLT12 {
fn load_from_config(&self, _config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
Ok(RuleLT12.erased())
}
fn lint_phase(&self) -> LintPhase {
LintPhase::Post
}
fn name(&self) -> &'static str {
"layout.end_of_file"
}
fn description(&self) -> &'static str {
"Files must end with a single trailing newline."
}
fn long_description(&self) -> &'static str {
r#"
**Anti-pattern**
The content in file does not end with a single trailing newline. The $ represents end of file.
```sql
SELECT
a
FROM foo$
-- Ending on an indented line means there is no newline
-- at the end of the file, the • represents space.
SELECT
••••a
FROM
••••foo
••••$
-- Ending on a semi-colon means the last line is not a
-- newline.
SELECT
a
FROM foo
;$
-- Ending with multiple newlines.
SELECT
a
FROM foo
$
```
**Best practice**
Add trailing newline to the end. The $ character represents end of file.
```sql
SELECT
a
FROM foo
$
-- Ensuring the last line is not indented so is just a
-- newline.
SELECT
••••a
FROM
••••foo
$
-- Even when ending on a semi-colon, ensure there is a
-- newline after.
SELECT
a
FROM foo
;
$
```
"#
}
fn groups(&self) -> &'static [RuleGroups] {
&[RuleGroups::All, RuleGroups::Core, RuleGroups::Layout]
}
targets_templated!();
fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
let source_missing_final_newline = templated_source_missing_final_newline(context);
let templated_extra_final_newline = templated_file_has_extra_final_newline(context);
let (parent_stack, segment) = get_last_segment(FunctionalContext::new(context).segment());
if segment.is_empty() {
return if source_missing_final_newline {
context
.templated_file
.as_ref()
.and_then(|templated_file| {
source_eof_anchor(&context.segment, templated_file.source_str.len())
})
.map(|anchor| LintResult::new(anchor.into(), Vec::new(), None, None))
.into_iter()
.collect()
} else {
Vec::new()
};
}
let trailing_newlines = Segments::from_vec(get_trailing_newlines(&context.segment), None);
let trailing_newline_count = trailing_newline_count(&trailing_newlines);
let has_non_literal_slices =
context
.templated_file
.as_ref()
.is_some_and(|templated_file| {
templated_file
.raw_sliced()
.iter()
.any(|slice| !slice.has_slice_kind(TemplateSliceKind::Literal))
});
if trailing_newlines.is_empty() || source_missing_final_newline {
let default_fix_anchor_segment = if parent_stack.len() == 1 {
segment.first().unwrap().clone()
} else {
parent_stack[1].clone()
};
let fix_anchor_segment = if source_missing_final_newline {
context
.templated_file
.as_ref()
.and_then(|templated_file| {
source_eof_anchor(&context.segment, templated_file.source_str.len())
})
.unwrap_or(default_fix_anchor_segment)
} else {
default_fix_anchor_segment
};
let fixes = if source_missing_final_newline {
Vec::new()
} else {
vec![LintFix::create_after(
fix_anchor_segment,
vec![SegmentBuilder::newline(context.tables.next_id(), "\n")],
None,
)]
};
vec![LintResult::new(
segment.first().unwrap().clone().into(),
fixes,
None,
None,
)]
} else if (!has_non_literal_slices && trailing_newline_count > 1)
|| templated_extra_final_newline
{
let fixes = if templated_extra_final_newline
|| has_non_literal_slices
|| trailing_newlines
.iter()
.any(|segment| segment.raw().chars().filter(|&ch| ch == '\n').count() > 1)
{
Vec::new()
} else {
trailing_newlines
.into_iter()
.skip(1)
.map(|d| LintFix::delete(d.clone()))
.collect()
};
vec![LintResult::new(
segment.first().unwrap().clone().into(),
fixes,
None,
None,
)]
} else {
vec![]
}
}
fn is_fix_compatible(&self) -> bool {
true
}
fn crawl_behaviour(&self) -> Crawler {
RootOnlyCrawler.into()
}
}