use crate::linter::config::LintConfig;
use crate::linter::rule::{LintContext, LintRule};
use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
use sqlparser::ast::Statement;
use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
#[derive(Default)]
pub struct ConventionTerminator {
multiline_newline: bool,
require_final_semicolon: bool,
}
impl ConventionTerminator {
pub fn from_config(config: &LintConfig) -> Self {
Self {
multiline_newline: config
.rule_option_bool(issue_codes::LINT_CV_006, "multiline_newline")
.unwrap_or(false),
require_final_semicolon: config
.rule_option_bool(issue_codes::LINT_CV_006, "require_final_semicolon")
.unwrap_or(false),
}
}
}
impl LintRule for ConventionTerminator {
fn code(&self) -> &'static str {
issue_codes::LINT_CV_006
}
fn name(&self) -> &'static str {
"Statement terminator"
}
fn description(&self) -> &'static str {
"Statements must end with a semi-colon."
}
fn check(&self, _stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
let tokens = tokenize_with_offsets_for_context(ctx);
let trailing = trailing_info(ctx, tokens.as_deref());
let has_terminal_semicolon = trailing.semicolon_offset.is_some();
if self.require_final_semicolon
&& is_last_statement(ctx, tokens.as_deref())
&& !has_terminal_semicolon
{
let edits = build_require_final_semicolon_edits(ctx, &trailing, self.multiline_newline);
let span = edits
.first()
.map(|e| e.span)
.unwrap_or_else(|| Span::new(ctx.statement_range.end, ctx.statement_range.end));
return vec![Issue::info(
issue_codes::LINT_CV_006,
"Final statement must end with a semi-colon.",
)
.with_statement(ctx.statement_index)
.with_span(span)
.with_autofix_edits(IssueAutofixApplicability::Safe, edits)];
}
let Some(semicolon_offset) = trailing.semicolon_offset else {
return Vec::new();
};
if self.multiline_newline {
return self.check_multiline_newline(ctx, &trailing, semicolon_offset);
}
if semicolon_offset != ctx.statement_range.end {
let edits = build_default_mode_fix(ctx, &trailing, semicolon_offset);
let mut issue = Issue::info(
issue_codes::LINT_CV_006,
"Statement terminator style is inconsistent.",
)
.with_statement(ctx.statement_index);
if !edits.is_empty() {
let span = edits
.first()
.map(|e| e.span)
.unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset));
issue = issue
.with_span(span)
.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
}
return vec![issue];
}
Vec::new()
}
}
impl ConventionTerminator {
fn check_multiline_newline(
&self,
ctx: &LintContext,
trailing: &TrailingInfo,
semicolon_offset: usize,
) -> Vec<Issue> {
let tokens = tokenize_with_offsets_for_context(ctx);
let code_end = actual_code_end(ctx);
let effective_multiline = tokens
.as_deref()
.map(|toks| {
toks.iter()
.filter(|t| t.start >= ctx.statement_range.start && t.end <= code_end)
.any(|t| matches!(t.token, Token::Whitespace(Whitespace::Newline)))
})
.unwrap_or_else(|| {
count_line_breaks(&ctx.sql[ctx.statement_range.start..code_end]) > 0
});
if effective_multiline {
let tokens_for_check = tokenize_with_offsets_for_context(ctx);
if is_valid_multiline_newline_style(ctx, trailing, semicolon_offset)
&& !has_standalone_comment_at_end_of_statement(ctx, tokens_for_check.as_deref())
{
return Vec::new();
}
let edits = build_multiline_newline_fix(ctx, trailing, semicolon_offset);
let mut issue = Issue::info(
issue_codes::LINT_CV_006,
"Multi-line statements must place the semi-colon on a new line.",
)
.with_statement(ctx.statement_index);
if !edits.is_empty() {
let span = edits
.first()
.map(|e| e.span)
.unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset + 1));
issue = issue
.with_span(span)
.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
}
return vec![issue];
}
if semicolon_offset != ctx.statement_range.end {
let edits = build_default_mode_fix(ctx, trailing, semicolon_offset);
let mut issue = Issue::info(
issue_codes::LINT_CV_006,
"Statement terminator style is inconsistent.",
)
.with_statement(ctx.statement_index);
if !edits.is_empty() {
let span = edits
.first()
.map(|e| e.span)
.unwrap_or_else(|| Span::new(ctx.statement_range.end, semicolon_offset));
issue = issue
.with_span(span)
.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
}
return vec![issue];
}
Vec::new()
}
}
fn is_valid_multiline_newline_style(
ctx: &LintContext,
trailing: &TrailingInfo,
semicolon_offset: usize,
) -> bool {
let anchor_end = find_last_content_end_before_semicolon(ctx, trailing, semicolon_offset);
let gap = &ctx.sql[anchor_end..semicolon_offset];
let total_gap = &ctx.sql[ctx.statement_range.end..semicolon_offset];
let total_newlines = count_line_breaks(total_gap);
let gap_newlines = count_line_breaks(gap);
let inline_comment_newlines = if trailing.inline_comment_after_stmt.is_some() {
1
} else {
0
};
let effective_newlines = gap_newlines + inline_comment_newlines;
if effective_newlines != 1 {
if total_newlines != 1 || trailing.inline_comment_after_stmt.is_none() {
return false;
}
}
trailing.comments_before_semicolon.is_empty()
&& gap.chars().all(|c| c.is_whitespace())
}
fn find_last_content_end_before_semicolon(
ctx: &LintContext,
trailing: &TrailingInfo,
semicolon_offset: usize,
) -> usize {
if let Some(ref comment) = trailing.inline_comment_after_stmt {
if comment.end <= semicolon_offset {
return comment.end;
}
}
ctx.statement_range.end
}
fn build_default_mode_fix(
ctx: &LintContext,
trailing: &TrailingInfo,
semicolon_offset: usize,
) -> Vec<IssuePatchEdit> {
let code_end = actual_code_end(ctx);
let gap_start = ctx.statement_range.end;
let semicolon_end = semicolon_offset + 1;
if trailing.comments_before_semicolon.is_empty() && trailing.inline_comment_after_stmt.is_none()
{
if code_end < gap_start {
let mut edits = vec![
IssuePatchEdit::new(Span::new(code_end, code_end), ";"),
IssuePatchEdit::new(Span::new(semicolon_offset, semicolon_end), ""),
];
let pre_gap = &ctx.sql[code_end..gap_start];
if !pre_gap.is_empty()
&& pre_gap.chars().all(char::is_whitespace)
&& !pre_gap.contains('\n')
{
}
edits.sort_by_key(|e| e.span.start);
return edits;
}
let gap = &ctx.sql[gap_start..semicolon_offset];
if gap.chars().all(char::is_whitespace) {
let span = Span::new(gap_start, semicolon_end);
return vec![IssuePatchEdit::new(span, ";")];
}
return Vec::new();
}
let mut edits = vec![
IssuePatchEdit::new(Span::new(code_end, code_end), ";"),
IssuePatchEdit::new(Span::new(semicolon_offset, semicolon_end), ""),
];
edits.sort_by_key(|e| e.span.start);
edits
}
fn build_multiline_newline_fix(
ctx: &LintContext,
trailing: &TrailingInfo,
semicolon_offset: usize,
) -> Vec<IssuePatchEdit> {
let semicolon_end = semicolon_offset + 1;
let after_semicolon = trailing_content_after_semicolon(ctx, semicolon_offset);
let indent = detect_statement_indent(ctx);
let code_end = actual_code_end(ctx);
let anchor_end = if let Some(ref comment) = trailing.inline_comment_after_stmt {
comment.end
} else if let Some(inner_end) = find_inline_comment_in_statement(ctx) {
inner_end
} else {
code_end
};
let after_semi_comment = after_semicolon.trim();
if !after_semi_comment.is_empty()
&& (after_semi_comment.starts_with("--") || after_semi_comment.starts_with("/*"))
{
let mut edits = Vec::new();
if after_semi_comment.starts_with("/*") {
let mut rep = String::new();
rep.push('\n');
rep.push_str(&indent);
rep.push(';');
edits.push(IssuePatchEdit::new(
Span::new(code_end, semicolon_end),
&rep,
));
} else {
edits.push(IssuePatchEdit::new(
Span::new(semicolon_offset, semicolon_end),
"",
));
let mut insert_pos = semicolon_end + after_semicolon.len();
if insert_pos < ctx.sql.len() && ctx.sql.as_bytes()[insert_pos] == b'\n' {
insert_pos += 1;
}
let mut rep = String::new();
if insert_pos == semicolon_end + after_semicolon.len() {
rep.push('\n');
}
rep.push_str(&indent);
rep.push(';');
edits.push(IssuePatchEdit::new(Span::new(insert_pos, insert_pos), &rep));
}
edits.sort_by_key(|e| e.span.start);
return edits;
}
let mut edits = Vec::new();
let delete_end = if after_semicolon.trim().is_empty() {
semicolon_end + after_semicolon.len()
} else {
semicolon_end
};
edits.push(IssuePatchEdit::new(
Span::new(semicolon_offset, delete_end),
"",
));
let anchor_has_newline =
anchor_end > 0 && matches!(ctx.sql.as_bytes().get(anchor_end - 1), Some(b'\n'));
let mut replacement = String::new();
if !anchor_has_newline {
replacement.push('\n');
}
replacement.push_str(&indent);
replacement.push(';');
edits.push(IssuePatchEdit::new(
Span::new(anchor_end, anchor_end),
&replacement,
));
let gap_has_comment = code_end < semicolon_offset
&& (ctx.sql[code_end..semicolon_offset].contains("--")
|| ctx.sql[code_end..semicolon_offset].contains("/*"));
if trailing.comments_before_semicolon.is_empty()
&& trailing.inline_comment_after_stmt.is_none()
&& code_end == anchor_end
&& !gap_has_comment
{
edits.clear();
let mut rep = String::new();
rep.push('\n');
rep.push_str(&indent);
rep.push(';');
edits.push(IssuePatchEdit::new(Span::new(code_end, delete_end), &rep));
}
edits.sort_by_key(|e| e.span.start);
edits
}
fn actual_code_end(ctx: &LintContext) -> usize {
let tokens = tokenize_with_offsets_for_context(ctx);
let Some(tokens) = tokens.as_deref() else {
return ctx.statement_range.end;
};
let last_code = tokens.iter().rfind(|t| {
t.start >= ctx.statement_range.start
&& t.start < ctx.statement_range.end
&& !is_spacing_whitespace(&t.token)
&& !is_comment_token(&t.token)
});
last_code.map_or(ctx.statement_range.end, |t| t.end)
}
fn build_require_final_semicolon_edits(
ctx: &LintContext,
trailing: &TrailingInfo,
multiline_newline: bool,
) -> Vec<IssuePatchEdit> {
let code_end = actual_code_end(ctx);
let is_multiline = count_line_breaks(&ctx.sql[ctx.statement_range.start..code_end]) > 0;
if multiline_newline && is_multiline {
let anchor_end = if let Some(ref comment) = trailing.inline_comment_after_stmt {
comment.end
} else if let Some(inner_comment) = find_inline_comment_in_statement(ctx) {
inner_comment
} else {
code_end
};
let indent = detect_statement_indent(ctx);
let anchor_has_newline =
anchor_end > 0 && matches!(ctx.sql.as_bytes().get(anchor_end - 1), Some(b'\n'));
let mut replacement = String::new();
if !anchor_has_newline {
replacement.push('\n');
}
replacement.push_str(&indent);
replacement.push(';');
let span = Span::new(anchor_end, anchor_end);
return vec![IssuePatchEdit::new(span, &replacement)];
}
let insert_span = Span::new(code_end, code_end);
vec![IssuePatchEdit::new(insert_span, ";")]
}
fn find_inline_comment_in_statement(ctx: &LintContext) -> Option<usize> {
let tokens = tokenize_with_offsets_for_context(ctx)?;
let code_end = tokens.iter().rfind(|t| {
t.start >= ctx.statement_range.start
&& t.start < ctx.statement_range.end
&& !is_spacing_whitespace(&t.token)
&& !is_comment_token(&t.token)
})?;
let code_end_line = offset_to_line_number(ctx.sql, code_end.end);
let inline = tokens.iter().find(|t| {
t.start > code_end.end
&& t.start < ctx.statement_range.end
&& is_single_line_comment(&t.token)
&& offset_to_line_number(ctx.sql, t.start) == code_end_line
})?;
Some(inline.end)
}
fn detect_statement_indent(ctx: &LintContext) -> String {
let start = ctx.statement_range.start;
let line_start = ctx.sql[..start].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
let prefix = &ctx.sql[line_start..start];
let indent: String = prefix.chars().take_while(|c| c.is_whitespace()).collect();
indent
}
fn trailing_content_after_semicolon<'a>(
ctx: &'a LintContext<'a>,
semicolon_offset: usize,
) -> &'a str {
let after = semicolon_offset + 1;
let rest = &ctx.sql[after..];
if let Some(nl_pos) = rest.find('\n') {
&rest[..nl_pos]
} else {
rest
}
}
struct TrailingInfo {
semicolon_offset: Option<usize>,
inline_comment_after_stmt: Option<CommentSpan>,
comments_before_semicolon: Vec<CommentSpan>,
}
#[derive(Clone)]
struct CommentSpan {
end: usize,
}
fn trailing_info(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> TrailingInfo {
let Some(tokens) = tokens else {
return TrailingInfo {
semicolon_offset: None,
inline_comment_after_stmt: None,
comments_before_semicolon: Vec::new(),
};
};
let stmt_end = ctx.statement_range.end;
let stmt_end_line = offset_to_line_number(ctx.sql, stmt_end);
let mut semicolon_offset = None;
let mut inline_comment_after_stmt = None;
let mut comments_before_semicolon = Vec::new();
let mut found_semicolon = false;
for token in tokens.iter().filter(|t| t.start >= stmt_end) {
match &token.token {
Token::SemiColon if !found_semicolon => {
semicolon_offset = Some(token.start);
found_semicolon = true;
}
trivia if is_trivia_token(trivia) => {
if !found_semicolon && is_comment_token(trivia) {
let token_line = offset_to_line_number(ctx.sql, token.start);
let span = CommentSpan { end: token.end };
if token_line == stmt_end_line
&& inline_comment_after_stmt.is_none()
&& is_single_line_comment(trivia)
{
inline_comment_after_stmt = Some(span);
} else {
comments_before_semicolon.push(span);
}
}
}
_ => {
if !found_semicolon {
break;
}
break;
}
}
}
TrailingInfo {
semicolon_offset,
inline_comment_after_stmt,
comments_before_semicolon,
}
}
fn is_single_line_comment(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::SingleLineComment { .. })
)
}
fn offset_to_line_number(sql: &str, offset: usize) -> usize {
sql.as_bytes()
.iter()
.take(offset.min(sql.len()))
.filter(|b| **b == b'\n')
.count()
+ 1
}
fn is_last_statement(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool {
let Some(tokens) = tokens else {
return false;
};
for token in tokens
.iter()
.filter(|token| token.start >= ctx.statement_range.end)
{
if matches!(token.token, Token::SemiColon)
|| is_trivia_token(&token.token)
|| is_go_batch_separator(token, tokens, ctx.dialect())
{
continue;
}
return false;
}
true
}
fn has_standalone_comment_at_end_of_statement(
ctx: &LintContext,
tokens: Option<&[LocatedToken]>,
) -> bool {
let Some(tokens) = tokens else {
return false;
};
let last_token = tokens.iter().rfind(|t| {
t.start >= ctx.statement_range.start
&& t.start < ctx.statement_range.end
&& !is_spacing_whitespace(&t.token)
});
let Some(last) = last_token else {
return false;
};
if !is_comment_token(&last.token) {
return false;
}
let prev_token = tokens.iter().rfind(|t| {
t.start >= ctx.statement_range.start
&& t.start < last.start
&& !is_spacing_whitespace(&t.token)
&& !is_comment_token(&t.token)
});
let Some(prev) = prev_token else {
return false;
};
if offset_to_line_number(ctx.sql, last.start) != offset_to_line_number(ctx.sql, prev.start) {
return true;
}
matches!(
&last.token,
Token::Whitespace(Whitespace::MultiLineComment(_))
) && last.start_line != last.end_line
}
struct LocatedToken {
token: Token,
start: usize,
end: usize,
start_line: usize,
end_line: usize,
}
fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
let tokens = ctx.with_document_tokens(|tokens| {
if tokens.is_empty() {
return None;
}
Some(
tokens
.iter()
.filter_map(|token| {
token_with_span_offsets(ctx.sql, token).map(|(start, end)| LocatedToken {
token: token.token.clone(),
start,
end,
start_line: token.span.start.line as usize,
end_line: token.span.end.line as usize,
})
})
.collect::<Vec<_>>(),
)
});
if let Some(tokens) = tokens {
return Some(tokens);
}
tokenize_with_offsets(ctx.sql, ctx.dialect())
}
fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
let dialect = dialect.to_sqlparser_dialect();
let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
let tokens = tokenizer.tokenize_with_location().ok()?;
let mut out = Vec::with_capacity(tokens.len());
for token in tokens {
let Some((start, end)) = token_with_span_offsets(sql, &token) else {
continue;
};
out.push(LocatedToken {
token: token.token,
start,
end,
start_line: token.span.start.line as usize,
end_line: token.span.end.line as usize,
});
}
Some(out)
}
fn is_comment_token(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::SingleLineComment { .. })
| Token::Whitespace(Whitespace::MultiLineComment(_))
)
}
fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
let start = line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let end = line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
Some((start, end))
}
fn is_trivia_token(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
| Token::Whitespace(Whitespace::SingleLineComment { .. })
| Token::Whitespace(Whitespace::MultiLineComment(_))
)
}
fn is_spacing_whitespace(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
)
}
fn is_go_batch_separator(token: &LocatedToken, tokens: &[LocatedToken], dialect: Dialect) -> bool {
if dialect != Dialect::Mssql {
return false;
}
let Token::Word(word) = &token.token else {
return false;
};
if !word.value.eq_ignore_ascii_case("GO") {
return false;
}
if token.start_line != token.end_line {
return false;
}
let line = token.start_line;
let mut go_count = 0usize;
for candidate in tokens {
if candidate.start_line != line {
continue;
}
if is_spacing_whitespace(&candidate.token) {
continue;
}
match &candidate.token {
Token::Word(word) if word.value.eq_ignore_ascii_case("GO") => {
go_count += 1;
}
_ => return false,
}
}
go_count == 1
}
fn count_line_breaks(text: &str) -> usize {
let mut count = 0usize;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\n' {
count += 1;
continue;
}
if ch == '\r' {
count += 1;
if matches!(chars.peek(), Some('\n')) {
let _ = chars.next();
}
}
}
count
}
fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
if line == 0 || column == 0 {
return None;
}
let mut current_line = 1usize;
let mut current_col = 1usize;
for (offset, ch) in sql.char_indices() {
if current_line == line && current_col == column {
return Some(offset);
}
if ch == '\n' {
current_line += 1;
current_col = 1;
} else {
current_col += 1;
}
}
if current_line == line && current_col == column {
return Some(sql.len());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::rule::with_active_dialect;
use crate::parser::parse_sql;
use crate::types::IssueAutofixApplicability;
fn run(sql: &str) -> Vec<Issue> {
let stmts = parse_sql(sql).expect("parse");
let rule = ConventionTerminator::default();
stmts
.iter()
.enumerate()
.flat_map(|(index, stmt)| {
rule.check(
stmt,
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: index,
},
)
})
.collect()
}
fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
let autofix = issue.autofix.as_ref()?;
let mut out = sql.to_string();
let mut edits = autofix.edits.clone();
edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
for edit in edits.iter().rev() {
out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
}
Some(out)
}
fn statement_is_multiline(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool {
let Some(tokens) = tokens else {
return count_line_breaks(ctx.statement_sql()) > 0;
};
tokens
.iter()
.filter(|token| {
token.start >= ctx.statement_range.start && token.end <= ctx.statement_range.end
})
.any(|token| is_multiline_trivia_token(&token.token))
}
fn is_multiline_trivia_token(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::Newline)
| Token::Whitespace(Whitespace::SingleLineComment { .. })
| Token::Whitespace(Whitespace::MultiLineComment(_))
)
}
#[test]
fn default_allows_missing_final_semicolon_in_multi_statement_file() {
let issues = run("select 1; select 2");
assert!(issues.is_empty());
}
#[test]
fn allows_consistent_terminated_statements() {
let issues = run("select 1; select 2;");
assert!(issues.is_empty());
}
#[test]
fn require_final_semicolon_flags_last_statement_without_terminator() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT 1";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
assert_eq!(
issues[0]
.autofix
.as_ref()
.map(|autofix| autofix.edits.len()),
Some(1)
);
assert_eq!(
issues[0]
.autofix
.as_ref()
.map(|autofix| autofix.applicability),
Some(IssueAutofixApplicability::Safe)
);
assert_eq!(
issues[0]
.autofix
.as_ref()
.and_then(|autofix| autofix.edits.first())
.map(|edit| edit.replacement.as_str()),
Some(";")
);
}
#[test]
fn multiline_newline_flags_inline_semicolon_for_multiline_statement() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"LINT_CV_006".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT\n 1;";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT\n 1".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
}
#[test]
fn default_flags_space_before_semicolon() {
let sql = "SELECT a FROM foo ;";
let stmts = parse_sql(sql).expect("parse");
let issues = ConventionTerminator::default().check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a FROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
assert_eq!(autofix.edits.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a FROM foo;");
}
#[test]
fn multiline_newline_flags_extra_blank_line_before_semicolon() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo\n\n;";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
}
#[test]
fn multiline_newline_flags_comment_before_semicolon() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo\n-- trailing\n;";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
}
#[test]
fn multiline_newline_flags_trailing_comment_inside_statement_range() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo\n-- trailing\n;";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo\n-- trailing".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
}
#[test]
fn require_final_semicolon_flags_missing_semicolon_before_trailing_go_batch_separator() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let stmt = &parse_sql("SELECT 1").expect("parse")[0];
let sql = "SELECT 1\nGO\n";
let issues = with_active_dialect(Dialect::Mssql, || {
rule.check(
stmt,
&LintContext {
sql,
statement_range: 0.."SELECT 1".len(),
statement_index: 0,
},
)
});
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
}
#[test]
fn require_final_semicolon_does_not_flag_non_last_statement_before_go_batch_separator() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let stmt = &parse_sql("SELECT 1").expect("parse")[0];
let sql = "SELECT 1\nGO\nSELECT 2;";
let issues = with_active_dialect(Dialect::Mssql, || {
rule.check(
stmt,
&LintContext {
sql,
statement_range: 0.."SELECT 1".len(),
statement_index: 0,
},
)
});
assert!(issues.is_empty());
}
#[test]
fn require_final_semicolon_does_not_treat_inline_comment_go_as_batch_separator() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let stmt = &parse_sql("SELECT 1").expect("parse")[0];
let sql = "SELECT 1\nGO -- not a standalone separator\n";
let issues = with_active_dialect(Dialect::Mssql, || {
rule.check(
stmt,
&LintContext {
sql,
statement_range: 0.."SELECT 1".len(),
statement_index: 0,
},
)
});
assert!(issues.is_empty());
}
#[test]
fn multiline_newline_allows_newline_within_string_literal() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT 'line1\nline2';";
let stmt = &parse_sql(sql).expect("parse")[0];
let issues = rule.check(
stmt,
&LintContext {
sql,
statement_range: 0.."SELECT 'line1\nline2'".len(),
statement_index: 0,
},
);
assert!(issues.is_empty());
}
#[test]
fn statement_is_multiline_fallback_handles_crlf_line_breaks() {
let sql = "SELECT\r\n 1";
let ctx = LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
};
assert!(statement_is_multiline(&ctx, None));
}
#[test]
fn multiline_newline_allows_inline_comment_before_newline_semicolon() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo -- inline comment\n;";
let stmts = parse_sql(sql).expect("parse");
let stmt_range = 0.."SELECT a\nFROM foo".len();
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: stmt_range,
statement_index: 0,
},
);
assert!(
issues.is_empty(),
"Should not flag: inline comment before newline+semicolon is valid in multiline_newline mode"
);
}
#[test]
fn default_mode_fix_newline_before_semicolon() {
let sql = "SELECT a FROM foo\n;";
let stmts = parse_sql(sql).expect("parse");
let issues = ConventionTerminator::default().check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a FROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a FROM foo;");
}
#[test]
fn default_mode_fix_comment_then_semicolon() {
let sql = "SELECT a FROM foo -- inline comment\n;";
let stmts = parse_sql(sql).expect("parse");
let issues = ConventionTerminator::default().check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a FROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a FROM foo; -- inline comment\n");
}
#[test]
fn require_final_semicolon_with_inline_comment() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a FROM foo -- inline comment\n";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a FROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a FROM foo; -- inline comment\n");
}
#[test]
fn multiline_newline_fix_moves_semicolon_to_new_line() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo;";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a\nFROM foo\n;");
}
#[test]
fn require_final_multiline_adds_semicolon_on_new_line() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true, "multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo\n";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a\nFROM foo\n;\n");
}
#[test]
fn require_final_multiline_with_inline_comment_avoids_double_newline() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"require_final_semicolon": true, "multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT a\nFROM foo -- inline comment\n";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT a\nFROM foo".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a\nFROM foo -- inline comment\n;");
}
#[test]
fn multiline_newline_block_comment_before_semicolon_inside_statement() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT foo\nFROM bar\n/* multiline\ncomment\n*/\n;\n";
let stmt_range = 0.."SELECT foo\nFROM bar\n/* multiline\ncomment\n*/".len();
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: stmt_range,
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT foo\nFROM bar\n;\n/* multiline\ncomment\n*/\n\n"
);
}
#[test]
fn multiline_newline_inline_block_comment_before_semicolon() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT foo\nFROM bar /* multiline\ncomment\n*/\n;\n";
let stmt_range = 0.."SELECT foo\nFROM bar /* multiline\ncomment\n*/".len();
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: stmt_range,
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_CV_006);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT foo\nFROM bar\n; /* multiline\ncomment\n*/\n\n"
);
}
#[test]
fn multiline_newline_trailing_block_comment_after_semicolon() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"convention.terminator".to_string(),
serde_json::json!({"multiline_newline": true}),
)]),
};
let rule = ConventionTerminator::from_config(&config);
let sql = "SELECT foo\nFROM bar; /* multiline\ncomment\n*/\n";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0.."SELECT foo\nFROM bar".len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT foo\nFROM bar\n; /* multiline\ncomment\n*/\n");
}
}