Skip to main content

sqrust_rules/convention/
explicit_alias.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use crate::capitalisation::{is_word_char, SkipMap};
3
4pub struct ExplicitAlias;
5
6impl Rule for ExplicitAlias {
7    fn name(&self) -> &'static str {
8        "Convention/ExplicitAlias"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        let source = &ctx.source;
13        let bytes = source.as_bytes();
14        let len = bytes.len();
15        let skip = SkipMap::build(source);
16
17        let mut diags = Vec::new();
18
19        // SQL keywords that can follow a table reference (not an alias)
20        let non_alias_keywords: &[&[u8]] = &[
21            b"WHERE", b"ON", b"SET", b"GROUP", b"ORDER", b"HAVING", b"LIMIT",
22            b"UNION", b"INTERSECT", b"EXCEPT", b"JOIN", b"INNER", b"LEFT",
23            b"RIGHT", b"FULL", b"OUTER", b"CROSS", b"LATERAL", b"USING",
24            b"FETCH", b"OFFSET", b"FOR", b"INTO", b"VALUES", b"RETURNING",
25        ];
26
27        let mut i = 0;
28        while i < len {
29            if !skip.is_code(i) {
30                i += 1;
31                continue;
32            }
33
34            // Look for FROM or JOIN keyword
35            if !is_word_char(bytes[i]) || (i > 0 && is_word_char(bytes[i - 1])) {
36                i += 1;
37                continue;
38            }
39
40            // Read word
41            let ws = i;
42            let mut we = i;
43            while we < len && is_word_char(bytes[we]) {
44                we += 1;
45            }
46            let word = &bytes[ws..we];
47
48            let is_from = word.eq_ignore_ascii_case(b"FROM");
49            let is_join = word.len() >= 4 && {
50                let suffix = &word[word.len() - 4..];
51                suffix.eq_ignore_ascii_case(b"JOIN")
52            };
53
54            if is_from || is_join {
55                // Skip whitespace after FROM/JOIN
56                let mut j = we;
57                while j < len && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') {
58                    j += 1;
59                }
60                if j >= len || !skip.is_code(j) {
61                    i = we;
62                    continue;
63                }
64
65                // Read table reference: either a word (table name) or a '(' (subquery)
66                let table_end;
67                if bytes[j] == b'(' {
68                    // Skip over the parenthesized subquery
69                    let mut depth = 0usize;
70                    let mut k = j;
71                    while k < len {
72                        if skip.is_code(k) {
73                            if bytes[k] == b'(' { depth += 1; }
74                            else if bytes[k] == b')' {
75                                depth -= 1;
76                                if depth == 0 { k += 1; break; }
77                            }
78                        }
79                        k += 1;
80                    }
81                    table_end = k;
82                } else {
83                    // Read table name (possibly schema.name)
84                    let mut k = j;
85                    while k < len && (is_word_char(bytes[k]) || bytes[k] == b'.') {
86                        k += 1;
87                    }
88                    table_end = k;
89                }
90
91                if table_end == 0 || table_end >= len {
92                    i = we;
93                    continue;
94                }
95
96                // Skip whitespace after table/subquery
97                let mut k = table_end;
98                while k < len && (bytes[k] == b' ' || bytes[k] == b'\t' || bytes[k] == b'\n' || bytes[k] == b'\r') {
99                    k += 1;
100                }
101
102                // Check what's next
103                if k >= len || !skip.is_code(k) || bytes[k] == b'\n' || bytes[k] == b'\r' || bytes[k] == b',' || bytes[k] == b')' || bytes[k] == b';' {
104                    i = we;
105                    continue;
106                }
107
108                // Check for AS keyword
109                if is_word_char(bytes[k]) {
110                    let as_start = k;
111                    let mut ae = k;
112                    while ae < len && is_word_char(bytes[ae]) {
113                        ae += 1;
114                    }
115                    let next_word = &bytes[as_start..ae];
116
117                    if next_word.eq_ignore_ascii_case(b"AS") {
118                        // Good — explicit alias with AS
119                        i = ae;
120                        continue;
121                    }
122
123                    // Check if it's a non-alias keyword
124                    let is_non_alias = non_alias_keywords.iter().any(|kw| next_word.eq_ignore_ascii_case(kw));
125                    if is_non_alias {
126                        i = we;
127                        continue;
128                    }
129
130                    // It's an implicit alias — flag it
131                    let (line, col) = offset_to_line_col(source, as_start);
132                    diags.push(Diagnostic {
133                        rule: self.name(),
134                        message: format!(
135                            "Table alias '{}' should use the AS keyword",
136                            String::from_utf8_lossy(next_word)
137                        ),
138                        line,
139                        col,
140                    });
141                    i = ae;
142                    continue;
143                }
144            }
145
146            i = we;
147        }
148
149        diags
150    }
151}
152
153fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
154    let before = &source[..offset.min(source.len())];
155    let line = before.chars().filter(|&c| c == '\n').count() + 1;
156    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
157    (line, col)
158}