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}