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