sqrust_rules/convention/
explicit_alias.rs1use 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 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 if !is_word_char(bytes[i]) || (i > 0 && is_word_char(bytes[i - 1])) {
36 i += 1;
37 continue;
38 }
39
40 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 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 let table_end;
67 if bytes[j] == b'(' {
68 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 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 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 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 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 i = ae;
120 continue;
121 }
122
123 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 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}