apollo_compiler/diagnostic.rs
1//! Pretty-printable diagnostic reports for errors that reference GraphQL documents.
2//!
3//! # Usage
4//! To use pretty-printing in custom errors, implement the [`ToCliReport`] trait.
5//!
6//! ```rust
7//! use apollo_compiler::parser::SourceSpan;
8//! use apollo_compiler::Schema;
9//! use apollo_compiler::Name;
10//! use apollo_compiler::diagnostic::CliReport;
11//! use apollo_compiler::diagnostic::Diagnostic;
12//! use apollo_compiler::diagnostic::ToCliReport;
13//!
14//! /// Error type for a small GraphQL schema linter.
15//! #[derive(Debug, thiserror::Error)]
16//! enum LintError {
17//! #[error("{name} should be PascalCase")]
18//! InvalidCase { name: Name },
19//! #[error("Missing @specifiedBy directive on scalar {name}")]
20//! NoSpecifiedBy {
21//! location: Option<SourceSpan>,
22//! name: Name,
23//! },
24//! }
25//!
26//! impl ToCliReport for LintError {
27//! fn location(&self) -> Option<SourceSpan> {
28//! match self {
29//! LintError::InvalidCase { name } => name.location(),
30//! LintError::NoSpecifiedBy { location, .. } => *location,
31//! }
32//! }
33//!
34//! fn report(&self, report: &mut CliReport<'_>) {
35//! match self {
36//! LintError::InvalidCase { name } => {
37//! report.with_label_opt(name.location(), "should be PascalCase");
38//! report.with_help(format!("Try using {}", to_pascal_case(name)));
39//! }
40//! LintError::NoSpecifiedBy { location, .. } => {
41//! report.with_label_opt(*location, "scalar does not have a specification");
42//! }
43//! }
44//! }
45//! }
46//!
47//! # fn to_pascal_case(name: &str) -> String { todo!() }
48//! ```
49//!
50//! The [`Diagnostic`] type wraps errors that implement [`ToCliReport`] and provides
51//! the pretty-printing functionality. [`ToCliReport::to_diagnostic`] returns a diagnostic
52//! ready for formatting:
53//!
54//! ```rust
55//! # use apollo_compiler::{parser::SourceSpan, Schema, diagnostic::{ToCliReport, CliReport}};
56//! # #[derive(Debug, thiserror::Error)]
57//! # #[error("")]
58//! # struct LintError {}
59//! # impl ToCliReport for LintError {
60//! # fn location(&self) -> Option<SourceSpan> { None }
61//! # fn report(&self, _report: &mut CliReport) {}
62//! # }
63//! fn print_errors(schema: &Schema, errors: &[LintError]) {
64//! for error in errors {
65//! // Debug-formatting uses colors.
66//! eprintln!("{:?}", error.to_diagnostic(&schema.sources));
67//! }
68//! }
69//! ```
70use crate::parser::FileId;
71use crate::parser::LineColumn;
72use crate::parser::SourceFile;
73use crate::parser::SourceMap;
74use crate::parser::SourceSpan;
75use crate::response::GraphQLError;
76#[cfg(doc)]
77use crate::ExecutableDocument;
78#[cfg(doc)]
79use crate::Schema;
80use ariadne::ColorGenerator;
81use ariadne::ReportKind;
82use std::cell::Cell;
83use std::fmt;
84use std::io;
85use std::ops::Range;
86use std::sync::Arc;
87use std::sync::OnceLock;
88
89/// An error bundled together with a source map, for conversion either
90/// to a pretty-printable CLI report or to a JSON-serializable GraphQL error.
91///
92/// Implements [`fmt::Debug`] _with_ ANSI colors enabled,
93/// for printing panic messages of [`Result<_, Diagnostic<_>>::unwrap`][Result::unwrap].
94///
95/// Implements [`fmt::Display`] _without_ colors,
96/// so [`.to_string()`][ToString] can be used in more varied contexts like unit tests.
97pub struct Diagnostic<'s, T>
98where
99 T: ToCliReport,
100{
101 pub sources: &'s SourceMap,
102 pub error: &'s T,
103}
104
105/// A diagnostic report that can be printed to a CLI with pretty colors and labeled lines of
106/// GraphQL source code.
107///
108/// Custom errors can use this in their `Display` or `Debug` implementations to build a report and
109/// then write it out with [`fmt`].
110///
111/// [`fmt`]: CliReport::fmt
112pub struct CliReport<'s> {
113 sources: &'s SourceMap,
114 colors: ColorGenerator,
115 report: ariadne::ReportBuilder<'static, AriadneSpan>,
116}
117
118/// Indicate when to use ANSI colors for printing.
119#[derive(Debug, Clone, Copy)]
120pub enum Color {
121 /// Do not use colors.
122 Never,
123 /// Use colors if stderr is a terminal.
124 StderrIsTerminal,
125}
126
127/// Conversion to [`CliReport`]
128pub trait ToCliReport: fmt::Display {
129 /// Return the main location for this error. May be `None` if a location doesn't make sense for
130 /// the particular error.
131 fn location(&self) -> Option<SourceSpan>;
132
133 /// Fill in the report with source code labels.
134 ///
135 /// The main message is already set to the output of [`fmt::Display`].
136 fn report(&self, report: &mut CliReport<'_>);
137
138 fn to_report<'s>(&self, sources: &'s SourceMap, color: Color) -> CliReport<'s> {
139 let mut report = CliReport::builder(sources, self.location(), color);
140 report.with_message(self);
141 self.report(&mut report);
142 report
143 }
144
145 /// Bundle this error together with a source map into a [`Diagnostic`]
146 ///
147 /// The map normally comes from [`Schema::sources`] or [`ExecutableDocument::sources`].
148 fn to_diagnostic<'s>(&'s self, sources: &'s SourceMap) -> Diagnostic<'s, Self>
149 where
150 Self: Sized,
151 {
152 Diagnostic {
153 sources,
154 error: self,
155 }
156 }
157}
158
159impl<T: ToCliReport> ToCliReport for &T {
160 fn location(&self) -> Option<SourceSpan> {
161 ToCliReport::location(*self)
162 }
163
164 fn report(&self, report: &mut CliReport) {
165 ToCliReport::report(*self, report)
166 }
167}
168
169/// An ariadne span type. We avoid implementing `ariadne::Span` for `SourceSpan`
170/// so ariadne doesn't leak into the public API of apollo-compiler.
171type AriadneSpan = (FileId, Range<usize>);
172
173/// Translate a SourceSpan into an ariadne span type.
174fn to_span(location: SourceSpan) -> Option<AriadneSpan> {
175 let start = location.offset();
176 let end = location.end_offset();
177 Some((location.file_id, start..end))
178}
179
180/// Provide a [`std::io::Write`] API for a [`std::fmt::Formatter`].
181struct WriteToFormatter<'a, 'b> {
182 f: &'a mut fmt::Formatter<'b>,
183}
184
185impl io::Write for WriteToFormatter<'_, '_> {
186 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
187 let s = std::str::from_utf8(buf).map_err(|_| io::ErrorKind::Other)?;
188 self.f.write_str(s).map_err(|_| io::ErrorKind::Other)?;
189 Ok(buf.len())
190 }
191
192 fn flush(&mut self) -> io::Result<()> {
193 Ok(())
194 }
195}
196
197impl<'s> CliReport<'s> {
198 /// Returns a builder for creating diagnostic reports.
199 ///
200 /// Provide GraphQL source files and the main location for the diagnostic.
201 /// Source files can be obtained from [`Schema::sources`] or [`ExecutableDocument::sources`].
202 pub fn builder(
203 sources: &'s SourceMap,
204 main_location: Option<SourceSpan>,
205 color: Color,
206 ) -> Self {
207 let span = main_location
208 .and_then(to_span)
209 .unwrap_or((FileId::NONE, 0..0));
210 let report = ariadne::Report::build(ReportKind::Error, span);
211 let enable_color = match color {
212 Color::Never => false,
213 // Rely on ariadne's `auto-color` feature, which uses `concolor` to enable colors
214 // only if stderr is a terminal.
215 Color::StderrIsTerminal => true,
216 };
217 let config = ariadne::Config::new()
218 .with_index_type(ariadne::IndexType::Byte)
219 .with_color(enable_color);
220 Self {
221 sources,
222 colors: ColorGenerator::new(),
223 report: report.with_config(config),
224 }
225 }
226
227 /// Set the main message for the report.
228 pub fn with_message(&mut self, message: impl ToString) {
229 self.report.set_message(message);
230 }
231
232 /// Set the help message for the report, usually a suggestion on how to fix the error.
233 pub fn with_help(&mut self, help: impl ToString) {
234 self.report.set_help(help);
235 }
236
237 /// Set a note for the report, providing additional information that isn't related to a
238 /// source location (when a label should be used).
239 pub fn with_note(&mut self, note: impl ToString) {
240 self.report.set_note(note);
241 }
242
243 /// Add a label at a given location. If the location is `None`, the message is discarded.
244 pub fn with_label_opt(&mut self, location: Option<SourceSpan>, message: impl ToString) {
245 if let Some(span) = location.and_then(to_span) {
246 self.report.add_label(
247 ariadne::Label::new(span)
248 .with_message(message)
249 .with_color(self.colors.next()),
250 );
251 }
252 }
253
254 /// Write the report to a [`Write`].
255 ///
256 /// [`Write`]: std::io::Write
257 pub fn write(self, w: impl std::io::Write) -> std::io::Result<()> {
258 let report = self.report.finish();
259 report.write(Cache(self.sources), w)
260 }
261
262 /// Write the report to a [`fmt::Formatter`].
263 pub fn fmt(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 self.write(WriteToFormatter { f }).map_err(|_| fmt::Error)
265 }
266
267 /// Write the report to a new [`String`]
268 pub fn into_string(self) -> String {
269 struct OneTimeDisplay<'s>(Cell<Option<CliReport<'s>>>);
270
271 impl fmt::Display for OneTimeDisplay<'_> {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 self.0.take().unwrap().fmt(f)
274 }
275 }
276
277 OneTimeDisplay(Cell::new(Some(self))).to_string()
278 }
279}
280
281struct Cache<'a>(&'a SourceMap);
282
283impl ariadne::Cache<FileId> for Cache<'_> {
284 type Storage = String;
285
286 fn fetch(&mut self, file_id: &FileId) -> Result<&ariadne::Source, impl fmt::Debug> {
287 struct NotFound(FileId);
288 impl fmt::Debug for NotFound {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 write!(f, "source file not found: {:?}", self.0)
291 }
292 }
293 if let Some(source_file) = self.0.get(file_id) {
294 Ok(source_file.ariadne())
295 } else if *file_id == FileId::NONE {
296 static EMPTY: OnceLock<ariadne::Source> = OnceLock::new();
297 Ok(EMPTY.get_or_init(|| ariadne::Source::from(String::new())))
298 } else {
299 Err(NotFound(*file_id))
300 }
301 }
302
303 fn display<'a>(&self, file_id: &'a FileId) -> Option<impl fmt::Display + 'a> {
304 enum Path {
305 SourceFile(Arc<SourceFile>),
306 NoSourceFile,
307 }
308 impl fmt::Display for Path {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 match self {
311 Path::SourceFile(source_file) => source_file.path().display().fmt(f),
312 Path::NoSourceFile => f.write_str("(no source file)"),
313 }
314 }
315 }
316
317 if *file_id != FileId::NONE {
318 let source_file = self.0.get(file_id)?;
319 Some(Path::SourceFile(source_file.clone()))
320 } else {
321 Some(Path::NoSourceFile)
322 }
323 }
324}
325
326impl<T: ToCliReport> std::error::Error for Diagnostic<'_, T> {}
327
328impl<T: ToCliReport> Diagnostic<'_, T> {
329 /// Get the line and column numbers where this diagnostic spans.
330 pub fn line_column_range(&self) -> Option<Range<LineColumn>> {
331 self.error.location()?.line_column_range(self.sources)
332 }
333
334 /// Get a [`serde`]-serializable version of the current diagnostic. The shape is compatible
335 /// with the JSON error shape described in [the GraphQL spec].
336 ///
337 /// [the GraphQL spec]: https://spec.graphql.org/draft/#sec-Errors
338 pub fn to_json(&self) -> GraphQLError
339 where
340 T: ToString,
341 {
342 GraphQLError::new(self.error.to_string(), self.error.location(), self.sources)
343 }
344
345 /// Produce the diagnostic report, optionally with colors for the CLI.
346 pub fn to_report(&self, color: Color) -> CliReport<'_> {
347 self.error.to_report(self.sources, color)
348 }
349}
350
351impl<T: ToCliReport> fmt::Debug for Diagnostic<'_, T> {
352 /// Pretty-format the diagnostic, with colors for the CLI.
353 ///
354 /// The debug formatting expects to be written to stderr and ANSI colors are used if stderr is
355 /// a terminal.
356 ///
357 /// To output *without* colors, format with `Display`: `format!("{diagnostic}")`
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 self.to_report(Color::StderrIsTerminal).fmt(f)
360 }
361}
362
363impl<T: ToCliReport> fmt::Display for Diagnostic<'_, T> {
364 /// Pretty-format the diagnostic without colors.
365 ///
366 /// To output *with* colors, format with `Debug`: `eprintln!("{diagnostic:?}")`
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 self.to_report(Color::Never).fmt(f)
369 }
370}