Skip to main content

libgraphql_parser/
graphql_parse_error.rs

1use crate::GraphQLErrorNote;
2use crate::GraphQLErrorNoteKind;
3use crate::GraphQLParseErrorKind;
4use crate::smallvec::SmallVec;
5use crate::SourceSpan;
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)]
12pub struct GraphQLParseError {
13    /// Human-readable primary error message.
14    ///
15    /// This is the main error description shown to users.
16    /// Examples: "Expected `:` after field name", "Unclosed `{`"
17    message: String,
18
19    /// Categorized error kind for programmatic handling.
20    ///
21    /// Enables tools to pattern-match on error types without parsing messages.
22    kind: GraphQLParseErrorKind,
23
24    /// Additional notes providing context, suggestions, and related locations.
25    ///
26    /// Each note has a kind (General, Help, Spec), message, and optional span:
27    /// - With span: Points to a related location (e.g., "opening `{` here")
28    /// - Without span: General advice not tied to a specific location
29    ///
30    /// Uses `SmallVec<[GraphQLErrorNote; 2]>` for consistency with lexer errors.
31    notes: SmallVec<[GraphQLErrorNote; 2]>,
32
33    /// Pre-resolved source span with line/column/byte-offset/file info.
34    ///
35    /// Eagerly resolved at construction time from the `SourceMap`, so
36    /// all position information (including byte offsets) is always
37    /// available without requiring a `SourceMap` at access time.
38    ///
39    /// The `SourcePosition::byte_offset()` values on
40    /// `start_inclusive` / `end_exclusive` carry the original byte
41    /// offsets from the `ByteSpan` that was resolved — these are the
42    /// same synthetic offsets used for `SpanMap` lookup in the
43    /// proc-macro path.
44    source_span: SourceSpan,
45}
46
47impl GraphQLParseError {
48    /// Creates a new parse error with no notes.
49    pub fn new(
50        message: impl Into<String>,
51        kind: GraphQLParseErrorKind,
52        source_span: SourceSpan,
53    ) -> Self {
54        Self {
55            message: message.into(),
56            kind,
57            notes: SmallVec::new(),
58            source_span,
59        }
60    }
61
62    /// Creates a new parse error with notes.
63    pub fn with_notes(
64        message: impl Into<String>,
65        kind: GraphQLParseErrorKind,
66        notes: SmallVec<[GraphQLErrorNote; 2]>,
67        source_span: SourceSpan,
68    ) -> Self {
69        Self {
70            message: message.into(),
71            kind,
72            notes,
73            source_span,
74        }
75    }
76
77    /// Creates a parse error from a lexer error token.
78    ///
79    /// When the parser encounters a `GraphQLTokenKind::Error` token,
80    /// this method converts it to a `GraphQLParseError`, preserving
81    /// the lexer's message and notes.
82    pub fn from_lexer_error(
83        message: impl Into<String>,
84        lexer_notes: SmallVec<[GraphQLErrorNote; 2]>,
85        source_span: SourceSpan,
86    ) -> Self {
87        Self {
88            message: message.into(),
89            kind: GraphQLParseErrorKind::LexerError,
90            notes: lexer_notes,
91            source_span,
92        }
93    }
94
95    /// Returns the human-readable error message.
96    pub fn message(&self) -> &str {
97        &self.message
98    }
99
100    /// Returns the pre-resolved source span for this error.
101    ///
102    /// This span is resolved at construction time, so it is always
103    /// available without a `SourceMap`. When the error was
104    /// constructed without position info, this returns a
105    /// zero-position span.
106    ///
107    /// The `SourcePosition::byte_offset()` values on
108    /// `start_inclusive` / `end_exclusive` carry the original byte
109    /// offsets that can be used for `SpanMap` lookup or
110    /// `SourceMap::resolve_offset()` calls.
111    pub fn source_span(&self) -> &SourceSpan {
112        &self.source_span
113    }
114
115    /// Returns the categorized error kind.
116    pub fn kind(&self) -> &GraphQLParseErrorKind {
117        &self.kind
118    }
119
120    /// Returns the additional notes for this error.
121    pub fn notes(&self) -> &SmallVec<[GraphQLErrorNote; 2]> {
122        &self.notes
123    }
124
125    /// Adds a general note without a span.
126    pub fn add_note(&mut self, message: impl Into<String>) {
127        self.notes.push(GraphQLErrorNote::general(message));
128    }
129
130    /// Adds a general note with a pre-resolved span (pointing to
131    /// a related location).
132    pub fn add_note_with_span(
133        &mut self,
134        message: impl Into<String>,
135        span: SourceSpan,
136    ) {
137        self.notes.push(
138            GraphQLErrorNote::general_with_span(message, span),
139        );
140    }
141
142    /// Adds a help note without a span.
143    pub fn add_help(&mut self, message: impl Into<String>) {
144        self.notes.push(GraphQLErrorNote::help(message));
145    }
146
147    /// Adds a help note with a pre-resolved span.
148    pub fn add_help_with_span(
149        &mut self,
150        message: impl Into<String>,
151        span: SourceSpan,
152    ) {
153        self.notes.push(
154            GraphQLErrorNote::help_with_span(message, span),
155        );
156    }
157
158    /// Adds a spec reference note.
159    pub fn add_spec(&mut self, url: impl Into<String>) {
160        self.notes.push(GraphQLErrorNote::spec(url));
161    }
162
163    /// Formats this error as a diagnostic string for CLI output.
164    ///
165    /// Produces output like:
166    /// ```text
167    /// error: Expected `:` after field name
168    ///   --> schema.graphql:5:12
169    ///    |
170    ///  5 |     userName String
171    ///    |              ^^^^^^
172    ///    |
173    ///    = note: Field definitions require `:` between name and type
174    ///    = help: Did you mean: `userName: String`?
175    /// ```
176    ///
177    /// All position information (file path, line, column) comes
178    /// from the pre-resolved `source_span` and note spans. The
179    /// optional `source` parameter provides the original source
180    /// text for snippet display — when `None`, the diagnostic
181    /// omits source snippets but still shows the error header,
182    /// location, and notes.
183    pub fn format_detailed(
184        &self,
185        source: Option<&str>,
186    ) -> String {
187        let mut output = String::new();
188
189        // Error header
190        output.push_str("error: ");
191        output.push_str(&self.message);
192        output.push('\n');
193
194        // Location line
195        let file_name = self.source_span.file_path
196            .as_ref()
197            .map(|p| p.display().to_string())
198            .unwrap_or_else(|| "<input>".to_string());
199        let line =
200            self.source_span.start_inclusive.line() + 1;
201        let column =
202            self.source_span.start_inclusive.col_utf8() + 1;
203        output.push_str(
204            &format!("  --> {file_name}:{line}:{column}\n"),
205        );
206
207        // Source snippet
208        if let Some(snippet) =
209            self.format_source_snippet(source)
210        {
211            output.push_str(&snippet);
212        }
213
214        // Notes
215        for note in &self.notes {
216            let prefix = match note.kind {
217                GraphQLErrorNoteKind::General => "note",
218                GraphQLErrorNoteKind::Help => "help",
219                GraphQLErrorNoteKind::Spec => "spec",
220            };
221            output.push_str(
222                &format!(
223                    "   = {prefix}: {}\n",
224                    note.message,
225                ),
226            );
227
228            if let Some(note_span) = &note.span
229                && let Some(snippet) =
230                    Self::format_note_snippet(
231                        source, note_span,
232                    )
233            {
234                output.push_str(&snippet);
235            }
236        }
237
238        output
239    }
240
241    /// Formats this error as a single-line summary.
242    ///
243    /// Produces output like:
244    /// ```text
245    /// schema.graphql:5:12: error: Expected `:` after field name
246    /// ```
247    ///
248    /// This is equivalent to the `Display` impl. Prefer using
249    /// `format!("{error}")` or `error.to_string()` directly.
250    pub fn format_oneline(&self) -> String {
251        self.to_string()
252    }
253
254    /// Formats the source snippet for the primary error span.
255    fn format_source_snippet(
256        &self,
257        source: Option<&str>,
258    ) -> Option<String> {
259        let source = source?;
260        let start_pos = &self.source_span.start_inclusive;
261        let end_pos = &self.source_span.end_exclusive;
262
263        let line_num = start_pos.line();
264        let line_content = get_line(source, line_num)?;
265        let display_line_num = line_num + 1;
266        let line_num_width =
267            display_line_num.to_string().len().max(2);
268
269        let mut output = String::new();
270
271        // Separator line
272        output.push_str(
273            &format!(
274                "{:>width$} |\n",
275                "",
276                width = line_num_width,
277            ),
278        );
279
280        // Source line
281        output.push_str(&format!(
282            "{display_line_num:>line_num_width$} | \
283             {line_content}\n"
284        ));
285
286        // Underline (caret line)
287        let col_start = start_pos.col_utf8();
288        let col_end = end_pos.col_utf8();
289        let underline_len = if col_end > col_start {
290            col_end - col_start
291        } else {
292            1
293        };
294
295        output.push_str(&format!(
296            "{:>width$} | {:>padding$}{}\n",
297            "",
298            "",
299            "^".repeat(underline_len),
300            width = line_num_width,
301            padding = col_start
302        ));
303
304        Some(output)
305    }
306
307    /// Formats a source snippet for a note's pre-resolved span.
308    fn format_note_snippet(
309        source: Option<&str>,
310        span: &SourceSpan,
311    ) -> Option<String> {
312        let source = source?;
313        let start_pos = &span.start_inclusive;
314
315        let line_num = start_pos.line();
316        let line_content = get_line(source, line_num)?;
317        let display_line_num = line_num + 1;
318        let line_num_width =
319            display_line_num.to_string().len().max(2);
320
321        let mut output = String::new();
322
323        // Source line with line number
324        output.push_str(&format!(
325            "     {display_line_num:>line_num_width$} | \
326             {line_content}\n"
327        ));
328
329        // Underline
330        let col_start = start_pos.col_utf8();
331        output.push_str(&format!(
332            "     {:>width$} | {:>padding$}-\n",
333            "",
334            "",
335            width = line_num_width,
336            padding = col_start
337        ));
338
339        Some(output)
340    }
341}
342
343impl std::fmt::Display for GraphQLParseError {
344    fn fmt(
345        &self,
346        f: &mut std::fmt::Formatter<'_>,
347    ) -> std::fmt::Result {
348        let file_name = self.source_span.file_path
349            .as_ref()
350            .map(|p| p.display().to_string())
351            .unwrap_or_else(|| "<input>".to_string());
352        let line =
353            self.source_span.start_inclusive.line() + 1;
354        let col =
355            self.source_span.start_inclusive.col_utf8() + 1;
356        write!(
357            f,
358            "{file_name}:{line}:{col}: error: {}",
359            self.message,
360        )
361    }
362}
363
364impl std::error::Error for GraphQLParseError {}
365
366/// Extracts the content of the line at the given 0-based line
367/// index from source text.
368///
369/// Recognizes `\n`, `\r\n`, and bare `\r` as line terminators per
370/// the GraphQL spec (Section 2.2). The returned line content is
371/// stripped of its trailing line terminator.
372///
373/// Returns `None` if `line_index` is out of bounds.
374///
375/// Note: `SourceMap::get_line()` provides similar functionality but
376/// uses a pre-computed `line_starts` table for O(1) line-start
377/// lookup. This version scans from the beginning each time (O(n)),
378/// which is fine for error formatting but would be less suitable
379/// for hot paths. Both must use the same line-terminator semantics.
380fn get_line(source: &str, line_index: usize) -> Option<&str> {
381    let bytes = source.as_bytes();
382    let mut current_line = 0;
383    let mut pos = 0;
384
385    // Skip lines until we reach the target line index
386    while current_line < line_index {
387        match memchr::memchr2(b'\n', b'\r', &bytes[pos..]) {
388            Some(offset) => {
389                pos += offset;
390                if bytes[pos] == b'\r'
391                    && pos + 1 < bytes.len()
392                    && bytes[pos + 1] == b'\n'
393                {
394                    pos += 2; // \r\n
395                } else {
396                    pos += 1; // \n or bare \r
397                }
398                current_line += 1;
399            },
400            None => return None, // line_index exceeds line count
401        }
402    }
403
404    // Find the end of the target line
405    let line_start = pos;
406    let line_end = memchr::memchr2(b'\n', b'\r', &bytes[pos..])
407        .map(|offset| pos + offset)
408        .unwrap_or(bytes.len());
409
410    Some(&source[line_start..line_end])
411}