Skip to main content

nodedb_sql/parser/preprocess/
lex.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared SQL lexer for preprocess passes.
4//!
5//! The lexer classifies a SQL string into non-overlapping segments, allowing
6//! preprocess passes to scan only plain `Text` segments — string literals,
7//! quoted identifiers, and comments are passed through opaquely and never
8//! matched against patterns.
9//!
10//! Supported SQL dialect features:
11//! - Single-quoted strings (`'...'`) with `''` escape and `E'...'` with
12//!   backslash escapes.
13//! - Double-quoted identifiers (`"..."`).
14//! - Line comments (`-- ...` to end-of-line).
15//! - Block comments (`/* ... */`, nestable per PostgreSQL).
16
17/// A classified segment of a SQL string.
18#[derive(Debug, PartialEq, Eq)]
19pub enum SqlSegment<'a> {
20    /// Unquoted SQL text.
21    Text(&'a str),
22    /// A single-quoted string literal, including the surrounding quotes.
23    SingleQuotedString(&'a str),
24    /// A double-quoted identifier, including the surrounding quotes.
25    QuotedIdent(&'a str),
26    /// A line comment starting with `--`, including the `--` prefix and
27    /// trailing newline (if present).
28    LineComment(&'a str),
29    /// A block comment delimited by `/* ... */` (nestable).
30    BlockComment(&'a str),
31}
32
33/// Segment a SQL string into classified [`SqlSegment`]s.
34///
35/// The entire input is covered exactly once (no bytes are skipped). Adjacent
36/// `Text` bytes are collected into a single segment.
37pub fn segments(sql: &str) -> Vec<SqlSegment<'_>> {
38    let mut out = Vec::new();
39    let bytes = sql.as_bytes();
40    let len = bytes.len();
41    let mut i = 0;
42    let mut text_start = 0;
43
44    macro_rules! flush_text {
45        () => {
46            if text_start < i {
47                out.push(SqlSegment::Text(&sql[text_start..i]));
48            }
49        };
50    }
51
52    while i < len {
53        // ── single-quoted string ──────────────────────────────────────────
54        // Optional `E` or `e` escape prefix before the opening quote.
55        let is_escape_prefix =
56            (bytes[i] == b'E' || bytes[i] == b'e') && i + 1 < len && bytes[i + 1] == b'\'';
57
58        if bytes[i] == b'\'' || is_escape_prefix {
59            flush_text!();
60            let start = i;
61            if is_escape_prefix {
62                i += 1; // skip `E`
63            }
64            i += 1; // skip opening `'`
65            let escape = is_escape_prefix;
66            while i < len {
67                match bytes[i] {
68                    b'\\' if escape => {
69                        // backslash escape: skip two chars
70                        i += 2;
71                    }
72                    b'\'' => {
73                        i += 1;
74                        // doubled-quote escape `''`
75                        if i < len && bytes[i] == b'\'' {
76                            i += 1;
77                        } else {
78                            break;
79                        }
80                    }
81                    _ => i += 1,
82                }
83            }
84            out.push(SqlSegment::SingleQuotedString(&sql[start..i]));
85            text_start = i;
86            continue;
87        }
88
89        // ── double-quoted identifier ──────────────────────────────────────
90        if bytes[i] == b'"' {
91            flush_text!();
92            let start = i;
93            i += 1; // skip opening `"`
94            while i < len {
95                match bytes[i] {
96                    b'"' => {
97                        i += 1;
98                        // doubled `""` escape inside quoted ident
99                        if i < len && bytes[i] == b'"' {
100                            i += 1;
101                        } else {
102                            break;
103                        }
104                    }
105                    _ => i += 1,
106                }
107            }
108            out.push(SqlSegment::QuotedIdent(&sql[start..i]));
109            text_start = i;
110            continue;
111        }
112
113        // ── line comment `-- ...` ─────────────────────────────────────────
114        if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
115            flush_text!();
116            let start = i;
117            while i < len && bytes[i] != b'\n' {
118                i += 1;
119            }
120            // include the newline if present
121            if i < len && bytes[i] == b'\n' {
122                i += 1;
123            }
124            out.push(SqlSegment::LineComment(&sql[start..i]));
125            text_start = i;
126            continue;
127        }
128
129        // ── block comment `/* ... */` (nestable) ─────────────────────────
130        if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
131            flush_text!();
132            let start = i;
133            i += 2; // skip `/*`
134            let mut depth: usize = 1;
135            while i < len && depth > 0 {
136                if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
137                    depth += 1;
138                    i += 2;
139                } else if bytes[i] == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
140                    depth -= 1;
141                    i += 2;
142                } else {
143                    i += 1;
144                }
145            }
146            out.push(SqlSegment::BlockComment(&sql[start..i]));
147            text_start = i;
148            continue;
149        }
150
151        // ── plain text byte ───────────────────────────────────────────────
152        i += 1;
153    }
154
155    // flush any trailing text
156    if text_start < len {
157        out.push(SqlSegment::Text(&sql[text_start..]));
158    }
159
160    out
161}
162
163/// Return the first SQL keyword/word in `sql`, skipping leading whitespace,
164/// line comments, and block comments. Returns `None` if the input is empty
165/// or contains only whitespace/comments.
166///
167/// The returned slice is a sub-slice of `sql` in its original case.
168pub fn first_sql_word(sql: &str) -> Option<&str> {
169    for seg in segments(sql) {
170        if let SqlSegment::Text(t) = seg {
171            let trimmed = t.trim_start();
172            if trimmed.is_empty() {
173                continue;
174            }
175            let end = trimmed
176                .find(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';')
177                .unwrap_or(trimmed.len());
178            if end > 0 {
179                return Some(&trimmed[..end]);
180            }
181        }
182    }
183    None
184}
185
186/// Return the second SQL keyword/word in `sql`, skipping leading whitespace,
187/// line comments, and block comments, then skipping the first word. Returns
188/// `None` if there is no second word.
189///
190/// The returned slice is a sub-slice of `sql` in its original case.
191pub fn second_sql_word(sql: &str) -> Option<&str> {
192    let mut found_first = false;
193    for seg in segments(sql) {
194        if let SqlSegment::Text(t) = seg {
195            let mut remaining = t;
196            loop {
197                let trimmed = remaining.trim_start();
198                if trimmed.is_empty() {
199                    break;
200                }
201                let end = trimmed
202                    .find(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';')
203                    .unwrap_or(trimmed.len());
204                if end == 0 {
205                    break;
206                }
207                if !found_first {
208                    found_first = true;
209                    // advance past this word
210                    remaining = &trimmed[end..];
211                } else {
212                    return Some(&trimmed[..end]);
213                }
214            }
215        }
216    }
217    None
218}
219
220/// Return `true` if `op` appears verbatim inside any `Text` segment of `sql`.
221/// The comparison is byte-exact (case-sensitive). Occurrences inside string
222/// literals, quoted identifiers, or comments are ignored.
223pub fn has_operator_outside_literals(sql: &str, op: &str) -> bool {
224    for seg in segments(sql) {
225        if let SqlSegment::Text(t) = seg
226            && t.contains(op)
227        {
228            return true;
229        }
230    }
231    false
232}
233
234/// Return the byte positions (relative to the start of `sql`) of every
235/// occurrence of `op` that falls inside a `Text` segment.
236pub fn find_operator_positions(sql: &str, op: &str) -> Vec<usize> {
237    let mut positions = Vec::new();
238    for seg in segments(sql) {
239        if let SqlSegment::Text(t) = seg {
240            // Safety: `t` is a sub-slice of `sql`; pointer arithmetic is valid.
241            let base = t.as_ptr() as usize - sql.as_ptr() as usize;
242            let mut search_from = 0;
243            while let Some(rel) = t[search_from..].find(op) {
244                let abs = base + search_from + rel;
245                positions.push(abs);
246                search_from += rel + op.len();
247            }
248        }
249    }
250    positions
251}
252
253/// Return `true` if `{` appears inside any `Text` segment of `sql`.
254pub fn has_brace_outside_literals(sql: &str) -> bool {
255    has_operator_outside_literals(sql, "{")
256}
257
258/// Dependency-neutral byte-stable ASCII matching helpers. Re-exported here
259/// to preserve the SQL parser's existing public API; SQL keyword boundary and
260/// literal-segment handling remain local to this module.
261pub use nodedb_types::{
262    find_ascii_case_insensitive, find_ascii_case_insensitive_from, rfind_ascii_case_insensitive,
263};
264
265fn is_identifier_char(character: char) -> bool {
266    character.is_alphanumeric() || character == '_'
267}
268
269fn has_word_boundaries(text: &str, position: usize, length: usize) -> bool {
270    let before_ok = position == 0
271        || !text[..position]
272            .chars()
273            .next_back()
274            .map(is_identifier_char)
275            .unwrap_or(false);
276    let after = position + length;
277    let after_ok = after >= text.len()
278        || !text[after..]
279            .chars()
280            .next()
281            .map(is_identifier_char)
282            .unwrap_or(false);
283    before_ok && after_ok
284}
285
286/// Find a whole ASCII keyword in `text`, comparing case-insensitively while
287/// returning an offset into the unchanged input.
288pub fn find_ascii_keyword(text: &str, keyword: &str) -> Option<usize> {
289    let mut start = 0;
290    while let Some(position) = find_ascii_case_insensitive_from(text, keyword, start) {
291        if has_word_boundaries(text, position, keyword.len()) {
292            return Some(position);
293        }
294        start = position + keyword.len().max(1);
295    }
296    None
297}
298
299/// Return the byte position (relative to `sql`) of the first case-insensitive
300/// occurrence of the keyword `kw` that falls inside a `Text` segment. Returns
301/// `None` if not found.
302///
303/// The match is word-boundary-aware: the character immediately before the
304/// match (if any) must not be alphanumeric or `_`, and the character
305/// immediately after the match (if any) must not be alphanumeric or `_`.
306pub fn keyword_position_outside_literals(sql: &str, kw: &str) -> Option<usize> {
307    for seg in segments(sql) {
308        if let SqlSegment::Text(text) = seg
309            && let Some(position) = find_ascii_keyword(text, kw)
310        {
311            let base = text.as_ptr() as usize - sql.as_ptr() as usize;
312            return Some(base + position);
313        }
314    }
315    None
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    // ── segments ────────────────────────────────────────────────────────────
323
324    #[test]
325    fn plain_text_is_single_segment() {
326        let segs = segments("SELECT 1");
327        assert_eq!(segs, vec![SqlSegment::Text("SELECT 1")]);
328    }
329
330    #[test]
331    fn single_quoted_string_opaque() {
332        let segs = segments("SELECT '<->'");
333        assert_eq!(
334            segs,
335            vec![
336                SqlSegment::Text("SELECT "),
337                SqlSegment::SingleQuotedString("'<->'"),
338            ]
339        );
340    }
341
342    #[test]
343    fn quoted_ident_opaque() {
344        let segs = segments(r#"SELECT "col_<->""#);
345        assert_eq!(
346            segs,
347            vec![
348                SqlSegment::Text("SELECT "),
349                SqlSegment::QuotedIdent(r#""col_<->""#),
350            ]
351        );
352    }
353
354    #[test]
355    fn line_comment_opaque() {
356        let segs = segments("SELECT col -- has <-> in comment\nFROM t");
357        // The segment after the newline belongs to a new Text segment.
358        assert!(
359            segs.iter()
360                .any(|s| matches!(s, SqlSegment::LineComment(c) if c.contains("<->")))
361        );
362        assert!(
363            segs.iter()
364                .any(|s| matches!(s, SqlSegment::Text(t) if t.contains("FROM")))
365        );
366        // `<->` must not appear in any Text segment.
367        for s in &segs {
368            if let SqlSegment::Text(t) = s {
369                assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
370            }
371        }
372    }
373
374    #[test]
375    fn block_comment_opaque() {
376        let segs = segments("SELECT /* <-> */ x");
377        assert!(
378            segs.iter()
379                .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
380        );
381        for s in &segs {
382            if let SqlSegment::Text(t) = s {
383                assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
384            }
385        }
386    }
387
388    #[test]
389    fn nested_block_comment() {
390        let segs = segments("SELECT /* /* nested */ <-> */ x");
391        // The outer `/* ... */` includes everything including `<->`.
392        assert!(
393            segs.iter()
394                .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
395        );
396        for s in &segs {
397            if let SqlSegment::Text(t) = s {
398                assert!(!t.contains("<->"), "nested <-> leaked into Text: {t}");
399            }
400        }
401    }
402
403    #[test]
404    fn doubled_quote_escape_in_string() {
405        let segs = segments("SELECT 'it''s'");
406        assert_eq!(
407            segs,
408            vec![
409                SqlSegment::Text("SELECT "),
410                SqlSegment::SingleQuotedString("'it''s'"),
411            ]
412        );
413    }
414
415    #[test]
416    fn escape_string_prefix() {
417        let segs = segments("SELECT E'foo\\nbar'");
418        assert_eq!(
419            segs,
420            vec![
421                SqlSegment::Text("SELECT "),
422                SqlSegment::SingleQuotedString("E'foo\\nbar'"),
423            ]
424        );
425    }
426
427    // ── first_sql_word ───────────────────────────────────────────────────────
428
429    #[test]
430    fn first_word_simple() {
431        assert_eq!(first_sql_word("SELECT 1"), Some("SELECT"));
432    }
433
434    #[test]
435    fn first_word_skips_line_comment() {
436        assert_eq!(first_sql_word("-- INSERT INTO t\nSELECT 1"), Some("SELECT"));
437    }
438
439    #[test]
440    fn first_word_skips_block_comment() {
441        assert_eq!(
442            first_sql_word("/* hint */ INSERT INTO t VALUES (1)"),
443            Some("INSERT")
444        );
445    }
446
447    #[test]
448    fn first_word_upsert_with_comment() {
449        assert_eq!(
450            first_sql_word("/* hint */ UPSERT INTO t { name: 'a' }"),
451            Some("UPSERT")
452        );
453    }
454
455    #[test]
456    fn first_word_empty() {
457        assert_eq!(first_sql_word("   "), None);
458    }
459
460    // ── has_operator_outside_literals ───────────────────────────────────────
461
462    #[test]
463    fn operator_in_plain_text() {
464        assert!(has_operator_outside_literals("a <-> b", "<->"));
465    }
466
467    #[test]
468    fn operator_in_string_not_detected() {
469        assert!(!has_operator_outside_literals("SELECT '<->'", "<->"));
470    }
471
472    #[test]
473    fn operator_in_line_comment_not_detected() {
474        assert!(!has_operator_outside_literals(
475            "SELECT col -- has <-> in comment\nFROM t",
476            "<->"
477        ));
478    }
479
480    #[test]
481    fn operator_in_block_comment_not_detected() {
482        assert!(!has_operator_outside_literals("SELECT /* <-> */ x", "<->"));
483    }
484
485    #[test]
486    fn operator_in_quoted_ident_not_detected() {
487        assert!(!has_operator_outside_literals(r#"SELECT "col_<->""#, "<->"));
488    }
489
490    // ── has_brace_outside_literals ──────────────────────────────────────────
491
492    #[test]
493    fn brace_in_plain_text() {
494        assert!(has_brace_outside_literals("func({ foo })"));
495    }
496
497    #[test]
498    fn brace_in_string_not_detected() {
499        assert!(!has_brace_outside_literals("func('{ foo }')"));
500    }
501
502    #[test]
503    fn brace_concat_expr_not_detected() {
504        // `'{' || x || '}'` — braces only inside string literals
505        assert!(!has_brace_outside_literals("'{' || x || '}'"));
506    }
507
508    // ── keyword_position_outside_literals ───────────────────────────────────
509
510    #[test]
511    fn keyword_found_in_plain_text() {
512        let sql = "SELECT * FROM t FOR SYSTEM_TIME AS OF 100";
513        assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_some());
514    }
515
516    #[test]
517    fn keyword_in_string_not_found() {
518        let sql = "SELECT * FROM t WHERE name = 'FOR SYSTEM_TIME'";
519        assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_none());
520    }
521
522    #[test]
523    fn keyword_position_correct() {
524        let sql = "SELECT x FOR SYSTEM_TIME AS OF 100";
525        let pos = keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").unwrap();
526        // verify the slice at that position matches the keyword (case-insensitively)
527        let found = &sql[pos..pos + "FOR SYSTEM_TIME".len()];
528        assert_eq!(found.to_uppercase(), "FOR SYSTEM_TIME");
529    }
530
531    #[test]
532    fn keyword_position_after_unicode_text_indexes_original_sql() {
533        let sql = "SELECT ffff FROM records";
534        let pos = keyword_position_outside_literals(sql, "FROM").unwrap();
535        assert_eq!(&sql[pos..pos + "FROM".len()], "FROM");
536    }
537}