Skip to main content

rsqlite_parser/
parse.rs

1use sqlparser::dialect::SQLiteDialect;
2use sqlparser::parser::Parser;
3
4use crate::error::ParseError;
5
6pub fn parse_sql(sql: &str) -> Result<Vec<sqlparser::ast::Statement>, ParseError> {
7    let dialect = SQLiteDialect {};
8    // Number anonymous `?` placeholders by their position in the SQL *text*
9    // FIRST, before any other rewrite can move them around. Downstream the
10    // planner numbers anonymous `?` in the order it *plans* clauses (WHERE
11    // before the SELECT list, etc.), which scrambles bindings when `?` spans
12    // projection + WHERE + LIMIT. Rewriting `?` → `?N` here pins each to its
13    // textual position, matching SQLite's positional-parameter semantics.
14    let numbered = number_placeholders(sql);
15    let preprocessed =
16        preprocess_update_limit(&preprocess_bitwise_shifts(&preprocess_bitwise_not(
17            &preprocess_is_truth_family(&preprocess_fts5_match(&preprocess_pragma(&numbered))),
18        )));
19    if is_vacuum(&preprocessed) {
20        return Ok(vec![make_pragma_statement("__vacuum", None)]);
21    }
22    if let Some(arg) = strip_keyword(&preprocessed, "REINDEX") {
23        return Ok(vec![make_pragma_statement("__reindex", Some(&arg))]);
24    }
25    if let Some(arg) = strip_keyword(&preprocessed, "ANALYZE") {
26        return Ok(vec![make_pragma_statement("__analyze", Some(&arg))]);
27    }
28    if let Some(stmt) = parse_trigger_statement(&preprocessed) {
29        return Ok(vec![stmt]);
30    }
31    if let Some(stmt) = parse_detach_statement(&preprocessed) {
32        return Ok(vec![stmt]);
33    }
34    if let Some(stmt) = parse_create_virtual_table(&preprocessed) {
35        return Ok(vec![stmt]);
36    }
37    let statements = Parser::parse_sql(&dialect, &preprocessed)?;
38    Ok(statements)
39}
40
41/// Rewrite each anonymous `?` placeholder to an explicit `?N` numbered by its
42/// left-to-right position in the SQL text (1-based, matching SQLite). `?` inside
43/// string literals, quoted identifiers (`"..."`, `` `...` ``, `[...]`), and
44/// comments is left untouched, and already-numbered `?N` is copied verbatim.
45fn number_placeholders(sql: &str) -> String {
46    let chars: Vec<char> = sql.chars().collect();
47    let mut out = String::with_capacity(sql.len() + 8);
48    let mut i = 0;
49    let mut n: u32 = 0;
50    while i < chars.len() {
51        let c = chars[i];
52        match c {
53            // string literal / quoted identifier — copy verbatim, honoring
54            // doubled-quote escapes for ' and "
55            '\'' | '"' | '`' => {
56                let quote = c;
57                out.push(c);
58                i += 1;
59                while i < chars.len() {
60                    let d = chars[i];
61                    out.push(d);
62                    i += 1;
63                    if d == quote {
64                        if (quote == '\'' || quote == '"') && i < chars.len() && chars[i] == quote {
65                            out.push(quote);
66                            i += 1;
67                        } else {
68                            break;
69                        }
70                    }
71                }
72            }
73            '[' => {
74                out.push(c);
75                i += 1;
76                while i < chars.len() {
77                    let d = chars[i];
78                    out.push(d);
79                    i += 1;
80                    if d == ']' {
81                        break;
82                    }
83                }
84            }
85            '-' if i + 1 < chars.len() && chars[i + 1] == '-' => {
86                while i < chars.len() && chars[i] != '\n' {
87                    out.push(chars[i]);
88                    i += 1;
89                }
90            }
91            '/' if i + 1 < chars.len() && chars[i + 1] == '*' => {
92                out.push('/');
93                out.push('*');
94                i += 2;
95                while i < chars.len() {
96                    if chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] == '/' {
97                        out.push('*');
98                        out.push('/');
99                        i += 2;
100                        break;
101                    }
102                    out.push(chars[i]);
103                    i += 1;
104                }
105            }
106            '?' => {
107                // already-numbered `?N` (or `?` followed by a digit): copy as-is
108                if i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
109                    out.push('?');
110                    i += 1;
111                } else {
112                    n += 1;
113                    out.push('?');
114                    out.push_str(&n.to_string());
115                    i += 1;
116                }
117            }
118            _ => {
119                out.push(c);
120                i += 1;
121            }
122        }
123    }
124    out
125}
126
127/// Detect `CREATE VIRTUAL TABLE [IF NOT EXISTS] <name> USING <module>(args…)`
128/// and emit a `PRAGMA __create_virtual_table('<name>|<module>|<args>')` so
129/// the planner can route it to the vtab module registry. sqlparser's
130/// SQLiteDialect doesn't accept the syntax natively, and emulating it via a
131/// custom dialect would ripple through too much; the pragma channel matches
132/// what we already do for triggers.
133fn parse_create_virtual_table(sql: &str) -> Option<sqlparser::ast::Statement> {
134    let trimmed = sql.trim().trim_end_matches(';').trim();
135    let upper = trimmed.to_uppercase();
136    let prefix = "CREATE VIRTUAL TABLE";
137    if !upper.starts_with(prefix) {
138        return None;
139    }
140    let rest = trimmed[prefix.len()..].trim_start();
141    let upper_rest = rest.to_uppercase();
142    let (rest, if_not_exists) = if upper_rest.starts_with("IF NOT EXISTS") {
143        (rest["IF NOT EXISTS".len()..].trim_start(), true)
144    } else {
145        (rest, false)
146    };
147
148    // Parse the table name (until whitespace).
149    let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
150    let name =
151        rest[..name_end].trim_matches(|c: char| c == '"' || c == '`' || c == '[' || c == ']');
152    if name.is_empty() {
153        return None;
154    }
155    let after_name = rest[name_end..].trim_start();
156
157    // Expect `USING <module>(args)`.
158    let upper_after = after_name.to_uppercase();
159    if !upper_after.starts_with("USING") {
160        return None;
161    }
162    let after_using = after_name["USING".len()..]
163        .trim_start()
164        .trim_end_matches(';')
165        .trim();
166    // Module-arg parens are optional — modules with no args (e.g.
167    // `kvstore`) write `USING kvstore` and we treat that as args="".
168    let (module, raw_args) = match after_using.find('(') {
169        Some(paren) => {
170            let close = after_using.rfind(')')?;
171            if close <= paren {
172                return None;
173            }
174            (after_using[..paren].trim(), &after_using[paren + 1..close])
175        }
176        None => (after_using, ""),
177    };
178    if module.is_empty() {
179        return None;
180    }
181
182    // Encode as `<if_not_exists>|<name>|<module>|<args>` so the planner
183    // can split on '|'.
184    let encoded = format!(
185        "{}|{}|{}|{}",
186        if if_not_exists { "1" } else { "0" },
187        name,
188        module,
189        raw_args
190    );
191    Some(make_pragma_statement(
192        "__create_virtual_table",
193        Some(&encoded),
194    ))
195}
196
197fn parse_trigger_statement(sql: &str) -> Option<sqlparser::ast::Statement> {
198    let upper = sql.trim().to_uppercase();
199    if upper.starts_with("CREATE TRIGGER") || upper.starts_with("CREATE TRIGGER") {
200        return parse_create_trigger(sql.trim());
201    }
202    if upper.starts_with("DROP TRIGGER") {
203        return parse_drop_trigger(sql.trim());
204    }
205    None
206}
207
208fn parse_create_trigger(sql: &str) -> Option<sqlparser::ast::Statement> {
209    let original_sql = sql.trim().trim_end_matches(';').trim();
210    let upper = original_sql.to_uppercase();
211    let tokens: Vec<&str> = upper.split_whitespace().collect();
212
213    let mut pos = 2; // skip "CREATE TRIGGER"
214    let mut if_not_exists = false;
215    if tokens.get(pos) == Some(&"IF")
216        && tokens.get(pos + 1) == Some(&"NOT")
217        && tokens.get(pos + 2) == Some(&"EXISTS")
218    {
219        if_not_exists = true;
220        pos += 3;
221    }
222
223    let name = tokens.get(pos)?.to_string();
224    pos += 1;
225
226    let timing = match tokens.get(pos).copied()? {
227        "BEFORE" => {
228            pos += 1;
229            "BEFORE"
230        }
231        "AFTER" => {
232            pos += 1;
233            "AFTER"
234        }
235        "INSTEAD" => {
236            if tokens.get(pos + 1) == Some(&"OF") {
237                pos += 2;
238                "INSTEAD OF"
239            } else {
240                return None;
241            }
242        }
243        _ => return None,
244    };
245
246    let event = match tokens.get(pos).copied()? {
247        "INSERT" => {
248            pos += 1;
249            "INSERT"
250        }
251        "UPDATE" => {
252            pos += 1;
253            "UPDATE"
254        }
255        "DELETE" => {
256            pos += 1;
257            "DELETE"
258        }
259        _ => return None,
260    };
261
262    if tokens.get(pos) != Some(&"ON") {
263        return None;
264    }
265    pos += 1;
266
267    let table_name = tokens.get(pos)?.to_string();
268    pos += 1;
269
270    if tokens.get(pos) == Some(&"FOR")
271        && tokens.get(pos + 1) == Some(&"EACH")
272        && tokens.get(pos + 2) == Some(&"ROW")
273    {
274        pos += 3;
275    }
276
277    // Find BEGIN in the original (case-preserving) text
278    let upper_sql = original_sql.to_uppercase();
279    let begin_idx = find_keyword_pos(&upper_sql, pos, &tokens, "BEGIN")?;
280
281    let when_condition = if tokens.get(pos) == Some(&"WHEN") {
282        let when_start = find_word_offset(original_sql, &tokens, pos + 1)?;
283        let when_end = begin_idx;
284        let cond = original_sql[when_start..when_end].trim().to_string();
285        Some(cond)
286    } else {
287        None
288    };
289
290    let body_start = begin_idx + "BEGIN".len();
291    let end_idx = upper_sql.rfind("END")?;
292    let body_sql = original_sql[body_start..end_idx].trim().to_string();
293
294    let encoded = format!(
295        "{}|{}|{}|{}|{}|{}|{}",
296        name,
297        table_name,
298        timing,
299        event,
300        if if_not_exists { "1" } else { "0" },
301        when_condition.as_deref().unwrap_or(""),
302        body_sql
303    );
304
305    Some(make_pragma_statement("__create_trigger", Some(&encoded)))
306}
307
308fn find_keyword_pos(
309    upper_sql: &str,
310    _start_token: usize,
311    _tokens: &[&str],
312    keyword: &str,
313) -> Option<usize> {
314    upper_sql.find(keyword)
315}
316
317fn find_word_offset(sql: &str, tokens: &[&str], token_idx: usize) -> Option<usize> {
318    let upper = sql.to_uppercase();
319    let mut search_start = 0;
320    for i in 0..token_idx {
321        let tok = tokens.get(i)?;
322        if let Some(pos) = upper[search_start..].find(tok) {
323            search_start += pos + tok.len();
324        }
325    }
326    while search_start < sql.len() && sql.as_bytes()[search_start] == b' ' {
327        search_start += 1;
328    }
329    Some(search_start)
330}
331
332fn parse_detach_statement(sql: &str) -> Option<sqlparser::ast::Statement> {
333    let trimmed = sql.trim().trim_end_matches(';').trim();
334    let upper = trimmed.to_uppercase();
335    if !upper.starts_with("DETACH") {
336        return None;
337    }
338    let upper_tokens: Vec<&str> = upper.split_whitespace().collect();
339    let orig_tokens: Vec<&str> = trimmed.split_whitespace().collect();
340    let mut pos = 1; // skip DETACH
341    if upper_tokens.get(pos) == Some(&"DATABASE") {
342        pos += 1;
343    }
344    let schema_name = orig_tokens.get(pos)?;
345    Some(make_pragma_statement("__detach", Some(schema_name)))
346}
347
348fn parse_drop_trigger(sql: &str) -> Option<sqlparser::ast::Statement> {
349    let trimmed = sql.trim().trim_end_matches(';').trim();
350    let upper = trimmed.to_uppercase();
351    let tokens: Vec<&str> = upper.split_whitespace().collect();
352
353    let mut pos = 2; // skip "DROP TRIGGER"
354    let mut if_exists = false;
355    if tokens.get(pos) == Some(&"IF") && tokens.get(pos + 1) == Some(&"EXISTS") {
356        if_exists = true;
357        pos += 2;
358    }
359    let name = tokens.get(pos)?.to_string();
360    let encoded = format!("{}|{}", name, if if_exists { "1" } else { "0" });
361    Some(make_pragma_statement("__drop_trigger", Some(&encoded)))
362}
363
364fn is_vacuum(sql: &str) -> bool {
365    let trimmed = sql.trim().trim_end_matches(';').trim();
366    trimmed.eq_ignore_ascii_case("VACUUM")
367}
368
369/// If `sql` starts with `keyword`, return everything after it (trimmed).
370/// `REINDEX` and `ANALYZE` accept an optional table/index name, so the
371/// argument may be empty.
372fn strip_keyword(sql: &str, keyword: &str) -> Option<String> {
373    let trimmed = sql.trim().trim_end_matches(';').trim();
374    let upper = trimmed.to_uppercase();
375    if upper == keyword {
376        return Some(String::new());
377    }
378    let prefix = format!("{keyword} ");
379    if upper.starts_with(&prefix) {
380        return Some(trimmed[prefix.len()..].trim().to_string());
381    }
382    None
383}
384
385fn make_pragma_statement(name: &str, value: Option<&str>) -> sqlparser::ast::Statement {
386    use sqlparser::ast::{Ident, ObjectName, Value};
387    sqlparser::ast::Statement::Pragma {
388        name: ObjectName::from(vec![Ident::new(name)]),
389        value: value.map(|v| Value::SingleQuotedString(v.to_string())),
390        is_eq: value.is_some(),
391    }
392}
393
394/// Translate `<ident> IS [NOT] TRUE/FALSE` into the SQLite-equivalent
395/// boolean form so SQLiteDialect (which rejects the syntax) accepts it:
396///
397/// - `x IS TRUE` → `(x IS NOT NULL AND x <> 0)`
398/// - `x IS FALSE` → `(x IS NOT NULL AND x = 0)`
399/// - `x IS NOT TRUE` → `(x IS NULL OR x = 0)`
400/// - `x IS NOT FALSE` → `(x IS NULL OR x <> 0)`
401///
402/// Only matches when the LHS is a simple identifier (`col`, `t.col`,
403/// `"quoted col"`). For arbitrary expressions on the left (`(a + b) IS
404/// TRUE`), use the function form `is_true(...)` instead.
405///
406/// Skips matches inside string literals to avoid corrupting query text.
407fn preprocess_is_truth_family(sql: &str) -> String {
408    // Fast path: most SQL strings don't contain `IS TRUE` / `IS FALSE`, and
409    // ASCII byte-walking the rare matches is incompatible with multi-byte
410    // UTF-8 string literals like `'é'`. Skip the rewrite entirely when no
411    // candidate keyword is present.
412    let upper_haystack = sql.to_ascii_uppercase();
413    if !upper_haystack.contains("IS TRUE")
414        && !upper_haystack.contains("IS FALSE")
415        && !upper_haystack.contains("IS NOT TRUE")
416        && !upper_haystack.contains("IS NOT FALSE")
417    {
418        return sql.to_string();
419    }
420
421    let bytes = sql.as_bytes();
422    let n = bytes.len();
423    let mut out = String::with_capacity(n);
424    let mut i = 0usize;
425
426    while i < n {
427        let ch = bytes[i] as char;
428        // Skip past string literals untouched.
429        if ch == '\'' || ch == '"' {
430            let quote = ch;
431            out.push(ch);
432            i += 1;
433            while i < n {
434                let c = bytes[i] as char;
435                out.push(c);
436                i += 1;
437                if c == quote {
438                    // SQL doubled-quote escape: '' inside ''.
439                    if i < n && (bytes[i] as char) == quote {
440                        out.push(quote);
441                        i += 1;
442                        continue;
443                    }
444                    break;
445                }
446            }
447            continue;
448        }
449        // Skip past line comments.
450        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
451            while i < n && bytes[i] as char != '\n' {
452                out.push(bytes[i] as char);
453                i += 1;
454            }
455            continue;
456        }
457
458        // Try to match an identifier or parenthesized expression
459        // optionally followed by IS [NOT] TRUE/FALSE.
460        let id_start = i;
461        let id = if ch == '(' {
462            // Parenthesized LHS: capture the balanced (...) verbatim so
463            // arbitrary expressions like `(a + b)` work.
464            let start = i;
465            let mut depth = 1;
466            i += 1;
467            while i < n && depth > 0 {
468                let c = bytes[i] as char;
469                if c == '(' {
470                    depth += 1;
471                } else if c == ')' {
472                    depth -= 1;
473                }
474                i += 1;
475            }
476            if depth != 0 {
477                out.push(ch);
478                i = start + 1;
479                continue;
480            }
481            String::from_utf8_lossy(&bytes[start..i]).into_owned()
482        } else {
483            scan_identifier(bytes, &mut i)
484        };
485        if id.is_empty() {
486            out.push(ch);
487            i += 1;
488            continue;
489        }
490        // Look for `IS [NOT] (TRUE|FALSE)` after optional whitespace.
491        let saved_i = i;
492        let mut j = i;
493        skip_ws(bytes, &mut j);
494        if !match_keyword(bytes, &mut j, "IS") {
495            // Not the pattern; emit the identifier as-is.
496            out.push_str(&id);
497            continue;
498        }
499        skip_ws(bytes, &mut j);
500        let negated = match_keyword(bytes, &mut j, "NOT");
501        if negated {
502            skip_ws(bytes, &mut j);
503        }
504        let truthy = if match_keyword(bytes, &mut j, "TRUE") {
505            Some(true)
506        } else if match_keyword(bytes, &mut j, "FALSE") {
507            Some(false)
508        } else {
509            None
510        };
511        match truthy {
512            Some(true_or_false) => {
513                // IS TRUE      → (x IS NOT NULL AND x <> 0)
514                // IS FALSE     → (x IS NOT NULL AND x = 0)
515                // IS NOT TRUE  → (x IS NULL OR x = 0)
516                // IS NOT FALSE → (x IS NULL OR x <> 0)
517                let cmp = if true_or_false ^ negated { "<>" } else { "=" };
518                let null_test = if negated { "IS NULL" } else { "IS NOT NULL" };
519                let combiner = if negated { "OR" } else { "AND" };
520                let expanded = format!("({id} {null_test} {combiner} {id} {cmp} 0)");
521                let _ = id_start;
522                out.push_str(&expanded);
523                i = j;
524            }
525            None => {
526                out.push_str(&id);
527                i = saved_i;
528            }
529        }
530    }
531
532    out
533}
534
535fn scan_identifier(bytes: &[u8], i: &mut usize) -> String {
536    let start = *i;
537    let n = bytes.len();
538    if start >= n {
539        return String::new();
540    }
541    let first = bytes[start] as char;
542    // Quoted identifier: "name" or `name`.
543    if first == '"' || first == '`' {
544        let quote = first;
545        *i += 1;
546        while *i < n {
547            let c = bytes[*i] as char;
548            *i += 1;
549            if c == quote {
550                break;
551            }
552        }
553        // Optional `.qualifier` after a quoted identifier.
554        if *i < n && bytes[*i] as char == '.' {
555            *i += 1;
556            scan_unquoted_id_tail(bytes, i);
557        }
558        return String::from_utf8_lossy(&bytes[start..*i]).into_owned();
559    }
560    if !first.is_ascii_alphabetic() && first != '_' {
561        return String::new();
562    }
563    *i += 1;
564    while *i < n {
565        let c = bytes[*i] as char;
566        if c.is_ascii_alphanumeric() || c == '_' {
567            *i += 1;
568        } else {
569            break;
570        }
571    }
572    // Optional `.qualifier`.
573    if *i < n && bytes[*i] as char == '.' {
574        *i += 1;
575        scan_unquoted_id_tail(bytes, i);
576    }
577    String::from_utf8_lossy(&bytes[start..*i]).into_owned()
578}
579
580fn scan_unquoted_id_tail(bytes: &[u8], i: &mut usize) {
581    let n = bytes.len();
582    while *i < n {
583        let c = bytes[*i] as char;
584        if c.is_ascii_alphanumeric() || c == '_' {
585            *i += 1;
586        } else {
587            break;
588        }
589    }
590}
591
592fn skip_ws(bytes: &[u8], i: &mut usize) {
593    let n = bytes.len();
594    while *i < n && (bytes[*i] as char).is_whitespace() {
595        *i += 1;
596    }
597}
598
599fn match_keyword(bytes: &[u8], i: &mut usize, kw: &str) -> bool {
600    let n = bytes.len();
601    let kw_bytes = kw.as_bytes();
602    if *i + kw_bytes.len() > n {
603        return false;
604    }
605    for (k, kb) in kw_bytes.iter().enumerate() {
606        let c = bytes[*i + k];
607        if c.to_ascii_uppercase() != *kb {
608            return false;
609        }
610    }
611    // Make sure the next char isn't an identifier continuation.
612    let after = *i + kw_bytes.len();
613    if after < n {
614        let c = bytes[after] as char;
615        if c.is_ascii_alphanumeric() || c == '_' {
616            return false;
617        }
618    }
619    *i = after;
620    true
621}
622
623/// Translate `<atom> << <atom>` and `<atom> >> <atom>` into the function
624/// forms `__shl(a, b)` / `__shr(a, b)` so SQLiteDialect can parse them.
625///
626/// `<atom>` is deliberately narrow: identifier (possibly qualified),
627/// parenthesized subexpression, integer literal, or a simple function
628/// call `name(args)`. The narrowness avoids the precedence trap where
629/// `a + b << c` (which means `(a+b) << c` per SQL precedence) would be
630/// mis-rewritten to `a + __shl(b, c)`. Anything outside the safe shape
631/// falls through and the user keeps `__shl(...)` / `__shr(...)`.
632///
633/// Shift chains apply right-to-left at the rewriter level, but since
634/// the rewrite is one-pass left-to-right scanning, `a << b << c` becomes
635/// `__shl(a, b) << c` after one pass and then `__shl(__shl(a, b), c)` if
636/// we re-scan. We loop until convergence (capped) so chains resolve.
637fn preprocess_bitwise_shifts(sql: &str) -> String {
638    if !sql.contains("<<") && !sql.contains(">>") {
639        return sql.to_string();
640    }
641
642    let mut current = sql.to_string();
643    for _ in 0..8 {
644        let rewritten = rewrite_shifts_one_pass(&current);
645        if rewritten == current {
646            break;
647        }
648        current = rewritten;
649    }
650    current
651}
652
653fn rewrite_shifts_one_pass(sql: &str) -> String {
654    let bytes = sql.as_bytes();
655    let n = bytes.len();
656    let mut out = String::with_capacity(n);
657    let mut i = 0usize;
658
659    while i < n {
660        let ch = bytes[i] as char;
661        if ch == '\'' || ch == '"' {
662            let quote = ch;
663            out.push(ch);
664            i += 1;
665            while i < n {
666                let c = bytes[i] as char;
667                out.push(c);
668                i += 1;
669                if c == quote {
670                    if i < n && (bytes[i] as char) == quote {
671                        out.push(quote);
672                        i += 1;
673                        continue;
674                    }
675                    break;
676                }
677            }
678            continue;
679        }
680        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
681            while i < n && bytes[i] as char != '\n' {
682                out.push(bytes[i] as char);
683                i += 1;
684            }
685            continue;
686        }
687        // Look for `atom <<` or `atom >>` anchored at the END of `out`.
688        // We only rewrite when the LHS is a "safe" trailing token in
689        // `out` and the RHS is also a safe atom.
690        if i + 2 <= n {
691            let pair = &sql[i..i + 2];
692            if pair == "<<" || pair == ">>" {
693                if let Some((lhs_start, lhs)) = trailing_atom(&out) {
694                    // Try to consume RHS atom.
695                    let after_op = i + 2;
696                    if let Some((rhs, consumed)) = leading_atom(&sql[after_op..]) {
697                        let func = if pair == "<<" { "__shl" } else { "__shr" };
698                        out.truncate(lhs_start);
699                        out.push_str(&format!("{func}({lhs}, {rhs})"));
700                        i = after_op + consumed;
701                        continue;
702                    }
703                }
704            }
705        }
706        out.push(ch);
707        i += 1;
708    }
709    out
710}
711
712/// Return the trailing safe-atom suffix of `out`, if any. The returned
713/// `start_index` is into the *trimmed* tail (i.e. matches `out` after
714/// dropping trailing whitespace) — callers `out.truncate(start_index)`
715/// to lop off the atom, which incidentally also drops the trailing
716/// whitespace before the operator.
717///
718/// Returns None if the atom is preceded by a higher-precedence binary
719/// operator (`+ - * / % ||`). In SQLite, `<<`/`>>` bind looser than
720/// these — so capturing only the trailing atom (e.g. `b` in `a + b << c`)
721/// would silently produce the wrong tree. Bailing here keeps sqlparser
722/// to surface the original error instead.
723fn trailing_atom(out: &str) -> Option<(usize, String)> {
724    let trimmed = out.trim_end();
725    let bytes = trimmed.as_bytes();
726    let n = bytes.len();
727    if n == 0 {
728        return None;
729    }
730    let last = bytes[n - 1] as char;
731
732    // Parenthesized expression (optionally preceded by a function name).
733    if last == ')' {
734        let mut depth = 0i32;
735        let mut start = n;
736        for k in (0..n).rev() {
737            let c = bytes[k] as char;
738            if c == ')' {
739                depth += 1;
740            } else if c == '(' {
741                depth -= 1;
742                if depth == 0 {
743                    start = k;
744                    break;
745                }
746            }
747        }
748        if depth != 0 {
749            return None;
750        }
751        // Extend back over any identifier characters preceding the `(`,
752        // so `__shl(1, 2)` returns as a single atom rather than just `(1, 2)`.
753        let mut id_start = start;
754        while id_start > 0 {
755            let c = bytes[id_start - 1] as char;
756            if c.is_ascii_alphanumeric() || c == '_' {
757                id_start -= 1;
758            } else {
759                break;
760            }
761        }
762        let text = String::from_utf8_lossy(&bytes[id_start..n]).into_owned();
763        return Some((id_start, text));
764    }
765
766    // Numeric literal or identifier (with optional `.qualifier`).
767    if last.is_ascii_digit() || last.is_ascii_alphabetic() || last == '_' {
768        let mut start = n;
769        while start > 0 {
770            let c = bytes[start - 1] as char;
771            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
772                start -= 1;
773            } else {
774                break;
775            }
776        }
777        // Bail if a higher-precedence binary operator immediately precedes
778        // the atom — see doc comment above. Walk back through whitespace.
779        let mut k = start;
780        while k > 0 && (bytes[k - 1] as char).is_whitespace() {
781            k -= 1;
782        }
783        if k > 0 {
784            let prev = bytes[k - 1] as char;
785            // Watch for `||` (concat) — both bytes are `|`.
786            let prev2 = if k >= 2 {
787                Some(bytes[k - 2] as char)
788            } else {
789                None
790            };
791            let prev_op_higher_prec =
792                matches!(prev, '+' | '-' | '*' | '/' | '%') || (prev == '|' && prev2 == Some('|'));
793            if prev_op_higher_prec {
794                return None;
795            }
796        }
797        let text = String::from_utf8_lossy(&bytes[start..n]).into_owned();
798        return Some((start, text));
799    }
800
801    None
802}
803
804/// Return the leading safe-atom prefix of `s`, with bytes-consumed.
805fn leading_atom(s: &str) -> Option<(String, usize)> {
806    let bytes = s.as_bytes();
807    let n = bytes.len();
808    let mut i = 0;
809    while i < n && (bytes[i] as char).is_whitespace() {
810        i += 1;
811    }
812    if i >= n {
813        return None;
814    }
815    let start = i;
816    let first = bytes[i] as char;
817
818    // Parenthesized.
819    if first == '(' {
820        let mut depth = 1;
821        i += 1;
822        while i < n && depth > 0 {
823            let c = bytes[i] as char;
824            if c == '(' {
825                depth += 1;
826            } else if c == ')' {
827                depth -= 1;
828            }
829            i += 1;
830        }
831        if depth != 0 {
832            return None;
833        }
834        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
835        return Some((text, i));
836    }
837
838    // Number (possibly negative — but we'd need to check the previous
839    // op context, so keep it simple and only match unsigned digits).
840    if first.is_ascii_digit() {
841        while i < n && (bytes[i] as char).is_ascii_digit() {
842            i += 1;
843        }
844        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
845        return Some((text, i));
846    }
847
848    // Identifier or function call.
849    if first.is_ascii_alphabetic() || first == '_' {
850        while i < n {
851            let c = bytes[i] as char;
852            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
853                i += 1;
854            } else {
855                break;
856            }
857        }
858        // Optional `(...)` for a function call.
859        if i < n && (bytes[i] as char) == '(' {
860            let mut depth = 1;
861            i += 1;
862            while i < n && depth > 0 {
863                let c = bytes[i] as char;
864                if c == '(' {
865                    depth += 1;
866                } else if c == ')' {
867                    depth -= 1;
868                }
869                i += 1;
870            }
871            if depth != 0 {
872                return None;
873            }
874        }
875        let text = String::from_utf8_lossy(&bytes[start..i]).into_owned();
876        return Some((text, i));
877    }
878
879    None
880}
881
882/// Translate prefix `~<operand>` (bitwise complement) into `__bnot(<operand>)`
883/// before parsing — sqlparser's SQLiteDialect rejects `~` as a unary token.
884///
885/// The operand can be:
886/// - a simple identifier (`col`, `t.col`, `"quoted col"`)
887/// - a parenthesized expression (`~(a + 1)` → `__bnot((a + 1))`)
888///
889/// Other forms (function calls, longer chains) fall through and the user
890/// uses the function form `__bnot(...)` directly.
891///
892/// Skips matches inside string literals, line comments, and `~` chars used
893/// as a binary operator (the SQLite dialect doesn't expose that anyway,
894/// but Postgres-style `~` regex match isn't our concern here).
895fn preprocess_bitwise_not(sql: &str) -> String {
896    if !sql.contains('~') {
897        return sql.to_string();
898    }
899
900    let bytes = sql.as_bytes();
901    let n = bytes.len();
902    let mut out = String::with_capacity(n);
903    let mut i = 0usize;
904
905    while i < n {
906        let ch = bytes[i] as char;
907        if ch == '\'' || ch == '"' {
908            let quote = ch;
909            out.push(ch);
910            i += 1;
911            while i < n {
912                let c = bytes[i] as char;
913                out.push(c);
914                i += 1;
915                if c == quote {
916                    if i < n && (bytes[i] as char) == quote {
917                        out.push(quote);
918                        i += 1;
919                        continue;
920                    }
921                    break;
922                }
923            }
924            continue;
925        }
926        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
927            while i < n && bytes[i] as char != '\n' {
928                out.push(bytes[i] as char);
929                i += 1;
930            }
931            continue;
932        }
933        if ch != '~' {
934            out.push(ch);
935            i += 1;
936            continue;
937        }
938
939        // Decide whether this `~` is in a prefix position. It's a prefix
940        // iff the previous *significant* output character is the start of
941        // a fresh expression — i.e. one of: empty, `(`, `,`, operator,
942        // keyword separator. We approximate by walking back through any
943        // trailing whitespace.
944        let prev_significant = out.trim_end().chars().next_back();
945        let is_prefix = match prev_significant {
946            None => true,
947            Some(c) => matches!(
948                c,
949                '(' | ',' | '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '~'
950            ),
951        } || {
952            // Or the preceding token is an SQL keyword that ends an
953            // expression boundary (WHERE, AND, OR, NOT, ON, BY, ...).
954            let trimmed = out.trim_end();
955            let last_word: String = trimmed
956                .chars()
957                .rev()
958                .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
959                .collect::<String>()
960                .chars()
961                .rev()
962                .collect();
963            matches!(
964                last_word.to_ascii_uppercase().as_str(),
965                "WHERE"
966                    | "AND"
967                    | "OR"
968                    | "NOT"
969                    | "ON"
970                    | "BY"
971                    | "CASE"
972                    | "WHEN"
973                    | "THEN"
974                    | "ELSE"
975                    | "SELECT"
976                    | "VALUES"
977                    | "RETURNING"
978                    | "HAVING"
979                    | "LIMIT"
980                    | "OFFSET"
981            )
982        };
983
984        if !is_prefix {
985            out.push(ch);
986            i += 1;
987            continue;
988        }
989
990        // Consume `~` and parse the operand.
991        i += 1;
992        skip_ws(bytes, &mut i);
993        if i >= n {
994            out.push('~');
995            continue;
996        }
997        let next_ch = bytes[i] as char;
998        if next_ch == '(' {
999            // Find matching close paren.
1000            let start = i;
1001            let mut depth = 1;
1002            i += 1;
1003            while i < n && depth > 0 {
1004                let c = bytes[i] as char;
1005                if c == '(' {
1006                    depth += 1;
1007                } else if c == ')' {
1008                    depth -= 1;
1009                }
1010                i += 1;
1011            }
1012            let operand = &sql[start..i];
1013            out.push_str(&format!("__bnot({operand})"));
1014            continue;
1015        }
1016        // Numeric literal — integer or decimal (no exponent / hex for now,
1017        // matching what SQLite would accept after `~`).
1018        if next_ch.is_ascii_digit() {
1019            let start = i;
1020            while i < n {
1021                let c = bytes[i] as char;
1022                if c.is_ascii_digit() || c == '.' {
1023                    i += 1;
1024                } else {
1025                    break;
1026                }
1027            }
1028            out.push_str(&format!("__bnot({})", &sql[start..i]));
1029            continue;
1030        }
1031        // Identifier (possibly qualified).
1032        let id = scan_identifier(bytes, &mut i);
1033        if id.is_empty() {
1034            out.push('~');
1035            continue;
1036        }
1037        out.push_str(&format!("__bnot({id})"));
1038    }
1039
1040    out
1041}
1042
1043/// Translate `UPDATE t SET ... [WHERE ...] (ORDER BY ... | LIMIT n)` into
1044/// the rowid-IN workaround SQLite documents:
1045///
1046///   UPDATE t SET ... WHERE rowid IN (
1047///     SELECT rowid FROM t [WHERE ...] [ORDER BY ...] [LIMIT n]
1048///   )
1049///
1050/// SQLite gates UPDATE-with-LIMIT/ORDER-BY behind a compile-time flag and
1051/// `sqlparser`'s SQLiteDialect doesn't accept it, so without this pre-pass
1052/// the user has to write the rowid-IN form by hand.
1053///
1054/// Conservative: only touches single-statement inputs that start with
1055/// `UPDATE`. Bails on multi-table UPDATE-FROM (where rewriting requires
1056/// also threading the FROM clause), or any case where the table name
1057/// can't be cleanly extracted.
1058fn preprocess_update_limit(sql: &str) -> String {
1059    let upper = sql.to_ascii_uppercase();
1060    let trimmed_upper = upper.trim_start();
1061    if !trimmed_upper.starts_with("UPDATE ") {
1062        return sql.to_string();
1063    }
1064    if !upper.contains(" ORDER BY ") && !upper.contains(" LIMIT ") {
1065        return sql.to_string();
1066    }
1067
1068    let kw_pos = find_top_level_keywords(sql, &["SET", "FROM", "WHERE", "ORDER", "LIMIT"]);
1069    let set_pos = match kw_pos.iter().find(|(k, _)| k == "SET") {
1070        Some((_, p)) => *p,
1071        None => return sql.to_string(),
1072    };
1073    if kw_pos.iter().any(|(k, _)| k == "FROM") {
1074        // UPDATE...FROM is a separate beast — leave it alone.
1075        return sql.to_string();
1076    }
1077    let where_pos = kw_pos.iter().find(|(k, _)| k == "WHERE").map(|(_, p)| *p);
1078    let order_pos = kw_pos.iter().find(|(k, _)| k == "ORDER").map(|(_, p)| *p);
1079    let limit_pos = kw_pos.iter().find(|(k, _)| k == "LIMIT").map(|(_, p)| *p);
1080
1081    if order_pos.is_none() && limit_pos.is_none() {
1082        return sql.to_string();
1083    }
1084
1085    // Locate UPDATE keyword start (after any leading whitespace).
1086    let leading_ws = sql.len() - sql.trim_start().len();
1087    let table_section_start = leading_ws + "UPDATE".len();
1088    let table_section = sql[table_section_start..set_pos].trim();
1089    if table_section.is_empty() {
1090        return sql.to_string();
1091    }
1092    // Strip an optional alias clause for the rowid subquery — keep the
1093    // outer UPDATE table name verbatim.
1094    let table_for_subquery = table_section
1095        .split_whitespace()
1096        .next()
1097        .unwrap_or(table_section);
1098
1099    let assignments_end = where_pos.or(order_pos).or(limit_pos).unwrap_or(sql.len());
1100    let assignments = sql[set_pos + "SET".len()..assignments_end].trim();
1101    if assignments.is_empty() {
1102        return sql.to_string();
1103    }
1104
1105    let where_body = where_pos.map(|wp| {
1106        let end = order_pos.or(limit_pos).unwrap_or(sql.len());
1107        sql[wp + "WHERE".len()..end].trim()
1108    });
1109
1110    let order_body = order_pos.map(|op| {
1111        // Skip past "ORDER" then optional whitespace then "BY".
1112        let after = sql[op + "ORDER".len()..].trim_start();
1113        let by_skip = sql[op + "ORDER".len()..].len() - after.len();
1114        let body_start = if after.to_ascii_uppercase().starts_with("BY") {
1115            op + "ORDER".len() + by_skip + "BY".len()
1116        } else {
1117            op + "ORDER".len() + by_skip
1118        };
1119        let end = limit_pos.unwrap_or(sql.len());
1120        sql[body_start..end].trim()
1121    });
1122
1123    let limit_body = limit_pos.map(|lp| {
1124        sql[lp + "LIMIT".len()..]
1125            .trim()
1126            .trim_end_matches(';')
1127            .trim()
1128    });
1129
1130    let mut sub = format!("SELECT rowid FROM {table_for_subquery}");
1131    if let Some(w) = where_body {
1132        sub.push_str(" WHERE ");
1133        sub.push_str(w);
1134    }
1135    if let Some(o) = order_body {
1136        sub.push_str(" ORDER BY ");
1137        sub.push_str(o);
1138    }
1139    if let Some(l) = limit_body {
1140        sub.push_str(" LIMIT ");
1141        sub.push_str(l);
1142    }
1143
1144    format!("UPDATE {table_section} SET {assignments} WHERE rowid IN ({sub})")
1145}
1146
1147/// Find positions of `keywords` that appear at the top level of `sql`
1148/// (i.e. not inside a parenthesized subexpression and not inside a string
1149/// literal). Each match must be at a word boundary on both sides.
1150/// Keywords are matched case-insensitively.
1151fn find_top_level_keywords(sql: &str, keywords: &[&str]) -> Vec<(String, usize)> {
1152    let bytes = sql.as_bytes();
1153    let n = bytes.len();
1154    let mut out: Vec<(String, usize)> = Vec::new();
1155    let mut i = 0usize;
1156    let mut depth = 0i32;
1157
1158    while i < n {
1159        let ch = bytes[i] as char;
1160        // Skip string literals.
1161        if ch == '\'' || ch == '"' {
1162            let quote = ch;
1163            i += 1;
1164            while i < n {
1165                let c = bytes[i] as char;
1166                i += 1;
1167                if c == quote {
1168                    if i < n && (bytes[i] as char) == quote {
1169                        i += 1;
1170                        continue;
1171                    }
1172                    break;
1173                }
1174            }
1175            continue;
1176        }
1177        // Skip line comments.
1178        if ch == '-' && i + 1 < n && bytes[i + 1] as char == '-' {
1179            while i < n && bytes[i] as char != '\n' {
1180                i += 1;
1181            }
1182            continue;
1183        }
1184        if ch == '(' {
1185            depth += 1;
1186            i += 1;
1187            continue;
1188        }
1189        if ch == ')' {
1190            depth -= 1;
1191            i += 1;
1192            continue;
1193        }
1194        if depth != 0 {
1195            i += 1;
1196            continue;
1197        }
1198
1199        let prev_is_word = i > 0 && {
1200            let p = bytes[i - 1] as char;
1201            p.is_ascii_alphanumeric() || p == '_'
1202        };
1203        if !prev_is_word {
1204            for kw in keywords {
1205                let kb = kw.as_bytes();
1206                if i + kb.len() > n {
1207                    continue;
1208                }
1209                let mut ok = true;
1210                for (k, b) in kb.iter().enumerate() {
1211                    if bytes[i + k].to_ascii_uppercase() != *b {
1212                        ok = false;
1213                        break;
1214                    }
1215                }
1216                if !ok {
1217                    continue;
1218                }
1219                if i + kb.len() < n {
1220                    let nc = bytes[i + kb.len()] as char;
1221                    if nc.is_ascii_alphanumeric() || nc == '_' {
1222                        continue;
1223                    }
1224                }
1225                out.push((kw.to_string(), i));
1226                break;
1227            }
1228        }
1229        i += 1;
1230    }
1231    out
1232}
1233
1234fn preprocess_pragma(sql: &str) -> String {
1235    let trimmed = sql.trim();
1236    if !trimmed.to_uppercase().starts_with("PRAGMA ") {
1237        return sql.to_string();
1238    }
1239    let after_pragma = trimmed[7..].trim();
1240    if let Some(paren_start) = after_pragma.find('(') {
1241        if let Some(paren_end) = after_pragma.rfind(')') {
1242            let arg = after_pragma[paren_start + 1..paren_end].trim();
1243            if !arg.starts_with('\'') && !arg.starts_with('"') {
1244                let name = &after_pragma[..paren_start];
1245                let rest = if paren_end + 1 < after_pragma.len() {
1246                    &after_pragma[paren_end + 1..]
1247                } else {
1248                    ""
1249                };
1250                return format!("PRAGMA {name}('{arg}'){rest}");
1251            }
1252        }
1253    }
1254    if let Some(eq_pos) = after_pragma.find('=') {
1255        let name = after_pragma[..eq_pos].trim();
1256        let val = after_pragma[eq_pos + 1..].trim().trim_end_matches(';');
1257        let val = val.trim();
1258        if val.eq_ignore_ascii_case("ON")
1259            || val.eq_ignore_ascii_case("YES")
1260            || val.eq_ignore_ascii_case("TRUE")
1261        {
1262            return format!("PRAGMA {name} = 1;");
1263        }
1264        if val.eq_ignore_ascii_case("OFF")
1265            || val.eq_ignore_ascii_case("NO")
1266            || val.eq_ignore_ascii_case("FALSE")
1267        {
1268            return format!("PRAGMA {name} = 0;");
1269        }
1270        if !val.starts_with('\'') && !val.starts_with('"') && val.parse::<i64>().is_err() {
1271            return format!("PRAGMA {name} = '{val}';");
1272        }
1273    }
1274    sql.to_string()
1275}
1276
1277/// Rewrite `<lhs> MATCH '<query>'` into
1278/// `__fts5_match_token(<lhs>, '<query>')`. SQLiteDialect doesn't accept
1279/// `MATCH` as a binary operator outside of the (rarely-used) FTS3 path,
1280/// so we hoist it to a scalar-function form before parsing. The
1281/// rewritten function is recognized by the executor as the FTS5 entry
1282/// point.
1283///
1284/// Conservative: only matches when the LHS is a simple identifier
1285/// (`col`, `t.col`) and the RHS is a single-quoted string literal.
1286fn preprocess_fts5_match(sql: &str) -> String {
1287    if !contains_keyword_ci(sql, "MATCH") {
1288        return sql.to_string();
1289    }
1290    let bytes = sql.as_bytes();
1291    let n = bytes.len();
1292    let mut out = String::with_capacity(n);
1293    let mut i = 0usize;
1294
1295    while i < n {
1296        let ch = bytes[i] as char;
1297        if ch == '\'' || ch == '"' {
1298            let quote = ch;
1299            out.push(ch);
1300            i += 1;
1301            while i < n {
1302                let c = bytes[i] as char;
1303                out.push(c);
1304                i += 1;
1305                if c == quote {
1306                    if i < n && (bytes[i] as char) == quote {
1307                        out.push(quote);
1308                        i += 1;
1309                        continue;
1310                    }
1311                    break;
1312                }
1313            }
1314            continue;
1315        }
1316        if ch == '-' && i + 1 < n && (bytes[i + 1] as char) == '-' {
1317            while i < n && (bytes[i] as char) != '\n' {
1318                out.push(bytes[i] as char);
1319                i += 1;
1320            }
1321            continue;
1322        }
1323
1324        if (ch == 'M' || ch == 'm') && looks_like_match_kw(bytes, i) {
1325            if let Some((id_start, id)) = trailing_identifier(&out) {
1326                let mut j = i + 5;
1327                let saw_ws = j < n && (bytes[j] as char).is_whitespace();
1328                while j < n && (bytes[j] as char).is_whitespace() {
1329                    j += 1;
1330                }
1331                if saw_ws && j < n && (bytes[j] as char) == '\'' {
1332                    let lit_start = j;
1333                    j += 1;
1334                    while j < n {
1335                        let c = bytes[j] as char;
1336                        if c == '\'' {
1337                            if j + 1 < n && (bytes[j + 1] as char) == '\'' {
1338                                j += 2;
1339                                continue;
1340                            }
1341                            j += 1;
1342                            break;
1343                        }
1344                        j += 1;
1345                    }
1346                    let lit = &sql[lit_start..j];
1347                    let lhs_quoted = id.replace('\'', "''");
1348                    out.truncate(id_start);
1349                    out.push_str(&format!("__fts5_match_token('{lhs_quoted}', {lit})"));
1350                    i = j;
1351                    continue;
1352                }
1353            }
1354        }
1355
1356        out.push(ch);
1357        i += 1;
1358    }
1359    out
1360}
1361
1362fn looks_like_match_kw(bytes: &[u8], i: usize) -> bool {
1363    if i + 5 > bytes.len() {
1364        return false;
1365    }
1366    let head = &bytes[i..i + 5];
1367    if !(head[0].eq_ignore_ascii_case(&b'M')
1368        && head[1].eq_ignore_ascii_case(&b'A')
1369        && head[2].eq_ignore_ascii_case(&b'T')
1370        && head[3].eq_ignore_ascii_case(&b'C')
1371        && head[4].eq_ignore_ascii_case(&b'H'))
1372    {
1373        return false;
1374    }
1375    if i > 0 {
1376        let prev = bytes[i - 1] as char;
1377        if prev.is_ascii_alphanumeric() || prev == '_' {
1378            return false;
1379        }
1380    }
1381    if let Some(b) = bytes.get(i + 5) {
1382        let c = *b as char;
1383        if c.is_ascii_alphanumeric() || c == '_' {
1384            return false;
1385        }
1386    }
1387    true
1388}
1389
1390fn trailing_identifier(out: &str) -> Option<(usize, String)> {
1391    let trimmed = out.trim_end();
1392    let bytes = trimmed.as_bytes();
1393    let n = bytes.len();
1394    if n == 0 {
1395        return None;
1396    }
1397    if bytes[n - 1] == b'"' {
1398        let k = n - 1;
1399        if k == 0 {
1400            return None;
1401        }
1402        let mut start = None;
1403        let mut j = k;
1404        while j > 0 {
1405            j -= 1;
1406            if bytes[j] == b'"' {
1407                start = Some(j);
1408                break;
1409            }
1410        }
1411        let s = start?;
1412        let mut q_start = s;
1413        if q_start > 0 && bytes[q_start - 1] == b'.' {
1414            let mut p = q_start - 1;
1415            while p > 0 {
1416                let c = bytes[p - 1] as char;
1417                if c.is_ascii_alphanumeric() || c == '_' {
1418                    p -= 1;
1419                } else {
1420                    break;
1421                }
1422            }
1423            q_start = p;
1424        }
1425        let text = String::from_utf8_lossy(&bytes[q_start..n]).into_owned();
1426        return Some((q_start, text));
1427    }
1428    let last = bytes[n - 1] as char;
1429    if !(last.is_ascii_alphanumeric() || last == '_') {
1430        return None;
1431    }
1432    let mut start = n;
1433    while start > 0 {
1434        let c = bytes[start - 1] as char;
1435        if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
1436            start -= 1;
1437        } else {
1438            break;
1439        }
1440    }
1441    let text = String::from_utf8_lossy(&bytes[start..n]).into_owned();
1442    Some((start, text))
1443}
1444
1445fn contains_keyword_ci(sql: &str, kw: &str) -> bool {
1446    let lower = sql.to_ascii_lowercase();
1447    let needle = kw.to_ascii_lowercase();
1448    let mut idx = 0;
1449    while let Some(p) = lower[idx..].find(&needle) {
1450        let abs = idx + p;
1451        let before_ok = abs == 0
1452            || !lower.as_bytes()[abs - 1].is_ascii_alphanumeric()
1453                && lower.as_bytes()[abs - 1] != b'_';
1454        let after = abs + needle.len();
1455        let after_ok = after == lower.len()
1456            || (!lower.as_bytes()[after].is_ascii_alphanumeric()
1457                && lower.as_bytes()[after] != b'_');
1458        if before_ok && after_ok {
1459            return true;
1460        }
1461        idx = abs + needle.len();
1462        if idx >= lower.len() {
1463            break;
1464        }
1465    }
1466    false
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    #[test]
1474    fn parse_simple_select() {
1475        let stmts = parse_sql("SELECT * FROM users").unwrap();
1476        assert_eq!(stmts.len(), 1);
1477    }
1478
1479    #[test]
1480    fn parse_create_table() {
1481        let stmts =
1482            parse_sql("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)").unwrap();
1483        assert_eq!(stmts.len(), 1);
1484    }
1485
1486    #[test]
1487    fn parse_insert() {
1488        let stmts = parse_sql("INSERT INTO users (name) VALUES ('alice')").unwrap();
1489        assert_eq!(stmts.len(), 1);
1490    }
1491
1492    #[test]
1493    fn parse_error() {
1494        assert!(parse_sql("SELECTT * FROM").is_err());
1495    }
1496
1497    /// Helper: assert the SQL parsed into a `__pragma` statement with the
1498    /// given internal name (e.g. "__vacuum", "__reindex", "__detach").
1499    fn assert_pseudo_pragma(sql: &str, expected_name: &str) {
1500        let stmts = parse_sql(sql).unwrap();
1501        assert_eq!(stmts.len(), 1, "expected one statement for {sql:?}");
1502        match &stmts[0] {
1503            sqlparser::ast::Statement::Pragma { name, .. } => {
1504                assert_eq!(
1505                    name.to_string().to_lowercase(),
1506                    expected_name,
1507                    "wrong pseudo-pragma for {sql:?}"
1508                );
1509            }
1510            other => panic!("expected pragma statement for {sql:?}, got {other:?}"),
1511        }
1512    }
1513
1514    // ---- VACUUM / REINDEX / ANALYZE pre-processing ----
1515
1516    #[test]
1517    fn parse_vacuum_bare() {
1518        assert_pseudo_pragma("VACUUM", "__vacuum");
1519        assert_pseudo_pragma("vacuum;", "__vacuum");
1520    }
1521
1522    #[test]
1523    fn parse_reindex_no_arg() {
1524        assert_pseudo_pragma("REINDEX", "__reindex");
1525    }
1526
1527    #[test]
1528    fn parse_reindex_with_target() {
1529        assert_pseudo_pragma("REINDEX my_index", "__reindex");
1530        assert_pseudo_pragma("reindex MY_TABLE;", "__reindex");
1531    }
1532
1533    #[test]
1534    fn parse_analyze_no_arg() {
1535        assert_pseudo_pragma("ANALYZE", "__analyze");
1536        assert_pseudo_pragma("analyze ;", "__analyze");
1537    }
1538
1539    #[test]
1540    fn parse_analyze_with_target() {
1541        assert_pseudo_pragma("ANALYZE users", "__analyze");
1542    }
1543
1544    // ---- DETACH ----
1545
1546    #[test]
1547    fn parse_detach_database() {
1548        assert_pseudo_pragma("DETACH DATABASE aux", "__detach");
1549        assert_pseudo_pragma("DETACH aux;", "__detach");
1550    }
1551
1552    // ---- CREATE TRIGGER (pre-processed into pseudo-pragma) ----
1553
1554    #[test]
1555    fn parse_create_trigger_after_insert() {
1556        assert_pseudo_pragma(
1557            "CREATE TRIGGER tlog AFTER INSERT ON t BEGIN SELECT 1; END",
1558            "__create_trigger",
1559        );
1560    }
1561
1562    #[test]
1563    fn parse_create_trigger_before_update_for_each_row() {
1564        assert_pseudo_pragma(
1565            "CREATE TRIGGER t1 BEFORE UPDATE ON things FOR EACH ROW BEGIN SELECT 1; END",
1566            "__create_trigger",
1567        );
1568    }
1569
1570    #[test]
1571    fn parse_create_trigger_if_not_exists() {
1572        assert_pseudo_pragma(
1573            "CREATE TRIGGER IF NOT EXISTS t1 AFTER DELETE ON x BEGIN SELECT 1; END",
1574            "__create_trigger",
1575        );
1576    }
1577
1578    #[test]
1579    fn parse_drop_trigger() {
1580        assert_pseudo_pragma("DROP TRIGGER tlog", "__drop_trigger");
1581        assert_pseudo_pragma("DROP TRIGGER IF EXISTS tlog", "__drop_trigger");
1582    }
1583
1584    // ---- PRAGMA preprocessing ----
1585
1586    #[test]
1587    fn parse_pragma_paren_form() {
1588        // PRAGMA table_info(users) — bare identifier in parens needs quoting
1589        // for sqlparser; preprocess wraps it.
1590        let stmts = parse_sql("PRAGMA table_info(users)").unwrap();
1591        assert_eq!(stmts.len(), 1);
1592        assert!(matches!(
1593            &stmts[0],
1594            sqlparser::ast::Statement::Pragma { .. }
1595        ));
1596    }
1597
1598    #[test]
1599    fn parse_pragma_equals_form() {
1600        let stmts = parse_sql("PRAGMA foreign_keys = ON").unwrap();
1601        assert_eq!(stmts.len(), 1);
1602        assert!(matches!(
1603            &stmts[0],
1604            sqlparser::ast::Statement::Pragma { .. }
1605        ));
1606    }
1607
1608    #[test]
1609    fn parse_pragma_simple_read_form() {
1610        let stmts = parse_sql("PRAGMA page_size").unwrap();
1611        assert_eq!(stmts.len(), 1);
1612        assert!(matches!(
1613            &stmts[0],
1614            sqlparser::ast::Statement::Pragma { .. }
1615        ));
1616    }
1617
1618    // ---- Multi-statement / generic pass-through ----
1619
1620    #[test]
1621    fn parse_multiple_statements() {
1622        let stmts = parse_sql("SELECT 1; SELECT 2;").unwrap();
1623        assert_eq!(stmts.len(), 2);
1624    }
1625}
1626
1627#[cfg(test)]
1628mod update_limit_tests {
1629    use super::*;
1630
1631    #[test]
1632    fn rewrite_limit_only() {
1633        let out = preprocess_update_limit("UPDATE t SET status = 'new' LIMIT 2");
1634        assert!(out.contains("LIMIT 2"), "out={out}");
1635        assert!(out.contains("rowid IN ("), "out={out}");
1636    }
1637
1638    #[test]
1639    fn rewrite_where_and_limit() {
1640        let out =
1641            preprocess_update_limit("UPDATE t SET status = 'updated' WHERE status = 'a' LIMIT 2");
1642        assert!(out.contains("WHERE status = 'a'"), "out={out}");
1643        assert!(out.contains("LIMIT 2"));
1644        assert!(out.contains("rowid IN ("));
1645    }
1646
1647    #[test]
1648    fn no_op_without_order_by_or_limit() {
1649        let sql = "UPDATE t SET status = 'x' WHERE id = 1";
1650        let out = preprocess_update_limit(sql);
1651        assert_eq!(out, sql);
1652    }
1653
1654    #[test]
1655    fn skips_string_literal_limit() {
1656        let sql = "UPDATE t SET label = 'has LIMIT in it' WHERE id = 1";
1657        let out = preprocess_update_limit(sql);
1658        assert_eq!(out, sql);
1659    }
1660}