Skip to main content

powdb_query/
lexer.rs

1use crate::token::Token;
2
3/// Lowercase words recognized by the PowQL lexer as keywords or built-in
4/// literal words. CLI completion imports this list so it cannot drift from
5/// the parser surface.
6pub const POWQL_KEYWORDS: &[&str] = &[
7    "abs",
8    "add",
9    "alter",
10    "and",
11    "as",
12    "asc",
13    "auto",
14    "avg",
15    "begin",
16    "between",
17    "case",
18    "cast",
19    "ceil",
20    "column",
21    "commit",
22    "concat",
23    "conflict",
24    "count",
25    "cross",
26    "date_add",
27    "date_diff",
28    "default",
29    "delete",
30    "dense_rank",
31    "desc",
32    "describe",
33    "distinct",
34    "drop",
35    "else",
36    "end",
37    "exists",
38    "explain",
39    "extract",
40    "false",
41    "filter",
42    "floor",
43    "group",
44    "having",
45    "in",
46    "index",
47    "inner",
48    "insert",
49    "is",
50    "join",
51    "json_type",
52    "json_text",
53    "left",
54    "length",
55    "let",
56    "like",
57    "limit",
58    "link",
59    "lower",
60    "match",
61    "materialize",
62    "materialized",
63    "max",
64    "min",
65    "multi",
66    "not",
67    "now",
68    "null",
69    "offset",
70    "on",
71    "or",
72    "order",
73    "outer",
74    "over",
75    "partition",
76    "pow",
77    "rank",
78    "raw",
79    "refresh",
80    "required",
81    "returning",
82    "right",
83    "rollback",
84    "round",
85    "row_number",
86    "schema",
87    "select",
88    "sqrt",
89    "substring",
90    "sum",
91    "then",
92    "transaction",
93    "trim",
94    "true",
95    "type",
96    "union",
97    "unique",
98    "update",
99    "upper",
100    "upsert",
101    "view",
102    "when",
103];
104
105/// Maximum allowed length for a string literal (16 MB).
106/// Prevents unbounded memory consumption from queries with multi-gigabyte strings.
107const MAX_STRING_LITERAL: usize = 16 * 1024 * 1024;
108
109#[derive(Debug)]
110pub struct LexError {
111    pub message: String,
112    pub position: usize,
113}
114
115impl std::fmt::Display for LexError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        write!(f, "at position {}: {}", self.position, self.message)
118    }
119}
120
121impl std::error::Error for LexError {}
122
123/// Tokenize a PowQL input string into a stream of tokens.
124///
125/// # Examples
126///
127/// ```
128/// use powdb_query::lexer::lex;
129/// use powdb_query::token::Token;
130///
131/// let tokens = lex("User filter .age > 30").unwrap();
132/// assert_eq!(tokens[0], Token::Ident("User".to_string()));
133/// assert_eq!(tokens[1], Token::Filter);
134/// assert_eq!(tokens[2], Token::DotIdent("age".to_string()));
135/// ```
136pub fn lex(input: &str) -> Result<Vec<Token>, LexError> {
137    let mut tokens = Vec::new();
138    let chars: Vec<char> = input.chars().collect();
139    let mut pos = 0;
140
141    while pos < chars.len() {
142        // Skip whitespace
143        if chars[pos].is_whitespace() {
144            pos += 1;
145            continue;
146        }
147
148        // Skip comments
149        if chars[pos] == '#' {
150            while pos < chars.len() && chars[pos] != '\n' {
151                pos += 1;
152            }
153            continue;
154        }
155
156        // Backtick-quoted dot-ident: .`field name` — lets a reserved word be
157        // used as a field reference in filter/order/project positions.
158        if chars[pos] == '.' && pos + 1 < chars.len() && chars[pos + 1] == '`' {
159            pos += 2; // skip `.` and opening backtick
160            let start = pos;
161            while pos < chars.len() && chars[pos] != '`' {
162                pos += 1;
163            }
164            if pos >= chars.len() {
165                return Err(LexError {
166                    message: "unterminated quoted identifier".into(),
167                    position: pos,
168                });
169            }
170            let name: String = chars[start..pos].iter().collect();
171            if name.is_empty() {
172                return Err(LexError {
173                    message: "empty quoted identifier".into(),
174                    position: start,
175                });
176            }
177            pos += 1; // closing backtick
178            tokens.push(Token::DotIdent(name));
179            continue;
180        }
181
182        // Backtick-quoted identifier: `column name` — always an identifier,
183        // never a keyword, so reserved words can be used as column/type names.
184        if chars[pos] == '`' {
185            pos += 1; // opening backtick
186            let start = pos;
187            while pos < chars.len() && chars[pos] != '`' {
188                pos += 1;
189            }
190            if pos >= chars.len() {
191                return Err(LexError {
192                    message: "unterminated quoted identifier".into(),
193                    position: pos,
194                });
195            }
196            let name: String = chars[start..pos].iter().collect();
197            if name.is_empty() {
198                return Err(LexError {
199                    message: "empty quoted identifier".into(),
200                    position: start,
201                });
202            }
203            pos += 1; // closing backtick
204            tokens.push(Token::Ident(name));
205            continue;
206        }
207
208        // Dot-ident: .fieldname
209        if chars[pos] == '.'
210            && pos + 1 < chars.len()
211            && (chars[pos + 1].is_alphabetic() || chars[pos + 1] == '_')
212        {
213            pos += 1; // skip dot
214            let start = pos;
215            while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
216                pos += 1;
217            }
218            let name: String = chars[start..pos].iter().collect();
219            tokens.push(Token::DotIdent(name));
220            continue;
221        }
222
223        // Param: $name
224        if chars[pos] == '$' {
225            pos += 1;
226            let start = pos;
227            while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
228                pos += 1;
229            }
230            let name: String = chars[start..pos].iter().collect();
231            tokens.push(Token::Param(name));
232            continue;
233        }
234
235        // String literal
236        if chars[pos] == '"' {
237            pos += 1;
238            let mut s = String::new();
239            while pos < chars.len() && chars[pos] != '"' {
240                if chars[pos] == '\\' && pos + 1 < chars.len() {
241                    match chars[pos + 1] {
242                        '"' => {
243                            s.push('"');
244                            pos += 2;
245                        }
246                        '\\' => {
247                            s.push('\\');
248                            pos += 2;
249                        }
250                        'n' => {
251                            s.push('\n');
252                            pos += 2;
253                        }
254                        't' => {
255                            s.push('\t');
256                            pos += 2;
257                        }
258                        _ => {
259                            s.push(chars[pos + 1]);
260                            pos += 2;
261                        }
262                    }
263                } else {
264                    s.push(chars[pos]);
265                    pos += 1;
266                }
267            }
268            if pos >= chars.len() {
269                return Err(LexError {
270                    message: "unterminated string".into(),
271                    position: pos,
272                });
273            }
274            pos += 1; // closing quote
275            if s.len() > MAX_STRING_LITERAL {
276                return Err(LexError {
277                    message: format!(
278                        "string literal exceeds maximum size of {}MB",
279                        MAX_STRING_LITERAL / (1024 * 1024)
280                    ),
281                    position: pos,
282                });
283            }
284            tokens.push(Token::StringLit(s));
285            continue;
286        }
287
288        // Number (int or float)
289        if chars[pos].is_ascii_digit()
290            || (chars[pos] == '-' && pos + 1 < chars.len() && chars[pos + 1].is_ascii_digit())
291        {
292            let start = pos;
293            if chars[pos] == '-' {
294                pos += 1;
295            }
296            while pos < chars.len() && chars[pos].is_ascii_digit() {
297                pos += 1;
298            }
299            if pos < chars.len()
300                && chars[pos] == '.'
301                && pos + 1 < chars.len()
302                && chars[pos + 1].is_ascii_digit()
303            {
304                pos += 1;
305                while pos < chars.len() && chars[pos].is_ascii_digit() {
306                    pos += 1;
307                }
308                let s: String = chars[start..pos].iter().collect();
309                let value = s.parse::<f64>().map_err(|_| LexError {
310                    message: format!("float literal out of range: {s}"),
311                    position: start,
312                })?;
313                tokens.push(Token::FloatLit(value));
314            } else {
315                let s: String = chars[start..pos].iter().collect();
316                let value = s.parse::<i64>().map_err(|_| LexError {
317                    message: format!("integer literal out of range for i64: {s}"),
318                    position: start,
319                })?;
320                tokens.push(Token::IntLit(value));
321            }
322            continue;
323        }
324
325        // Identifiers and keywords
326        if chars[pos].is_alphabetic() || chars[pos] == '_' {
327            let start = pos;
328            while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
329                pos += 1;
330            }
331            let word: String = chars[start..pos].iter().collect();
332            let token = match word.as_str() {
333                "type" => Token::Type,
334                "filter" => Token::Filter,
335                "order" => Token::Order,
336                "limit" => Token::Limit,
337                "offset" => Token::Offset,
338                "insert" => Token::Insert,
339                "update" => Token::Update,
340                "delete" => Token::Delete,
341                "default" => Token::Default,
342                "upsert" => Token::Upsert,
343                "returning" => Token::Returning,
344                "conflict" => Token::Conflict,
345                "select" => Token::Select,
346                "required" => Token::Required,
347                "multi" => Token::Multi,
348                "link" => Token::Link,
349                "index" => Token::Index,
350                "unique" => Token::Unique,
351                "on" => Token::On,
352                "asc" => Token::Asc,
353                "auto" => Token::Auto,
354                "desc" => Token::Desc,
355                "and" => Token::And,
356                "or" => Token::Or,
357                "not" => Token::Not,
358                "exists" => Token::Exists,
359                "let" => Token::Let,
360                "as" => Token::As,
361                "match" => Token::Match,
362                "group" => Token::Group,
363                "join" => Token::Join,
364                "inner" => Token::Inner,
365                "left" => Token::LeftKw,
366                "right" => Token::RightKw,
367                "outer" => Token::Outer,
368                "cross" => Token::Cross,
369                "transaction" => Token::Transaction,
370                "begin" => Token::Begin,
371                "commit" => Token::Commit,
372                "rollback" => Token::Rollback,
373                "view" => Token::View,
374                "materialized" => Token::Materialized,
375                "materialize" => Token::Materialized,
376                "refresh" => Token::Refresh,
377                "union" => Token::Union,
378                "having" => Token::Having,
379                "distinct" => Token::Distinct,
380                "in" => Token::In,
381                "between" => Token::Between,
382                "like" => Token::Like,
383                "count" => Token::Count,
384                "avg" => Token::Avg,
385                "sum" => Token::Sum,
386                "min" => Token::Min,
387                "max" => Token::Max,
388                "raw" => Token::Raw,
389                "is" => Token::Is,
390                "null" => Token::Null,
391                "upper" => Token::Upper,
392                "lower" => Token::Lower,
393                "length" => Token::Length,
394                "trim" => Token::Trim,
395                "substring" => Token::Substring,
396                "concat" => Token::Concat,
397                "abs" => Token::Abs,
398                "round" => Token::Round,
399                "ceil" => Token::Ceil,
400                "floor" => Token::Floor,
401                "sqrt" => Token::Sqrt,
402                "pow" => Token::Pow,
403                "now" => Token::Now,
404                "extract" => Token::Extract,
405                "date_add" => Token::DateAdd,
406                "date_diff" => Token::DateDiff,
407                "json_type" => Token::JsonType,
408                "json_text" => Token::JsonText,
409                "cast" => Token::Cast,
410                "case" => Token::Case,
411                "when" => Token::When,
412                "then" => Token::Then,
413                "else" => Token::Else,
414                "end" => Token::End,
415                "over" => Token::Over,
416                "partition" => Token::Partition,
417                "row_number" => Token::RowNumber,
418                "rank" => Token::Rank,
419                "dense_rank" => Token::DenseRank,
420                "alter" => Token::Alter,
421                "drop" => Token::Drop,
422                "add" => Token::Add,
423                "column" => Token::Column,
424                "explain" => Token::Explain,
425                "schema" => Token::Schema,
426                "describe" => Token::Describe,
427                "true" => Token::BoolLit(true),
428                "false" => Token::BoolLit(false),
429                _ => Token::Ident(word),
430            };
431            tokens.push(token);
432            continue;
433        }
434
435        // Two-char operators
436        if pos + 1 < chars.len() {
437            let two: String = chars[pos..pos + 2].iter().collect();
438            match two.as_str() {
439                ":=" => {
440                    tokens.push(Token::Assign);
441                    pos += 2;
442                    continue;
443                }
444                "->" => {
445                    tokens.push(Token::Arrow);
446                    pos += 2;
447                    continue;
448                }
449                "!=" => {
450                    tokens.push(Token::Neq);
451                    pos += 2;
452                    continue;
453                }
454                "<=" => {
455                    tokens.push(Token::Lte);
456                    pos += 2;
457                    continue;
458                }
459                ">=" => {
460                    tokens.push(Token::Gte);
461                    pos += 2;
462                    continue;
463                }
464                "??" => {
465                    tokens.push(Token::Coalesce);
466                    pos += 2;
467                    continue;
468                }
469                _ => {}
470            }
471        }
472
473        // Single-char operators
474        let token = match chars[pos] {
475            '=' => Token::Eq,
476            '<' => Token::Lt,
477            '>' => Token::Gt,
478            '|' => Token::Pipe,
479            '+' => Token::Plus,
480            '-' => Token::Minus,
481            '*' => Token::Star,
482            '/' => Token::Slash,
483            '{' => Token::LBrace,
484            '}' => Token::RBrace,
485            '(' => Token::LParen,
486            ')' => Token::RParen,
487            ',' => Token::Comma,
488            ':' => Token::Colon,
489            '.' => Token::Dot,
490            c => {
491                return Err(LexError {
492                    message: format!("unexpected character: {c}"),
493                    position: pos,
494                })
495            }
496        };
497        tokens.push(token);
498        pos += 1;
499    }
500
501    tokens.push(Token::Eof);
502    Ok(tokens)
503}
504
505/// Split a PowQL source string into individual statements on top-level `;`,
506/// mirroring the lexer's string- and comment-scanning rules so a `;` inside a
507/// `"..."` literal or a `#` comment is never a boundary. Segments are trimmed
508/// and empty ones dropped, so leading/trailing/doubled `;` and blank lines are
509/// harmless.
510///
511/// Infallible by design: an unterminated string leaves its remainder as the
512/// final segment, so the "unterminated string" error surfaces once, at
513/// execution time, from [`lex`] — this function never reports it.
514pub fn split_statements(input: &str) -> Vec<&str> {
515    #[derive(PartialEq)]
516    enum State {
517        Normal,
518        InString,
519        InComment,
520    }
521
522    let mut out = Vec::new();
523    let mut start = 0usize;
524    let mut state = State::Normal;
525    let mut chars = input.char_indices();
526
527    while let Some((i, c)) = chars.next() {
528        match state {
529            State::Normal => match c {
530                ';' => {
531                    let seg = input[start..i].trim();
532                    if !seg.is_empty() {
533                        out.push(seg);
534                    }
535                    start = i + 1; // `;` is one byte
536                }
537                '"' => state = State::InString,
538                '#' => state = State::InComment,
539                _ => {}
540            },
541            State::InString => match c {
542                // Mirror the lexer: a backslash consumes the next char
543                // unconditionally, so `\"` and `\;` stay inside the string.
544                '\\' => {
545                    chars.next();
546                }
547                '"' => state = State::Normal,
548                _ => {}
549            },
550            State::InComment => {
551                if c == '\n' {
552                    state = State::Normal;
553                }
554            }
555        }
556    }
557
558    let seg = input[start..].trim();
559    if !seg.is_empty() {
560        out.push(seg);
561    }
562    out
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::token::Token;
569
570    #[test]
571    fn test_lex_simple_query() {
572        let tokens = lex("User filter .age > 30").unwrap();
573        assert_eq!(
574            tokens,
575            vec![
576                Token::Ident("User".into()),
577                Token::Filter,
578                Token::DotIdent("age".into()),
579                Token::Gt,
580                Token::IntLit(30),
581                Token::Eof,
582            ]
583        );
584    }
585
586    #[test]
587    fn test_lex_projection() {
588        let tokens = lex("User { name, email }").unwrap();
589        assert_eq!(
590            tokens,
591            vec![
592                Token::Ident("User".into()),
593                Token::LBrace,
594                Token::Ident("name".into()),
595                Token::Comma,
596                Token::Ident("email".into()),
597                Token::RBrace,
598                Token::Eof,
599            ]
600        );
601    }
602
603    #[test]
604    fn test_lex_insert() {
605        let tokens = lex(r#"insert User { name := "Alice", age := 30 }"#).unwrap();
606        assert_eq!(
607            tokens,
608            vec![
609                Token::Insert,
610                Token::Ident("User".into()),
611                Token::LBrace,
612                Token::Ident("name".into()),
613                Token::Assign,
614                Token::StringLit("Alice".into()),
615                Token::Comma,
616                Token::Ident("age".into()),
617                Token::Assign,
618                Token::IntLit(30),
619                Token::RBrace,
620                Token::Eof,
621            ]
622        );
623    }
624
625    #[test]
626    fn test_lex_params() {
627        let tokens = lex("User filter .age > $min_age").unwrap();
628        assert_eq!(
629            tokens,
630            vec![
631                Token::Ident("User".into()),
632                Token::Filter,
633                Token::DotIdent("age".into()),
634                Token::Gt,
635                Token::Param("min_age".into()),
636                Token::Eof,
637            ]
638        );
639    }
640
641    #[test]
642    fn test_lex_string_with_escapes() {
643        let tokens = lex(r#""hello \"world\"""#).unwrap();
644        assert_eq!(
645            tokens,
646            vec![Token::StringLit("hello \"world\"".into()), Token::Eof,]
647        );
648    }
649
650    #[test]
651    fn test_lex_aggregation() {
652        let tokens = lex("count(User)").unwrap();
653        assert_eq!(
654            tokens,
655            vec![
656                Token::Count,
657                Token::LParen,
658                Token::Ident("User".into()),
659                Token::RParen,
660                Token::Eof,
661            ]
662        );
663    }
664
665    /// Regression for issue #24: an integer literal with more digits than
666    /// i64 can hold previously reached `s.parse::<i64>().unwrap()` and
667    /// panicked. It must return a `LexError` instead.
668    #[test]
669    fn test_lex_backtick_identifier() {
670        // A reserved word inside backticks lexes as a plain identifier.
671        let tokens = lex("type Post { `type`: str }").unwrap();
672        assert_eq!(
673            tokens,
674            vec![
675                Token::Type,
676                Token::Ident("Post".into()),
677                Token::LBrace,
678                Token::Ident("type".into()),
679                Token::Colon,
680                Token::Ident("str".into()),
681                Token::RBrace,
682                Token::Eof,
683            ]
684        );
685    }
686
687    #[test]
688    fn test_lex_backtick_dot_identifier() {
689        // `.`type`` is a field reference to a column named `type`.
690        let tokens = lex("Post filter .`type` = \"x\"").unwrap();
691        assert_eq!(
692            tokens,
693            vec![
694                Token::Ident("Post".into()),
695                Token::Filter,
696                Token::DotIdent("type".into()),
697                Token::Eq,
698                Token::StringLit("x".into()),
699                Token::Eof,
700            ]
701        );
702    }
703
704    #[test]
705    fn test_lex_backtick_allows_spaces_in_identifier() {
706        let tokens = lex("`full name`").unwrap();
707        assert_eq!(tokens, vec![Token::Ident("full name".into()), Token::Eof]);
708    }
709
710    #[test]
711    fn test_lex_unterminated_backtick_errors() {
712        let err = lex("`type").expect_err("must error");
713        assert!(err.message.contains("unterminated quoted identifier"));
714    }
715
716    #[test]
717    fn test_lex_empty_backtick_errors() {
718        let err = lex("``").expect_err("must error");
719        assert!(err.message.contains("empty quoted identifier"));
720    }
721
722    #[test]
723    fn test_lex_schema_and_describe_keywords() {
724        assert_eq!(lex("schema").unwrap(), vec![Token::Schema, Token::Eof]);
725        assert_eq!(
726            lex("describe Post").unwrap(),
727            vec![Token::Describe, Token::Ident("Post".into()), Token::Eof]
728        );
729    }
730
731    #[test]
732    fn test_lex_intlit_overflow_returns_err() {
733        // 22 digits — well past i64::MAX (19 digits).
734        let err = lex("4444444441111111144444").expect_err("must error, not panic");
735        assert!(
736            err.message.contains("integer literal out of range"),
737            "unexpected message: {}",
738            err.message
739        );
740        assert_eq!(err.position, 0);
741    }
742
743    /// Same bug, reached via the exact fuzzer reproducer from the
744    /// libFuzzer artifact attached to issue #24 (base64
745    /// `YXMJCQkJCQkJCQkJCQkJNDQ0NDQ0NDQ0MTExMTExMTQ0NDQJCQkJCQk=`).
746    #[test]
747    fn test_lex_fuzz_repro_issue_24() {
748        let input = "as\t\t\t\t\t\t\t\t\t\t\t\t\t44444444411111114444\t\t\t\t\t\t";
749        let err = lex(input).expect_err("fuzz reproducer must now error, not panic");
750        assert!(err.message.contains("integer literal"));
751    }
752
753    // ── split_statements (issue #150) ──────────────────────────────────
754
755    #[test]
756    fn test_split_top_level_semicolons() {
757        assert_eq!(
758            split_statements("insert A { a := 1 }; insert B { b := 2 }"),
759            vec!["insert A { a := 1 }", "insert B { b := 2 }"]
760        );
761    }
762
763    #[test]
764    fn test_split_semicolon_in_string_not_split() {
765        // The core #150 repro: a `;` inside a string literal must not split.
766        assert_eq!(
767            split_statements(r#"insert Note { body := "hello; world" }"#),
768            vec![r#"insert Note { body := "hello; world" }"#]
769        );
770    }
771
772    #[test]
773    fn test_split_escaped_quote_then_semicolon() {
774        // `\"` keeps us inside the string, so the following `;` does not
775        // split; the string closes at the final unescaped `"`, then the
776        // top-level `;` splits.
777        let input = r#"insert A { v := "a\"; b" }; insert B { c := 1 }"#;
778        assert_eq!(
779            split_statements(input),
780            vec![r#"insert A { v := "a\"; b" }"#, "insert B { c := 1 }"]
781        );
782    }
783
784    #[test]
785    fn test_split_backslash_consumes_any_char() {
786        // `"\\"` is a single-backslash string (the `\` escapes the `\`); the
787        // string closes at the second `"`, so the trailing `;` splits.
788        let input = r#"insert A { v := "\\" }; x"#;
789        assert_eq!(
790            split_statements(input),
791            vec![r#"insert A { v := "\\" }"#, "x"]
792        );
793    }
794
795    #[test]
796    fn test_split_semicolon_in_comment_not_split() {
797        let input = "insert A { a := 1 } # trailing; comment\n; insert B { b := 2 }";
798        assert_eq!(
799            split_statements(input),
800            vec![
801                "insert A { a := 1 } # trailing; comment",
802                "insert B { b := 2 }"
803            ]
804        );
805    }
806
807    #[test]
808    fn test_split_drops_empty_segments() {
809        // Leading, doubled, and trailing `;` plus blank lines all drop.
810        assert_eq!(split_statements("; A ;; B ;\n\n"), vec!["A", "B"]);
811    }
812
813    #[test]
814    fn test_split_no_semicolon_backcompat() {
815        // Byte-identical to the old `split(';').map(trim).filter(non-empty)`
816        // single-statement behavior.
817        assert_eq!(split_statements("count(User)"), vec!["count(User)"]);
818        assert!(split_statements("   ").is_empty());
819    }
820
821    #[test]
822    fn test_split_unterminated_string_tail() {
823        // Never errors: the unterminated string becomes the final segment.
824        let input = r#"insert A { a := 1 }; insert B { b := "oops"#;
825        assert_eq!(
826            split_statements(input),
827            vec!["insert A { a := 1 }", r#"insert B { b := "oops"#]
828        );
829    }
830
831    #[test]
832    fn test_split_multiline_string_with_semicolon() {
833        let input = "insert A { body := \"line1;\nline2\" }; insert B { b := 2 }";
834        assert_eq!(
835            split_statements(input),
836            vec![
837                "insert A { body := \"line1;\nline2\" }",
838                "insert B { b := 2 }"
839            ]
840        );
841    }
842}