turbopack_cli_utils/
issue.rs

1use std::{
2    borrow::Cow,
3    cmp::min,
4    collections::hash_map::Entry,
5    fmt::Write as _,
6    path::{Path, PathBuf},
7    str::FromStr,
8    sync::{Arc, Mutex},
9};
10
11use anyhow::{Result, anyhow};
12use crossterm::style::{StyledContent, Stylize};
13use owo_colors::{OwoColorize as _, Style};
14use rustc_hash::{FxHashMap, FxHashSet};
15use turbo_rcstr::RcStr;
16use turbo_tasks::{RawVc, ReadRef, TransientInstance, TransientValue, Vc};
17use turbo_tasks_fs::{FileLinesContent, source_context::get_source_context};
18use turbopack_core::issue::{
19    CapturedIssues, IssueReporter, IssueSeverity, PlainIssue, PlainIssueProcessingPathItem,
20    PlainIssueSource, PlainTraceItem, StyledString,
21};
22
23use crate::source_context::format_source_context_lines;
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub struct IssueSeverityCliOption(pub IssueSeverity);
27
28impl serde::Serialize for IssueSeverityCliOption {
29    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
30        serializer.serialize_str(&self.0.to_string())
31    }
32}
33
34impl<'de> serde::Deserialize<'de> for IssueSeverityCliOption {
35    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
36        let s = String::deserialize(deserializer)?;
37        IssueSeverityCliOption::from_str(&s).map_err(serde::de::Error::custom)
38    }
39}
40
41impl clap::ValueEnum for IssueSeverityCliOption {
42    fn value_variants<'a>() -> &'a [Self] {
43        const VARIANTS: [IssueSeverityCliOption; 8] = [
44            IssueSeverityCliOption(IssueSeverity::Bug),
45            IssueSeverityCliOption(IssueSeverity::Fatal),
46            IssueSeverityCliOption(IssueSeverity::Error),
47            IssueSeverityCliOption(IssueSeverity::Warning),
48            IssueSeverityCliOption(IssueSeverity::Hint),
49            IssueSeverityCliOption(IssueSeverity::Note),
50            IssueSeverityCliOption(IssueSeverity::Suggestion),
51            IssueSeverityCliOption(IssueSeverity::Info),
52        ];
53        &VARIANTS
54    }
55
56    fn to_possible_value<'a>(&self) -> Option<clap::builder::PossibleValue> {
57        Some(clap::builder::PossibleValue::new(self.0.as_str()).help(self.0.as_help_str()))
58    }
59}
60
61impl FromStr for IssueSeverityCliOption {
62    type Err = anyhow::Error;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        <IssueSeverityCliOption as clap::ValueEnum>::from_str(s, true).map_err(|s| anyhow!("{}", s))
66    }
67}
68
69fn severity_to_style(severity: IssueSeverity) -> Style {
70    match severity {
71        IssueSeverity::Bug => Style::new().bright_red().underline(),
72        IssueSeverity::Fatal => Style::new().bright_red().underline(),
73        IssueSeverity::Error => Style::new().bright_red(),
74        IssueSeverity::Warning => Style::new().bright_yellow(),
75        IssueSeverity::Hint => Style::new().bold(),
76        IssueSeverity::Note => Style::new().bold(),
77        IssueSeverity::Suggestion => Style::new().bright_green().underline(),
78        IssueSeverity::Info => Style::new().bright_green(),
79    }
80}
81
82fn format_source_content(source: &PlainIssueSource, formatted_issue: &mut String) {
83    if let FileLinesContent::Lines(lines) = source.asset.content.lines_ref()
84        && let Some((start, end)) = source.range
85    {
86        let lines = lines.iter().map(|l| l.content.as_str());
87        let ctx = get_source_context(lines, start.line, start.column, end.line, end.column);
88        format_source_context_lines(&ctx, formatted_issue);
89    }
90}
91
92fn format_optional_path(
93    path: &Option<Vec<ReadRef<PlainIssueProcessingPathItem>>>,
94    formatted_issue: &mut String,
95) -> Result<()> {
96    if let Some(path) = path {
97        let mut last_context = None;
98        for item in path.iter().rev() {
99            let PlainIssueProcessingPathItem {
100                file_path: ref context,
101                ref description,
102            } = **item;
103            if let Some(context) = context {
104                let option_context = Some(context.clone());
105                if last_context == option_context {
106                    writeln!(formatted_issue, " at {description}")?;
107                } else {
108                    writeln!(
109                        formatted_issue,
110                        " at {} ({})",
111                        context.to_string().bright_blue(),
112                        description
113                    )?;
114                    last_context = option_context;
115                }
116            } else {
117                writeln!(formatted_issue, " at {description}")?;
118                last_context = None;
119            }
120        }
121    }
122    Ok(())
123}
124
125pub fn format_issue(
126    plain_issue: &PlainIssue,
127    path: Option<String>,
128    options: &LogOptions,
129) -> String {
130    let &LogOptions {
131        ref current_dir,
132        log_detail,
133        ..
134    } = options;
135
136    let mut issue_text = String::new();
137
138    let severity = plain_issue.severity;
139    // TODO CLICKABLE PATHS
140    let context_path = plain_issue
141        .file_path
142        .replace("[project]", &current_dir.to_string_lossy())
143        .replace("/./", "/")
144        .replace("\\\\?\\", "");
145    let stage = plain_issue.stage.to_string();
146
147    let mut styled_issue = style_issue_source(plain_issue, &context_path);
148    let description = &plain_issue.description;
149    if let Some(description) = description {
150        writeln!(
151            styled_issue,
152            "\n{}",
153            render_styled_string_to_ansi(description)
154        )
155        .unwrap();
156    }
157
158    if log_detail {
159        styled_issue.push('\n');
160        let detail = &plain_issue.detail;
161        if let Some(detail) = detail {
162            for line in render_styled_string_to_ansi(detail).split('\n') {
163                writeln!(styled_issue, "| {line}").unwrap();
164            }
165        }
166        let documentation_link = &plain_issue.documentation_link;
167        if !documentation_link.is_empty() {
168            writeln!(styled_issue, "\ndocumentation: {documentation_link}").unwrap();
169        }
170        if let Some(path) = path {
171            writeln!(styled_issue, "{path}").unwrap();
172        }
173    }
174    let traces = &*plain_issue.import_traces;
175    if !traces.is_empty() {
176        /// Returns the leaf layer name, which is the first present layer name in the trace
177        fn leaf_layer_name(items: &[PlainTraceItem]) -> Option<RcStr> {
178            items
179                .iter()
180                .find(|t| t.layer.is_some())
181                .and_then(|t| t.layer.clone())
182        }
183        /// Returns whether or not all layers in the trace are identical
184        /// If a layer is missing we ignore it in this analysis
185        fn are_layers_identical(items: &[PlainTraceItem]) -> bool {
186            let Some(first_present_layer) = items.iter().position(|t| t.layer.is_some()) else {
187                return true; // if all layers are absent they are the same.
188            };
189            let layer = &items[first_present_layer].layer;
190            items
191                .iter()
192                .skip(first_present_layer + 1)
193                .all(|t| t.layer.is_none() || &t.layer == layer)
194        }
195        fn format_trace_items(
196            out: &mut String,
197            indent: &'static str,
198            print_layers: bool,
199            items: &[PlainTraceItem],
200        ) {
201            for item in items {
202                out.push_str(indent);
203                // We want to format the filepath but with a few caveats
204                // - if it is part of the `[project]` filesystem, omit the fs name
205                // - format the label at the end
206                // - if it is the last item add the special marker `[entrypoint]` to help clarify
207                //   that this is an application entry point
208                // TODO(lukesandberg): some formatting could be useful. We could use colors,
209                // bold/faint, links?
210                if item.fs_name != "project" {
211                    out.push('[');
212                    out.push_str(&item.fs_name);
213                    out.push_str("]/");
214                } else {
215                    // This is consistent with webpack's output
216                    out.push_str("./");
217                }
218                out.push_str(&item.path);
219                if let Some(ref label) = item.layer
220                    && print_layers
221                {
222                    out.push_str(" [");
223                    out.push_str(label);
224                    out.push(']');
225                }
226                out.push('\n');
227            }
228        }
229
230        // For each trace we:
231        // * display the layer in the header if the trace has a consistent layer
232        // * label the traces with their index, unless the layer is sufficiently unique.
233        writeln!(
234            styled_issue,
235            "Import trace{}:",
236            if traces.len() > 1 { "s" } else { "" }
237        )
238        .unwrap();
239        let every_trace_has_a_distinct_root_layer = traces
240            .iter()
241            .filter_map(|t| leaf_layer_name(t))
242            .collect::<FxHashSet<RcStr>>()
243            .len()
244            == traces.len();
245        for (index, trace) in traces.iter().enumerate() {
246            let layer = leaf_layer_name(trace);
247            let mut trace_indent = "    ";
248            if every_trace_has_a_distinct_root_layer {
249                writeln!(styled_issue, "  {}:", layer.unwrap()).unwrap();
250            } else if traces.len() > 1 {
251                write!(styled_issue, "  #{}", index + 1).unwrap();
252                if let Some(layer) = layer {
253                    write!(styled_issue, " [{layer}]").unwrap();
254                }
255                writeln!(styled_issue, ":").unwrap();
256            } else if let Some(layer) = layer {
257                write!(styled_issue, " [{layer}]").unwrap();
258            } else {
259                // There is one trace and no layer (!?) just indent once
260                trace_indent = "  ";
261            }
262
263            format_trace_items(
264                &mut styled_issue,
265                trace_indent,
266                !are_layers_identical(trace),
267                trace,
268            );
269        }
270    }
271
272    let severity = severity.style(severity_to_style(severity));
273    write!(issue_text, "{severity} - [{stage}] ").unwrap();
274    for (index, line) in styled_issue.lines().enumerate() {
275        // don't indent the first line
276        if index > 0 {
277            issue_text.push_str("  ");
278        }
279        issue_text.push_str(line);
280        issue_text.push('\n');
281    }
282
283    issue_text
284}
285
286pub type GroupedIssues =
287    FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
288
289const DEFAULT_SHOW_COUNT: usize = 3;
290
291const ORDERED_GROUPS: &[IssueSeverity] = &[
292    IssueSeverity::Bug,
293    IssueSeverity::Fatal,
294    IssueSeverity::Error,
295    IssueSeverity::Warning,
296    IssueSeverity::Hint,
297    IssueSeverity::Note,
298    IssueSeverity::Suggestion,
299    IssueSeverity::Info,
300];
301
302#[turbo_tasks::value(shared)]
303#[derive(Debug, Clone)]
304pub struct LogOptions {
305    pub current_dir: PathBuf,
306    pub project_dir: PathBuf,
307    pub show_all: bool,
308    pub log_detail: bool,
309    pub log_level: IssueSeverity,
310}
311
312/// Tracks the state of currently seen issues.
313///
314/// An issue is considered seen as long as a single source has pulled the issue.
315/// When a source repulls emitted issues due to a recomputation somewhere in its
316/// graph, there are a few possibilities:
317///
318/// 1. An issue from this pull is brand new to all sources, in which case it will be logged and the
319///    issue's count is inremented.
320/// 2. An issue from this pull is brand new to this source but another source has already pulled it,
321///    in which case it will be logged and the issue's count is incremented.
322/// 3. The previous pull from this source had already seen the issue, in which case the issue will
323///    be skipped and the issue's count remains constant.
324/// 4. An issue seen in a previous pull was not repulled, and the issue's count is decremented.
325///
326/// Once an issue's count reaches zero, it's removed. If it is ever seen again,
327/// it is considered new and will be relogged.
328#[derive(Default)]
329struct SeenIssues {
330    /// Keeps track of all issue pulled from the source. Used so that we can
331    /// decrement issues that are not pulled in the current synchronization.
332    source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
333
334    /// Counts the number of times a particular issue is seen across all
335    /// sources. As long as the count is positive, an issue is considered
336    /// "seen" and will not be relogged. Once the count reaches zero, the
337    /// issue is removed and the next time its seen it will be considered new.
338    issues_count: FxHashMap<u64, usize>,
339}
340
341impl SeenIssues {
342    fn new() -> Self {
343        Default::default()
344    }
345
346    /// Synchronizes state between the issues previously pulled from this
347    /// source, to the issues now pulled.
348    fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
349        let old = self.source_to_issue_ids.entry(source).or_default();
350
351        // difference is the issues that were never counted before.
352        let difference = issue_ids
353            .iter()
354            .filter(|id| match self.issues_count.entry(**id) {
355                Entry::Vacant(e) => {
356                    // If the issue not currently counted, then it's new and should be logged.
357                    e.insert(1);
358                    true
359                }
360                Entry::Occupied(mut e) => {
361                    if old.contains(*id) {
362                        // If old contains the id, then we don't need to change the count, but we
363                        // do need to remove the entry. Doing so allows us to iterate the final old
364                        // state and decrement old issues.
365                        old.remove(*id);
366                    } else {
367                        // If old didn't contain the entry, then this issue was already counted
368                        // from a difference source.
369                        *e.get_mut() += 1;
370                    }
371                    false
372                }
373            })
374            .cloned()
375            .collect::<FxHashSet<_>>();
376
377        // Old now contains only the ids that were not present in the new issue_ids.
378        for id in old.iter() {
379            match self.issues_count.entry(*id) {
380                Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
381                Entry::Occupied(mut e) => {
382                    let v = e.get_mut();
383                    if *v == 1 {
384                        // If this was the last counter of the issue, then we need to prune the
385                        // value to free memory.
386                        e.remove();
387                    } else {
388                        // Another source counted the issue, and it must not be relogged until all
389                        // sources remove it.
390                        *v -= 1;
391                    }
392                }
393            }
394        }
395
396        *old = issue_ids;
397        difference
398    }
399}
400
401/// Logs emitted issues to console logs, deduplicating issues between peeks of
402/// the collected issues.
403///
404/// The ConsoleUi can be shared and capture issues from multiple sources, with deduplication
405/// operating across all issues.
406#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
407#[derive(Clone)]
408pub struct ConsoleUi {
409    options: LogOptions,
410
411    #[turbo_tasks(trace_ignore, debug_ignore)]
412    seen: Arc<Mutex<SeenIssues>>,
413}
414
415impl PartialEq for ConsoleUi {
416    fn eq(&self, other: &Self) -> bool {
417        self.options == other.options
418    }
419}
420
421#[turbo_tasks::value_impl]
422impl ConsoleUi {
423    #[turbo_tasks::function]
424    pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
425        ConsoleUi {
426            options: (*options).clone(),
427            seen: Arc::new(Mutex::new(SeenIssues::new())),
428        }
429        .cell()
430    }
431}
432
433#[turbo_tasks::value_impl]
434impl IssueReporter for ConsoleUi {
435    #[turbo_tasks::function]
436    async fn report_issues(
437        &self,
438        issues: TransientInstance<CapturedIssues>,
439        source: TransientValue<RawVc>,
440        min_failing_severity: IssueSeverity,
441    ) -> Result<Vc<bool>> {
442        let issues = &*issues;
443        let LogOptions {
444            ref current_dir,
445            ref project_dir,
446            show_all,
447            log_detail,
448            log_level,
449            ..
450        } = self.options;
451        let mut grouped_issues: GroupedIssues = FxHashMap::default();
452
453        let plain_issues = issues.get_plain_issues().await?;
454        let issues = plain_issues
455            .iter()
456            .map(|plain_issue| {
457                let id = plain_issue.internal_hash_ref(false);
458                (plain_issue, id)
459            })
460            .collect::<Vec<_>>();
461
462        let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
463        let mut new_ids = self
464            .seen
465            .lock()
466            .unwrap()
467            .new_ids(source.into_value(), issue_ids);
468
469        let mut has_fatal = false;
470        for (plain_issue, id) in issues {
471            if !new_ids.remove(&id) {
472                continue;
473            }
474
475            let severity = plain_issue.severity;
476            if severity <= min_failing_severity {
477                has_fatal = true;
478            }
479
480            let context_path =
481                make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
482            let stage = plain_issue.stage.to_string();
483            let processing_path = &*plain_issue.processing_path;
484            let severity_map = grouped_issues.entry(severity).or_default();
485            let category_map = severity_map.entry(stage.clone()).or_default();
486            let issues = category_map.entry(context_path.to_string()).or_default();
487
488            let mut styled_issue = style_issue_source(plain_issue, &context_path);
489            let description = &plain_issue.description;
490            if let Some(description) = description {
491                writeln!(
492                    &mut styled_issue,
493                    "\n{}",
494                    render_styled_string_to_ansi(description)
495                )?;
496            }
497
498            if log_detail {
499                styled_issue.push('\n');
500                let detail = &plain_issue.detail;
501                if let Some(detail) = detail {
502                    for line in render_styled_string_to_ansi(detail).split('\n') {
503                        writeln!(&mut styled_issue, "| {line}")?;
504                    }
505                }
506                let documentation_link = &plain_issue.documentation_link;
507                if !documentation_link.is_empty() {
508                    writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
509                }
510                format_optional_path(processing_path, &mut styled_issue)?;
511            }
512            issues.push(styled_issue);
513        }
514
515        for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
516            if let Some(severity_map) = grouped_issues.get_mut(&severity) {
517                let severity_map_size = severity_map.len();
518                let indent = if severity_map_size == 1 {
519                    print!("{} - ", severity.style(severity_to_style(severity)));
520                    ""
521                } else {
522                    println!("{} -", severity.style(severity_to_style(severity)));
523                    "  "
524                };
525                let severity_map_take_count = if show_all {
526                    severity_map_size
527                } else {
528                    DEFAULT_SHOW_COUNT
529                };
530                let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
531                categories.sort();
532                for category in categories.iter().take(severity_map_take_count) {
533                    let category_issues = severity_map.get_mut(category).unwrap();
534                    let category_issues_size = category_issues.len();
535                    let indent = if category_issues_size == 1 && indent.is_empty() {
536                        print!("[{category}] ");
537                        "".to_string()
538                    } else {
539                        println!("{indent}[{category}]");
540                        format!("{indent}  ")
541                    };
542                    let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
543                        .iter_mut()
544                        .partition(|(context, _)| !context.contains("node_modules"));
545                    contexts.sort_by_key(|(c, _)| *c);
546                    if show_all {
547                        vendor_contexts.sort_by_key(|(c, _)| *c);
548                        contexts.extend(vendor_contexts);
549                    }
550                    let category_issues_take_count = if show_all {
551                        category_issues_size
552                    } else {
553                        min(contexts.len(), DEFAULT_SHOW_COUNT)
554                    };
555                    for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
556                        issues.sort();
557                        println!("{indent}{}", context.bright_blue());
558                        let issues_size = issues.len();
559                        let issues_take_count = if show_all {
560                            issues_size
561                        } else {
562                            DEFAULT_SHOW_COUNT
563                        };
564                        for issue in issues.iter().take(issues_take_count) {
565                            let mut i = 0;
566                            for line in issue.lines() {
567                                println!("{indent}  {line}");
568                                i += 1;
569                            }
570                            if i > 1 {
571                                // Spacing after multi line issues
572                                println!();
573                            }
574                        }
575                        if issues_size > issues_take_count {
576                            println!("{indent}  {}", show_all_message("issues", issues_size));
577                        }
578                    }
579                    if category_issues_size > category_issues_take_count {
580                        println!(
581                            "{indent}{}",
582                            show_all_message_with_shown_count(
583                                "paths",
584                                category_issues_size,
585                                category_issues_take_count
586                            )
587                        );
588                    }
589                }
590                if severity_map_size > severity_map_take_count {
591                    println!(
592                        "{indent}{}",
593                        show_all_message("categories", severity_map_size)
594                    )
595                }
596            }
597        }
598
599        Ok(Vc::cell(has_fatal))
600    }
601}
602
603fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
604    if let Some(path_in_project) = path.strip_prefix("[project]/") {
605        let abs_path = if std::path::MAIN_SEPARATOR != '/' {
606            project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
607        } else {
608            project_dir.join(path_in_project)
609        };
610        let relative = abs_path
611            .strip_prefix(cwd)
612            .unwrap_or(&abs_path)
613            .to_string_lossy()
614            .to_string();
615        relative.into()
616    } else {
617        path.into()
618    }
619}
620
621fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
622    show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
623}
624
625fn show_all_message_with_shown_count(
626    label: &str,
627    size: usize,
628    shown: usize,
629) -> StyledContent<String> {
630    if shown == 0 {
631        format!(
632            "... [{} {label}] are hidden, run with {} to show them",
633            size,
634            "--show-all".bright_green()
635        )
636        .bold()
637    } else {
638        format!(
639            "... [{} more {label}] are hidden, run with {} to show all",
640            size - shown,
641            "--show-all".bright_green()
642        )
643        .bold()
644    }
645}
646
647fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
648    match styled_string {
649        StyledString::Line(parts) => {
650            let mut string = String::new();
651            for part in parts {
652                string.push_str(&render_styled_string_to_ansi(part));
653            }
654            string.push('\n');
655            string
656        }
657        StyledString::Stack(parts) => {
658            let mut string = String::new();
659            for part in parts {
660                string.push_str(&render_styled_string_to_ansi(part));
661                string.push('\n');
662            }
663            string
664        }
665        StyledString::Text(string) => string.to_string(),
666        StyledString::Code(string) => string.blue().to_string(),
667        StyledString::Strong(string) => string.bold().to_string(),
668    }
669}
670
671fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
672    let title = &plain_issue.title;
673    let formatted_title = match title {
674        StyledString::Text(text) => text.bold().to_string(),
675        _ => render_styled_string_to_ansi(title),
676    };
677
678    if let Some(source) = &plain_issue.source {
679        let mut styled_issue = match source.range {
680            Some((start, _)) => format!(
681                "{}:{}:{}  {}",
682                context_path,
683                start.line + 1,
684                start.column,
685                formatted_title
686            ),
687            None => format!("{context_path}  {formatted_title}"),
688        };
689        styled_issue.push('\n');
690        format_source_content(source, &mut styled_issue);
691        styled_issue
692    } else {
693        format!("{context_path}  {formatted_title}\n")
694    }
695}