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