Skip to main content

sqrust_rules/convention/
concat_operator.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct ConcatOperator;
4
5/// Converts a byte offset to a 1-indexed (line, col) pair.
6fn line_col(source: &str, offset: usize) -> (usize, usize) {
7    let before = &source[..offset];
8    let line = before.chars().filter(|&c| c == '\n').count() + 1;
9    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
10    (line, col)
11}
12
13/// Builds a skip table: `true` at every byte inside strings, comments, or
14/// quoted identifiers.
15fn build_skip(bytes: &[u8]) -> Vec<bool> {
16    let len = bytes.len();
17    let mut skip = vec![false; len];
18    let mut i = 0;
19
20    while i < len {
21        // Line comment: -- ... newline
22        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
23            skip[i] = true;
24            skip[i + 1] = true;
25            i += 2;
26            while i < len && bytes[i] != b'\n' {
27                skip[i] = true;
28                i += 1;
29            }
30            continue;
31        }
32
33        // Block comment: /* ... */
34        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
35            skip[i] = true;
36            skip[i + 1] = true;
37            i += 2;
38            while i < len {
39                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
40                    skip[i] = true;
41                    skip[i + 1] = true;
42                    i += 2;
43                    break;
44                }
45                skip[i] = true;
46                i += 1;
47            }
48            continue;
49        }
50
51        // Single-quoted string: '...' with '' escape
52        if bytes[i] == b'\'' {
53            skip[i] = true;
54            i += 1;
55            while i < len {
56                if bytes[i] == b'\'' {
57                    skip[i] = true;
58                    i += 1;
59                    if i < len && bytes[i] == b'\'' {
60                        skip[i] = true;
61                        i += 1;
62                        continue;
63                    }
64                    break;
65                }
66                skip[i] = true;
67                i += 1;
68            }
69            continue;
70        }
71
72        // Double-quoted identifier: "..."
73        if bytes[i] == b'"' {
74            skip[i] = true;
75            i += 1;
76            while i < len && bytes[i] != b'"' {
77                skip[i] = true;
78                i += 1;
79            }
80            if i < len {
81                skip[i] = true;
82                i += 1;
83            }
84            continue;
85        }
86
87        // Backtick identifier: `...`
88        if bytes[i] == b'`' {
89            skip[i] = true;
90            i += 1;
91            while i < len && bytes[i] != b'`' {
92                skip[i] = true;
93                i += 1;
94            }
95            if i < len {
96                skip[i] = true;
97                i += 1;
98            }
99            continue;
100        }
101
102        i += 1;
103    }
104
105    skip
106}
107
108/// Scans `source` for `||` operators outside strings/comments.
109/// Returns the byte offset of the first `|` for each occurrence.
110fn find_concat_offsets(source: &str, skip: &[bool]) -> Vec<usize> {
111    let bytes = source.as_bytes();
112    let len = bytes.len();
113    let mut results = Vec::new();
114    let mut i = 0;
115
116    while i + 1 < len {
117        if skip[i] {
118            i += 1;
119            continue;
120        }
121
122        if bytes[i] == b'|' && bytes[i + 1] == b'|' && !skip[i + 1] {
123            results.push(i);
124            // Skip both bytes to avoid re-matching the second `|`
125            i += 2;
126            continue;
127        }
128
129        i += 1;
130    }
131
132    results
133}
134
135impl Rule for ConcatOperator {
136    fn name(&self) -> &'static str {
137        "Convention/ConcatOperator"
138    }
139
140    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
141        if !ctx.parse_errors.is_empty() {
142            return Vec::new();
143        }
144
145        let source = &ctx.source;
146        let bytes = source.as_bytes();
147        let skip = build_skip(bytes);
148        let offsets = find_concat_offsets(source, &skip);
149
150        offsets
151            .into_iter()
152            .map(|offset| {
153                let (line, col) = line_col(source, offset);
154                Diagnostic {
155                    rule: self.name(),
156                    message: "Use CONCAT() instead of || for cross-database portability"
157                        .to_string(),
158                    line,
159                    col,
160                }
161            })
162            .collect()
163    }
164}