Skip to main content

sqrust_rules/lint/
insert_or_replace.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{SqliteOnConflict, Statement};
3
4pub struct InsertOrReplace;
5
6impl Rule for InsertOrReplace {
7    fn name(&self) -> &'static str {
8        "Lint/InsertOrReplace"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        // Skip files that failed to parse — AST is incomplete.
13        if !ctx.parse_errors.is_empty() {
14            return Vec::new();
15        }
16
17        let mut diags = Vec::new();
18        let source = &ctx.source;
19        let source_upper = source.to_uppercase();
20
21        // Advance search_from past each REPLACE keyword we flag or skip.
22        let mut search_from = 0usize;
23
24        for stmt in &ctx.statements {
25            if let Statement::Insert(insert) = stmt {
26                // Detect REPLACE INTO (MySQL): replace_into = true
27                let is_replace_into = insert.replace_into;
28
29                // Detect INSERT OR REPLACE INTO (SQLite):
30                // or = Some(SqliteOnConflict::Replace)
31                let is_insert_or_replace = matches!(insert.or, Some(SqliteOnConflict::Replace));
32
33                if is_replace_into || is_insert_or_replace {
34                    let (line, col) =
35                        find_keyword_position(source, &source_upper, "REPLACE", &mut search_from);
36                    diags.push(Diagnostic {
37                        rule: self.name(),
38                        message:
39                            "INSERT OR REPLACE/REPLACE INTO silently deletes and re-inserts rows; prefer INSERT ... ON CONFLICT"
40                                .to_string(),
41                        line,
42                        col,
43                    });
44                } else {
45                    // A regular INSERT — advance past "INSERT" so the next
46                    // search for REPLACE does not get confused.
47                    advance_past_keyword(source, &source_upper, "INSERT", &mut search_from);
48                }
49            }
50        }
51
52        diags
53    }
54}
55
56/// Finds the 1-indexed (line, col) of the next word-boundary occurrence of
57/// `keyword` (already uppercased) in `source_upper` starting from `search_from`.
58/// Updates `search_from` to just past the match. Falls back to (1, 1).
59fn find_keyword_position(
60    source: &str,
61    source_upper: &str,
62    keyword: &str,
63    search_from: &mut usize,
64) -> (usize, usize) {
65    let (line, col, new_from) = find_keyword_inner(source, source_upper, keyword, *search_from);
66    *search_from = new_from;
67    (line, col)
68}
69
70/// Advance `search_from` past the next word-boundary occurrence of `keyword`
71/// without emitting a diagnostic.
72fn advance_past_keyword(
73    source: &str,
74    source_upper: &str,
75    keyword: &str,
76    search_from: &mut usize,
77) {
78    let (_, _, new_from) = find_keyword_inner(source, source_upper, keyword, *search_from);
79    *search_from = new_from;
80}
81
82/// Core search: returns (line, col, next_search_from).
83/// Requires `keyword` to be already uppercased.
84fn find_keyword_inner(
85    source: &str,
86    source_upper: &str,
87    keyword: &str,
88    start: usize,
89) -> (usize, usize, usize) {
90    let kw_len = keyword.len();
91    let bytes = source_upper.as_bytes();
92    let text_len = bytes.len();
93
94    let mut pos = start;
95    while pos < text_len {
96        let Some(rel) = source_upper[pos..].find(keyword) else {
97            break;
98        };
99        let abs = pos + rel;
100
101        // Word-boundary check: character before keyword.
102        let before_ok = abs == 0
103            || {
104                let b = bytes[abs - 1];
105                !b.is_ascii_alphanumeric() && b != b'_'
106            };
107        // Word-boundary check: character after keyword.
108        let after = abs + kw_len;
109        let after_ok = after >= text_len
110            || {
111                let b = bytes[after];
112                !b.is_ascii_alphanumeric() && b != b'_'
113            };
114
115        if before_ok && after_ok {
116            let (line, col) = offset_to_line_col(source, abs);
117            return (line, col, after);
118        }
119        pos = abs + 1;
120    }
121
122    (1, 1, start)
123}
124
125/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
126fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
127    let before = &source[..offset];
128    let line = before.chars().filter(|&c| c == '\n').count() + 1;
129    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
130    (line, col)
131}