Skip to main content

sqrust_rules/convention/
count_star.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3use crate::capitalisation::{is_word_char, SkipMap};
4
5pub struct CountStar;
6
7impl Rule for CountStar {
8    fn name(&self) -> &'static str {
9        "Convention/CountStar"
10    }
11
12    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
13        let source = &ctx.source;
14        let bytes = source.as_bytes();
15        let len = bytes.len();
16        let skip_map = SkipMap::build(source);
17
18        let mut diags = Vec::new();
19
20        // Pattern: <non-word-char> COUNT ( 1 )
21        // We scan for 'C'/'c' and attempt to match COUNT(1) at that position.
22        let mut i = 0;
23        while i < len {
24            // Quick pre-check: byte must be 'C' or 'c'
25            if bytes[i] != b'C' && bytes[i] != b'c' {
26                i += 1;
27                continue;
28            }
29
30            // Word boundary before: preceding byte must not be a word char
31            if i > 0 && is_word_char(bytes[i - 1]) {
32                i += 1;
33                continue;
34            }
35
36            // Must have at least 8 bytes for "COUNT(1)" (5 + 1 + 1 + 1)
37            if i + 7 >= len {
38                i += 1;
39                continue;
40            }
41
42            // Match C O U N T (case-insensitive)
43            let is_count = bytes[i].eq_ignore_ascii_case(&b'C')
44                && bytes[i + 1].eq_ignore_ascii_case(&b'O')
45                && bytes[i + 2].eq_ignore_ascii_case(&b'U')
46                && bytes[i + 3].eq_ignore_ascii_case(&b'N')
47                && bytes[i + 4].eq_ignore_ascii_case(&b'T');
48            if !is_count {
49                i += 1;
50                continue;
51            }
52
53            // Next char after COUNT must be '(' with no word char after COUNT
54            // (i.e. COUNT must end the word — not COUNTx)
55            if is_word_char(bytes[i + 5]) {
56                // e.g. COUNTER — skip
57                i += 1;
58                continue;
59            }
60
61            // Must be followed immediately by '('
62            if bytes[i + 5] != b'(' {
63                i += 1;
64                continue;
65            }
66
67            // Then exactly '1'
68            if bytes[i + 6] != b'1' {
69                i += 1;
70                continue;
71            }
72
73            // Then ')'
74            if bytes[i + 7] != b')' {
75                i += 1;
76                continue;
77            }
78
79            // The '1' at i+6 must be at a code position (not in string/comment)
80            if !skip_map.is_code(i + 6) {
81                i += 1;
82                continue;
83            }
84
85            // The 'C' at i must also be at a code position
86            if !skip_map.is_code(i) {
87                i += 1;
88                continue;
89            }
90
91            let (line, col) = line_col(source, i);
92            diags.push(Diagnostic {
93                rule: self.name(),
94                message: "Use COUNT(*) instead of COUNT(1)".to_string(),
95                line,
96                col,
97            });
98
99            // Advance past COUNT(1)
100            i += 8;
101        }
102
103        diags
104    }
105}
106
107/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
108fn line_col(source: &str, offset: usize) -> (usize, usize) {
109    let before = &source[..offset];
110    let line = before.chars().filter(|&c| c == '\n').count() + 1;
111    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
112    (line, col)
113}