diff --git a/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs new file mode 100644 index 000000000..4a1302b16 --- /dev/null +++ b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Consume-tracking cursor over a graph DSL token list. +//! +//! The module's parse is seek-based: a clause reader finds its keyword +//! wherever it appears and ignores every token between. That is fine while +//! every token belongs to some clause. It is wrong the moment a token belongs +//! to none: `GRAPH TRAVERSE FROM 1 DEPTS 3 IN g` finds no `DEPTH`, defaults +//! the depth, and leaves `DEPTS 3` unread — the statement answers a different +//! question than it asked, with no error. +//! +//! The cursor records which tokens each clause consumed. After the statement +//! is built, [`Cursor::finish`] refuses the first token no clause claimed. +//! A typo is then a parse error that names the token, and a new clause cannot +//! be added without deciding what it consumes. + +use super::tokenizer::Tok; +use crate::error::SqlError; + +/// A token list plus the set of tokens a clause has claimed. +pub(super) struct Cursor<'a> { + toks: Vec>, + used: Vec, +} + +impl<'a> Cursor<'a> { + /// Build a cursor over `toks`. The first `prefix_len` tokens are the + /// command words (`GRAPH TRAVERSE`), which the dispatcher matched and no + /// clause will claim. + pub(super) fn new(toks: Vec>, prefix_len: usize) -> Self { + let mut used = vec![false; toks.len()]; + for slot in used.iter_mut().take(prefix_len.min(toks.len())) { + *slot = true; + } + Self { toks, used } + } + + fn is_keyword(tok: &Tok<'_>, keyword: &str) -> bool { + matches!(tok, Tok::Word(w) if w.eq_ignore_ascii_case(keyword)) + } + + /// Position of `keyword`, claiming it. A keyword already claimed by an + /// earlier clause still matches: `IN` in one statement is one clause, but + /// the seek-based readers may be called in any order. + fn find(&mut self, keyword: &str) -> Option { + let pos = self + .toks + .iter() + .position(|tok| Self::is_keyword(tok, keyword))?; + self.used[pos] = true; + Some(pos) + } + + /// Claim the value token at `pos` when it is a word or a quoted literal. + /// An object literal is claimed by the callers that accept one. + fn claim_text(&mut self, pos: usize) -> Option { + let value = match self.toks.get(pos)? { + Tok::Quoted(s) => s.clone().into_owned(), + Tok::Word(w) => (*w).to_string(), + Tok::Object(_) => return None, + }; + self.used[pos] = true; + Some(value) + } + + /// The word or quoted literal after `keyword`. + pub(super) fn quoted_after(&mut self, keyword: &str) -> Option { + let pos = self.find(keyword)?; + self.claim_text(pos + 1) + } + + /// The word or quoted literal after `keyword`, searching only at or after + /// the first `anchor` token. Used where one keyword introduces two clauses, + /// as `ON` does for `ON ` and `BM25 ON `. + pub(super) fn quoted_after_from(&mut self, anchor: &str, keyword: &str) -> Option { + let anchor_pos = self + .toks + .iter() + .position(|tok| Self::is_keyword(tok, anchor))?; + let offset = self.toks[anchor_pos..] + .iter() + .position(|tok| Self::is_keyword(tok, keyword))?; + let pos = anchor_pos + offset; + self.used[pos] = true; + self.claim_text(pos + 1) + } + + /// Every consecutive word or quoted literal after `keyword`, up to the + /// first token that is neither. Used by `AS