Skip to main content

sqrust_rules/lint/
empty_in_list.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct EmptyInList;
4
5impl Rule for EmptyInList {
6    fn name(&self) -> &'static str {
7        "Lint/EmptyInList"
8    }
9
10    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11        let source = &ctx.source;
12        let bytes = source.as_bytes();
13        let len = bytes.len();
14        let skip = build_skip(bytes);
15
16        // Uppercase copy for case-insensitive scanning.
17        let upper: Vec<u8> = bytes.iter().map(|b| b.to_ascii_uppercase()).collect();
18
19        let mut diags = Vec::new();
20        let mut i = 0usize;
21
22        while i < len {
23            // Skip positions inside strings or comments.
24            if skip[i] {
25                i += 1;
26                continue;
27            }
28
29            // Try to match the keyword `IN` at position i.
30            // We need a word boundary before and after.
31            if let Some(after_in) = match_keyword_at(&upper, &skip, i, len, b"IN") {
32                // After `IN`, skip optional whitespace outside skip regions.
33                let mut j = after_in;
34                while j < len && bytes[j].is_ascii_whitespace() && !skip[j] {
35                    j += 1;
36                }
37
38                // Expect `(` outside a skip region.
39                if j < len && bytes[j] == b'(' && !skip[j] {
40                    let open_paren = j;
41                    j += 1;
42
43                    // Skip optional whitespace inside the parens (outside skip regions).
44                    while j < len && bytes[j].is_ascii_whitespace() && !skip[j] {
45                        j += 1;
46                    }
47
48                    // If the very next non-whitespace char is `)`, the list is empty.
49                    if j < len && bytes[j] == b')' && !skip[j] {
50                        // Report at the position of the `IN` keyword.
51                        let _ = open_paren; // suppress unused warning
52                        let (line, col) = offset_to_line_col(source, i);
53                        diags.push(Diagnostic {
54                            rule: self.name(),
55                            message: "Empty IN list always evaluates to FALSE".to_string(),
56                            line,
57                            col,
58                        });
59                        // Advance past `)` to avoid re-matching.
60                        i = j + 1;
61                        continue;
62                    }
63                }
64
65                // Not empty IN — advance past the keyword.
66                i = after_in;
67                continue;
68            }
69
70            i += 1;
71        }
72
73        diags
74    }
75}
76
77/// Returns `Some(pos_after_keyword)` if `kw` matches at `pos` in `upper`
78/// (case-insensitive via the pre-uppercased `upper` slice), with word
79/// boundaries on both sides and the position not inside a skip region.
80/// Returns `None` otherwise.
81fn match_keyword_at(
82    upper: &[u8],
83    skip: &[bool],
84    pos: usize,
85    len: usize,
86    kw: &[u8],
87) -> Option<usize> {
88    let kw_len = kw.len();
89    if pos + kw_len > len {
90        return None;
91    }
92    // Must be outside a skip region.
93    if skip[pos] {
94        return None;
95    }
96    // Bytes must match (upper already uppercased).
97    if &upper[pos..pos + kw_len] != kw {
98        return None;
99    }
100    // Word boundary before.
101    let before_ok = pos == 0 || {
102        let b = upper[pos - 1];
103        !b.is_ascii_alphanumeric() && b != b'_'
104    };
105    // Word boundary after.
106    let after_pos = pos + kw_len;
107    let after_ok = after_pos >= len || {
108        let b = upper[after_pos];
109        !b.is_ascii_alphanumeric() && b != b'_'
110    };
111    if before_ok && after_ok {
112        Some(after_pos)
113    } else {
114        None
115    }
116}
117
118/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
119fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
120    let before = &source[..offset];
121    let line = before.chars().filter(|&c| c == '\n').count() + 1;
122    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
123    (line, col)
124}
125
126/// Builds a skip table: `true` for every byte offset that is inside a
127/// string literal, line comment, block comment, or quoted identifier.
128fn build_skip(bytes: &[u8]) -> Vec<bool> {
129    let len = bytes.len();
130    let mut skip = vec![false; len];
131    let mut i = 0usize;
132
133    while i < len {
134        // Line comment: -- ... end-of-line
135        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
136            let start = i;
137            while i < len && bytes[i] != b'\n' {
138                i += 1;
139            }
140            for s in &mut skip[start..i] {
141                *s = true;
142            }
143            // Don't advance — newline itself is not skipped.
144            continue;
145        }
146
147        // Block comment: /* ... */
148        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
149            let start = i;
150            i += 2;
151            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
152                i += 1;
153            }
154            let end = if i + 1 < len { i + 2 } else { i + 1 };
155            for s in &mut skip[start..end.min(len)] {
156                *s = true;
157            }
158            i = end;
159            continue;
160        }
161
162        // Single-quoted string: '...' with '' as escaped quote
163        if bytes[i] == b'\'' {
164            let start = i;
165            i += 1;
166            while i < len {
167                if bytes[i] == b'\'' {
168                    if i + 1 < len && bytes[i + 1] == b'\'' {
169                        i += 2; // escaped quote
170                    } else {
171                        i += 1; // closing quote
172                        break;
173                    }
174                } else {
175                    i += 1;
176                }
177            }
178            for s in &mut skip[start..i.min(len)] {
179                *s = true;
180            }
181            continue;
182        }
183
184        // Double-quoted identifier: "..."
185        if bytes[i] == b'"' {
186            let start = i;
187            i += 1;
188            while i < len && bytes[i] != b'"' {
189                i += 1;
190            }
191            let end = if i < len { i + 1 } else { i };
192            for s in &mut skip[start..end.min(len)] {
193                *s = true;
194            }
195            i = end;
196            continue;
197        }
198
199        // Backtick identifier: `...`
200        if bytes[i] == b'`' {
201            let start = i;
202            i += 1;
203            while i < len && bytes[i] != b'`' {
204                i += 1;
205            }
206            let end = if i < len { i + 1 } else { i };
207            for s in &mut skip[start..end.min(len)] {
208                *s = true;
209            }
210            i = end;
211            continue;
212        }
213
214        i += 1;
215    }
216
217    skip
218}