Skip to main content

flowscope_core/linter/rules/
cv_006.rs

1//! LINT_CV_006: Statement terminator.
2//!
3//! Enforce consistent semicolon termination within a SQL document.
4
5use 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        // require_final_semicolon: last statement without semicolon
49        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        // Default mode: semicolon should be immediately after statement (no gap)
76        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        // Determine multiline based on actual code tokens (not comments,
109        // not string literal content). Use code_end to exclude trailing
110        // comments that the parser may have included in the range.
111        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        // Single-line statement with multiline_newline: semicolon should be immediately after
148        if semicolon_offset != ctx.statement_range.end {
149            // Use the default mode fix (same-line placement) for single-line statements.
150            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
172/// Check if the semicolon placement is valid for multiline_newline mode.
173///
174/// Valid placement means the semicolon is on its own line immediately after
175/// the last statement content line (which may include a trailing inline
176/// comment). The pattern is:
177///   - Statement body (may end with inline comment on last line)
178///   - Exactly one newline
179///   - Semicolon (optionally followed by spacing/comments on the same line)
180fn is_valid_multiline_newline_style(
181    ctx: &LintContext,
182    trailing: &TrailingInfo,
183    semicolon_offset: usize,
184) -> bool {
185    // Find the "anchor": the last non-whitespace content before the semicolon.
186    // This could be the statement body end or a trailing inline comment.
187    let anchor_end = find_last_content_end_before_semicolon(ctx, trailing, semicolon_offset);
188
189    // The gap between the anchor and the semicolon should have exactly one newline.
190    // Note: single-line comment tokens include the trailing newline in their span,
191    // so if the anchor is a single-line comment, the newline may be inside the comment span.
192    let gap = &ctx.sql[anchor_end..semicolon_offset];
193
194    // Count total newlines from statement end through to the semicolon
195    let total_gap = &ctx.sql[ctx.statement_range.end..semicolon_offset];
196    let total_newlines = count_line_breaks(total_gap);
197
198    // For valid placement, there should be exactly one newline between the last
199    // content and the semicolon. For inline comments that include the trailing newline,
200    // the gap may be empty but the comment's newline counts.
201    let gap_newlines = count_line_breaks(gap);
202    let inline_comment_newlines = if trailing.inline_comment_after_stmt.is_some() {
203        // Single-line comments include their trailing newline in the span
204        1
205    } else {
206        0
207    };
208
209    let effective_newlines = gap_newlines + inline_comment_newlines;
210    if effective_newlines != 1 {
211        // If the gap itself has no newlines and no inline comment provides one,
212        // and the total gap also has no newlines, it's invalid.
213        // However, if total_newlines == 1 and we have an inline comment whose
214        // newline accounts for the separation, that's valid.
215        if total_newlines != 1 || trailing.inline_comment_after_stmt.is_none() {
216            return false;
217        }
218    }
219
220    // Verify there are no standalone comments between anchor and semicolon
221    trailing.comments_before_semicolon.is_empty()
222        // And the gap is only whitespace
223        && gap.chars().all(|c| c.is_whitespace())
224}
225
226/// Find the byte offset of the end of the last meaningful content before
227/// the semicolon. This is either the end of the statement range or the end
228/// of a trailing inline comment on the last line of the statement.
229fn find_last_content_end_before_semicolon(
230    ctx: &LintContext,
231    trailing: &TrailingInfo,
232    semicolon_offset: usize,
233) -> usize {
234    // If there's a trailing inline comment on the same line as the statement end,
235    // it counts as part of the "statement content" for newline-placement purposes.
236    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
244/// Build autofix edits for the default mode (semicolon should be on the same
245/// line, immediately after the statement, no gap).
246///
247/// Uses a two-edit strategy to avoid overlapping with comment protected
248/// ranges: (1) insert semicolon at the actual code end, (2) delete the
249/// misplaced semicolon. This way neither edit spans over a comment.
250fn 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 there are no comments between statement and semicolon,
260    // just collapse the gap.
261    if trailing.comments_before_semicolon.is_empty() && trailing.inline_comment_after_stmt.is_none()
262    {
263        // Check if the comment is inside the statement range (parser included it)
264        if code_end < gap_start {
265            // Comment is inside statement range. Use two-edit strategy:
266            // 1. Insert ; at code_end   2. Delete old ;
267            let mut edits = vec![
268                IssuePatchEdit::new(Span::new(code_end, code_end), ";"),
269                IssuePatchEdit::new(Span::new(semicolon_offset, semicolon_end), ""),
270            ];
271            // Also remove whitespace between code_end and the gap_start
272            // (only safe whitespace before any comments)
273            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                // Whitespace-only gap before the comment — but don't touch
279                // if it contains a newline (part of comment token).
280            }
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            // Replace gap + semicolon with just semicolon
287            let span = Span::new(gap_start, semicolon_end);
288            return vec![IssuePatchEdit::new(span, ";")];
289        }
290        return Vec::new();
291    }
292
293    // Comments are present between statement end and semicolon.
294    // Use two-edit strategy: insert ; at code_end, delete old ;.
295    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
303/// Build autofix edits for multiline_newline mode.
304///
305/// The semicolon should be on its own new line, indented at the statement level,
306/// immediately after the last content line (which may include a trailing inline
307/// comment).
308///
309/// Uses a two-edit strategy to avoid overlapping with comment protected
310/// ranges: (1) delete the old semicolon, (2) insert `\n;` at the anchor
311/// point (after inline comments, before standalone comments).
312fn 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    // Determine the indent for the semicolon line.
321    let indent = detect_statement_indent(ctx);
322
323    // Determine anchor for the new semicolon position. Prefer a location
324    // outside any comment protected ranges.
325    let code_end = actual_code_end(ctx);
326
327    // Check for an inline comment on the same line as code_end (either
328    // tracked by trailing_info or inside the statement range).
329    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    // Check for an inline comment AFTER the semicolon on the same line.
338    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            // Block comment after semicolon: keep the comment after the
346            // semicolon but move both to a new line.
347            // Pattern: `foo; /* comment */` → `foo\n; /* comment */`
348            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            // Single-line comment after semicolon: move comment before
358            // semicolon on the code line, then semicolon on its own line.
359            // Pattern: `foo ; -- comment` → `foo -- comment\n;`
360
361            // Delete just the semicolon, preserving space before comment.
362            edits.push(IssuePatchEdit::new(
363                Span::new(semicolon_offset, semicolon_end),
364                "",
365            ));
366
367            // Find the end of the after-semicolon content line.
368            // Single-line comment tokens include the trailing \n.
369            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            // Insert ; on its own new line
374            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    // Edit 1: Delete the old semicolon (and any trailing content on its line).
390    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    // Edit 2: Insert newline + indent + semicolon at the anchor point.
401    // Token ends are exclusive, so anchor_end is the first byte OUTSIDE
402    // the comment token — safe for zero-width inserts.
403    // Single-line comment tokens include their trailing \n, so skip the
404    // extra \n prefix when the anchor already ends with one.
405    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    // Also clean up whitespace between code_end and the semicolon when no
419    // comments are involved (simple gap case).
420    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        // Remove any whitespace gap between code end and semicolon that isn't
429        // covered by the other edits. For the simple case `stmt_end;` -> `stmt_end\n;`
430        // we can just replace `code_end..semicolon_end` directly since there's no
431        // comment in between.
432        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
444/// Find the end of the actual SQL code within the statement range, excluding
445/// any trailing comments or whitespace that the parser may have included.
446///
447/// Returns the byte offset immediately after the last non-comment,
448/// non-whitespace token that starts within the statement range. Falls back
449/// to `statement_range.end` when tokens are unavailable.
450fn 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
464/// Build autofix edits for require_final_semicolon when no semicolon exists.
465///
466/// Inserts the semicolon at the actual code end (before any trailing
467/// comments) so the edit does not overlap comment protected ranges.
468fn 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        // Multiline + require_final: insert semicolon on its own new line after the
478        // last content (including any trailing inline comment).
479        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        // Single-line comment tokens include their trailing \n in the span.
490        // If the anchor already ends with \n, use just indent + ; to avoid
491        // a double newline.
492        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    // Insert semicolon at the actual code end to avoid overlapping with
507    // comment protected ranges.
508    let insert_span = Span::new(code_end, code_end);
509    vec![IssuePatchEdit::new(insert_span, ";")]
510}
511
512/// Find the end of an inline comment on the last line of code within the
513/// statement range (where the parser included the comment in the range).
514fn 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    // Look for a single-line comment starting on the same line as the last code token
525    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
534/// Detect the indentation level of the first line of the statement.
535fn detect_statement_indent(ctx: &LintContext) -> String {
536    let start = ctx.statement_range.start;
537    // Walk backwards from statement start to find the beginning of the line
538    let line_start = ctx.sql[..start].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
539    let prefix = &ctx.sql[line_start..start];
540    // Extract leading whitespace
541    let indent: String = prefix.chars().take_while(|c| c.is_whitespace()).collect();
542    indent
543}
544
545/// Get content after the semicolon (typically trailing comments/whitespace on the
546/// same line or rest of the source up to next statement).
547fn trailing_content_after_semicolon<'a>(
548    ctx: &'a LintContext<'a>,
549    semicolon_offset: usize,
550) -> &'a str {
551    let after = semicolon_offset + 1;
552    // Find the end of the trailing content: up to the next newline or end of document
553    // that is still within the "trailing" zone (not part of the next statement).
554    let rest = &ctx.sql[after..];
555    // Take content up to the end of the line
556    if let Some(nl_pos) = rest.find('\n') {
557        &rest[..nl_pos]
558    } else {
559        rest
560    }
561}
562
563/// Information about the trailing tokens after a statement.
564struct TrailingInfo {
565    /// Byte offset of the terminal semicolon, if present.
566    semicolon_offset: Option<usize>,
567    /// Inline comment on the same line as statement end, before the semicolon
568    /// or before EOF (for require_final_semicolon cases).
569    inline_comment_after_stmt: Option<CommentSpan>,
570    /// Standalone comments (on their own lines) between statement and semicolon.
571    comments_before_semicolon: Vec<CommentSpan>,
572}
573
574#[derive(Clone)]
575struct CommentSpan {
576    end: usize,
577}
578
579/// Analyze the trailing tokens after statement_range.end to collect
580/// information about semicolons, comments, and whitespace.
581fn 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                // Hit non-trivia after semicolon; stop.
623                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
670/// Checks if the statement range ends with a standalone comment on its own line.
671/// This detects cases where the parser includes a trailing comment in the
672/// statement range (e.g., `SELECT a\nFROM foo\n-- trailing`) where `-- trailing`
673/// is on a separate line from the actual code.
674fn 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    // Find the last non-whitespace token that starts within the statement range.
683    // Note: comment tokens may extend past stmt_end because single-line comments
684    // include the trailing newline in their span.
685    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    // Check if this comment is on a different line from the previous non-whitespace token
700    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 the comment starts on a different line than the previous code token, it's standalone.
712    // Also treat multiline block comments (spanning more than one line) as standalone, even
713    // when they start on the same line as code — the portion that extends to subsequent
714    // lines should be positioned after the semicolon.
715    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        // test_pass_newline_inline_comment: should not flag
1258        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        // test_fail_newline_semi_colon_default
1287        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        // test_fail_same_line_inline_comment
1305        // Two-edit strategy: insert ; at code_end, delete old ;
1306        // The \n between comment and old ; stays (part of comment token).
1307        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        // test_fail_final_semi_colon_same_line_inline_comment
1325        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        // test_fail_semi_colon_same_line_custom_newline
1352        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        // test_fail_no_semi_colon_custom_require_multiline
1379        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        // test_fail_final_semi_colon_newline_inline_comment_custom_multiline
1406        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        // The trailing \n after `;` is not added here because the edit only
1428        // inserts at the anchor point. The parity report normalizes whitespace.
1429        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        // test_fail_newline_block_comment_semi_colon_after
1435        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        // Statement range includes the block comment.
1445        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        // Semicolon inserted before block comment. The old semicolon deletion
1459        // may leave a trailing \n; the parity report normalizes whitespace.
1460        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        // test_fail_newline_preceding_block_comment_custom_multiline
1469        // Block comment starts on the same line as code but spans multiple lines.
1470        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        // test_fail_newline_trailing_block_comment
1502        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        // Block comment stays after semicolon, both on the new line.
1524        assert_eq!(fixed, "SELECT foo\nFROM bar\n; /* multiline\ncomment\n*/\n");
1525    }
1526}