sqrust_rules/ambiguous/
group_by_position.rs1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3use crate::capitalisation::SkipMap;
4
5pub struct GroupByPosition;
6
7impl Rule for GroupByPosition {
8 fn name(&self) -> &'static str {
9 "Ambiguous/GroupByPosition"
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 let mut i = 0;
20
21 while i < len {
22 if !skip_map.is_code(i) {
24 i += 1;
25 continue;
26 }
27
28 if let Some(after_group) = match_keyword(bytes, &skip_map, i, b"GROUP") {
30 let after_ws = skip_whitespace(bytes, after_group);
32
33 if let Some(after_by) = match_keyword(bytes, &skip_map, after_ws, b"BY") {
35 scan_positional_list(
37 bytes,
38 &skip_map,
39 source,
40 after_by,
41 self.name(),
42 "Avoid positional GROUP BY references; use column names",
43 GROUP_BY_STOP_KEYWORDS,
44 &mut diags,
45 );
46 i = after_by;
47 continue;
48 }
49 }
50
51 i += 1;
52 }
53
54 diags
55 }
56}
57
58const GROUP_BY_STOP_KEYWORDS: &[&[u8]] = &[
60 b"HAVING", b"ORDER", b"LIMIT", b"UNION", b"INTERSECT", b"EXCEPT",
61];
62
63pub(super) const ORDER_BY_STOP_KEYWORDS: &[&[u8]] = &[
65 b"LIMIT", b"UNION", b"INTERSECT", b"EXCEPT",
66];
67
68pub(super) fn match_keyword(
76 bytes: &[u8],
77 skip_map: &SkipMap,
78 i: usize,
79 keyword: &[u8],
80) -> Option<usize> {
81 let len = bytes.len();
82 let klen = keyword.len();
83
84 if i + klen > len {
85 return None;
86 }
87
88 if !skip_map.is_code(i) {
90 return None;
91 }
92
93 if i > 0 && is_word_char(bytes[i - 1]) {
95 return None;
96 }
97
98 for k in 0..klen {
100 if !bytes[i + k].eq_ignore_ascii_case(&keyword[k]) {
101 return None;
102 }
103 if !skip_map.is_code(i + k) {
104 return None;
105 }
106 }
107
108 let end = i + klen;
110 if end < len && is_word_char(bytes[end]) {
111 return None;
112 }
113
114 Some(end)
115}
116
117pub(super) fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
119 while i < bytes.len()
120 && (bytes[i] == b' '
121 || bytes[i] == b'\t'
122 || bytes[i] == b'\n'
123 || bytes[i] == b'\r')
124 {
125 i += 1;
126 }
127 i
128}
129
130#[inline]
132fn is_word_char(ch: u8) -> bool {
133 ch.is_ascii_alphanumeric() || ch == b'_'
134}
135
136fn line_col(source: &str, offset: usize) -> (usize, usize) {
138 let before = &source[..offset];
139 let line = before.chars().filter(|&c| c == '\n').count() + 1;
140 let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
141 (line, col)
142}
143
144fn is_bare_integer(slice: &[u8]) -> bool {
146 !slice.is_empty() && slice.iter().all(|b| b.is_ascii_digit())
147}
148
149fn trim_leading(bytes: &[u8]) -> &[u8] {
151 let start = bytes
152 .iter()
153 .take_while(|&&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r')
154 .count();
155 &bytes[start..]
156}
157
158fn trim_trailing(bytes: &[u8]) -> &[u8] {
160 let end = bytes
161 .iter()
162 .rposition(|&b| b != b' ' && b != b'\t' && b != b'\n' && b != b'\r')
163 .map(|p| p + 1)
164 .unwrap_or(0);
165 &bytes[..end]
166}
167
168fn strip_trailing_direction(bytes: &[u8]) -> &[u8] {
171 let trimmed = trim_trailing(bytes);
172 let word_end = trimmed.len();
173 if word_end == 0 {
174 return trimmed;
175 }
176 let mut word_start = word_end;
178 while word_start > 0 && is_word_char(trimmed[word_start - 1]) {
179 word_start -= 1;
180 }
181 let last_word = &trimmed[word_start..word_end];
182 if (last_word.eq_ignore_ascii_case(b"ASC") || last_word.eq_ignore_ascii_case(b"DESC"))
183 && word_start > 0
184 {
185 trim_trailing(&trimmed[..word_start])
186 } else {
187 trimmed
188 }
189}
190
191fn find_first_byte_offset(bytes: &[u8], search_start: usize, first_byte: u8) -> usize {
194 let mut i = search_start;
195 while i < bytes.len() {
196 if bytes[i] == first_byte {
197 return i;
198 }
199 i += 1;
200 }
201 search_start
202}
203
204pub(super) fn scan_positional_list(
214 bytes: &[u8],
215 skip_map: &SkipMap,
216 source: &str,
217 start: usize,
218 rule_name: &'static str,
219 message: &'static str,
220 stop_keywords: &[&[u8]],
221 diags: &mut Vec<Diagnostic>,
222) {
223 let len = bytes.len();
224 let mut i = start;
225
226 'outer: loop {
227 while i < len
229 && (bytes[i] == b' '
230 || bytes[i] == b'\t'
231 || bytes[i] == b'\n'
232 || bytes[i] == b'\r')
233 {
234 i += 1;
235 }
236
237 if i >= len {
238 break;
239 }
240
241 if skip_map.is_code(i) && bytes[i] == b';' {
243 break;
244 }
245
246 for &stop in stop_keywords {
248 if match_keyword(bytes, skip_map, i, stop).is_some() {
249 break 'outer;
250 }
251 }
252
253 let item_start_abs = i;
255
256 let mut item_end_abs = i;
258 while item_end_abs < len {
259 if !skip_map.is_code(item_end_abs) {
260 item_end_abs += 1;
261 continue;
262 }
263
264 if bytes[item_end_abs] == b',' || bytes[item_end_abs] == b';' {
265 break;
266 }
267
268 let mut stopped = false;
269 for &stop in stop_keywords {
270 if match_keyword(bytes, skip_map, item_end_abs, stop).is_some() {
271 stopped = true;
272 break;
273 }
274 }
275 if stopped {
276 break;
277 }
278
279 item_end_abs += 1;
280 }
281
282 let item_slice = &bytes[item_start_abs..item_end_abs];
284
285 let trimmed = trim_leading(item_slice);
287 let trimmed = strip_trailing_direction(trimmed);
288 let trimmed = trim_trailing(trimmed);
289
290 if !trimmed.is_empty() && is_bare_integer(trimmed) {
291 let first_digit = trimmed[0];
294 let int_abs = find_first_byte_offset(bytes, item_start_abs, first_digit);
295 let (line, col) = line_col(source, int_abs);
296 diags.push(Diagnostic {
297 rule: rule_name,
298 message: message.to_string(),
299 line,
300 col,
301 });
302 }
303
304 if item_end_abs < len && bytes[item_end_abs] == b',' {
306 i = item_end_abs + 1;
307 } else {
308 break;
309 }
310 }
311}