Skip to main content

turbopack_core/issue/
mod.rs

1pub mod analyze;
2pub mod code_gen;
3pub mod module;
4pub mod resolve;
5
6use std::{
7    cmp::min,
8    fmt::{Display, Formatter},
9};
10
11use anyhow::{Result, bail};
12use async_trait::async_trait;
13use auto_hash_map::AutoSet;
14use bincode::{Decode, Encode};
15use serde::{Deserialize, Serialize};
16use turbo_esregex::EsRegex;
17use turbo_rcstr::{RcStr, rcstr};
18use turbo_tasks::{
19    CollectiblesSource, NonLocalValue, OperationVc, RawVc, ReadRef, ResolvedVc, TryFlatJoinIterExt,
20    TryJoinIterExt, Upcast, ValueDefault, ValueToString, ValueToStringRef, Vc, emit,
21    trace::TraceRawVcs,
22};
23use turbo_tasks_fs::{
24    FileContent, FileLine, FileLinesContent, FileSystem, FileSystemPath, glob::Glob,
25    json::UnparsableJson,
26};
27use turbo_tasks_hash::{DeterministicHash, Xxh3Hash64Hasher};
28
29use crate::{
30    asset::{Asset, AssetContent},
31    condition::ContextCondition,
32    generated_code_source::GeneratedCodeSource,
33    ident::{AssetIdent, Layer},
34    source::Source,
35    source_map::{GenerateSourceMap, SourceMap, TokenWithSource},
36    source_pos::SourcePos,
37};
38
39#[turbo_tasks::value(shared, task_input)]
40#[derive(PartialOrd, Ord, Copy, Clone, Hash, Debug, DeterministicHash, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub enum IssueSeverity {
43    Bug,
44    Fatal,
45    Error,
46    Warning,
47    Hint,
48    Note,
49    Suggestion,
50    Info,
51}
52
53impl IssueSeverity {
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            IssueSeverity::Bug => "bug",
57            IssueSeverity::Fatal => "fatal",
58            IssueSeverity::Error => "error",
59            IssueSeverity::Warning => "warning",
60            IssueSeverity::Hint => "hint",
61            IssueSeverity::Note => "note",
62            IssueSeverity::Suggestion => "suggestion",
63            IssueSeverity::Info => "info",
64        }
65    }
66
67    pub fn as_help_str(&self) -> &'static str {
68        match self {
69            IssueSeverity::Bug => "bug in implementation",
70            IssueSeverity::Fatal => "unrecoverable problem",
71            IssueSeverity::Error => "problem that cause a broken result",
72            IssueSeverity::Warning => "problem should be addressed in short term",
73            IssueSeverity::Hint => "idea for improvement",
74            IssueSeverity::Note => "detail that is worth mentioning",
75            IssueSeverity::Suggestion => "change proposal for improvement",
76            IssueSeverity::Info => "detail that is worth telling",
77        }
78    }
79}
80
81impl Display for IssueSeverity {
82    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87/// Represents a section of structured styled text. This can be interpreted and
88/// rendered by various UIs as appropriate, e.g. HTML for display on the web,
89/// ANSI sequences in TTYs.
90#[derive(Clone, Debug, PartialOrd, Ord, DeterministicHash, Serialize)]
91#[turbo_tasks::value(shared)]
92pub enum StyledString {
93    /// Multiple [StyledString]s concatenated into a single line. Each item is
94    /// considered as inline element. Items might contain line breaks, which
95    /// would be considered as soft line breaks.
96    Line(Vec<StyledString>),
97    /// Multiple [StyledString]s stacked vertically. They are considered as
98    /// block elements, just like the top level [StyledString].
99    Stack(Vec<StyledString>),
100    /// Some prose text.
101    Text(RcStr),
102    /// Code snippet.
103    // TODO add language to support syntax highlighting
104    Code(RcStr),
105    /// Some important text.
106    Strong(RcStr),
107}
108
109impl StyledString {
110    pub fn to_unstyled_string(&self) -> String {
111        match self {
112            StyledString::Line(items) => items
113                .iter()
114                .map(|item| item.to_unstyled_string())
115                .collect::<Vec<_>>()
116                .join(""),
117            StyledString::Stack(items) => items
118                .iter()
119                .map(|item| item.to_unstyled_string())
120                .collect::<Vec<_>>()
121                .join("\n"),
122            StyledString::Text(s) | StyledString::Code(s) | StyledString::Strong(s) => {
123                s.to_string()
124            }
125        }
126    }
127}
128
129#[async_trait]
130#[turbo_tasks::value_trait]
131pub trait Issue {
132    /// Severity allows the user to filter out unimportant issues, with Bug
133    /// being the highest priority and Info being the lowest.
134    fn severity(&self) -> IssueSeverity {
135        IssueSeverity::Error
136    }
137
138    /// The file path that generated the issue, displayed to the user as message
139    /// header.
140    async fn file_path(&self) -> Result<FileSystemPath>;
141
142    /// The stage of the compilation process at which the issue occurred. This
143    /// is used to sort issues.
144    fn stage(&self) -> IssueStage;
145
146    /// The issue title should be descriptive of the issue, but should be a
147    /// single line. This is displayed to the user directly under the issue
148    /// header.
149    async fn title(&self) -> Result<StyledString>;
150
151    /// A more verbose message of the issue, appropriate for providing multiline
152    /// information of the issue.
153    async fn description(&self) -> Result<Option<StyledString>> {
154        Ok(None)
155    }
156
157    /// Full details of the issue, appropriate for providing debug level
158    /// information. Only displayed if the user explicitly asks for detailed
159    /// messages (not to be confused with severity).
160    async fn detail(&self) -> Result<Option<StyledString>> {
161        Ok(None)
162    }
163
164    /// A link to relevant documentation of the issue. Only displayed in console
165    /// if the user explicitly asks for detailed messages.
166    fn documentation_link(&self) -> RcStr {
167        rcstr!("")
168    }
169
170    /// The source location that caused the issue. Eg, for a parsing error it
171    /// should point at the offending character. Displayed to the user alongside
172    /// the title/description.
173    fn source(&self) -> Option<IssueSource> {
174        None
175    }
176
177    /// Additional source locations related to this issue (e.g., generated code
178    /// from a loader). Each source includes a description and location.
179    /// These are displayed alongside the primary source to give users full
180    /// context about the error.
181    async fn additional_sources(&self) -> Result<Vec<AdditionalIssueSource>> {
182        Ok(vec![])
183    }
184}
185
186// A collectible trait that allows traces to be computed for a given module.
187#[turbo_tasks::value_trait]
188pub trait ImportTracer {
189    #[turbo_tasks::function]
190    fn get_traces(self: Vc<Self>, path: FileSystemPath) -> Vc<ImportTraces>;
191}
192
193#[turbo_tasks::value]
194#[derive(Debug)]
195pub struct DelegatingImportTracer {
196    delegates: AutoSet<ResolvedVc<Box<dyn ImportTracer>>>,
197}
198
199impl DelegatingImportTracer {
200    async fn get_traces(&self, path: FileSystemPath) -> Result<Vec<ImportTrace>> {
201        Ok(self
202            .delegates
203            .iter()
204            .map(|d| d.get_traces(path.clone()))
205            .try_join()
206            .await?
207            .iter()
208            .flat_map(|v| v.0.iter().cloned())
209            .collect())
210    }
211}
212
213pub type ImportTrace = Vec<ReadRef<AssetIdent>>;
214
215#[turbo_tasks::value(shared)]
216pub struct ImportTraces(pub Vec<ImportTrace>);
217
218#[turbo_tasks::value_impl]
219impl ValueDefault for ImportTraces {
220    #[turbo_tasks::function]
221    fn value_default() -> Vc<Self> {
222        Self::cell(ImportTraces(vec![]))
223    }
224}
225
226pub trait IssueExt {
227    fn emit(self);
228}
229
230impl<T> IssueExt for ResolvedVc<T>
231where
232    T: Upcast<Box<dyn Issue>>,
233{
234    fn emit(self) {
235        emit(ResolvedVc::upcast_non_strict::<Box<dyn Issue>>(self));
236    }
237}
238
239#[turbo_tasks::value(transparent)]
240pub struct Issues(Vec<ResolvedVc<Box<dyn Issue>>>);
241
242/// A pattern that can match by exact string, glob, or regex.
243#[derive(Clone, Debug, PartialEq, Eq, TraceRawVcs, NonLocalValue, Encode, Decode)]
244pub enum IgnoreIssuePattern {
245    /// The value must exactly equal the pattern string.
246    ExactString(RcStr),
247    /// The pattern is treated as a glob (uses turbo-tasks-fs glob matching).
248    Glob(Glob),
249    /// The pattern is a regular expression (supports ES-style patterns via `EsRegex`).
250    Regex(EsRegex),
251}
252
253impl IgnoreIssuePattern {
254    /// Test whether the pattern matches the given value.
255    pub fn matches(&self, value: &str) -> bool {
256        match self {
257            IgnoreIssuePattern::ExactString(s) => value == s.as_str(),
258            IgnoreIssuePattern::Glob(glob) => glob.matches(value),
259            IgnoreIssuePattern::Regex(regex) => regex.is_match(value),
260        }
261    }
262}
263
264/// A rule describing an issue to ignore. `path` is mandatory;
265/// `title` and `description` are optional additional filters.
266#[derive(Clone, Debug, PartialEq, Eq, TraceRawVcs, NonLocalValue, Encode, Decode)]
267pub struct IgnoreIssue {
268    /// File-path pattern (mandatory).
269    pub path: IgnoreIssuePattern,
270    /// Title pattern (optional).
271    pub title: Option<IgnoreIssuePattern>,
272    /// Description pattern (optional).
273    pub description: Option<IgnoreIssuePattern>,
274}
275
276#[turbo_tasks::value(shared)]
277pub struct IssueFilter {
278    /// The minimum severity for issues
279    severity: IssueSeverity,
280    /// The minimum severity for issues in node_modules
281    foreign_severity: IssueSeverity,
282    /// Issues matching any of these rules are ignored (dropped from results).
283    ignore_rules: Box<[IgnoreIssue]>,
284}
285
286impl IssueFilter {
287    /// A filter that lets everything through.
288    pub fn everything() -> Self {
289        IssueFilter {
290            severity: IssueSeverity::Info,
291            foreign_severity: IssueSeverity::Info,
292            ignore_rules: Box::from([]),
293        }
294    }
295
296    /// Construct a filter with the standard warning/foreign-error severities.
297    pub fn warnings_and_foreign_errors() -> Self {
298        IssueFilter {
299            severity: IssueSeverity::Warning,
300            foreign_severity: IssueSeverity::Error,
301            ignore_rules: Box::from([]),
302        }
303    }
304
305    /// Set the ignore rules for this filter.
306    pub fn with_ignore_rules(mut self, rules: Box<[IgnoreIssue]>) -> Self {
307        self.ignore_rules = rules;
308        self
309    }
310
311    /// Returns true if the issue is allowed by this filter.
312    pub async fn matches(&self, issue: ResolvedVc<Box<dyn Issue>>) -> Result<bool> {
313        Ok(self.matches_all_fast_path()
314            || self
315                .matches_ref_slow_path(&*issue.into_trait_ref().await?)
316                .await?)
317    }
318
319    pub async fn matches_ref(&self, issue: &dyn Issue) -> Result<bool> {
320        Ok(self.matches_all_fast_path() || self.matches_ref_slow_path(issue).await?)
321    }
322
323    fn matches_all_fast_path(&self) -> bool {
324        self.severity == IssueSeverity::Info
325            && self.foreign_severity == IssueSeverity::Info
326            && self.ignore_rules.is_empty()
327    }
328
329    async fn matches_ref_slow_path(&self, issue: &dyn Issue) -> Result<bool> {
330        // Fetch the file path once — it's used by both severity and ignore-rule
331        // checks.
332        let file_path = issue.file_path().await?;
333
334        // Check severity first — this is cheap and avoids fetching
335        // title/description for issues that would be filtered out anyway.
336        let severity = issue.severity();
337        // NOTE: Lower severities are _more_ severe
338        let severity_allowed = if severity <= self.severity || severity <= self.foreign_severity {
339            // we need to check the path to see if it is foreign or not.  Only await the
340            // path if it might possibly matter
341            if severity <= self.severity && severity <= self.foreign_severity {
342                // it matches no matter where the path is
343                true
344            } else if ContextCondition::InNodeModules.matches(&file_path) {
345                severity <= self.foreign_severity
346            } else {
347                severity <= self.severity
348            }
349        } else {
350            // it is too low severity to match either way
351            false
352        };
353
354        if !severity_allowed {
355            return Ok(false);
356        }
357
358        // Check ignore rules — if any rule matches, the issue is dropped.
359        // Title and description are fetched lazily: only when a rule's path
360        // matches and the rule also specifies a title/description pattern.
361        if !self.ignore_rules.is_empty() {
362            let file_path_str = file_path.to_string();
363            let mut title_str: Option<String> = None;
364            let mut description_text: Option<Option<String>> = None;
365
366            for rule in &self.ignore_rules {
367                if !rule.path.matches(&file_path_str) {
368                    continue;
369                }
370                if let Some(ref title_pat) = rule.title {
371                    if title_str.is_none() {
372                        title_str = Some(issue.title().await?.to_unstyled_string());
373                    }
374                    if !title_pat.matches(title_str.as_deref().unwrap()) {
375                        continue;
376                    }
377                }
378                if let Some(ref desc_pat) = rule.description {
379                    if description_text.is_none() {
380                        description_text =
381                            Some(issue.description().await?.map(|s| s.to_unstyled_string()));
382                    }
383                    match description_text.as_ref().unwrap().as_deref() {
384                        Some(desc) if desc_pat.matches(desc) => {}
385                        _ => continue,
386                    }
387                }
388                // All specified fields matched — ignore this issue.
389                return Ok(false);
390            }
391        }
392
393        Ok(true)
394    }
395}
396
397/// A list of issues captured with [`CollectibleIssuesExt::peek_issues`].
398#[turbo_tasks::value(shared)]
399#[derive(Debug)]
400pub struct CapturedIssues {
401    issues: AutoSet<ResolvedVc<Box<dyn Issue>>>,
402    tracer: ResolvedVc<DelegatingImportTracer>,
403}
404
405impl CapturedIssues {
406    /// Returns an iterator over the issues.
407    pub fn iter(&self) -> impl Iterator<Item = ResolvedVc<Box<dyn Issue>>> + '_ {
408        self.issues.iter().copied()
409    }
410
411    // Returns all the issues as formatted `PlainIssues`.
412    pub async fn get_plain_issues(&self, filter: &IssueFilter) -> Result<Vec<ReadRef<PlainIssue>>> {
413        let mut list = self
414            .issues
415            .iter()
416            .map(async |issue| {
417                if filter.matches(*issue).await? {
418                    Ok(Some(
419                        PlainIssue::from_issue(**issue, Some(*self.tracer)).await?,
420                    ))
421                } else {
422                    Ok(None)
423                }
424            })
425            .try_flat_join()
426            .await?;
427        list.sort();
428        Ok(list)
429    }
430}
431
432#[turbo_tasks::task_input]
433#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TraceRawVcs, Encode, Decode)]
434pub struct IssueSource {
435    source: ResolvedVc<Box<dyn Source>>,
436    range: Option<SourceRange>,
437}
438
439/// The end position is the first character after the range
440#[turbo_tasks::task_input]
441#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TraceRawVcs, Encode, Decode)]
442enum SourceRange {
443    LineColumn(SourcePos, SourcePos),
444    ByteOffset(u32, u32),
445}
446
447impl IssueSource {
448    // Sometimes we only have the source file that causes an issue, not the
449    // exact location, such as as in some generated code.
450    pub fn from_source_only(source: ResolvedVc<Box<dyn Source>>) -> Self {
451        IssueSource {
452            source,
453            range: None,
454        }
455    }
456
457    /// Drops the precise range while preserving the source file.
458    pub fn without_range(self) -> Self {
459        IssueSource {
460            range: None,
461            ..self
462        }
463    }
464
465    pub fn from_line_col(
466        source: ResolvedVc<Box<dyn Source>>,
467        start: SourcePos,
468        end: SourcePos,
469    ) -> Self {
470        IssueSource {
471            source,
472            range: Some(SourceRange::LineColumn(start, end)),
473        }
474    }
475
476    pub fn from_single_line_col(source: ResolvedVc<Box<dyn Source>>, pos: SourcePos) -> Self {
477        IssueSource {
478            source,
479            range: Some(SourceRange::LineColumn(
480                pos,
481                SourcePos {
482                    line: pos.line,
483                    // The end position is the first character after the range
484                    column: pos.column + 1,
485                },
486            )),
487        }
488    }
489
490    async fn into_plain(self) -> Result<PlainIssueSource> {
491        let Self { mut source, range } = self;
492
493        let range = if let Some(range) = range {
494            let mut range = match range {
495                SourceRange::LineColumn(start, end) => Some((start, end)),
496                SourceRange::ByteOffset(start, end) => {
497                    // Defensively read the content, an error there should not prevent all issue
498                    // formatting.  Best practice is for `content` to return `NotFound` instead of
499                    // an error.
500                    if let Ok(content) = self.source.content().lines().await
501                        && let FileLinesContent::Lines(lines) = &*content
502                    {
503                        let start = find_line_and_column(lines.as_ref(), start);
504                        let end = find_line_and_column(lines.as_ref(), end);
505                        Some((start, end))
506                    } else {
507                        None
508                    }
509                }
510            };
511
512            // If we have a source map, map the line/column to the original source.
513            if let Some((start, end)) = range {
514                let mapped = source_pos(source, start, end).await?;
515
516                if let Some((mapped_source, start, end)) = mapped {
517                    range = Some((start, end));
518                    source = mapped_source;
519                }
520            }
521            range
522        } else {
523            None
524        };
525        Ok(PlainIssueSource {
526            asset: PlainSource::from_source(*source).await?,
527            range,
528        })
529    }
530
531    /// Create an [`IssueSource`] from an [`UnparsableJson`] error, using its
532    /// start/end location if available.
533    pub fn from_unparsable_json(
534        source: ResolvedVc<Box<dyn Source>>,
535        error: &UnparsableJson,
536    ) -> Self {
537        match (error.start_location, error.end_location) {
538            (None, None) => Self::from_source_only(source),
539            (Some((line, column)), None) | (None, Some((line, column))) => Self::from_line_col(
540                source,
541                SourcePos { line, column },
542                SourcePos { line, column },
543            ),
544            (Some((start_line, start_column)), Some((end_line, end_column))) => {
545                Self::from_line_col(
546                    source,
547                    SourcePos {
548                        line: start_line,
549                        column: start_column,
550                    },
551                    SourcePos {
552                        line: end_line,
553                        column: end_column,
554                    },
555                )
556            }
557        }
558    }
559
560    /// Create a [`IssueSource`] from byte offsets given by an swc ast node
561    /// span.
562    ///
563    /// Arguments:
564    ///
565    /// * `source`: The source code in which to look up the byte offsets.
566    /// * `start`: The start index of the span. Must use **1-based** indexing.
567    /// * `end`: The end index of the span. Must use **1-based** indexing.
568    pub fn from_swc_offsets(source: ResolvedVc<Box<dyn Source>>, start: u32, end: u32) -> Self {
569        IssueSource {
570            source,
571            range: match (start == 0, end == 0) {
572                (true, true) => None,
573                (false, false) => Some(SourceRange::ByteOffset(start - 1, end - 1)),
574                (false, true) => Some(SourceRange::ByteOffset(start - 1, start - 1)),
575                (true, false) => Some(SourceRange::ByteOffset(end - 1, end - 1)),
576            },
577        }
578    }
579
580    /// Returns an `IssueSource` representing a span of code in the `source`.
581    /// Positions are derived from byte offsets and stored as lines and columns.
582    /// Requires a binary search of the source text to perform this.
583    ///
584    /// Arguments:
585    ///
586    /// * `source`: The source code in which to look up the byte offsets.
587    /// * `start`: Byte offset into the source that the text begins. 0-based index and inclusive.
588    /// * `end`: Byte offset into the source that the text ends. 0-based index and exclusive.
589    pub async fn from_byte_offset(
590        source: ResolvedVc<Box<dyn Source>>,
591        start: u32,
592        end: u32,
593    ) -> Result<Self> {
594        Ok(IssueSource {
595            source,
596            range: if let FileLinesContent::Lines(lines) = &*source.content().lines().await? {
597                let start = find_line_and_column(lines.as_ref(), start);
598                let end = find_line_and_column(lines.as_ref(), end);
599                Some(SourceRange::LineColumn(start, end))
600            } else {
601                None
602            },
603        })
604    }
605
606    /// Returns the file path for the source file.
607    pub async fn file_path(&self) -> Result<FileSystemPath> {
608        Ok(self.source.ident().await?.path.clone())
609    }
610
611    /// If this source implements `GenerateSourceMap`, returns an
612    /// `AdditionalIssueSource` that wraps the source in a `GeneratedCodeSource`
613    /// (stripping source-map support) so the generated code is shown alongside
614    /// the original in error messages. Returns `None` otherwise.
615    pub async fn to_generated_code_source(&self) -> Result<Option<AdditionalIssueSource>> {
616        if ResolvedVc::try_sidecast::<Box<dyn GenerateSourceMap>>(self.source).is_some() {
617            let description = self.source.description().await?;
618            let generated = Vc::upcast::<Box<dyn Source>>(GeneratedCodeSource::new(*self.source))
619                .to_resolved()
620                .await?;
621            return Ok(Some(AdditionalIssueSource {
622                description: format!("Generated code of {}", description).into(),
623                source: IssueSource {
624                    source: generated,
625                    // The range is intentionally copied verbatim: the offsets
626                    // are already in generated-source coordinates (they came
627                    // from parsing the loader output), so no remapping is
628                    // needed here.
629                    range: self.range,
630                },
631            }));
632        }
633        Ok(None)
634    }
635}
636
637impl IssueSource {
638    /// Returns bytes offsets corresponding the source range in the format used by swc's Spans.
639    pub async fn to_swc_offsets(&self) -> Result<Option<(u32, u32)>> {
640        Ok(match &self.range {
641            Some(range) => match range {
642                SourceRange::ByteOffset(start, end) => Some((*start + 1, *end + 1)),
643                SourceRange::LineColumn(start, end) => {
644                    if let FileLinesContent::Lines(lines) = &*self.source.content().lines().await? {
645                        let start = find_offset(lines.as_ref(), *start) + 1;
646                        let end = find_offset(lines.as_ref(), *end) + 1;
647                        Some((start, end))
648                    } else {
649                        None
650                    }
651                }
652            },
653            _ => None,
654        })
655    }
656}
657
658async fn source_pos(
659    source: ResolvedVc<Box<dyn Source>>,
660    start: SourcePos,
661    end: SourcePos,
662) -> Result<Option<(ResolvedVc<Box<dyn Source>>, SourcePos, SourcePos)>> {
663    let Some(generator) = ResolvedVc::try_sidecast::<Box<dyn GenerateSourceMap>>(source) else {
664        return Ok(None);
665    };
666
667    let srcmap = generator.generate_source_map();
668    let Some(srcmap) = &*SourceMap::new_from_rope_cached(srcmap).await? else {
669        return Ok(None);
670    };
671
672    let find = async |line: u32, col: u32| {
673        let TokenWithSource {
674            token,
675            source_content,
676        } = &srcmap.lookup_token_and_source(line, col).await?;
677
678        match token {
679            crate::source_map::Token::Synthetic(t) => anyhow::Ok((
680                SourcePos {
681                    line: t.generated_line as _,
682                    column: t.generated_column as _,
683                },
684                *source_content,
685            )),
686            crate::source_map::Token::Original(t) => anyhow::Ok((
687                SourcePos {
688                    line: t.original_line as _,
689                    column: t.original_column as _,
690                },
691                *source_content,
692            )),
693        }
694    };
695
696    let (start, content_1) = find(start.line, start.column).await?;
697    let (end, content_2) = find(end.line, end.column).await?;
698
699    let Some((content_1, content_2)) = content_1.zip(content_2) else {
700        return Ok(None);
701    };
702
703    if content_1 != content_2 {
704        return Ok(None);
705    }
706
707    Ok(Some((content_1, start, end)))
708}
709
710/// A labeled issue source used to provide additional context in error messages.
711/// For example, when a webpack loader produces broken code, the primary source
712/// shows the original file, while an additional source shows the generated code.
713#[turbo_tasks::value(shared)]
714pub struct AdditionalIssueSource {
715    pub description: RcStr,
716    pub source: IssueSource,
717}
718
719#[turbo_tasks::value(shared, transparent)]
720pub struct AdditionalIssueSources(Vec<AdditionalIssueSource>);
721
722#[turbo_tasks::value_impl]
723impl AdditionalIssueSources {
724    #[turbo_tasks::function]
725    pub fn empty() -> Vc<Self> {
726        Vc::cell(Vec::new())
727    }
728}
729
730// A structured reference to a file with module level details for displaying in an import trace
731#[derive(
732    Serialize,
733    PartialEq,
734    Eq,
735    PartialOrd,
736    Ord,
737    Clone,
738    Debug,
739    TraceRawVcs,
740    NonLocalValue,
741    DeterministicHash,
742)]
743#[serde(rename_all = "camelCase")]
744pub struct PlainTraceItem {
745    // The name of the filesystem
746    pub fs_name: RcStr,
747    // The root path of the filesystem, for constructing links
748    pub root_path: RcStr,
749    // The path of the file, relative to the filesystem root
750    pub path: RcStr,
751    // An optional label attached to the module that clarifies where in the module graph it is.
752    pub layer: Option<RcStr>,
753}
754
755impl PlainTraceItem {
756    async fn from_asset_ident(asset: ReadRef<AssetIdent>) -> Result<Self> {
757        // TODO(lukesandberg): How should we display paths? it would be good to display all paths
758        // relative to the cwd or the project root.
759        let fs_path = asset.path.clone();
760        let fs_name = fs_path.fs.to_string().owned().await?;
761        let root_path = fs_path.fs.root().await?.path.clone();
762        let path = fs_path.path.clone();
763        let layer = asset.layer.as_ref().map(Layer::user_friendly_name).cloned();
764        Ok(Self {
765            fs_name,
766            root_path,
767            path,
768            layer,
769        })
770    }
771}
772
773pub type PlainTrace = Vec<PlainTraceItem>;
774
775// Flatten and simplify this set of import traces into a simpler format for formatting.
776async fn into_plain_trace(traces: Vec<Vec<ReadRef<AssetIdent>>>) -> Result<Vec<PlainTrace>> {
777    let mut plain_traces = traces
778        .into_iter()
779        .map(async |trace| {
780            let mut plain_trace = trace
781                .into_iter()
782                .filter(|asset| {
783                    // If there are nested assets, this is a synthetic module which is likely to be
784                    // confusing/distracting.  Just skip it.
785                    asset.assets.is_empty()
786                })
787                .map(PlainTraceItem::from_asset_ident)
788                .try_join()
789                .await?;
790
791            // After simplifying the trace, we may end up with apparent duplicates.
792            // Consider this example:
793            // Import trace:
794            // ./[project]/app/global.scss.css [app-client] (css) [app-client]
795            // ./[project]/app/layout.js [app-client] (ecmascript) [app-client]
796            // ./[project]/app/layout.js [app-rsc] (client reference proxy) [app-rsc]
797            // ./[project]/app/layout.js [app-rsc] (ecmascript) [app-rsc]
798            // ./[project]/app/layout.js [app-rsc] (ecmascript, Next.js Server Component) [app-rsc]
799            //
800            // In that case, there are an number of 'shim modules' that are inserted by next with
801            // different `modifiers` that are used to model the server->client hand off.  The
802            // simplification performed by `PlainTraceItem::from_asset_ident` drops these
803            // 'modifiers' and so we would end up with 'app/layout.js' appearing to be duplicated
804            // several times.  These modules are implementation details of the application so we
805            // just deduplicate them here.
806
807            plain_trace.dedup();
808
809            Ok(plain_trace)
810        })
811        .try_join()
812        .await?;
813
814    // Trim any empty traces and traces that only contain 1 item.  Showing a trace that points to
815    // the file with the issue is not useful.
816    plain_traces.retain(|t| t.len() > 1);
817    // Sort so the shortest traces come first, and break ties by the trace itself to ensure
818    // stability
819    plain_traces.sort_by(|a, b| {
820        // Sort by length first, so that shorter traces come first.
821        a.len().cmp(&b.len()).then_with(|| a.cmp(b))
822    });
823
824    // Now see if there are any overlaps
825    // If two of the traces overlap that means one is a suffix of another one.  Because we are
826    // computing shortest paths in the same graph and the shortest path algorithm we use is
827    // deterministic.
828    // Technically this is a quadratic algorithm since we need to compare each trace with all
829    // subsequent traces, however there are rarely more than 3 traces and certainly never more
830    // than 10.
831    if plain_traces.len() > 1 {
832        let mut i = 0;
833        while i < plain_traces.len() - 1 {
834            let mut j = plain_traces.len() - 1;
835            while j > i {
836                if plain_traces[j].ends_with(&plain_traces[i]) {
837                    // Remove the longer trace.
838                    // This typically happens due to things like server->client transitions where
839                    // the same file appears multiple times under different modules identifiers.
840                    // On the one hand the shorter trace is simpler, on the other hand the longer
841                    // trace might be more 'interesting' and even relevant.
842                    plain_traces.remove(j);
843                }
844                j -= 1;
845            }
846            i += 1;
847        }
848    }
849
850    Ok(plain_traces)
851}
852
853#[turbo_tasks::value(shared)]
854#[derive(Clone, Debug, PartialOrd, Ord, DeterministicHash, Serialize)]
855pub enum IssueStage {
856    Config,
857    AppStructure,
858    ProcessModule,
859    /// Read file.
860    Load,
861    SourceTransform,
862    Parse,
863    /// TODO: Add index of the transform
864    Transform,
865    Analysis,
866    Resolve,
867    Bindings,
868    CodeGen,
869    Emit,
870    Unsupported,
871    Misc,
872    Other(RcStr),
873}
874
875impl Display for IssueStage {
876    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
877        match self {
878            IssueStage::Config => write!(f, "config"),
879            IssueStage::Resolve => write!(f, "resolve"),
880            IssueStage::ProcessModule => write!(f, "process module"),
881            IssueStage::Load => write!(f, "load"),
882            IssueStage::SourceTransform => write!(f, "source transform"),
883            IssueStage::Parse => write!(f, "parse"),
884            IssueStage::Transform => write!(f, "transform"),
885            IssueStage::Analysis => write!(f, "analysis"),
886            IssueStage::Bindings => write!(f, "bindings"),
887            IssueStage::CodeGen => write!(f, "code gen"),
888            IssueStage::Emit => write!(f, "emit"),
889            IssueStage::Unsupported => write!(f, "unsupported"),
890            IssueStage::AppStructure => write!(f, "app structure"),
891            IssueStage::Misc => write!(f, "misc"),
892            IssueStage::Other(s) => write!(f, "{s}"),
893        }
894    }
895}
896
897#[turbo_tasks::value(serialization = "skip")]
898#[derive(Clone, Debug, PartialOrd, Ord)]
899pub struct PlainIssue {
900    pub severity: IssueSeverity,
901    pub stage: IssueStage,
902
903    pub title: StyledString,
904    pub file_path: RcStr,
905
906    pub description: Option<StyledString>,
907    pub detail: Option<StyledString>,
908    pub documentation_link: RcStr,
909
910    pub source: Option<PlainIssueSource>,
911    pub additional_sources: Vec<PlainAdditionalIssueSource>,
912    pub import_traces: Vec<PlainTrace>,
913}
914
915/// A collection of [`PlainIssue`]s collected from a single source.
916///
917/// Returned by [`collect_issues`] so that the (plain) issues can be read strongly
918/// consistently from a top-level task and handed to a non-turbo-task [`IssueReporter`].
919#[turbo_tasks::value(serialization = "skip")]
920#[derive(Debug)]
921pub struct PlainIssues(pub Vec<ReadRef<PlainIssue>>);
922
923#[turbo_tasks::value(serialization = "skip")]
924#[derive(Clone, Debug, PartialOrd, Ord)]
925pub struct PlainAdditionalIssueSource {
926    pub description: RcStr,
927    pub source: PlainIssueSource,
928}
929
930fn hash_plain_issue(issue: &PlainIssue, hasher: &mut Xxh3Hash64Hasher, full: bool) {
931    hasher.write_ref(&issue.severity);
932    hasher.write_ref(&issue.file_path);
933    hasher.write_ref(&issue.stage);
934    hasher.write_ref(&issue.title);
935    hasher.write_ref(&issue.description);
936    hasher.write_ref(&issue.detail);
937    hasher.write_ref(&issue.documentation_link);
938
939    if let Some(source) = &issue.source {
940        hasher.write_value(1_u8);
941        // I'm assuming we don't need to hash the contents. Not 100% correct, but
942        // probably 99%.
943        hasher.write_ref(&source.range);
944    } else {
945        hasher.write_value(0_u8);
946    }
947
948    // `additional_sources` is intentionally not hashed: it carries supplementary
949    // display info (e.g. generated code from a loader) that does not change the
950    // identity of the underlying problem.  Two issues that differ only in their
951    // generated-code snippet still represent the same root cause and should be
952    // deduplicated.
953
954    if full {
955        hasher.write_ref(&issue.import_traces);
956    }
957}
958
959impl PlainIssue {
960    /// We need deduplicate issues that can come from unique paths, but represent the same
961    /// underlying problem. E.g., a parse error for a file that is compiled in both client and
962    /// server contexts.
963    ///
964    /// Passing `full` will also hash any sub-issues and processing paths. While useful for
965    /// generating exact matching hashes, it's possible for the same issue to pass from multiple
966    /// processing paths, making for overly verbose logging.
967    pub fn internal_hash_ref(&self, full: bool) -> u64 {
968        let mut hasher = Xxh3Hash64Hasher::new();
969        hash_plain_issue(self, &mut hasher, full);
970        hasher.finish()
971    }
972}
973
974#[turbo_tasks::value_impl]
975impl PlainIssue {
976    /// Translate an [Issue] into a [PlainIssue]. A more regular structure suitable for printing and
977    /// serialization.
978    #[turbo_tasks::function]
979    pub async fn from_issue(
980        issue: ResolvedVc<Box<dyn Issue>>,
981        import_tracer: Option<ResolvedVc<DelegatingImportTracer>>,
982    ) -> Result<Vc<Self>> {
983        Ok(
984            Self::from_issue_ref(&*issue.into_trait_ref().await?, import_tracer)
985                .await?
986                .cell(),
987        )
988    }
989}
990
991impl PlainIssue {
992    pub async fn from_issue_ref(
993        trait_ref: &dyn Issue,
994        import_tracer: Option<ResolvedVc<DelegatingImportTracer>>,
995    ) -> Result<Self> {
996        let severity = trait_ref.severity();
997        let file_path = trait_ref.file_path().await?;
998        let file_path_str = file_path.to_string_ref().await?;
999
1000        Ok(Self {
1001            severity,
1002            file_path: file_path_str,
1003            stage: trait_ref.stage(),
1004            title: trait_ref.title().await?,
1005            description: trait_ref.description().await?,
1006            detail: trait_ref.detail().await?,
1007            documentation_link: trait_ref.documentation_link(),
1008            source: {
1009                if let Some(s) = trait_ref.source() {
1010                    Some(s.into_plain().await?)
1011                } else {
1012                    None
1013                }
1014            },
1015            additional_sources: {
1016                trait_ref
1017                    .additional_sources()
1018                    .await?
1019                    .into_iter()
1020                    .map(async |s| {
1021                        Ok(PlainAdditionalIssueSource {
1022                            source: s.source.into_plain().await?,
1023                            description: s.description,
1024                        })
1025                    })
1026                    .try_join()
1027                    .await?
1028            },
1029            import_traces: match import_tracer {
1030                Some(tracer) => {
1031                    into_plain_trace(tracer.await?.get_traces(file_path).await?).await?
1032                }
1033                None => vec![],
1034            },
1035        })
1036    }
1037}
1038
1039#[turbo_tasks::value(serialization = "skip")]
1040#[derive(Clone, Debug, PartialOrd, Ord)]
1041pub struct PlainIssueSource {
1042    pub asset: ReadRef<PlainSource>,
1043    pub range: Option<(SourcePos, SourcePos)>,
1044}
1045
1046#[turbo_tasks::value(serialization = "skip")]
1047#[derive(Clone, Debug, PartialOrd, Ord)]
1048pub struct PlainSource {
1049    pub ident: RcStr,
1050    pub file_path: RcStr,
1051    #[turbo_tasks(debug_ignore)]
1052    pub content: ReadRef<FileContent>,
1053}
1054
1055#[turbo_tasks::value_impl]
1056impl PlainSource {
1057    #[turbo_tasks::function]
1058    pub async fn from_source(asset: ResolvedVc<Box<dyn Source>>) -> Result<Vc<PlainSource>> {
1059        // Defensively read the content, an error there should not prevent all issue
1060        // formatting.  Best practice is for `content` to return `NotFound` instead of
1061        // an error.
1062        let content = if let Ok(asset_content) = asset.content().await
1063            && let AssetContent::File(file_content) = &*asset_content
1064            && let Ok(file_content) = file_content.await
1065        {
1066            file_content
1067        } else {
1068            ReadRef::new_owned(FileContent::NotFound)
1069        };
1070        let ident = asset.ident();
1071
1072        Ok(PlainSource {
1073            ident: ident.to_string().owned().await?,
1074            file_path: ident.await?.path.to_string_ref().await?,
1075            content,
1076        }
1077        .cell())
1078    }
1079}
1080
1081#[async_trait]
1082#[turbo_tasks::value_trait]
1083pub trait IssueReporter {
1084    /// Reports already-collected issues to the user (e.g. to stdio). Returns whether fatal
1085    /// (program-ending) issues were present.
1086    ///
1087    /// This is intentionally *not* a `#[turbo_tasks::function]`: it performs no turbo-tasks
1088    /// reads of its own (the issues are collected ahead of time by [`collect_issues`]), so it
1089    /// is safe to call from a top-level task.
1090    ///
1091    /// # Arguments:
1092    ///
1093    /// * `issues` - The plain issues already collected from the source.
1094    /// * `source` - The root [`RawVc`] from which the issues were traced. Can be used by
1095    ///   implementers as a dedup key to determine which issues are new. This must be derived from
1096    ///   the `OperationVc` the issues were collected from.
1097    /// * `min_failing_severity` - The minimum issue severity level considered to fatally end the
1098    ///   program.
1099    async fn report_issues(
1100        &self,
1101        issues: ReadRef<PlainIssues>,
1102        source: RawVc,
1103        min_failing_severity: IssueSeverity,
1104    ) -> Result<bool>;
1105}
1106
1107pub trait CollectibleIssuesExt
1108where
1109    Self: Sized,
1110{
1111    /// Returns all issues from `source`
1112    ///
1113    /// Must be called in a turbo-task as this constructs a `cell`
1114    fn peek_issues(self) -> CapturedIssues;
1115
1116    /// Drops all issues from `source`
1117    ///
1118    /// This unemits the issues. They will not propagate up.
1119    fn drop_issues(self);
1120}
1121
1122impl<T> CollectibleIssuesExt for T
1123where
1124    T: CollectiblesSource + Copy + Send,
1125{
1126    fn peek_issues(self) -> CapturedIssues {
1127        CapturedIssues {
1128            issues: self.peek_collectibles(),
1129
1130            tracer: DelegatingImportTracer {
1131                delegates: self.peek_collectibles(),
1132            }
1133            .resolved_cell(),
1134        }
1135    }
1136
1137    fn drop_issues(self) {
1138        self.drop_collectibles::<Box<dyn Issue>>();
1139    }
1140}
1141
1142/// Collects all issues emitted by `source` as resolved [`PlainIssue`]s.
1143///
1144/// This is an `operation` function so its (plain) result can be read *strongly consistently* (via
1145/// [`OperationVc::read_strongly_consistent`]) from a top-level task without tripping the
1146/// eventually-consistent-read assertion. The per-issue `PlainIssue::from_issue` reads happen
1147/// *inside* this task, where eventually-consistent reads are legal.
1148#[turbo_tasks::function(operation, root)]
1149async fn collect_issues(source: OperationVc<()>) -> Result<Vc<PlainIssues>> {
1150    let plain = source
1151        .peek_issues()
1152        .get_plain_issues(&IssueFilter::everything())
1153        .await?;
1154    Ok(PlainIssues(plain).cell())
1155}
1156
1157/// A helper function to print out issues to the console.
1158///
1159/// Must be called in a turbo-task as this constructs a `cell`
1160pub async fn handle_issues<T: Send>(
1161    source_op: OperationVc<T>,
1162    issue_reporter: Vc<Box<dyn IssueReporter>>,
1163    min_failing_severity: IssueSeverity,
1164    path: Option<&str>,
1165    operation: Option<&str>,
1166) -> Result<()> {
1167    let source_vc = source_op.connect();
1168    let _ = source_op.resolve().strongly_consistent().await?;
1169    let source_raw = Vc::into_raw(source_vc);
1170
1171    // Collect the issues in a dedicated `operation` task and read its *plain* result strongly
1172    // consistently. This is safe at the top level (unlike an eventually-consistent read), while
1173    // the per-issue reads happen inside `collect_issues`. The source is type-erased to
1174    // `OperationVc<()>` so a single non-generic task can collect issues for any source.
1175    let erased_source = OperationVc::<()>::try_from(source_raw)?;
1176    let issues = collect_issues(erased_source)
1177        .read_strongly_consistent()
1178        .await?;
1179
1180    // `report_issues` is a plain async method; reach it via a `TraitRef`. Resolve the reporter
1181    // strongly consistently first so that `into_trait_ref` is a plain cell read (rather than an
1182    // eventually-consistent task-output read) at the top level.
1183    let reporter = issue_reporter
1184        .to_resolved()
1185        .strongly_consistent()
1186        .await?
1187        .into_trait_ref()
1188        .await?;
1189    let has_fatal = reporter
1190        .report_issues(issues, source_raw, min_failing_severity)
1191        .await?;
1192
1193    if has_fatal {
1194        let mut message = "Fatal issue(s) occurred".to_owned();
1195        if let Some(path) = path.as_ref() {
1196            message += &format!(" in {path}");
1197        };
1198        if let Some(operation) = operation.as_ref() {
1199            message += &format!(" ({operation})");
1200        };
1201
1202        bail!(message)
1203    } else {
1204        Ok(())
1205    }
1206}
1207
1208fn find_line_and_column(lines: &[FileLine], offset: u32) -> SourcePos {
1209    match lines.binary_search_by(|line| line.bytes_offset.cmp(&offset)) {
1210        Ok(i) => SourcePos {
1211            line: i as u32,
1212            column: 0,
1213        },
1214        Err(i) => {
1215            if i == 0 {
1216                SourcePos {
1217                    line: 0,
1218                    column: offset,
1219                }
1220            } else {
1221                let line = &lines[i - 1];
1222                SourcePos {
1223                    line: (i - 1) as u32,
1224                    column: min(line.content.len() as u32, offset - line.bytes_offset),
1225                }
1226            }
1227        }
1228    }
1229}
1230
1231fn find_offset(lines: &[FileLine], pos: SourcePos) -> u32 {
1232    let line = &lines[pos.line as usize];
1233    line.bytes_offset + pos.column
1234}