Skip to main content

sqrust_rules/convention/
no_null_default.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct NoNullDefault;
4
5/// Converts a byte offset in `source` 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/// Returns `true` if `ch` is a SQL word character (`[a-zA-Z0-9_]`).
14#[inline]
15fn is_word_char(ch: u8) -> bool {
16    ch.is_ascii_alphanumeric() || ch == b'_'
17}
18
19#[inline]
20fn is_whitespace(ch: u8) -> bool {
21    ch == b' ' || ch == b'\t' || ch == b'\n' || ch == b'\r'
22}
23
24/// Builds a skip table: `true` at every byte inside strings or comments.
25fn build_skip(bytes: &[u8]) -> Vec<bool> {
26    let len = bytes.len();
27    let mut skip = vec![false; len];
28    let mut i = 0;
29
30    while i < len {
31        // Line comment: -- ... end-of-line
32        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
33            skip[i] = true;
34            skip[i + 1] = true;
35            i += 2;
36            while i < len && bytes[i] != b'\n' {
37                skip[i] = true;
38                i += 1;
39            }
40            continue;
41        }
42
43        // Block comment: /* ... */
44        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
45            skip[i] = true;
46            skip[i + 1] = true;
47            i += 2;
48            while i < len {
49                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
50                    skip[i] = true;
51                    skip[i + 1] = true;
52                    i += 2;
53                    break;
54                }
55                skip[i] = true;
56                i += 1;
57            }
58            continue;
59        }
60
61        // Single-quoted string: '...' with '' escape (SQL standard)
62        if bytes[i] == b'\'' {
63            skip[i] = true;
64            i += 1;
65            while i < len {
66                if bytes[i] == b'\'' {
67                    skip[i] = true;
68                    i += 1;
69                    if i < len && bytes[i] == b'\'' {
70                        skip[i] = true;
71                        i += 1;
72                        continue;
73                    }
74                    break;
75                }
76                skip[i] = true;
77                i += 1;
78            }
79            continue;
80        }
81
82        // Double-quoted identifier: "..."
83        if bytes[i] == b'"' {
84            skip[i] = true;
85            i += 1;
86            while i < len && bytes[i] != b'"' {
87                skip[i] = true;
88                i += 1;
89            }
90            if i < len {
91                skip[i] = true;
92                i += 1;
93            }
94            continue;
95        }
96
97        // Backtick identifier: `...`
98        if bytes[i] == b'`' {
99            skip[i] = true;
100            i += 1;
101            while i < len && bytes[i] != b'`' {
102                skip[i] = true;
103                i += 1;
104            }
105            if i < len {
106                skip[i] = true;
107                i += 1;
108            }
109            continue;
110        }
111
112        i += 1;
113    }
114
115    skip
116}
117
118/// Returns `true` if `bytes[pos..]` starts with `keyword` (case-insensitive)
119/// and all matched bytes are code (not inside string/comment).
120fn matches_keyword_at(bytes: &[u8], len: usize, skip: &[bool], pos: usize, keyword: &[u8]) -> bool {
121    let kw_len = keyword.len();
122    if pos + kw_len > len {
123        return false;
124    }
125    (0..kw_len).all(|k| !skip[pos + k] && bytes[pos + k].eq_ignore_ascii_case(&keyword[k]))
126}
127
128/// Scans `source` for `DEFAULT NULL` patterns outside strings/comments.
129/// Returns byte offsets of each `DEFAULT` keyword that is followed by `NULL`.
130fn find_default_null_offsets(source: &str, skip: &[bool]) -> Vec<usize> {
131    let bytes = source.as_bytes();
132    let len = bytes.len();
133    let mut results = Vec::new();
134    let mut i = 0;
135
136    while i < len {
137        if skip[i] {
138            i += 1;
139            continue;
140        }
141
142        // Try to match DEFAULT at position i.
143        if !matches_keyword_at(bytes, len, skip, i, b"DEFAULT") {
144            i += 1;
145            continue;
146        }
147
148        // Word boundary before DEFAULT.
149        if i > 0 && is_word_char(bytes[i - 1]) {
150            i += 1;
151            continue;
152        }
153
154        let default_start = i;
155        let default_end = i + 7; // len("DEFAULT") == 7
156
157        // Word boundary after DEFAULT.
158        if default_end < len && is_word_char(bytes[default_end]) {
159            i += 1;
160            continue;
161        }
162
163        // Skip whitespace between DEFAULT and what follows.
164        let mut j = default_end;
165        while j < len && !skip[j] && is_whitespace(bytes[j]) {
166            j += 1;
167        }
168
169        // There must be at least one whitespace between DEFAULT and NULL.
170        if j == default_end {
171            i += 1;
172            continue;
173        }
174
175        // Try to match NULL at position j.
176        if !matches_keyword_at(bytes, len, skip, j, b"NULL") {
177            i += 1;
178            continue;
179        }
180
181        // Word boundary before NULL.
182        if j > 0 && is_word_char(bytes[j - 1]) {
183            i += 1;
184            continue;
185        }
186
187        let null_end = j + 4; // len("NULL") == 4
188
189        // Word boundary after NULL.
190        if null_end < len && is_word_char(bytes[null_end]) {
191            i += 1;
192            continue;
193        }
194
195        results.push(default_start);
196        i = null_end;
197    }
198
199    results
200}
201
202impl Rule for NoNullDefault {
203    fn name(&self) -> &'static str {
204        "Convention/NoNullDefault"
205    }
206
207    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
208        let source = &ctx.source;
209        let bytes = source.as_bytes();
210        let skip = build_skip(bytes);
211        let offsets = find_default_null_offsets(source, &skip);
212
213        offsets
214            .into_iter()
215            .map(|offset| {
216                let (line, col) = line_col(source, offset);
217                Diagnostic {
218                    rule: self.name(),
219                    message: "DEFAULT NULL is redundant; omit it to use the implicit default"
220                        .to_string(),
221                    line,
222                    col,
223                }
224            })
225            .collect()
226    }
227}