Skip to main content

libgraphql_parser/
graphql_parse_error.rs

1use crate::GraphQLErrorNote;
2use crate::GraphQLErrorNoteKind;
3use crate::GraphQLErrorNotes;
4use crate::GraphQLParseErrorKind;
5use crate::GraphQLSourceSpan;
6
7/// A parse error with location information and contextual notes.
8///
9/// This structure provides comprehensive error information for both
10/// human-readable CLI output and programmatic handling by tools.
11#[derive(Debug, Clone, thiserror::Error)]
12#[error("{}", self.format_oneline())]
13pub struct GraphQLParseError {
14    /// Human-readable primary error message.
15    ///
16    /// This is the main error description shown to users.
17    /// Examples: "Expected `:` after field name", "Unclosed `{`"
18    message: String,
19
20    /// The primary span where the error was detected.
21    ///
22    /// This location is highlighted as the main error site in CLI output.
23    /// - For "unexpected token" errors: the unexpected token's span
24    /// - For "expected X" errors: where X should have appeared
25    /// - For "unclosed delimiter" errors: the position where closing was expected
26    span: GraphQLSourceSpan,
27
28    /// Categorized error kind for programmatic handling.
29    ///
30    /// Enables tools to pattern-match on error types without parsing messages.
31    kind: GraphQLParseErrorKind,
32
33    /// Additional notes providing context, suggestions, and related locations.
34    ///
35    /// Each note has a kind (General, Help, Spec), message, and optional span:
36    /// - With span: Points to a related location (e.g., "opening `{` here")
37    /// - Without span: General advice not tied to a specific location
38    ///
39    /// Uses `GraphQLErrorNotes` for consistency with lexer errors.
40    notes: GraphQLErrorNotes,
41}
42
43impl GraphQLParseError {
44    /// Creates a new parse error with no notes.
45    pub fn new(
46        message: impl Into<String>,
47        span: GraphQLSourceSpan,
48        kind: GraphQLParseErrorKind,
49    ) -> Self {
50        Self {
51            message: message.into(),
52            span,
53            kind,
54            notes: GraphQLErrorNotes::new(),
55        }
56    }
57
58    /// Creates a new parse error with notes.
59    pub fn with_notes(
60        message: impl Into<String>,
61        span: GraphQLSourceSpan,
62        kind: GraphQLParseErrorKind,
63        notes: GraphQLErrorNotes,
64    ) -> Self {
65        Self {
66            message: message.into(),
67            span,
68            kind,
69            notes,
70        }
71    }
72
73    /// Creates a parse error from a lexer error token.
74    ///
75    /// When the parser encounters a `GraphQLTokenKind::Error` token, this
76    /// method converts it to a `GraphQLParseError`, preserving the lexer's
77    /// message and notes.
78    pub fn from_lexer_error(
79        message: impl Into<String>,
80        span: GraphQLSourceSpan,
81        lexer_notes: GraphQLErrorNotes,
82    ) -> Self {
83        Self {
84            message: message.into(),
85            span,
86            kind: GraphQLParseErrorKind::LexerError,
87            notes: lexer_notes,
88        }
89    }
90
91    /// Returns the human-readable error message.
92    pub fn message(&self) -> &str {
93        &self.message
94    }
95
96    /// Returns the primary span where the error was detected.
97    pub fn span(&self) -> &GraphQLSourceSpan {
98        &self.span
99    }
100
101    /// Returns the categorized error kind.
102    pub fn kind(&self) -> &GraphQLParseErrorKind {
103        &self.kind
104    }
105
106    /// Returns the additional notes for this error.
107    pub fn notes(&self) -> &GraphQLErrorNotes {
108        &self.notes
109    }
110
111    /// Adds a general note without a span.
112    pub fn add_note(&mut self, message: impl Into<String>) {
113        self.notes.push(GraphQLErrorNote::general(message));
114    }
115
116    /// Adds a general note with a span (pointing to a related location).
117    pub fn add_note_with_span(&mut self, message: impl Into<String>, span: GraphQLSourceSpan) {
118        self.notes
119            .push(GraphQLErrorNote::general_with_span(message, span));
120    }
121
122    /// Adds a help note without a span.
123    pub fn add_help(&mut self, message: impl Into<String>) {
124        self.notes.push(GraphQLErrorNote::help(message));
125    }
126
127    /// Adds a help note with a span.
128    pub fn add_help_with_span(&mut self, message: impl Into<String>, span: GraphQLSourceSpan) {
129        self.notes
130            .push(GraphQLErrorNote::help_with_span(message, span));
131    }
132
133    /// Adds a spec reference note.
134    pub fn add_spec(&mut self, url: impl Into<String>) {
135        self.notes.push(GraphQLErrorNote::spec(url));
136    }
137
138    /// Formats this error as a diagnostic string for CLI output.
139    ///
140    /// Produces output like:
141    /// ```text
142    /// error: Expected `:` after field name
143    ///   --> schema.graphql:5:12
144    ///    |
145    ///  5 |     userName String
146    ///    |              ^^^^^^ expected `:`
147    ///    |
148    ///    = note: Field definitions require `:` between name and type
149    ///    = help: Did you mean: `userName: String`?
150    /// ```
151    ///
152    /// # Arguments
153    /// - `source`: Optional source text for snippet extraction. If `None`,
154    ///   snippets are omitted but line/column info is still shown.
155    pub fn format_detailed(&self, source: Option<&str>) -> String {
156        let mut output = String::new();
157
158        // Error header
159        output.push_str("error: ");
160        output.push_str(&self.message);
161        output.push('\n');
162
163        // Location line
164        let file_name = self
165            .span
166            .file_path
167            .as_ref()
168            .map(|p| p.display().to_string())
169            .unwrap_or_else(|| "<input>".to_string());
170        let line = self.span.start_inclusive.line() + 1;
171        let column = self.span.start_inclusive.col_utf8() + 1;
172        output.push_str(&format!("  --> {file_name}:{line}:{column}\n"));
173
174        // Source snippet (if source is provided)
175        if let Some(src) = source
176            && let Some(snippet) = self.format_source_snippet(src)
177        {
178            output.push_str(&snippet);
179        }
180
181        // Notes
182        for note in &self.notes {
183            let prefix = match note.kind {
184                GraphQLErrorNoteKind::General => "note",
185                GraphQLErrorNoteKind::Help => "help",
186                GraphQLErrorNoteKind::Spec => "spec",
187            };
188            output.push_str(&format!("   = {prefix}: {}\n", note.message));
189
190            // If the note has a span and we have source, show that location too
191            if let (Some(note_span), Some(src)) = (&note.span, source)
192                && let Some(snippet) = self.format_note_snippet(src, note_span)
193            {
194                output.push_str(&snippet);
195            }
196        }
197
198        output
199    }
200
201    /// Formats this error as a single-line summary.
202    ///
203    /// Produces output like:
204    /// ```text
205    /// schema.graphql:5:12: error: Expected `:` after field name
206    /// ```
207    pub fn format_oneline(&self) -> String {
208        let file_name = self
209            .span
210            .file_path
211            .as_ref()
212            .map(|p| p.display().to_string())
213            .unwrap_or_else(|| "<input>".to_string());
214        let line = self.span.start_inclusive.line() + 1;
215        let column = self.span.start_inclusive.col_utf8() + 1;
216
217        format!("{file_name}:{line}:{column}: error: {}", self.message)
218    }
219
220    /// Formats the source snippet for the primary error span.
221    fn format_source_snippet(&self, source: &str) -> Option<String> {
222        let lines: Vec<&str> = source.lines().collect();
223        let line_num = self.span.start_inclusive.line();
224
225        // Line numbers are 0-indexed internally
226        if line_num >= lines.len() {
227            return None;
228        }
229
230        let line_content = lines[line_num];
231        let display_line_num = line_num + 1; // Display as 1-indexed for humans
232        let line_num_width = display_line_num.to_string().len().max(2);
233
234        let mut output = String::new();
235
236        // Separator line
237        output.push_str(&format!("{:>width$} |\n", "", width = line_num_width));
238
239        // Source line
240        output.push_str(&format!(
241            "{display_line_num:>line_num_width$} | {line_content}\n"
242        ));
243
244        // Underline (caret line)
245        let col_start = self.span.start_inclusive.col_utf8();
246        let col_end = self.span.end_exclusive.col_utf8();
247        let underline_len = if col_end > col_start {
248            col_end - col_start
249        } else {
250            1
251        };
252
253        output.push_str(&format!(
254            "{:>width$} | {:>padding$}{}\n",
255            "",
256            "",
257            "^".repeat(underline_len),
258            width = line_num_width,
259            padding = col_start
260        ));
261
262        Some(output)
263    }
264
265    /// Formats a source snippet for a note's span.
266    fn format_note_snippet(&self, source: &str, span: &GraphQLSourceSpan) -> Option<String> {
267        let lines: Vec<&str> = source.lines().collect();
268        let line_num = span.start_inclusive.line();
269
270        // Line numbers are 0-indexed internally
271        if line_num >= lines.len() {
272            return None;
273        }
274
275        let line_content = lines[line_num];
276        let display_line_num = line_num + 1; // Display as 1-indexed for humans
277        let line_num_width = display_line_num.to_string().len().max(2);
278
279        let mut output = String::new();
280
281        // Source line with line number
282        output.push_str(&format!(
283            "     {display_line_num:>line_num_width$} | {line_content}\n"
284        ));
285
286        // Underline
287        let col_start = span.start_inclusive.col_utf8();
288        output.push_str(&format!(
289            "     {:>width$} | {:>padding$}-\n",
290            "",
291            "",
292            width = line_num_width,
293            padding = col_start
294        ));
295
296        Some(output)
297    }
298}