Skip to main content

radixdb_sql/
error.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Parser error types
16//!
17//! This module provides error types for SQL parsing.
18
19use super::token::Position;
20use std::fmt;
21
22/// A single parse error
23#[derive(Debug, Clone, PartialEq)]
24pub struct ParseError {
25    /// Error message
26    pub message: String,
27    /// Position in source
28    pub position: Position,
29    /// SQL context where error occurred
30    pub context: String,
31}
32
33impl ParseError {
34    /// Create a new parse error
35    pub fn new(message: impl Into<String>, position: Position) -> Self {
36        Self {
37            message: message.into(),
38            position,
39            context: String::new(),
40        }
41    }
42
43    /// Create a parse error with context
44    pub fn with_context(
45        message: impl Into<String>,
46        position: Position,
47        context: impl Into<String>,
48    ) -> Self {
49        Self {
50            message: message.into(),
51            position,
52            context: context.into(),
53        }
54    }
55
56    /// Format the error with context for display
57    pub fn format_error(&self) -> String {
58        if self.context.is_empty() {
59            return self.to_string();
60        }
61
62        let lines: Vec<&str> = self.context.lines().collect();
63        if self.position.line == 0 || self.position.line > lines.len() {
64            return self.to_string();
65        }
66
67        let line = lines[self.position.line - 1];
68        let pointer = " ".repeat(self.position.column.saturating_sub(1)) + "^";
69
70        format!("{}\n{}\n{}", self, line, pointer)
71    }
72}
73
74impl fmt::Display for ParseError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{} at position {}", self.message, self.position)
77    }
78}
79
80impl std::error::Error for ParseError {}
81
82/// Collection of parse errors
83#[derive(Debug, Clone)]
84pub struct ParseErrors {
85    /// List of errors
86    pub errors: Vec<ParseError>,
87    /// Original SQL string
88    pub sql: String,
89}
90
91impl ParseErrors {
92    /// Create a new empty error collection
93    pub fn new(sql: impl Into<String>) -> Self {
94        Self {
95            errors: Vec::new(),
96            sql: sql.into(),
97        }
98    }
99
100    /// Create from a vector of errors
101    pub fn from_errors(errors: Vec<ParseError>) -> Self {
102        Self {
103            errors,
104            sql: String::new(),
105        }
106    }
107
108    /// Create from errors while retaining the original SQL source.
109    pub fn from_errors_with_sql(errors: Vec<ParseError>, sql: impl Into<String>) -> Self {
110        Self {
111            errors,
112            sql: sql.into(),
113        }
114    }
115
116    /// Add an error
117    pub fn push(&mut self, error: ParseError) {
118        self.errors.push(error);
119    }
120
121    /// Check if there are any errors
122    pub fn is_empty(&self) -> bool {
123        self.errors.is_empty()
124    }
125
126    /// Get the number of errors
127    pub fn len(&self) -> usize {
128        self.errors.len()
129    }
130
131    /// Format all errors for display
132    pub fn format_errors(&self) -> String {
133        if self.errors.is_empty() {
134            return String::new();
135        }
136
137        let mut result = format!(
138            "SQL parsing failed with {} error(s):\n\n",
139            self.errors.len()
140        );
141
142        for (i, err) in self.errors.iter().enumerate() {
143            result.push_str(&format!("Error {}: {}\n", i + 1, err.message));
144
145            // Add context from SQL
146            let lines: Vec<&str> = self.sql.lines().collect();
147            if err.position.line > 0 && err.position.line <= lines.len() {
148                let line = lines[err.position.line - 1];
149                let prefix = format!("Line {}: ", err.position.line);
150                result.push_str(&format!("{}{}\n", prefix, line));
151                let pointer =
152                    " ".repeat(prefix.chars().count() + err.position.column.saturating_sub(1));
153                result.push_str(&format!("{}^\n", pointer));
154            }
155
156            // Add suggestion
157            if let Some(suggestion) = get_suggestion(err) {
158                result.push_str(&format!("Suggestion: {}\n", suggestion));
159            }
160
161            result.push('\n');
162        }
163
164        result
165    }
166}
167
168impl fmt::Display for ParseErrors {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        if self.errors.is_empty() {
171            write!(f, "SQL parse error")
172        } else {
173            write!(f, "{}", self.errors[0])
174        }
175    }
176}
177
178impl std::error::Error for ParseErrors {}
179
180/// Get a helpful suggestion for a parse error
181fn get_suggestion(err: &ParseError) -> Option<String> {
182    let msg = &err.message;
183    let ctx = &err.context;
184
185    // Expected token errors
186    if msg.contains("expected table name or subquery") {
187        return Some("You might be missing a column or table name, or using a reserved keyword without proper quoting. Try enclosing names in double quotes if they're reserved words.".to_string());
188    }
189
190    if ctx.contains("SELET") {
191        return Some("Did you mean 'SELECT'?".to_string());
192    }
193
194    if msg.contains("expected ')' or ','") {
195        return Some("You're missing a closing parenthesis. Make sure all opening parentheses are matched with closing ones.".to_string());
196    }
197
198    if msg.contains("expected next token to be PUNCTUATOR") {
199        return Some("A punctuation character like '(', ')', ',', ';' is expected here. Check for missing parentheses or commas in lists.".to_string());
200    }
201
202    if ctx.contains("LEFTJOIN") {
203        return Some(
204            "Did you mean 'LEFT JOIN'? LEFT JOIN needs a space between the words.".to_string(),
205        );
206    }
207
208    if msg.contains("expected next token to be IDENTIFIER") {
209        return Some("You might be missing a column or table name, or using a reserved keyword without proper quoting.".to_string());
210    }
211
212    if msg.contains("expected next token to be KEYWORD") {
213        return Some(
214            "A SQL keyword (like SELECT, FROM, WHERE, GROUP BY, etc.) is expected here."
215                .to_string(),
216        );
217    }
218
219    if msg.contains("expected next token to be OPERATOR") {
220        return Some("An operator such as =, <, >, <=, >=, <>, != is expected here.".to_string());
221    }
222
223    if msg.contains("expected next token to be NUMBER") {
224        return Some("A numeric value is expected here. Make sure you're providing a valid number without quotes.".to_string());
225    }
226
227    if msg.contains("expected next token to be STRING") {
228        return Some(
229            "A string value is expected here. String literals should be enclosed in single quotes."
230                .to_string(),
231        );
232    }
233
234    // Unexpected token errors
235    if msg.contains("unexpected token OPERATOR") {
236        return Some("You have an unexpected operator here. Check if you're missing a value or have an extra operator.".to_string());
237    }
238
239    if msg.contains("unexpected token PUNCTUATOR") {
240        return Some("There's an unexpected punctuation character here. Check for mismatched parentheses or extra commas.".to_string());
241    }
242
243    if msg.contains("unexpected token EOF") {
244        return Some("Your SQL statement is incomplete. You might be missing a closing parenthesis, quote, or the end of a clause.".to_string());
245    }
246
247    // Common typos
248    if msg.contains("SELET") || ctx.contains("SELET") {
249        return Some("Did you mean 'SELECT'?".to_string());
250    }
251
252    if msg.contains("UPDAT") || ctx.contains("UPDAT") {
253        return Some("Did you mean 'UPDATE'?".to_string());
254    }
255
256    if msg.contains("DELET") || ctx.contains("DELET") {
257        return Some("Did you mean 'DELETE'?".to_string());
258    }
259
260    if msg.contains("GROUPBY") || ctx.contains("GROUPBY") {
261        return Some(
262            "Did you mean 'GROUP BY'? GROUP BY needs a space between the words.".to_string(),
263        );
264    }
265
266    if msg.contains("ORDERBY") || ctx.contains("ORDERBY") {
267        return Some(
268            "Did you mean 'ORDER BY'? ORDER BY needs a space between the words.".to_string(),
269        );
270    }
271
272    // JOIN issues
273    if ctx.contains("JOIN") && !ctx.contains("ON") {
274        return Some(
275            "Your JOIN clause is missing the ON condition that specifies how tables are related."
276                .to_string(),
277        );
278    }
279
280    // Missing parentheses
281    if msg.contains("missing ')'") {
282        return Some("You're missing a closing parenthesis.".to_string());
283    }
284
285    if msg.contains("missing '('") {
286        return Some("You're missing an opening parenthesis.".to_string());
287    }
288
289    // Default suggestion
290    Some("Check syntax near this location. Common issues include missing keywords, misplaced clauses, unclosed parentheses, or incorrect identifiers.".to_string())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_parse_error_display() {
299        let err = ParseError::new("unexpected token", Position::new(10, 1, 11));
300        assert_eq!(
301            err.to_string(),
302            "unexpected token at position line 1, column 11"
303        );
304    }
305
306    #[test]
307    fn test_parse_error_with_context() {
308        let err = ParseError::with_context(
309            "unexpected token",
310            Position::new(7, 1, 8),
311            "SELECT * FORM users",
312        );
313        let formatted = err.format_error();
314        assert!(formatted.contains("SELECT * FORM users"));
315        assert!(formatted.contains("^"));
316    }
317
318    #[test]
319    fn test_parse_errors_collection() {
320        let mut errors = ParseErrors::new("SELECT SELET FROM");
321        assert!(errors.is_empty());
322
323        errors.push(ParseError::new("unexpected token", Position::new(7, 1, 8)));
324        assert_eq!(errors.len(), 1);
325        assert!(!errors.is_empty());
326    }
327
328    #[test]
329    fn test_suggestion_for_typo() {
330        let err = ParseError::with_context(
331            "unexpected identifier",
332            Position::new(0, 1, 1),
333            "SELET * FROM users",
334        );
335        let suggestion = get_suggestion(&err);
336        assert!(suggestion.is_some());
337        assert!(suggestion.unwrap().contains("SELECT"));
338    }
339
340    #[test]
341    fn test_suggestion_for_missing_identifier() {
342        let err = ParseError::new(
343            "expected next token to be IDENTIFIER",
344            Position::new(0, 1, 1),
345        );
346        let suggestion = get_suggestion(&err);
347        assert!(suggestion.is_some());
348        assert!(suggestion.unwrap().contains("column or table name"));
349    }
350}