1use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10
11#[derive(Default)]
12pub struct ConventionTerminator {
13 multiline_newline: bool,
14 require_final_semicolon: bool,
15}
16
17impl ConventionTerminator {
18 pub fn from_config(config: &LintConfig) -> Self {
19 Self {
20 multiline_newline: config
21 .rule_option_bool(issue_codes::LINT_CV_006, "multiline_newline")
22 .unwrap_or(false),
23 require_final_semicolon: config
24 .rule_option_bool(issue_codes::LINT_CV_006, "require_final_semicolon")
25 .unwrap_or(false),
26 }
27 }
28}
29
30impl LintRule for ConventionTerminator {
31 fn code(&self) -> &'static str {
32 issue_codes::LINT_CV_006
33 }
34
35 fn name(&self) -> &'static str {
36 "Statement terminator"
37 }
38
39 fn description(&self) -> &'static str {
40 "Statements must end with a semi-colon."
41 }
42
43 fn check(&self, _stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
44 let tokens = tokenize_with_offsets_for_context(ctx);
45 let trailing = trailing_info(ctx, tokens.as_deref());
46 let has_terminal_semicolon = trailing.semicolon_offset.is_some();
47
48 if self.require_final_semicolon
50 && is_last_statement(ctx, tokens.as_deref())
51 && !has_terminal_semicolon
52 {
53 let edits = build_require_final_semicolon_edits(ctx, &trailing, self.multiline_newline);
54 let span = edits
55 .first()
56 .map(|e| e.span)
57 .unwrap_or_else(|| Span::new(ctx.statement_range.end, ctx.statement_range.end));
58 return vec![Issue::info(
59 issue_codes::LINT_CV_006,
60 "Final statement must end with a semi-colon.",
61 )
62 .with_statement(ctx.statement_index)
63 .with_span(span)
64 .with_autofix_edits(IssueAutofixApplicability::Safe, edits)];
65 }
66
67 let Some(semicolon_offset) = trailing.semicolon_offset else {
68 return Vec::new();
69 };
70
71 if self.multiline_newline {
72 return self.check_multiline_newline(ctx, &trailing, semicolon_offset);
73 }
74
75 if semicolon_offset != ctx.statement_range.end {
77 let edits = build_default_mode_fix(ctx, &trailing, semicolon_offset);
78 let mut issue = Issue::info(
79 issue_codes::LINT_CV_006,
80 "Statement terminator style is inconsistent.",
81 )
82 .with_statement(ctx.statement_index);
83 if !edits.is_empty() {
84 let span = edits
85 .first()
86 .map(|e| e.span)
87 .unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset));
88 issue = issue
89 .with_span(span)
90 .with_autofix_edits(IssueAutofixApplicability::Safe, edits);
91 }
92 return vec![issue];
93 }
94
95 Vec::new()
96 }
97}
98
99impl ConventionTerminator {
100 fn check_multiline_newline(
101 &self,
102 ctx: &LintContext,
103 trailing: &TrailingInfo,
104 semicolon_offset: usize,
105 ) -> Vec<Issue> {
106 let tokens = tokenize_with_offsets_for_context(ctx);
107 let code_end = actual_code_end(ctx);
108 let effective_multiline = tokens
112 .as_deref()
113 .map(|toks| {
114 toks.iter()
115 .filter(|t| t.start >= ctx.statement_range.start && t.end <= code_end)
116 .any(|t| matches!(t.token, Token::Whitespace(Whitespace::Newline)))
117 })
118 .unwrap_or_else(|| {
119 count_line_breaks(&ctx.sql[ctx.statement_range.start..code_end]) > 0
120 });
121
122 if effective_multiline {
123 let tokens_for_check = tokenize_with_offsets_for_context(ctx);
124 if is_valid_multiline_newline_style(ctx, trailing, semicolon_offset)
125 && !has_standalone_comment_at_end_of_statement(ctx, tokens_for_check.as_deref())
126 {
127 return Vec::new();
128 }
129 let edits = build_multiline_newline_fix(ctx, trailing, semicolon_offset);
130 let mut issue = Issue::info(
131 issue_codes::LINT_CV_006,
132 "Multi-line statements must place the semi-colon on a new line.",
133 )
134 .with_statement(ctx.statement_index);
135 if !edits.is_empty() {
136 let span = edits
137 .first()
138 .map(|e| e.span)
139 .unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset + 1));
140 issue = issue
141 .with_span(span)
142 .with_autofix_edits(IssueAutofixApplicability::Safe, edits);
143 }
144 return vec![issue];
145 }
146
147 if semicolon_offset != ctx.statement_range.end {
149 let edits = build_default_mode_fix(ctx, trailing, semicolon_offset);
151 let mut issue = Issue::info(
152 issue_codes::LINT_CV_006,
153 "Statement terminator style is inconsistent.",
154 )
155 .with_statement(ctx.statement_index);
156 if !edits.is_empty() {
157 let span = edits
158 .first()
159 .map(|e| e.span)
160 .unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset));
161 issue = issue
162 .with_span(span)
163 .with_autofix_edits(IssueAutofixApplicability::Safe, edits);
164 }
165 return vec![issue];
166 }
167
168 Vec::new()
169 }
170}
171
172fn is_valid_multiline_newline_style(
181 ctx: &LintContext,
182 trailing: &TrailingInfo,
183 semicolon_offset: usize,
184) -> bool {
185 let anchor_end = find_last_content_end_before_semicolon(ctx, trailing, semicolon_offset);
188
189 let gap = &ctx.sql[anchor_end..semicolon_offset];
193
194 let total_gap = &ctx.sql[ctx.statement_range.end..semicolon_offset];
196 let total_newlines = count_line_breaks(total_gap);
197
198 let gap_newlines = count_line_breaks(gap);
202 let inline_comment_newlines = if trailing.inline_comment_after_stmt.is_some() {
203 1
205 } else {
206 0
207 };
208
209 let effective_newlines = gap_newlines + inline_comment_newlines;
210 if effective_newlines != 1 {
211 if total_newlines != 1 || trailing.inline_comment_after_stmt.is_none() {
216 return false;
217 }
218 }
219
220 trailing.comments_before_semicolon.is_empty()
222 && gap.chars().all(|c| c.is_whitespace())
224}
225
226fn find_last_content_end_before_semicolon(
230 ctx: &LintContext,
231 trailing: &TrailingInfo,
232 semicolon_offset: usize,
233) -> usize {
234 if let Some(ref comment) = trailing.inline_comment_after_stmt {
237 if comment.end <= semicolon_offset {
238 return comment.end;
239 }
240 }
241 ctx.statement_range.end
242}
243
244fn build_default_mode_fix(
251 ctx: &LintContext,
252 trailing: &TrailingInfo,
253 semicolon_offset: usize,
254) -> Vec<IssuePatchEdit> {
255 let code_end = actual_code_end(ctx);
256 let gap_start = ctx.statement_range.end;
257 let semicolon_end = semicolon_offset + 1;
258
259 if trailing.comments_before_semicolon.is_empty() && trailing.inline_comment_after_stmt.is_none()
262 {
263 if code_end < gap_start {
265 let mut edits = vec![
268 IssuePatchEdit::new(Span::new(code_end, code_end), ";"),
269 IssuePatchEdit::new(Span::new(semicolon_offset, semicolon_end), ""),
270 ];
271 let pre_gap = &ctx.sql[code_end..gap_start];
274 if !pre_gap.is_empty()
275 && pre_gap.chars().all(char::is_whitespace)
276 && !pre_gap.contains('\n')
277 {
278 }
281 edits.sort_by_key(|e| e.span.start);
282 return edits;
283 }
284 let gap = &ctx.sql[gap_start..semicolon_offset];
285 if gap.chars().all(char::is_whitespace) {
286 let span = Span::new(gap_start, semicolon_end);
288 return vec![IssuePatchEdit::new(span, ";")];
289 }
290 return Vec::new();
291 }
292
293 let mut edits = vec![
296 IssuePatchEdit::new(Span::new(code_end, code_end), ";"),
297 IssuePatchEdit::new(Span::new(semicolon_offset, semicolon_end), ""),
298 ];
299 edits.sort_by_key(|e| e.span.start);
300 edits
301}
302
303fn build_multiline_newline_fix(
313 ctx: &LintContext,
314 trailing: &TrailingInfo,
315 semicolon_offset: usize,
316) -> Vec<IssuePatchEdit> {
317 let semicolon_end = semicolon_offset + 1;
318 let after_semicolon = trailing_content_after_semicolon(ctx, semicolon_offset);
319
320 let indent = detect_statement_indent(ctx);
322
323 let code_end = actual_code_end(ctx);
326
327 let anchor_end = if let Some(ref comment) = trailing.inline_comment_after_stmt {
330 comment.end
331 } else if let Some(inner_end) = find_inline_comment_in_statement(ctx) {
332 inner_end
333 } else {
334 code_end
335 };
336
337 let after_semi_comment = after_semicolon.trim();
339 if !after_semi_comment.is_empty()
340 && (after_semi_comment.starts_with("--") || after_semi_comment.starts_with("/*"))
341 {
342 let mut edits = Vec::new();
343
344 if after_semi_comment.starts_with("/*") {
345 let mut rep = String::new();
349 rep.push('\n');
350 rep.push_str(&indent);
351 rep.push(';');
352 edits.push(IssuePatchEdit::new(
353 Span::new(code_end, semicolon_end),
354 &rep,
355 ));
356 } else {
357 edits.push(IssuePatchEdit::new(
363 Span::new(semicolon_offset, semicolon_end),
364 "",
365 ));
366
367 let mut insert_pos = semicolon_end + after_semicolon.len();
370 if insert_pos < ctx.sql.len() && ctx.sql.as_bytes()[insert_pos] == b'\n' {
371 insert_pos += 1;
372 }
373 let mut rep = String::new();
375 if insert_pos == semicolon_end + after_semicolon.len() {
376 rep.push('\n');
377 }
378 rep.push_str(&indent);
379 rep.push(';');
380 edits.push(IssuePatchEdit::new(Span::new(insert_pos, insert_pos), &rep));
381 }
382
383 edits.sort_by_key(|e| e.span.start);
384 return edits;
385 }
386
387 let mut edits = Vec::new();
388
389 let delete_end = if after_semicolon.trim().is_empty() {
391 semicolon_end + after_semicolon.len()
392 } else {
393 semicolon_end
394 };
395 edits.push(IssuePatchEdit::new(
396 Span::new(semicolon_offset, delete_end),
397 "",
398 ));
399
400 let anchor_has_newline =
406 anchor_end > 0 && matches!(ctx.sql.as_bytes().get(anchor_end - 1), Some(b'\n'));
407 let mut replacement = String::new();
408 if !anchor_has_newline {
409 replacement.push('\n');
410 }
411 replacement.push_str(&indent);
412 replacement.push(';');
413 edits.push(IssuePatchEdit::new(
414 Span::new(anchor_end, anchor_end),
415 &replacement,
416 ));
417
418 let gap_has_comment = code_end < semicolon_offset
421 && (ctx.sql[code_end..semicolon_offset].contains("--")
422 || ctx.sql[code_end..semicolon_offset].contains("/*"));
423 if trailing.comments_before_semicolon.is_empty()
424 && trailing.inline_comment_after_stmt.is_none()
425 && code_end == anchor_end
426 && !gap_has_comment
427 {
428 edits.clear();
433 let mut rep = String::new();
434 rep.push('\n');
435 rep.push_str(&indent);
436 rep.push(';');
437 edits.push(IssuePatchEdit::new(Span::new(code_end, delete_end), &rep));
438 }
439
440 edits.sort_by_key(|e| e.span.start);
441 edits
442}
443
444fn actual_code_end(ctx: &LintContext) -> usize {
451 let tokens = tokenize_with_offsets_for_context(ctx);
452 let Some(tokens) = tokens.as_deref() else {
453 return ctx.statement_range.end;
454 };
455 let last_code = tokens.iter().rfind(|t| {
456 t.start >= ctx.statement_range.start
457 && t.start < ctx.statement_range.end
458 && !is_spacing_whitespace(&t.token)
459 && !is_comment_token(&t.token)
460 });
461 last_code.map_or(ctx.statement_range.end, |t| t.end)
462}
463
464fn build_require_final_semicolon_edits(
469 ctx: &LintContext,
470 trailing: &TrailingInfo,
471 multiline_newline: bool,
472) -> Vec<IssuePatchEdit> {
473 let code_end = actual_code_end(ctx);
474 let is_multiline = count_line_breaks(&ctx.sql[ctx.statement_range.start..code_end]) > 0;
475
476 if multiline_newline && is_multiline {
477 let anchor_end = if let Some(ref comment) = trailing.inline_comment_after_stmt {
480 comment.end
481 } else if let Some(inner_comment) = find_inline_comment_in_statement(ctx) {
482 inner_comment
483 } else {
484 code_end
485 };
486
487 let indent = detect_statement_indent(ctx);
488
489 let anchor_has_newline =
493 anchor_end > 0 && matches!(ctx.sql.as_bytes().get(anchor_end - 1), Some(b'\n'));
494
495 let mut replacement = String::new();
496 if !anchor_has_newline {
497 replacement.push('\n');
498 }
499 replacement.push_str(&indent);
500 replacement.push(';');
501
502 let span = Span::new(anchor_end, anchor_end);
503 return vec![IssuePatchEdit::new(span, &replacement)];
504 }
505
506 let insert_span = Span::new(code_end, code_end);
509 vec![IssuePatchEdit::new(insert_span, ";")]
510}
511
512fn find_inline_comment_in_statement(ctx: &LintContext) -> Option<usize> {
515 let tokens = tokenize_with_offsets_for_context(ctx)?;
516 let code_end = tokens.iter().rfind(|t| {
517 t.start >= ctx.statement_range.start
518 && t.start < ctx.statement_range.end
519 && !is_spacing_whitespace(&t.token)
520 && !is_comment_token(&t.token)
521 })?;
522 let code_end_line = offset_to_line_number(ctx.sql, code_end.end);
523
524 let inline = tokens.iter().find(|t| {
526 t.start > code_end.end
527 && t.start < ctx.statement_range.end
528 && is_single_line_comment(&t.token)
529 && offset_to_line_number(ctx.sql, t.start) == code_end_line
530 })?;
531 Some(inline.end)
532}
533
534fn detect_statement_indent(ctx: &LintContext) -> String {
536 let start = ctx.statement_range.start;
537 let line_start = ctx.sql[..start].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
539 let prefix = &ctx.sql[line_start..start];
540 let indent: String = prefix.chars().take_while(|c| c.is_whitespace()).collect();
542 indent
543}
544
545fn trailing_content_after_semicolon<'a>(
548 ctx: &'a LintContext<'a>,
549 semicolon_offset: usize,
550) -> &'a str {
551 let after = semicolon_offset + 1;
552 let rest = &ctx.sql[after..];
555 if let Some(nl_pos) = rest.find('\n') {
557 &rest[..nl_pos]
558 } else {
559 rest
560 }
561}
562
563struct TrailingInfo {
565 semicolon_offset: Option<usize>,
567 inline_comment_after_stmt: Option<CommentSpan>,
570 comments_before_semicolon: Vec<CommentSpan>,
572}
573
574#[derive(Clone)]
575struct CommentSpan {
576 end: usize,
577}
578
579fn trailing_info(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> TrailingInfo {
582 let Some(tokens) = tokens else {
583 return TrailingInfo {
584 semicolon_offset: None,
585 inline_comment_after_stmt: None,
586 comments_before_semicolon: Vec::new(),
587 };
588 };
589
590 let stmt_end = ctx.statement_range.end;
591 let stmt_end_line = offset_to_line_number(ctx.sql, stmt_end);
592
593 let mut semicolon_offset = None;
594 let mut inline_comment_after_stmt = None;
595 let mut comments_before_semicolon = Vec::new();
596 let mut found_semicolon = false;
597
598 for token in tokens.iter().filter(|t| t.start >= stmt_end) {
599 match &token.token {
600 Token::SemiColon if !found_semicolon => {
601 semicolon_offset = Some(token.start);
602 found_semicolon = true;
603 }
604 trivia if is_trivia_token(trivia) => {
605 if !found_semicolon && is_comment_token(trivia) {
606 let token_line = offset_to_line_number(ctx.sql, token.start);
607 let span = CommentSpan { end: token.end };
608 if token_line == stmt_end_line
609 && inline_comment_after_stmt.is_none()
610 && is_single_line_comment(trivia)
611 {
612 inline_comment_after_stmt = Some(span);
613 } else {
614 comments_before_semicolon.push(span);
615 }
616 }
617 }
618 _ => {
619 if !found_semicolon {
620 break;
621 }
622 break;
624 }
625 }
626 }
627
628 TrailingInfo {
629 semicolon_offset,
630 inline_comment_after_stmt,
631 comments_before_semicolon,
632 }
633}
634
635fn is_single_line_comment(token: &Token) -> bool {
636 matches!(
637 token,
638 Token::Whitespace(Whitespace::SingleLineComment { .. })
639 )
640}
641
642fn offset_to_line_number(sql: &str, offset: usize) -> usize {
643 sql.as_bytes()
644 .iter()
645 .take(offset.min(sql.len()))
646 .filter(|b| **b == b'\n')
647 .count()
648 + 1
649}
650
651fn is_last_statement(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool {
652 let Some(tokens) = tokens else {
653 return false;
654 };
655 for token in tokens
656 .iter()
657 .filter(|token| token.start >= ctx.statement_range.end)
658 {
659 if matches!(token.token, Token::SemiColon)
660 || is_trivia_token(&token.token)
661 || is_go_batch_separator(token, tokens, ctx.dialect())
662 {
663 continue;
664 }
665 return false;
666 }
667 true
668}
669
670fn has_standalone_comment_at_end_of_statement(
675 ctx: &LintContext,
676 tokens: Option<&[LocatedToken]>,
677) -> bool {
678 let Some(tokens) = tokens else {
679 return false;
680 };
681
682 let last_token = tokens.iter().rfind(|t| {
686 t.start >= ctx.statement_range.start
687 && t.start < ctx.statement_range.end
688 && !is_spacing_whitespace(&t.token)
689 });
690
691 let Some(last) = last_token else {
692 return false;
693 };
694
695 if !is_comment_token(&last.token) {
696 return false;
697 }
698
699 let prev_token = tokens.iter().rfind(|t| {
701 t.start >= ctx.statement_range.start
702 && t.start < last.start
703 && !is_spacing_whitespace(&t.token)
704 && !is_comment_token(&t.token)
705 });
706
707 let Some(prev) = prev_token else {
708 return false;
709 };
710
711 if offset_to_line_number(ctx.sql, last.start) != offset_to_line_number(ctx.sql, prev.start) {
716 return true;
717 }
718
719 matches!(
720 &last.token,
721 Token::Whitespace(Whitespace::MultiLineComment(_))
722 ) && last.start_line != last.end_line
723}
724
725struct LocatedToken {
726 token: Token,
727 start: usize,
728 end: usize,
729 start_line: usize,
730 end_line: usize,
731}
732
733fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
734 let tokens = ctx.with_document_tokens(|tokens| {
735 if tokens.is_empty() {
736 return None;
737 }
738
739 Some(
740 tokens
741 .iter()
742 .filter_map(|token| {
743 token_with_span_offsets(ctx.sql, token).map(|(start, end)| LocatedToken {
744 token: token.token.clone(),
745 start,
746 end,
747 start_line: token.span.start.line as usize,
748 end_line: token.span.end.line as usize,
749 })
750 })
751 .collect::<Vec<_>>(),
752 )
753 });
754
755 if let Some(tokens) = tokens {
756 return Some(tokens);
757 }
758
759 tokenize_with_offsets(ctx.sql, ctx.dialect())
760}
761
762fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
763 let dialect = dialect.to_sqlparser_dialect();
764 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
765 let tokens = tokenizer.tokenize_with_location().ok()?;
766
767 let mut out = Vec::with_capacity(tokens.len());
768 for token in tokens {
769 let Some((start, end)) = token_with_span_offsets(sql, &token) else {
770 continue;
771 };
772 out.push(LocatedToken {
773 token: token.token,
774 start,
775 end,
776 start_line: token.span.start.line as usize,
777 end_line: token.span.end.line as usize,
778 });
779 }
780
781 Some(out)
782}
783
784fn is_comment_token(token: &Token) -> bool {
785 matches!(
786 token,
787 Token::Whitespace(Whitespace::SingleLineComment { .. })
788 | Token::Whitespace(Whitespace::MultiLineComment(_))
789 )
790}
791
792fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
793 let start = line_col_to_offset(
794 sql,
795 token.span.start.line as usize,
796 token.span.start.column as usize,
797 )?;
798 let end = line_col_to_offset(
799 sql,
800 token.span.end.line as usize,
801 token.span.end.column as usize,
802 )?;
803 Some((start, end))
804}
805
806fn is_trivia_token(token: &Token) -> bool {
807 matches!(
808 token,
809 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
810 | Token::Whitespace(Whitespace::SingleLineComment { .. })
811 | Token::Whitespace(Whitespace::MultiLineComment(_))
812 )
813}
814
815fn is_spacing_whitespace(token: &Token) -> bool {
816 matches!(
817 token,
818 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
819 )
820}
821
822fn is_go_batch_separator(token: &LocatedToken, tokens: &[LocatedToken], dialect: Dialect) -> bool {
823 if dialect != Dialect::Mssql {
824 return false;
825 }
826 let Token::Word(word) = &token.token else {
827 return false;
828 };
829 if !word.value.eq_ignore_ascii_case("GO") {
830 return false;
831 }
832 if token.start_line != token.end_line {
833 return false;
834 }
835
836 let line = token.start_line;
837 let mut go_count = 0usize;
838 for candidate in tokens {
839 if candidate.start_line != line {
840 continue;
841 }
842 if is_spacing_whitespace(&candidate.token) {
843 continue;
844 }
845 match &candidate.token {
846 Token::Word(word) if word.value.eq_ignore_ascii_case("GO") => {
847 go_count += 1;
848 }
849 _ => return false,
850 }
851 }
852
853 go_count == 1
854}
855
856fn count_line_breaks(text: &str) -> usize {
857 let mut count = 0usize;
858 let mut chars = text.chars().peekable();
859 while let Some(ch) = chars.next() {
860 if ch == '\n' {
861 count += 1;
862 continue;
863 }
864 if ch == '\r' {
865 count += 1;
866 if matches!(chars.peek(), Some('\n')) {
867 let _ = chars.next();
868 }
869 }
870 }
871 count
872}
873
874fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
875 if line == 0 || column == 0 {
876 return None;
877 }
878
879 let mut current_line = 1usize;
880 let mut current_col = 1usize;
881
882 for (offset, ch) in sql.char_indices() {
883 if current_line == line && current_col == column {
884 return Some(offset);
885 }
886
887 if ch == '\n' {
888 current_line += 1;
889 current_col = 1;
890 } else {
891 current_col += 1;
892 }
893 }
894
895 if current_line == line && current_col == column {
896 return Some(sql.len());
897 }
898
899 None
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905 use crate::linter::rule::with_active_dialect;
906 use crate::parser::parse_sql;
907 use crate::types::IssueAutofixApplicability;
908
909 fn run(sql: &str) -> Vec<Issue> {
910 let stmts = parse_sql(sql).expect("parse");
911 let rule = ConventionTerminator::default();
912 stmts
913 .iter()
914 .enumerate()
915 .flat_map(|(index, stmt)| {
916 rule.check(
917 stmt,
918 &LintContext {
919 sql,
920 statement_range: 0..sql.len(),
921 statement_index: index,
922 },
923 )
924 })
925 .collect()
926 }
927
928 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
929 let autofix = issue.autofix.as_ref()?;
930 let mut out = sql.to_string();
931 let mut edits = autofix.edits.clone();
932 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
933 for edit in edits.iter().rev() {
934 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
935 }
936 Some(out)
937 }
938
939 fn statement_is_multiline(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool {
940 let Some(tokens) = tokens else {
941 return count_line_breaks(ctx.statement_sql()) > 0;
942 };
943
944 tokens
945 .iter()
946 .filter(|token| {
947 token.start >= ctx.statement_range.start && token.end <= ctx.statement_range.end
948 })
949 .any(|token| is_multiline_trivia_token(&token.token))
950 }
951
952 fn is_multiline_trivia_token(token: &Token) -> bool {
953 matches!(
954 token,
955 Token::Whitespace(Whitespace::Newline)
956 | Token::Whitespace(Whitespace::SingleLineComment { .. })
957 | Token::Whitespace(Whitespace::MultiLineComment(_))
958 )
959 }
960
961 #[test]
962 fn default_allows_missing_final_semicolon_in_multi_statement_file() {
963 let issues = run("select 1; select 2");
964 assert!(issues.is_empty());
965 }
966
967 #[test]
968 fn allows_consistent_terminated_statements() {
969 let issues = run("select 1; select 2;");
970 assert!(issues.is_empty());
971 }
972
973 #[test]
974 fn require_final_semicolon_flags_last_statement_without_terminator() {
975 let config = LintConfig {
976 enabled: true,
977 disabled_rules: vec![],
978 rule_configs: std::collections::BTreeMap::from([(
979 "convention.terminator".to_string(),
980 serde_json::json!({"require_final_semicolon": true}),
981 )]),
982 };
983 let rule = ConventionTerminator::from_config(&config);
984 let sql = "SELECT 1";
985 let stmts = parse_sql(sql).expect("parse");
986 let issues = rule.check(
987 &stmts[0],
988 &LintContext {
989 sql,
990 statement_range: 0..sql.len(),
991 statement_index: 0,
992 },
993 );
994 assert_eq!(issues.len(), 1);
995 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
996 assert_eq!(
997 issues[0]
998 .autofix
999 .as_ref()
1000 .map(|autofix| autofix.edits.len()),
1001 Some(1)
1002 );
1003 assert_eq!(
1004 issues[0]
1005 .autofix
1006 .as_ref()
1007 .map(|autofix| autofix.applicability),
1008 Some(IssueAutofixApplicability::Safe)
1009 );
1010 assert_eq!(
1011 issues[0]
1012 .autofix
1013 .as_ref()
1014 .and_then(|autofix| autofix.edits.first())
1015 .map(|edit| edit.replacement.as_str()),
1016 Some(";")
1017 );
1018 }
1019
1020 #[test]
1021 fn multiline_newline_flags_inline_semicolon_for_multiline_statement() {
1022 let config = LintConfig {
1023 enabled: true,
1024 disabled_rules: vec![],
1025 rule_configs: std::collections::BTreeMap::from([(
1026 "LINT_CV_006".to_string(),
1027 serde_json::json!({"multiline_newline": true}),
1028 )]),
1029 };
1030 let rule = ConventionTerminator::from_config(&config);
1031 let sql = "SELECT\n 1;";
1032 let stmts = parse_sql(sql).expect("parse");
1033 let issues = rule.check(
1034 &stmts[0],
1035 &LintContext {
1036 sql,
1037 statement_range: 0.."SELECT\n 1".len(),
1038 statement_index: 0,
1039 },
1040 );
1041 assert_eq!(issues.len(), 1);
1042 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1043 }
1044
1045 #[test]
1046 fn default_flags_space_before_semicolon() {
1047 let sql = "SELECT a FROM foo ;";
1048 let stmts = parse_sql(sql).expect("parse");
1049 let issues = ConventionTerminator::default().check(
1050 &stmts[0],
1051 &LintContext {
1052 sql,
1053 statement_range: 0.."SELECT a FROM foo".len(),
1054 statement_index: 0,
1055 },
1056 );
1057 assert_eq!(issues.len(), 1);
1058 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1059 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
1060 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1061 assert_eq!(autofix.edits.len(), 1);
1062 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1063 assert_eq!(fixed, "SELECT a FROM foo;");
1064 }
1065
1066 #[test]
1067 fn multiline_newline_flags_extra_blank_line_before_semicolon() {
1068 let config = LintConfig {
1069 enabled: true,
1070 disabled_rules: vec![],
1071 rule_configs: std::collections::BTreeMap::from([(
1072 "convention.terminator".to_string(),
1073 serde_json::json!({"multiline_newline": true}),
1074 )]),
1075 };
1076 let rule = ConventionTerminator::from_config(&config);
1077 let sql = "SELECT a\nFROM foo\n\n;";
1078 let stmts = parse_sql(sql).expect("parse");
1079 let issues = rule.check(
1080 &stmts[0],
1081 &LintContext {
1082 sql,
1083 statement_range: 0.."SELECT a\nFROM foo".len(),
1084 statement_index: 0,
1085 },
1086 );
1087 assert_eq!(issues.len(), 1);
1088 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1089 }
1090
1091 #[test]
1092 fn multiline_newline_flags_comment_before_semicolon() {
1093 let config = LintConfig {
1094 enabled: true,
1095 disabled_rules: vec![],
1096 rule_configs: std::collections::BTreeMap::from([(
1097 "convention.terminator".to_string(),
1098 serde_json::json!({"multiline_newline": true}),
1099 )]),
1100 };
1101 let rule = ConventionTerminator::from_config(&config);
1102 let sql = "SELECT a\nFROM foo\n-- trailing\n;";
1103 let stmts = parse_sql(sql).expect("parse");
1104 let issues = rule.check(
1105 &stmts[0],
1106 &LintContext {
1107 sql,
1108 statement_range: 0.."SELECT a\nFROM foo".len(),
1109 statement_index: 0,
1110 },
1111 );
1112 assert_eq!(issues.len(), 1);
1113 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1114 }
1115
1116 #[test]
1117 fn multiline_newline_flags_trailing_comment_inside_statement_range() {
1118 let config = LintConfig {
1119 enabled: true,
1120 disabled_rules: vec![],
1121 rule_configs: std::collections::BTreeMap::from([(
1122 "convention.terminator".to_string(),
1123 serde_json::json!({"multiline_newline": true}),
1124 )]),
1125 };
1126 let rule = ConventionTerminator::from_config(&config);
1127 let sql = "SELECT a\nFROM foo\n-- trailing\n;";
1128 let stmts = parse_sql(sql).expect("parse");
1129 let issues = rule.check(
1130 &stmts[0],
1131 &LintContext {
1132 sql,
1133 statement_range: 0.."SELECT a\nFROM foo\n-- trailing".len(),
1134 statement_index: 0,
1135 },
1136 );
1137 assert_eq!(issues.len(), 1);
1138 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1139 }
1140
1141 #[test]
1142 fn require_final_semicolon_flags_missing_semicolon_before_trailing_go_batch_separator() {
1143 let config = LintConfig {
1144 enabled: true,
1145 disabled_rules: vec![],
1146 rule_configs: std::collections::BTreeMap::from([(
1147 "convention.terminator".to_string(),
1148 serde_json::json!({"require_final_semicolon": true}),
1149 )]),
1150 };
1151 let rule = ConventionTerminator::from_config(&config);
1152 let stmt = &parse_sql("SELECT 1").expect("parse")[0];
1153 let sql = "SELECT 1\nGO\n";
1154 let issues = with_active_dialect(Dialect::Mssql, || {
1155 rule.check(
1156 stmt,
1157 &LintContext {
1158 sql,
1159 statement_range: 0.."SELECT 1".len(),
1160 statement_index: 0,
1161 },
1162 )
1163 });
1164 assert_eq!(issues.len(), 1);
1165 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1166 }
1167
1168 #[test]
1169 fn require_final_semicolon_does_not_flag_non_last_statement_before_go_batch_separator() {
1170 let config = LintConfig {
1171 enabled: true,
1172 disabled_rules: vec![],
1173 rule_configs: std::collections::BTreeMap::from([(
1174 "convention.terminator".to_string(),
1175 serde_json::json!({"require_final_semicolon": true}),
1176 )]),
1177 };
1178 let rule = ConventionTerminator::from_config(&config);
1179 let stmt = &parse_sql("SELECT 1").expect("parse")[0];
1180 let sql = "SELECT 1\nGO\nSELECT 2;";
1181 let issues = with_active_dialect(Dialect::Mssql, || {
1182 rule.check(
1183 stmt,
1184 &LintContext {
1185 sql,
1186 statement_range: 0.."SELECT 1".len(),
1187 statement_index: 0,
1188 },
1189 )
1190 });
1191 assert!(issues.is_empty());
1192 }
1193
1194 #[test]
1195 fn require_final_semicolon_does_not_treat_inline_comment_go_as_batch_separator() {
1196 let config = LintConfig {
1197 enabled: true,
1198 disabled_rules: vec![],
1199 rule_configs: std::collections::BTreeMap::from([(
1200 "convention.terminator".to_string(),
1201 serde_json::json!({"require_final_semicolon": true}),
1202 )]),
1203 };
1204 let rule = ConventionTerminator::from_config(&config);
1205 let stmt = &parse_sql("SELECT 1").expect("parse")[0];
1206 let sql = "SELECT 1\nGO -- not a standalone separator\n";
1207 let issues = with_active_dialect(Dialect::Mssql, || {
1208 rule.check(
1209 stmt,
1210 &LintContext {
1211 sql,
1212 statement_range: 0.."SELECT 1".len(),
1213 statement_index: 0,
1214 },
1215 )
1216 });
1217 assert!(issues.is_empty());
1218 }
1219
1220 #[test]
1221 fn multiline_newline_allows_newline_within_string_literal() {
1222 let config = LintConfig {
1223 enabled: true,
1224 disabled_rules: vec![],
1225 rule_configs: std::collections::BTreeMap::from([(
1226 "convention.terminator".to_string(),
1227 serde_json::json!({"multiline_newline": true}),
1228 )]),
1229 };
1230 let rule = ConventionTerminator::from_config(&config);
1231 let sql = "SELECT 'line1\nline2';";
1232 let stmt = &parse_sql(sql).expect("parse")[0];
1233 let issues = rule.check(
1234 stmt,
1235 &LintContext {
1236 sql,
1237 statement_range: 0.."SELECT 'line1\nline2'".len(),
1238 statement_index: 0,
1239 },
1240 );
1241 assert!(issues.is_empty());
1242 }
1243
1244 #[test]
1245 fn statement_is_multiline_fallback_handles_crlf_line_breaks() {
1246 let sql = "SELECT\r\n 1";
1247 let ctx = LintContext {
1248 sql,
1249 statement_range: 0..sql.len(),
1250 statement_index: 0,
1251 };
1252 assert!(statement_is_multiline(&ctx, None));
1253 }
1254
1255 #[test]
1256 fn multiline_newline_allows_inline_comment_before_newline_semicolon() {
1257 let config = LintConfig {
1259 enabled: true,
1260 disabled_rules: vec![],
1261 rule_configs: std::collections::BTreeMap::from([(
1262 "convention.terminator".to_string(),
1263 serde_json::json!({"multiline_newline": true}),
1264 )]),
1265 };
1266 let rule = ConventionTerminator::from_config(&config);
1267 let sql = "SELECT a\nFROM foo -- inline comment\n;";
1268 let stmts = parse_sql(sql).expect("parse");
1269 let stmt_range = 0.."SELECT a\nFROM foo".len();
1270 let issues = rule.check(
1271 &stmts[0],
1272 &LintContext {
1273 sql,
1274 statement_range: stmt_range,
1275 statement_index: 0,
1276 },
1277 );
1278 assert!(
1279 issues.is_empty(),
1280 "Should not flag: inline comment before newline+semicolon is valid in multiline_newline mode"
1281 );
1282 }
1283
1284 #[test]
1285 fn default_mode_fix_newline_before_semicolon() {
1286 let sql = "SELECT a FROM foo\n;";
1288 let stmts = parse_sql(sql).expect("parse");
1289 let issues = ConventionTerminator::default().check(
1290 &stmts[0],
1291 &LintContext {
1292 sql,
1293 statement_range: 0.."SELECT a FROM foo".len(),
1294 statement_index: 0,
1295 },
1296 );
1297 assert_eq!(issues.len(), 1);
1298 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1299 assert_eq!(fixed, "SELECT a FROM foo;");
1300 }
1301
1302 #[test]
1303 fn default_mode_fix_comment_then_semicolon() {
1304 let sql = "SELECT a FROM foo -- inline comment\n;";
1308 let stmts = parse_sql(sql).expect("parse");
1309 let issues = ConventionTerminator::default().check(
1310 &stmts[0],
1311 &LintContext {
1312 sql,
1313 statement_range: 0.."SELECT a FROM foo".len(),
1314 statement_index: 0,
1315 },
1316 );
1317 assert_eq!(issues.len(), 1);
1318 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1319 assert_eq!(fixed, "SELECT a FROM foo; -- inline comment\n");
1320 }
1321
1322 #[test]
1323 fn require_final_semicolon_with_inline_comment() {
1324 let config = LintConfig {
1326 enabled: true,
1327 disabled_rules: vec![],
1328 rule_configs: std::collections::BTreeMap::from([(
1329 "convention.terminator".to_string(),
1330 serde_json::json!({"require_final_semicolon": true}),
1331 )]),
1332 };
1333 let rule = ConventionTerminator::from_config(&config);
1334 let sql = "SELECT a FROM foo -- inline comment\n";
1335 let stmts = parse_sql(sql).expect("parse");
1336 let issues = rule.check(
1337 &stmts[0],
1338 &LintContext {
1339 sql,
1340 statement_range: 0.."SELECT a FROM foo".len(),
1341 statement_index: 0,
1342 },
1343 );
1344 assert_eq!(issues.len(), 1);
1345 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1346 assert_eq!(fixed, "SELECT a FROM foo; -- inline comment\n");
1347 }
1348
1349 #[test]
1350 fn multiline_newline_fix_moves_semicolon_to_new_line() {
1351 let config = LintConfig {
1353 enabled: true,
1354 disabled_rules: vec![],
1355 rule_configs: std::collections::BTreeMap::from([(
1356 "convention.terminator".to_string(),
1357 serde_json::json!({"multiline_newline": true}),
1358 )]),
1359 };
1360 let rule = ConventionTerminator::from_config(&config);
1361 let sql = "SELECT a\nFROM foo;";
1362 let stmts = parse_sql(sql).expect("parse");
1363 let issues = rule.check(
1364 &stmts[0],
1365 &LintContext {
1366 sql,
1367 statement_range: 0.."SELECT a\nFROM foo".len(),
1368 statement_index: 0,
1369 },
1370 );
1371 assert_eq!(issues.len(), 1);
1372 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1373 assert_eq!(fixed, "SELECT a\nFROM foo\n;");
1374 }
1375
1376 #[test]
1377 fn require_final_multiline_adds_semicolon_on_new_line() {
1378 let config = LintConfig {
1380 enabled: true,
1381 disabled_rules: vec![],
1382 rule_configs: std::collections::BTreeMap::from([(
1383 "convention.terminator".to_string(),
1384 serde_json::json!({"require_final_semicolon": true, "multiline_newline": true}),
1385 )]),
1386 };
1387 let rule = ConventionTerminator::from_config(&config);
1388 let sql = "SELECT a\nFROM foo\n";
1389 let stmts = parse_sql(sql).expect("parse");
1390 let issues = rule.check(
1391 &stmts[0],
1392 &LintContext {
1393 sql,
1394 statement_range: 0.."SELECT a\nFROM foo".len(),
1395 statement_index: 0,
1396 },
1397 );
1398 assert_eq!(issues.len(), 1);
1399 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1400 assert_eq!(fixed, "SELECT a\nFROM foo\n;\n");
1401 }
1402
1403 #[test]
1404 fn require_final_multiline_with_inline_comment_avoids_double_newline() {
1405 let config = LintConfig {
1407 enabled: true,
1408 disabled_rules: vec![],
1409 rule_configs: std::collections::BTreeMap::from([(
1410 "convention.terminator".to_string(),
1411 serde_json::json!({"require_final_semicolon": true, "multiline_newline": true}),
1412 )]),
1413 };
1414 let rule = ConventionTerminator::from_config(&config);
1415 let sql = "SELECT a\nFROM foo -- inline comment\n";
1416 let stmts = parse_sql(sql).expect("parse");
1417 let issues = rule.check(
1418 &stmts[0],
1419 &LintContext {
1420 sql,
1421 statement_range: 0.."SELECT a\nFROM foo".len(),
1422 statement_index: 0,
1423 },
1424 );
1425 assert_eq!(issues.len(), 1);
1426 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1427 assert_eq!(fixed, "SELECT a\nFROM foo -- inline comment\n;");
1430 }
1431
1432 #[test]
1433 fn multiline_newline_block_comment_before_semicolon_inside_statement() {
1434 let config = LintConfig {
1436 enabled: true,
1437 disabled_rules: vec![],
1438 rule_configs: std::collections::BTreeMap::from([(
1439 "convention.terminator".to_string(),
1440 serde_json::json!({"multiline_newline": true}),
1441 )]),
1442 };
1443 let rule = ConventionTerminator::from_config(&config);
1444 let sql = "SELECT foo\nFROM bar\n/* multiline\ncomment\n*/\n;\n";
1446 let stmt_range = 0.."SELECT foo\nFROM bar\n/* multiline\ncomment\n*/".len();
1447 let stmts = parse_sql(sql).expect("parse");
1448 let issues = rule.check(
1449 &stmts[0],
1450 &LintContext {
1451 sql,
1452 statement_range: stmt_range,
1453 statement_index: 0,
1454 },
1455 );
1456 assert_eq!(issues.len(), 1);
1457 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1458 assert_eq!(
1461 fixed,
1462 "SELECT foo\nFROM bar\n;\n/* multiline\ncomment\n*/\n\n"
1463 );
1464 }
1465
1466 #[test]
1467 fn multiline_newline_inline_block_comment_before_semicolon() {
1468 let config = LintConfig {
1471 enabled: true,
1472 disabled_rules: vec![],
1473 rule_configs: std::collections::BTreeMap::from([(
1474 "convention.terminator".to_string(),
1475 serde_json::json!({"multiline_newline": true}),
1476 )]),
1477 };
1478 let rule = ConventionTerminator::from_config(&config);
1479 let sql = "SELECT foo\nFROM bar /* multiline\ncomment\n*/\n;\n";
1480 let stmt_range = 0.."SELECT foo\nFROM bar /* multiline\ncomment\n*/".len();
1481 let stmts = parse_sql(sql).expect("parse");
1482 let issues = rule.check(
1483 &stmts[0],
1484 &LintContext {
1485 sql,
1486 statement_range: stmt_range,
1487 statement_index: 0,
1488 },
1489 );
1490 assert_eq!(issues.len(), 1);
1491 assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
1492 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1493 assert_eq!(
1494 fixed,
1495 "SELECT foo\nFROM bar\n; /* multiline\ncomment\n*/\n\n"
1496 );
1497 }
1498
1499 #[test]
1500 fn multiline_newline_trailing_block_comment_after_semicolon() {
1501 let config = LintConfig {
1503 enabled: true,
1504 disabled_rules: vec![],
1505 rule_configs: std::collections::BTreeMap::from([(
1506 "convention.terminator".to_string(),
1507 serde_json::json!({"multiline_newline": true}),
1508 )]),
1509 };
1510 let rule = ConventionTerminator::from_config(&config);
1511 let sql = "SELECT foo\nFROM bar; /* multiline\ncomment\n*/\n";
1512 let stmts = parse_sql(sql).expect("parse");
1513 let issues = rule.check(
1514 &stmts[0],
1515 &LintContext {
1516 sql,
1517 statement_range: 0.."SELECT foo\nFROM bar".len(),
1518 statement_index: 0,
1519 },
1520 );
1521 assert_eq!(issues.len(), 1);
1522 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1523 assert_eq!(fixed, "SELECT foo\nFROM bar\n; /* multiline\ncomment\n*/\n");
1525 }
1526}