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/// Find an ASCII `needle` in `text` without allocating a case-transformed
259/// copy. The returned byte offset always belongs to `text`.
260pub fn find_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
261    find_ascii_case_insensitive_from(text, needle, 0)
262}
263
264/// Find the last ASCII `needle` in `text` without allocating a transformed
265/// copy. The returned byte offset always belongs to `text`.
266pub fn rfind_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
267    if needle.is_empty() {
268        return Some(text.len());
269    }
270    if !needle.is_ascii() || needle.len() > text.len() {
271        return None;
272    }
273
274    let haystack = text.as_bytes();
275    let needle = needle.as_bytes();
276    (0..=haystack.len() - needle.len()).rev().find(|&position| {
277        haystack[position..position + needle.len()]
278            .iter()
279            .zip(needle)
280            .all(|(left, right)| left.eq_ignore_ascii_case(right))
281    })
282}
283
284/// Find an ASCII `needle` at or after byte offset `start` in `text`.
285///
286/// `start` need not be a UTF-8 character boundary because matching is performed
287/// on bytes. A successful position is always a boundary: an ASCII needle can
288/// only begin on an ASCII byte in the original string.
289pub fn find_ascii_case_insensitive_from(text: &str, needle: &str, start: usize) -> Option<usize> {
290    if needle.is_empty() {
291        return (start <= text.len()).then_some(start);
292    }
293    if !needle.is_ascii() || start > text.len() || needle.len() > text.len() - start {
294        return None;
295    }
296
297    let haystack = text.as_bytes();
298    let needle = needle.as_bytes();
299    (start..=haystack.len() - needle.len()).find(|&position| {
300        haystack[position..position + needle.len()]
301            .iter()
302            .zip(needle)
303            .all(|(left, right)| left.eq_ignore_ascii_case(right))
304    })
305}
306
307fn is_identifier_char(character: char) -> bool {
308    character.is_alphanumeric() || character == '_'
309}
310
311fn has_word_boundaries(text: &str, position: usize, length: usize) -> bool {
312    let before_ok = position == 0
313        || !text[..position]
314            .chars()
315            .next_back()
316            .map(is_identifier_char)
317            .unwrap_or(false);
318    let after = position + length;
319    let after_ok = after >= text.len()
320        || !text[after..]
321            .chars()
322            .next()
323            .map(is_identifier_char)
324            .unwrap_or(false);
325    before_ok && after_ok
326}
327
328/// Find a whole ASCII keyword in `text`, comparing case-insensitively while
329/// returning an offset into the unchanged input.
330pub fn find_ascii_keyword(text: &str, keyword: &str) -> Option<usize> {
331    let mut start = 0;
332    while let Some(position) = find_ascii_case_insensitive_from(text, keyword, start) {
333        if has_word_boundaries(text, position, keyword.len()) {
334            return Some(position);
335        }
336        start = position + keyword.len().max(1);
337    }
338    None
339}
340
341/// Return the byte position (relative to `sql`) of the first case-insensitive
342/// occurrence of the keyword `kw` that falls inside a `Text` segment. Returns
343/// `None` if not found.
344///
345/// The match is word-boundary-aware: the character immediately before the
346/// match (if any) must not be alphanumeric or `_`, and the character
347/// immediately after the match (if any) must not be alphanumeric or `_`.
348pub fn keyword_position_outside_literals(sql: &str, kw: &str) -> Option<usize> {
349    for seg in segments(sql) {
350        if let SqlSegment::Text(text) = seg
351            && let Some(position) = find_ascii_keyword(text, kw)
352        {
353            let base = text.as_ptr() as usize - sql.as_ptr() as usize;
354            return Some(base + position);
355        }
356    }
357    None
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    // ── segments ────────────────────────────────────────────────────────────
365
366    #[test]
367    fn plain_text_is_single_segment() {
368        let segs = segments("SELECT 1");
369        assert_eq!(segs, vec![SqlSegment::Text("SELECT 1")]);
370    }
371
372    #[test]
373    fn single_quoted_string_opaque() {
374        let segs = segments("SELECT '<->'");
375        assert_eq!(
376            segs,
377            vec![
378                SqlSegment::Text("SELECT "),
379                SqlSegment::SingleQuotedString("'<->'"),
380            ]
381        );
382    }
383
384    #[test]
385    fn quoted_ident_opaque() {
386        let segs = segments(r#"SELECT "col_<->""#);
387        assert_eq!(
388            segs,
389            vec![
390                SqlSegment::Text("SELECT "),
391                SqlSegment::QuotedIdent(r#""col_<->""#),
392            ]
393        );
394    }
395
396    #[test]
397    fn line_comment_opaque() {
398        let segs = segments("SELECT col -- has <-> in comment\nFROM t");
399        // The segment after the newline belongs to a new Text segment.
400        assert!(
401            segs.iter()
402                .any(|s| matches!(s, SqlSegment::LineComment(c) if c.contains("<->")))
403        );
404        assert!(
405            segs.iter()
406                .any(|s| matches!(s, SqlSegment::Text(t) if t.contains("FROM")))
407        );
408        // `<->` must not appear in any Text segment.
409        for s in &segs {
410            if let SqlSegment::Text(t) = s {
411                assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
412            }
413        }
414    }
415
416    #[test]
417    fn block_comment_opaque() {
418        let segs = segments("SELECT /* <-> */ x");
419        assert!(
420            segs.iter()
421                .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
422        );
423        for s in &segs {
424            if let SqlSegment::Text(t) = s {
425                assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
426            }
427        }
428    }
429
430    #[test]
431    fn nested_block_comment() {
432        let segs = segments("SELECT /* /* nested */ <-> */ x");
433        // The outer `/* ... */` includes everything including `<->`.
434        assert!(
435            segs.iter()
436                .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
437        );
438        for s in &segs {
439            if let SqlSegment::Text(t) = s {
440                assert!(!t.contains("<->"), "nested <-> leaked into Text: {t}");
441            }
442        }
443    }
444
445    #[test]
446    fn doubled_quote_escape_in_string() {
447        let segs = segments("SELECT 'it''s'");
448        assert_eq!(
449            segs,
450            vec![
451                SqlSegment::Text("SELECT "),
452                SqlSegment::SingleQuotedString("'it''s'"),
453            ]
454        );
455    }
456
457    #[test]
458    fn escape_string_prefix() {
459        let segs = segments("SELECT E'foo\\nbar'");
460        assert_eq!(
461            segs,
462            vec![
463                SqlSegment::Text("SELECT "),
464                SqlSegment::SingleQuotedString("E'foo\\nbar'"),
465            ]
466        );
467    }
468
469    // ── first_sql_word ───────────────────────────────────────────────────────
470
471    #[test]
472    fn first_word_simple() {
473        assert_eq!(first_sql_word("SELECT 1"), Some("SELECT"));
474    }
475
476    #[test]
477    fn first_word_skips_line_comment() {
478        assert_eq!(first_sql_word("-- INSERT INTO t\nSELECT 1"), Some("SELECT"));
479    }
480
481    #[test]
482    fn first_word_skips_block_comment() {
483        assert_eq!(
484            first_sql_word("/* hint */ INSERT INTO t VALUES (1)"),
485            Some("INSERT")
486        );
487    }
488
489    #[test]
490    fn first_word_upsert_with_comment() {
491        assert_eq!(
492            first_sql_word("/* hint */ UPSERT INTO t { name: 'a' }"),
493            Some("UPSERT")
494        );
495    }
496
497    #[test]
498    fn first_word_empty() {
499        assert_eq!(first_sql_word("   "), None);
500    }
501
502    // ── has_operator_outside_literals ───────────────────────────────────────
503
504    #[test]
505    fn operator_in_plain_text() {
506        assert!(has_operator_outside_literals("a <-> b", "<->"));
507    }
508
509    #[test]
510    fn operator_in_string_not_detected() {
511        assert!(!has_operator_outside_literals("SELECT '<->'", "<->"));
512    }
513
514    #[test]
515    fn operator_in_line_comment_not_detected() {
516        assert!(!has_operator_outside_literals(
517            "SELECT col -- has <-> in comment\nFROM t",
518            "<->"
519        ));
520    }
521
522    #[test]
523    fn operator_in_block_comment_not_detected() {
524        assert!(!has_operator_outside_literals("SELECT /* <-> */ x", "<->"));
525    }
526
527    #[test]
528    fn operator_in_quoted_ident_not_detected() {
529        assert!(!has_operator_outside_literals(r#"SELECT "col_<->""#, "<->"));
530    }
531
532    // ── has_brace_outside_literals ──────────────────────────────────────────
533
534    #[test]
535    fn brace_in_plain_text() {
536        assert!(has_brace_outside_literals("func({ foo })"));
537    }
538
539    #[test]
540    fn brace_in_string_not_detected() {
541        assert!(!has_brace_outside_literals("func('{ foo }')"));
542    }
543
544    #[test]
545    fn brace_concat_expr_not_detected() {
546        // `'{' || x || '}'` — braces only inside string literals
547        assert!(!has_brace_outside_literals("'{' || x || '}'"));
548    }
549
550    // ── keyword_position_outside_literals ───────────────────────────────────
551
552    #[test]
553    fn keyword_found_in_plain_text() {
554        let sql = "SELECT * FROM t FOR SYSTEM_TIME AS OF 100";
555        assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_some());
556    }
557
558    #[test]
559    fn keyword_in_string_not_found() {
560        let sql = "SELECT * FROM t WHERE name = 'FOR SYSTEM_TIME'";
561        assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_none());
562    }
563
564    #[test]
565    fn keyword_position_correct() {
566        let sql = "SELECT x FOR SYSTEM_TIME AS OF 100";
567        let pos = keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").unwrap();
568        // verify the slice at that position matches the keyword (case-insensitively)
569        let found = &sql[pos..pos + "FOR SYSTEM_TIME".len()];
570        assert_eq!(found.to_uppercase(), "FOR SYSTEM_TIME");
571    }
572
573    #[test]
574    fn keyword_position_after_unicode_text_indexes_original_sql() {
575        let sql = "SELECT ffff FROM records";
576        let pos = keyword_position_outside_literals(sql, "FROM").unwrap();
577        assert_eq!(&sql[pos..pos + "FROM".len()], "FROM");
578    }
579}