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