Skip to main content

rolldown_error/build_diagnostic/
mod.rs

1pub mod constructors;
2pub mod diagnostic;
3pub mod events;
4
5use rustc_hash::FxHashSet;
6use std::{
7  fmt::Display,
8  ops::{Deref, DerefMut},
9};
10
11use crate::{
12  build_diagnostic::events::plugin_error::CausedPlugin,
13  types::diagnostic_options::DiagnosticOptions, utils::downcast_napi_error_diagnostics,
14};
15
16use self::{diagnostic::Diagnostic, events::BuildEvent, events::tsconfig_error::TsConfigError};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Severity {
20  Info,
21  Error,
22  Warning,
23}
24
25pub struct BuildDiagnostic {
26  inner: Box<dyn BuildEvent>,
27  severity: Severity,
28}
29
30// `BuildEvent` is not `Debug` (dropping the supertrait lets the per-event `Debug`
31// impls be dead-stripped from release builds), so format the diagnostic via its
32// public accessors instead of the boxed event's `Debug`.
33//
34// We render through `to_diagnostic()` rather than reading `inner.message()`
35// directly: for plugin-wrapped diagnostics `PluginError::message()` intentionally
36// returns an empty string (the real text is injected later by `on_diagnostic`), so
37// using the raw message here would print an empty `message`. `to_diagnostic()` runs
38// `on_diagnostic`, which populates the real content. This is the error/cold path.
39impl std::fmt::Debug for BuildDiagnostic {
40  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41    let diagnostic = self.to_diagnostic();
42    // `inner` is rendered (via `to_diagnostic`) rather than printed directly, so the
43    // struct is intentionally non-exhaustive over its raw fields.
44    f.debug_struct("BuildDiagnostic")
45      .field("severity", &self.severity)
46      .field("kind", &diagnostic.kind)
47      .field("message", &diagnostic.title)
48      .finish_non_exhaustive()
49  }
50}
51
52impl std::error::Error for BuildDiagnostic {}
53
54impl Display for BuildDiagnostic {
55  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56    self.inner.message(&DiagnosticOptions::default()).fmt(f)
57  }
58}
59
60impl BuildDiagnostic {
61  fn new_inner(inner: impl Into<Box<dyn BuildEvent>>) -> Self {
62    Self { inner: inner.into(), severity: Severity::Error }
63  }
64
65  pub fn id(&self) -> Option<String> {
66    self.inner.id()
67  }
68
69  pub fn plugin(&self) -> Option<String> {
70    self.inner.plugin()
71  }
72
73  pub fn kind(&self) -> crate::types::event_kind::EventKind {
74    self.inner.kind()
75  }
76
77  pub fn exporter(&self) -> Option<String> {
78    self.inner.exporter()
79  }
80
81  pub fn ids(&self) -> Option<Vec<String>> {
82    self.inner.ids()
83  }
84
85  pub fn severity(&self) -> Severity {
86    self.severity
87  }
88
89  #[must_use]
90  pub fn with_severity(mut self, severity: Severity) -> Self {
91    self.severity = severity;
92    self
93  }
94
95  #[must_use]
96  pub fn with_severity_warning(mut self) -> Self {
97    self.severity = Severity::Warning;
98    self
99  }
100
101  pub fn to_diagnostic(&self) -> Diagnostic {
102    self.to_diagnostic_with(&DiagnosticOptions::default())
103  }
104
105  pub fn to_diagnostic_with(&self, opts: &DiagnosticOptions) -> Diagnostic {
106    let mut diagnostic =
107      Diagnostic::new(self.kind().to_string(), self.inner.message(opts), self.severity);
108    self.inner.on_diagnostic(&mut diagnostic, opts);
109    diagnostic
110  }
111
112  pub fn to_message_with(&self, opts: &DiagnosticOptions) -> String {
113    self.inner.message(opts)
114  }
115
116  #[cfg(feature = "napi")]
117  pub fn downcast_napi_error(&self) -> Result<&napi::Error, &Self> {
118    self.inner.as_napi_error().ok_or(self)
119  }
120
121  /// Attempt to downcast the inner event to a specific type (mutable).
122  pub fn downcast_mut<T: 'static + BuildEvent>(&mut self) -> Option<&mut T> {
123    self.inner.as_any_mut().downcast_mut()
124  }
125}
126
127impl From<anyhow::Error> for BuildDiagnostic {
128  fn from(err: anyhow::Error) -> Self {
129    downcast_napi_error_diagnostics(err).unwrap_or_else(BuildDiagnostic::unhandleable_error)
130  }
131}
132
133#[derive(Default)]
134pub struct BatchedBuildDiagnostic(Vec<BuildDiagnostic>);
135
136impl std::fmt::Debug for BatchedBuildDiagnostic {
137  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138    f.debug_tuple("BatchedBuildDiagnostic").field(&self.0).finish()
139  }
140}
141
142impl BatchedBuildDiagnostic {
143  pub fn new(vec: Vec<BuildDiagnostic>) -> Self {
144    Self(vec)
145  }
146
147  pub fn into_vec(self) -> Vec<BuildDiagnostic> {
148    self.0
149  }
150}
151
152impl std::error::Error for BatchedBuildDiagnostic {}
153
154impl Display for BatchedBuildDiagnostic {
155  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156    self.0.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join("\n").fmt(f)
157  }
158}
159
160impl From<BuildDiagnostic> for BatchedBuildDiagnostic {
161  fn from(v: BuildDiagnostic) -> Self {
162    Self::new(vec![v])
163  }
164}
165
166impl From<Vec<BuildDiagnostic>> for BatchedBuildDiagnostic {
167  fn from(v: Vec<BuildDiagnostic>) -> Self {
168    Self::new(v)
169  }
170}
171
172impl From<anyhow::Error> for BatchedBuildDiagnostic {
173  fn from(error: anyhow::Error) -> Self {
174    let caused_plugin = error.downcast_ref::<CausedPlugin>().cloned();
175    match error.downcast::<Self>() {
176      Ok(batched) => {
177        if let Some(plugin) = caused_plugin {
178          Self::new(
179            batched
180              .into_vec()
181              .into_iter()
182              .map(|diag| BuildDiagnostic::plugin_error(plugin.clone(), diag.into()))
183              .collect(),
184          )
185        } else {
186          batched
187        }
188      }
189      Err(error) => {
190        // TODO: improve below logic
191        let diagnostic = if let Some(plugin) = caused_plugin {
192          downcast_napi_error_diagnostics(error)
193            .unwrap_or_else(|error| BuildDiagnostic::plugin_error(plugin, error))
194        } else {
195          BuildDiagnostic::from(error)
196        };
197        Self::new(vec![diagnostic])
198      }
199    }
200  }
201}
202
203impl Deref for BatchedBuildDiagnostic {
204  type Target = Vec<BuildDiagnostic>;
205
206  fn deref(&self) -> &Self::Target {
207    &self.0
208  }
209}
210
211impl DerefMut for BatchedBuildDiagnostic {
212  fn deref_mut(&mut self) -> &mut Self::Target {
213    &mut self.0
214  }
215}
216
217/// A mixed-severity diagnostic accumulator.
218///
219/// Unlike [`BatchedBuildDiagnostic`] — which is error-only and lives in the
220/// `Result` `Err` channel — `Diagnostics` holds warnings, infos, and errors
221/// together. Severity is read from each element via [`BuildDiagnostic::severity`],
222/// so callers no longer need to thread a separate `errors` and `warnings` `Vec`
223/// side by side.
224///
225/// At a drain checkpoint, [`Diagnostics::partition`] (or [`Diagnostics::into_result`])
226/// splits the error-severity subset back out to feed the `Result` channel.
227#[derive(Debug, Default)]
228pub struct Diagnostics {
229  diagnostics: Vec<BuildDiagnostic>,
230  /// Cached: `true` once any error-severity diagnostic has been stored. Kept in
231  /// sync by every mutator, so [`Diagnostics::has_errors`] is O(1) and
232  /// [`Diagnostics::into_result`] can skip the partition scan on the common
233  /// (no-error) path. A diagnostic's severity is frozen once stored — it is set
234  /// only by the consuming `with_severity*` builders before `push`, and this
235  /// type hands out no `&mut` to its elements — so the flag never goes stale.
236  has_error: bool,
237}
238
239impl Diagnostics {
240  pub fn new() -> Self {
241    Self::default()
242  }
243
244  pub fn push(&mut self, diagnostic: BuildDiagnostic) {
245    self.has_error |= diagnostic.severity() == Severity::Error;
246    self.diagnostics.push(diagnostic);
247  }
248
249  pub fn extend(&mut self, diagnostics: impl IntoIterator<Item = BuildDiagnostic>) {
250    // Route through `push` so `has_error` stays in sync for each element.
251    for diagnostic in diagnostics {
252      self.push(diagnostic);
253    }
254  }
255
256  pub fn is_empty(&self) -> bool {
257    self.diagnostics.is_empty()
258  }
259
260  pub fn has_errors(&self) -> bool {
261    self.has_error
262  }
263
264  /// Splits into `(warnings + infos, errors)`, preserving the relative order
265  /// within each group. Because error- and warning-severity diagnostics were
266  /// never interleaved into a single ordered stream before this type existed,
267  /// merging then re-splitting yields the same two `Vec`s callers used to hold.
268  pub fn partition(self) -> (Vec<BuildDiagnostic>, Vec<BuildDiagnostic>) {
269    self.diagnostics.into_iter().partition(|d| d.severity() != Severity::Error)
270  }
271
272  /// Extracts all error-severity diagnostics, leaving the rest in `self`.
273  pub fn extract_errors(&mut self) -> Vec<BuildDiagnostic> {
274    self.has_error = false;
275    self.diagnostics.extract_if(0.., |d| d.severity() == Severity::Error).collect()
276  }
277
278  /// Drain checkpoint: returns `Err(errors)` if any error-severity diagnostic is
279  /// present, otherwise `Ok(warnings + infos)`. Mirrors the existing
280  /// `if !errors.is_empty() { return Err(errors.into()) }` guard.
281  ///
282  /// When no error was ever stored, returns every diagnostic as-is without
283  /// partitioning — the cached `has_error` flag makes this the common fast path.
284  pub fn into_result(self) -> crate::BuildResult<Vec<BuildDiagnostic>> {
285    if !self.has_error {
286      return Ok(self.diagnostics);
287    }
288    let (warnings, errors) = self.partition();
289    if errors.is_empty() { Ok(warnings) } else { Err(errors.into()) }
290  }
291}
292
293impl From<Vec<BuildDiagnostic>> for Diagnostics {
294  fn from(diagnostics: Vec<BuildDiagnostic>) -> Self {
295    let has_error = diagnostics.iter().any(|d| d.severity() == Severity::Error);
296    Self { diagnostics, has_error }
297  }
298}
299
300impl IntoIterator for Diagnostics {
301  type Item = BuildDiagnostic;
302  type IntoIter = std::vec::IntoIter<BuildDiagnostic>;
303
304  fn into_iter(self) -> Self::IntoIter {
305    self.diagnostics.into_iter()
306  }
307}
308
309/// Consolidates diagnostics by merging those that can be grouped together.
310///
311/// Currently consolidates:
312/// - `TsConfigError` diagnostics with the same reason into a single diagnostic
313pub fn consolidate_diagnostics(mut diagnostics: Vec<BuildDiagnostic>) -> Vec<BuildDiagnostic> {
314  let mut seen_tsconfig_reasons = FxHashSet::<String>::default();
315  diagnostics.retain_mut(|diag| {
316    diag
317      .downcast_mut::<TsConfigError>()
318      .is_none_or(|tsconfig_err| seen_tsconfig_reasons.insert(tsconfig_err.reason.to_string()))
319  });
320  diagnostics
321}
322
323#[cfg(test)]
324mod tests {
325  use super::BuildDiagnostic;
326  use crate::build_diagnostic::events::plugin_error::CausedPlugin;
327
328  // A plugin-wrapped diagnostic's inner `PluginError::message()` is intentionally
329  // empty (the real text is injected via `on_diagnostic`). `Debug` must still render
330  // the underlying error text, so render through `to_diagnostic()`.
331  #[test]
332  fn debug_renders_nested_plugin_diagnostic_message() {
333    let inner =
334      BuildDiagnostic::bundler_initialize_error("the underlying failure text".to_string(), None);
335    let plugin_diag =
336      BuildDiagnostic::plugin_error(CausedPlugin::new("my-plugin".into()), inner.into());
337
338    let debug_output = format!("{plugin_diag:?}");
339    assert!(
340      debug_output.contains("the underlying failure text"),
341      "expected non-empty underlying message in Debug output, got: {debug_output}"
342    );
343  }
344}