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}