Skip to main content

reddb_rql/parser/
error.rs

1//! Parser error types
2
3use std::fmt::{self, Write};
4
5use crate::lexer::{LexerError, LexerLimitHit, Position, Token};
6
7/// Parse error
8#[derive(Debug, Clone)]
9pub struct ParseError {
10    /// Error message
11    pub message: String,
12    /// Position where error occurred
13    pub position: Position,
14    /// Expected tokens (for better error messages)
15    pub expected: Vec<String>,
16    /// Optional structured kind for hardening / DoS errors
17    pub kind: ParseErrorKind,
18}
19
20/// Categorical kind for a parse error.
21///
22/// Most parse errors are plain `Syntax` failures; the variants
23/// below carry structured information for the parser-hardening
24/// layer (issue #87) so callers can distinguish DoS-style refusals
25/// from grammar errors without string matching.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ParseErrorKind {
28    /// Generic syntax / semantic error.
29    Syntax,
30    /// Recursion-depth limit exceeded during parsing.
31    DepthLimit {
32        limit_name: &'static str,
33        value: usize,
34    },
35    /// Input larger than the configured byte cap.
36    InputTooLarge {
37        limit_name: &'static str,
38        value: usize,
39    },
40    /// Identifier longer than the configured character cap.
41    IdentifierTooLong {
42        limit_name: &'static str,
43        value: usize,
44    },
45    /// Parser consumed more tokens than the configured cap.
46    TokenLimit {
47        limit_name: &'static str,
48        value: usize,
49    },
50    /// A literal value (integer / float) parsed cleanly but lies
51    /// outside the semantic range expected for its slot — e.g.
52    /// `MAX_SIZE 0`, `lat = 91.0`, `K = 0`, or a negative integer
53    /// where a positive one is required. The structured payload lets
54    /// the snapshot/property harness distinguish these from generic
55    /// syntax errors without string matching.
56    ValueOutOfRange {
57        /// Stable slot name, e.g. `"MAX_SIZE"`, `"lat"`, `"radius"`.
58        field: &'static str,
59        /// Free-text constraint, e.g. `"must be > 0"`,
60        /// `"must be in -90.0..=90.0"`.
61        constraint: &'static str,
62    },
63    /// The lexer recognized this token, but the parser does not support
64    /// it in the current grammar position.
65    UnsupportedToken { token: String },
66}
67
68impl ParseError {
69    /// Create a new parse error
70    pub fn new(message: impl Into<String>, position: Position) -> Self {
71        Self {
72            message: message.into(),
73            position,
74            expected: Vec::new(),
75            kind: ParseErrorKind::Syntax,
76        }
77    }
78
79    /// Create error with expected tokens
80    ///
81    /// `found` is rendered through [`SafeTokenDisplay`] so caller-controlled
82    /// bytes inside `Token::Ident` / `Token::String` / `Token::JsonLiteral` /
83    /// `Token::Float` / `Token::Integer` payloads are escaped via Rust's
84    /// `escape_debug` rules (CR / LF / NUL / quote bytes become `\n`,
85    /// `\r`, `\0`, `\"`, …). Static keyword and punctuation arms keep their
86    /// existing UPPER-CASE rendering so error messages and snapshot tests
87    /// stay readable. This prevents F-05 smuggling through the downstream
88    /// JSON / audit / log / gRPC sinks that embed `ParseError::message`.
89    pub fn expected(expected: Vec<&str>, found: &Token, position: Position) -> Self {
90        Self {
91            message: format!("Unexpected token: {}", SafeTokenDisplay(found)),
92            position,
93            expected: expected.into_iter().map(|s| s.to_string()).collect(),
94            kind: ParseErrorKind::Syntax,
95        }
96    }
97
98    /// Create an error when a lexer-known keyword appears in a parser
99    /// position where that keyword has no supported production.
100    pub fn unsupported_recognized_token(found: &Token, position: Position) -> Option<Self> {
101        let token = recognized_keyword_name(found)?;
102        Some(Self {
103            message: format!("token {token} is recognized but not supported in this position"),
104            position,
105            expected: Vec::new(),
106            kind: ParseErrorKind::UnsupportedToken { token },
107        })
108    }
109
110    /// Recursion depth limit hit. The structured `kind` carries the
111    /// name + numeric value so the snapshot/property harness can
112    /// pattern-match without string slicing.
113    pub fn depth_limit(limit_name: &'static str, value: usize, position: Position) -> Self {
114        Self {
115            message: format!(
116                "recursion depth limit exceeded ({} = {})",
117                limit_name, value
118            ),
119            position,
120            expected: Vec::new(),
121            kind: ParseErrorKind::DepthLimit { limit_name, value },
122        }
123    }
124
125    /// Input bytes exceeded the configured cap.
126    pub fn input_too_large(limit_name: &'static str, value: usize, position: Position) -> Self {
127        Self {
128            message: format!(
129                "input exceeds maximum size ({} = {} bytes)",
130                limit_name, value
131            ),
132            position,
133            expected: Vec::new(),
134            kind: ParseErrorKind::InputTooLarge { limit_name, value },
135        }
136    }
137
138    /// Identifier exceeded the configured character cap.
139    pub fn identifier_too_long(limit_name: &'static str, value: usize, position: Position) -> Self {
140        Self {
141            message: format!(
142                "identifier exceeds maximum length ({} = {} chars)",
143                limit_name, value
144            ),
145            position,
146            expected: Vec::new(),
147            kind: ParseErrorKind::IdentifierTooLong { limit_name, value },
148        }
149    }
150
151    /// Token budget exceeded during parsing.
152    pub fn token_limit(limit_name: &'static str, value: usize, position: Position) -> Self {
153        Self {
154            message: format!("parser token limit exceeded ({} = {})", limit_name, value),
155            position,
156            expected: Vec::new(),
157            kind: ParseErrorKind::TokenLimit { limit_name, value },
158        }
159    }
160
161    /// A literal value lies outside the allowed range for its slot.
162    /// The free-text `constraint` is included verbatim in the message
163    /// so callers can render a single line without re-formatting.
164    pub fn value_out_of_range(
165        field: &'static str,
166        constraint: &'static str,
167        position: Position,
168    ) -> Self {
169        Self {
170            message: format!("{} {}", field, constraint),
171            position,
172            expected: Vec::new(),
173            kind: ParseErrorKind::ValueOutOfRange { field, constraint },
174        }
175    }
176
177    /// Didactic (#1704): a document-model newcomer wrote the Postgres
178    /// `CREATE TABLE <name> DOCUMENT (…)` form. Documents are schemaless
179    /// and have no column list — the native form is `CREATE DOCUMENT`.
180    pub fn document_create_table(position: Position) -> Self {
181        Self {
182            message: "documents are schemaless: write `CREATE DOCUMENT <name>` \
183                      (no column list) instead of `CREATE TABLE <name> DOCUMENT (…)` — \
184                      top-level body fields auto-flatten into queryable columns"
185                .to_string(),
186            position,
187            expected: Vec::new(),
188            kind: ParseErrorKind::Syntax,
189        }
190    }
191
192    /// Didactic (#1704): a document-model newcomer reached for a Postgres
193    /// `GENERATED ALWAYS AS (…) STORED` column. In the document model,
194    /// top-level body fields already auto-flatten into queryable columns.
195    pub fn generated_column_unneeded(position: Position) -> Self {
196        Self {
197            message: "top-level document body fields auto-flatten into queryable \
198                      columns, so no generated column is needed: drop the \
199                      `GENERATED ALWAYS AS (…) STORED` clause and query the field by \
200                      its dotted path (e.g. `body.details.ip`)"
201                .to_string(),
202            position,
203            expected: Vec::new(),
204            kind: ParseErrorKind::Syntax,
205        }
206    }
207
208    /// Didactic (#1704): a document-model newcomer used the Postgres JSON
209    /// operators `->` / `->>` in a query. The native idiom is the dotted
210    /// path. (Graph-mode `->` / `<-` traversal is parsed elsewhere and is
211    /// unaffected.)
212    pub fn json_arrow_operator(position: Position) -> Self {
213        Self {
214            message: "the `->` / `->>` JSON operators are not supported: use the \
215                      dotted-path idiom instead (e.g. `body.details.ip` rather than \
216                      `body->'details'->>'ip'`)"
217                .to_string(),
218            position,
219            expected: Vec::new(),
220            kind: ParseErrorKind::Syntax,
221        }
222    }
223
224    /// Didactic (#1709 / ADR 0067): the document INSERT column list is
225    /// removed. Documents are written with a bare inline JSON literal, so
226    /// the old ceremonial `(body)` column list no longer exists.
227    pub fn document_insert_column_list(position: Position) -> Self {
228        Self {
229            message: "the document INSERT column list is removed: write \
230                      `INSERT INTO <collection> DOCUMENT VALUES ({\"level\": \"info\"})` — \
231                      a bare inline JSON literal with no `(body)` column list"
232                .to_string(),
233            position,
234            expected: Vec::new(),
235            kind: ParseErrorKind::Syntax,
236        }
237    }
238
239    /// Didactic (#1709 / ADR 0067): legacy `_ttl` / `_ttl_ms` /
240    /// `_expires_at` document INSERT columns are removed; expiry is set
241    /// with the `WITH TTL` clause instead.
242    pub fn document_insert_ttl_column(position: Position) -> Self {
243        Self {
244            message: "legacy `_ttl` metadata columns are removed from document INSERT: \
245                      set expiry with the `WITH TTL <duration>` clause (e.g. \
246                      `INSERT INTO events DOCUMENT VALUES ({\"level\": \"info\"}) WITH TTL 30 m`)"
247                .to_string(),
248            position,
249            expected: Vec::new(),
250            kind: ParseErrorKind::Syntax,
251        }
252    }
253
254    /// Didactic (#1709 / ADR 0067): the quoted-string body coercion
255    /// (`VALUES ('{…}')`) is removed. JSON is written as an inline literal;
256    /// `JSON_PARSE(<expr>)` converts a runtime string.
257    pub fn document_insert_quoted_body(position: Position) -> Self {
258        Self {
259            message: "document INSERT no longer coerces a quoted string to JSON: write the \
260                      body as an inline JSON literal — \
261                      `INSERT INTO events DOCUMENT VALUES ({\"level\": \"info\"})` — or wrap a \
262                      runtime string with `JSON_PARSE(<expr>)`"
263                .to_string(),
264            position,
265            expected: Vec::new(),
266            kind: ParseErrorKind::Syntax,
267        }
268    }
269}
270
271fn recognized_keyword_name(token: &Token) -> Option<String> {
272    match token {
273        Token::String(_)
274        | Token::Integer(_)
275        | Token::Float(_)
276        | Token::JsonLiteral(_)
277        | Token::Ident(_)
278        | Token::Eq
279        | Token::Ne
280        | Token::Lt
281        | Token::Le
282        | Token::Gt
283        | Token::Ge
284        | Token::Plus
285        | Token::Minus
286        | Token::Star
287        | Token::Slash
288        | Token::Percent
289        | Token::LParen
290        | Token::RParen
291        | Token::LBracket
292        | Token::RBracket
293        | Token::LBrace
294        | Token::RBrace
295        | Token::Comma
296        | Token::Dot
297        | Token::Colon
298        | Token::Semi
299        | Token::Dollar
300        | Token::Question
301        | Token::Arrow
302        | Token::ArrowLeft
303        | Token::Dash
304        | Token::DotDot
305        | Token::Pipe
306        | Token::DoublePipe
307        | Token::Eof => None,
308        other => Some(SafeTokenDisplay(other).to_string()),
309    }
310}
311
312impl fmt::Display for ParseError {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        write!(f, "Parse error at {}: {}", self.position, self.message)?;
315        if !self.expected.is_empty() {
316            write!(f, " (expected: {})", self.expected.join(", "))?;
317        }
318        Ok(())
319    }
320}
321
322impl std::error::Error for ParseError {}
323
324/// `Display` adapter that emits a `Token` while escaping the
325/// caller-controlled byte payload of `Ident` / `String` / `JsonLiteral` /
326/// `Integer` / `Float` arms.
327///
328/// F-05 (serialization-boundary audit, 2026-05-06): SQL parser error
329/// messages flow into JSON HTTP bodies, JSONL audit rows, gRPC
330/// `Status::message`, PG3 `ErrorResponse`, and `tracing::warn!` log
331/// lines. The default `Token` Display arms emit raw user bytes for
332/// `Token::Ident("foo\nbar")` etc., which lets a tenant smuggle CR /
333/// LF / NUL / quote bytes through every downstream sink at once.
334///
335/// This adapter renders user-controlled arms via `escape_debug` (the
336/// same rules `{:?}` applies to a `&str`) and leaves keyword /
337/// punctuation arms untouched so existing snapshot tests and operator
338/// log readability are preserved.
339pub struct SafeTokenDisplay<'a>(pub &'a Token);
340
341impl fmt::Display for SafeTokenDisplay<'_> {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self.0 {
344            // User-controlled byte payloads. Render via `escape_debug`
345            // so embedded CR / LF / NUL / quote bytes do not reach
346            // downstream serialization sinks unescaped.
347            Token::Ident(s) => write_escaped(f, s),
348            Token::String(s) => {
349                f.write_str("'")?;
350                write_escaped(f, s)?;
351                f.write_str("'")
352            }
353            Token::JsonLiteral(s) => write_escaped(f, s),
354            // Numeric tokens come straight from the lexer; their
355            // canonical Display form is bounded ASCII, but the lexer
356            // builds them via `to_string` so they cannot carry control
357            // bytes. Pass through Display.
358            Token::Integer(_) | Token::Float(_) => fmt::Display::fmt(self.0, f),
359            // Static keyword / punctuation arms — fall back to the
360            // existing Display output verbatim.
361            other => fmt::Display::fmt(other, f),
362        }
363    }
364}
365
366fn write_escaped(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
367    for ch in s.chars() {
368        // `escape_debug` matches Rust's Debug rules: ASCII control
369        // bytes become `\n`, `\r`, `\0`, `\t`, …; non-ASCII printable
370        // characters pass through; backslash and double-quote are
371        // escaped.
372        for esc in ch.escape_debug() {
373            f.write_char(esc)?;
374        }
375    }
376    Ok(())
377}
378
379impl From<LexerError> for ParseError {
380    fn from(e: LexerError) -> Self {
381        let kind = match &e.limit_hit {
382            Some(LexerLimitHit::IdentifierTooLong { limit_name, value }) => {
383                ParseErrorKind::IdentifierTooLong {
384                    limit_name,
385                    value: *value,
386                }
387            }
388            None => ParseErrorKind::Syntax,
389        };
390        ParseError {
391            message: e.message,
392            position: e.position,
393            expected: Vec::new(),
394            kind,
395        }
396    }
397}