Skip to main content

sqrust_rules/lint/
alter_table_add_not_null_without_default.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct AlterTableAddNotNullWithoutDefault;
4
5impl Rule for AlterTableAddNotNullWithoutDefault {
6    fn name(&self) -> &'static str {
7        "Lint/AlterTableAddNotNullWithoutDefault"
8    }
9
10    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11        let source = &ctx.source;
12        let bytes = source.as_bytes();
13        let len = bytes.len();
14        let skip = build_skip(bytes);
15
16        let mut diags = Vec::new();
17        let upper = source.to_uppercase();
18        let upper_bytes = upper.as_bytes();
19
20        let mut i = 0;
21        while i < len {
22            // Skip positions inside strings or comments.
23            if skip[i] {
24                i += 1;
25                continue;
26            }
27
28            // Look for "ALTER" keyword (word-boundary, case-insensitive via upper).
29            if !upper[i..].starts_with("ALTER") {
30                i += 1;
31                continue;
32            }
33
34            let alter_start = i;
35            let alter_end = i + 5; // "ALTER".len()
36
37            // Word-boundary check before ALTER.
38            let before_ok = alter_start == 0 || {
39                let b = upper_bytes[alter_start - 1];
40                !b.is_ascii_alphanumeric() && b != b'_'
41            };
42
43            // Word-boundary check after ALTER.
44            let after_ok = alter_end >= len || {
45                let b = upper_bytes[alter_end];
46                !b.is_ascii_alphanumeric() && b != b'_'
47            };
48
49            if !before_ok || !after_ok {
50                i += 1;
51                continue;
52            }
53
54            // Skip whitespace and check for TABLE keyword.
55            let mut j = alter_end;
56            while j < len && bytes[j].is_ascii_whitespace() && !skip[j] {
57                j += 1;
58            }
59
60            if j >= len || skip[j] || !upper[j..].starts_with("TABLE") {
61                i += 1;
62                continue;
63            }
64
65            let table_end = j + 5; // "TABLE".len()
66            let table_after_ok = table_end >= len || {
67                let b = upper_bytes[table_end];
68                !b.is_ascii_alphanumeric() && b != b'_'
69            };
70
71            if !table_after_ok {
72                i += 1;
73                continue;
74            }
75
76            // We have ALTER TABLE. Now find the end of this statement (semicolon or end-of-input),
77            // staying outside skip regions for the semicolon check but extracting the full
78            // statement text for analysis.
79            let stmt_start = alter_start;
80            let mut stmt_end = len;
81            let mut k = table_end;
82            while k < len {
83                if !skip[k] && bytes[k] == b';' {
84                    stmt_end = k;
85                    break;
86                }
87                k += 1;
88            }
89
90            // Extract the statement slice (upper-cased for keyword search).
91            let stmt_upper = &upper[stmt_start..stmt_end];
92
93            // Check if NOT NULL appears in this statement (word-boundary).
94            let not_null_offset = find_word_boundary_keyword(stmt_upper, "NOT NULL");
95
96            if let Some(rel_offset) = not_null_offset {
97                // Check if DEFAULT also appears in this statement (word-boundary).
98                // If DEFAULT is present, do not flag — the column has a fallback value.
99                if !contains_word_boundary_keyword(stmt_upper, "DEFAULT") {
100                    // Report position of NOT NULL within the original source.
101                    let abs_offset = stmt_start + rel_offset;
102                    let (line, col) = offset_to_line_col(source, abs_offset);
103                    diags.push(Diagnostic {
104                        rule: self.name(),
105                        message:
106                            "Adding a NOT NULL column without DEFAULT will fail on non-empty tables"
107                                .to_string(),
108                        line,
109                        col,
110                    });
111                }
112            }
113
114            // Advance past this statement.
115            i = stmt_end + 1;
116        }
117
118        diags
119    }
120}
121
122/// Searches for `keyword` (already uppercased, may contain a space like "NOT NULL")
123/// with word-boundary checks on both ends. Returns the byte offset of the first match
124/// relative to `text`, or `None`.
125fn find_word_boundary_keyword(text: &str, keyword: &str) -> Option<usize> {
126    let kw_len = keyword.len();
127    let bytes = text.as_bytes();
128    let text_len = bytes.len();
129    let mut search_from = 0;
130
131    while search_from < text_len {
132        let Some(rel) = text[search_from..].find(keyword) else {
133            break;
134        };
135        let abs = search_from + rel;
136
137        let before_ok = abs == 0 || {
138            let b = bytes[abs - 1];
139            !b.is_ascii_alphanumeric() && b != b'_'
140        };
141        let after = abs + kw_len;
142        let after_ok = after >= text_len || {
143            let b = bytes[after];
144            !b.is_ascii_alphanumeric() && b != b'_'
145        };
146
147        if before_ok && after_ok {
148            return Some(abs);
149        }
150        search_from = abs + 1;
151    }
152
153    None
154}
155
156/// Returns `true` if `keyword` (already uppercased) appears with word boundaries in `text`.
157fn contains_word_boundary_keyword(text: &str, keyword: &str) -> bool {
158    find_word_boundary_keyword(text, keyword).is_some()
159}
160
161/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
162fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
163    let before = &source[..offset];
164    let line = before.chars().filter(|&c| c == '\n').count() + 1;
165    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
166    (line, col)
167}
168
169/// Builds a skip table: `true` for every byte offset inside a string literal,
170/// line comment, or block comment.
171fn build_skip(bytes: &[u8]) -> Vec<bool> {
172    let len = bytes.len();
173    let mut skip = vec![false; len];
174    let mut i = 0;
175
176    while i < len {
177        // Line comment: -- ... end-of-line
178        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
179            skip[i] = true;
180            skip[i + 1] = true;
181            i += 2;
182            while i < len && bytes[i] != b'\n' {
183                skip[i] = true;
184                i += 1;
185            }
186            continue;
187        }
188
189        // Block comment: /* ... */
190        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
191            skip[i] = true;
192            skip[i + 1] = true;
193            i += 2;
194            while i < len {
195                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
196                    skip[i] = true;
197                    skip[i + 1] = true;
198                    i += 2;
199                    break;
200                }
201                skip[i] = true;
202                i += 1;
203            }
204            continue;
205        }
206
207        // Single-quoted string: '...' with '' as escaped quote
208        if bytes[i] == b'\'' {
209            skip[i] = true;
210            i += 1;
211            while i < len {
212                if bytes[i] == b'\'' {
213                    skip[i] = true;
214                    i += 1;
215                    // '' inside a string is an escaped quote — continue in string
216                    if i < len && bytes[i] == b'\'' {
217                        skip[i] = true;
218                        i += 1;
219                        continue;
220                    }
221                    break; // end of string
222                }
223                skip[i] = true;
224                i += 1;
225            }
226            continue;
227        }
228
229        // Double-quoted identifier: "..."
230        if bytes[i] == b'"' {
231            skip[i] = true;
232            i += 1;
233            while i < len && bytes[i] != b'"' {
234                skip[i] = true;
235                i += 1;
236            }
237            if i < len {
238                skip[i] = true;
239                i += 1;
240            }
241            continue;
242        }
243
244        // Backtick identifier: `...`
245        if bytes[i] == b'`' {
246            skip[i] = true;
247            i += 1;
248            while i < len && bytes[i] != b'`' {
249                skip[i] = true;
250                i += 1;
251            }
252            if i < len {
253                skip[i] = true;
254                i += 1;
255            }
256            continue;
257        }
258
259        i += 1;
260    }
261
262    skip
263}