Skip to main content

sqrust_rules/layout/
comparison_operator_spacing.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct ComparisonOperatorSpacing;
4
5impl Rule for ComparisonOperatorSpacing {
6    fn name(&self) -> &'static str {
7        "Layout/ComparisonOperatorSpacing"
8    }
9
10    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11        find_violations(&ctx.source, self.name())
12    }
13}
14
15fn find_violations(source: &str, rule_name: &'static str) -> Vec<Diagnostic> {
16    let bytes = source.as_bytes();
17    let len = bytes.len();
18    let mut diags = Vec::new();
19
20    let mut i = 0;
21    let mut in_string = false;
22    let mut in_line_comment = false;
23    let mut block_depth: usize = 0;
24
25    while i < len {
26        // Reset line comment at newline
27        if bytes[i] == b'\n' {
28            in_line_comment = false;
29            i += 1;
30            continue;
31        }
32
33        // Skip line comment content
34        if in_line_comment {
35            i += 1;
36            continue;
37        }
38
39        // Single-quoted string handling (outside block comments)
40        if !in_string && block_depth == 0 && bytes[i] == b'\'' {
41            in_string = true;
42            i += 1;
43            continue;
44        }
45        if in_string {
46            if bytes[i] == b'\'' {
47                // SQL '' escape
48                if i + 1 < len && bytes[i + 1] == b'\'' {
49                    i += 2;
50                    continue;
51                }
52                in_string = false;
53            }
54            i += 1;
55            continue;
56        }
57
58        // Block comment open: /*
59        if block_depth == 0 && i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
60            block_depth += 1;
61            i += 2;
62            continue;
63        }
64        // Inside block comment
65        if block_depth > 0 {
66            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
67                block_depth -= 1;
68                i += 2;
69            } else {
70                i += 1;
71            }
72            continue;
73        }
74
75        // Line comment start: --
76        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
77            in_line_comment = true;
78            i += 2;
79            continue;
80        }
81
82        // ── Operator detection ────────────────────────────────────────────────
83
84        // Check for multi-character operators first to avoid double-flagging
85        // their component characters.
86
87        // !=  (two bytes: ! =)
88        if bytes[i] == b'!' && i + 1 < len && bytes[i + 1] == b'=' {
89            let op = "!=";
90            if !well_spaced(bytes, i, 2) {
91                let (line, col) = byte_offset_to_line_col(source, i);
92                diags.push(make_diag(rule_name, op, line, col));
93            }
94            i += 2;
95            continue;
96        }
97
98        // <>, <=, >=  — all start with < or >
99        if bytes[i] == b'<' {
100            // Skip <<  (bit-shift)
101            if i + 1 < len && bytes[i + 1] == b'<' {
102                i += 2;
103                continue;
104            }
105            // Skip ->  (not applicable to '<', but skip =>)
106            // Handle <>, <=
107            if i + 1 < len && (bytes[i + 1] == b'>' || bytes[i + 1] == b'=') {
108                let op = if bytes[i + 1] == b'>' { "<>" } else { "<=" };
109                if !well_spaced(bytes, i, 2) {
110                    let (line, col) = byte_offset_to_line_col(source, i);
111                    diags.push(make_diag(rule_name, op, line, col));
112                }
113                i += 2;
114                continue;
115            }
116            // Skip <!  (XML/HTML-like: <!)
117            if i + 1 < len && bytes[i + 1] == b'!' {
118                i += 1;
119                continue;
120            }
121            // Skip </  (XML/HTML-like: </)
122            if i + 1 < len && bytes[i + 1] == b'/' {
123                i += 1;
124                continue;
125            }
126            // Single <
127            if !well_spaced(bytes, i, 1) {
128                let (line, col) = byte_offset_to_line_col(source, i);
129                diags.push(make_diag(rule_name, "<", line, col));
130            }
131            i += 1;
132            continue;
133        }
134
135        if bytes[i] == b'>' {
136            // Skip >>  (bit-shift)
137            if i + 1 < len && bytes[i + 1] == b'>' {
138                i += 2;
139                continue;
140            }
141            // >=
142            if i + 1 < len && bytes[i + 1] == b'=' {
143                let op = ">=";
144                if !well_spaced(bytes, i, 2) {
145                    let (line, col) = byte_offset_to_line_col(source, i);
146                    diags.push(make_diag(rule_name, op, line, col));
147                }
148                i += 2;
149                continue;
150            }
151            // Single >  — skip => (fat arrow, handled by prior '=' check if any)
152            // The '>' here could be part of '->' or '=>'. Check the byte before.
153            // '->' : byte before '>' is '-'
154            // '=>' : byte before '>' is '='
155            let prev = if i > 0 { bytes[i - 1] } else { b' ' };
156            if prev == b'-' || prev == b'=' {
157                // part of -> or => — skip
158                i += 1;
159                continue;
160            }
161            if !well_spaced(bytes, i, 1) {
162                let (line, col) = byte_offset_to_line_col(source, i);
163                diags.push(make_diag(rule_name, ">", line, col));
164            }
165            i += 1;
166            continue;
167        }
168
169        i += 1;
170    }
171
172    diags
173}
174
175/// Returns true if the operator of `op_len` bytes starting at `pos` is
176/// surrounded by whitespace (or start/end of source) on both sides.
177fn well_spaced(bytes: &[u8], pos: usize, op_len: usize) -> bool {
178    let before_ok = if pos == 0 {
179        true
180    } else {
181        let b = bytes[pos - 1];
182        b == b' ' || b == b'\t'
183    };
184
185    let after_pos = pos + op_len;
186    let after_ok = if after_pos >= bytes.len() {
187        true
188    } else {
189        let b = bytes[after_pos];
190        b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
191    };
192
193    before_ok && after_ok
194}
195
196fn make_diag(rule_name: &'static str, op: &str, line: usize, col: usize) -> Diagnostic {
197    Diagnostic {
198        rule: rule_name,
199        message: format!("Missing spaces around comparison operator '{op}'"),
200        line,
201        col,
202    }
203}
204
205/// Converts a byte offset into a 1-indexed (line, col) pair.
206fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
207    let mut line = 1usize;
208    let mut line_start = 0usize;
209    for (i, ch) in source.char_indices() {
210        if i == offset {
211            break;
212        }
213        if ch == '\n' {
214            line += 1;
215            line_start = i + 1;
216        }
217    }
218    let col = offset - line_start + 1;
219    (line, col)
220}