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) = ¬e.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}