sqrust_rules/layout/arithmetic_operator_at_line_end.rs
1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct ArithmeticOperatorAtLineEnd;
4
5impl Rule for ArithmeticOperatorAtLineEnd {
6 fn name(&self) -> &'static str {
7 "Layout/ArithmeticOperatorAtLineEnd"
8 }
9
10 fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11 let mut diags = Vec::new();
12
13 for (line_num, line) in ctx.lines() {
14 let trimmed = line.trim_end();
15 if trimmed.is_empty() {
16 continue;
17 }
18
19 // Skip whole-line comments (-- ...)
20 let stripped = trimmed.trim_start();
21 if stripped.starts_with("--") {
22 continue;
23 }
24
25 // Get the last character of the trimmed line
26 let last_char = match trimmed.chars().last() {
27 Some(c) => c,
28 None => continue,
29 };
30
31 // Determine whether the trailing character is a flaggable operator.
32 // Rules:
33 // '+' => flag
34 // '/' => flag
35 // '-' => flag only if preceded by a non-'-' character (guards against trailing --)
36 // '*' => exempt (SELECT *, COUNT(*))
37 let op_char: Option<char> = match last_char {
38 '+' | '/' => Some(last_char),
39 '-' => {
40 // Look at the second-to-last char to rule out "--"
41 let second_last = trimmed.chars().rev().nth(1);
42 if second_last == Some('-') {
43 None
44 } else {
45 Some('-')
46 }
47 }
48 _ => None,
49 };
50
51 let op_char = match op_char {
52 Some(c) => c,
53 None => continue,
54 };
55
56 // Check whether the operator character is inside a single-quoted string.
57 // We do a simple single-pass scan of the trimmed line tracking string state.
58 // This handles single-line strings correctly. Cross-line strings are uncommon
59 // in SQL and are treated as outside-string for the purpose of this check.
60 if operator_is_in_string(trimmed) {
61 continue;
62 }
63
64 // col is 1-indexed position of the trailing operator character.
65 // trimmed has the same leading content as line, just trailing whitespace stripped,
66 // so trimmed.len() characters are already the exact byte-length up to (and
67 // including) the operator.
68 let col = trimmed.len();
69
70 diags.push(Diagnostic {
71 rule: self.name(),
72 message: format!(
73 "Arithmetic operator '{}' at line end; move to start of next line for clarity",
74 op_char
75 ),
76 line: line_num,
77 col,
78 });
79 }
80
81 diags
82 }
83}
84
85/// Returns true when the last character of `line` is inside a single-quoted
86/// string literal. Handles escaped-quote via doubled-quote (`''`) convention.
87fn operator_is_in_string(line: &str) -> bool {
88 let mut in_string = false;
89 let chars: Vec<char> = line.chars().collect();
90 let len = chars.len();
91 let mut i = 0;
92
93 while i < len {
94 let c = chars[i];
95 if in_string {
96 if c == '\'' {
97 // Peek ahead: '' is an escaped quote, stay in string
98 if i + 1 < len && chars[i + 1] == '\'' {
99 i += 2;
100 continue;
101 }
102 in_string = false;
103 }
104 } else if c == '\'' {
105 in_string = true;
106 }
107 i += 1;
108 }
109
110 // If we finished the scan still inside a string, the last character is in
111 // a string (the string didn't close on this line).
112 in_string
113}