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        if traces.len() == 1 {
230            let trace = &traces[0];
231            // We don't put the layer in the header for the single case. Either they are all the
232            // same in which case it should be clear from the filename or they are different and we
233            // need to print them on the items anyway.
234            writeln!(styled_issue, "Import trace:").unwrap();
235            format_trace_items(&mut styled_issue, "  ", !are_layers_identical(trace), trace);
236        } else {
237            // When there are multiple traces we:
238            // * display the layer in the header if the trace has a consistent layer
239            // * label the traces with their index, unless the layer is sufficiently unique.
240            styled_issue.push_str("Import traces:\n");
241            let every_trace_has_a_distinct_root_layer = traces
242                .iter()
243                .filter_map(|t| leaf_layer_name(t))
244                .collect::<FxHashSet<RcStr>>()
245                .len()
246                == traces.len();
247            if every_trace_has_a_distinct_root_layer {
248                for trace in traces {
249                    writeln!(styled_issue, "  {}:", leaf_layer_name(trace).unwrap()).unwrap();
250                    format_trace_items(&mut styled_issue, "    ", false, trace);
251                }
252            } else {
253                for (index, trace) in traces.iter().enumerate() {
254                    let printed_layer = match leaf_layer_name(trace) {
255                        Some(layer) => {
256                            writeln!(styled_issue, "  #{} [{layer}]:", index + 1).unwrap();
257                            false
258                        }
259                        None => {
260                            writeln!(styled_issue, "  #{}:", index + 1).unwrap();
261                            true
262                        }
263                    };
264                    format_trace_items(
265                        &mut styled_issue,
266                        "    ",
267                        !printed_layer || !are_layers_identical(trace),
268                        trace,
269                    );
270                }
271            }
272        }
273    }
274
275    let severity = severity.style(severity_to_style(severity));
276    write!(issue_text, "{severity} - [{stage}] ").unwrap();
277    for (index, line) in styled_issue.lines().enumerate() {
278        // don't indent the first line
279        if index > 0 {
280            issue_text.push_str("  ");
281        }
282        issue_text.push_str(line);
283        issue_text.push('\n');
284    }
285
286    issue_text
287}
288
289pub type GroupedIssues =
290    FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
291
292const DEFAULT_SHOW_COUNT: usize = 3;
293
294const ORDERED_GROUPS: &[IssueSeverity] = &[
295    IssueSeverity::Bug,
296    IssueSeverity::Fatal,
297    IssueSeverity::Error,
298    IssueSeverity::Warning,
299    IssueSeverity::Hint,
300    IssueSeverity::Note,
301    IssueSeverity::Suggestion,
302    IssueSeverity::Info,
303];
304
305#[turbo_tasks::value(shared)]
306#[derive(Debug, Clone)]
307pub struct LogOptions {
308    pub current_dir: PathBuf,
309    pub project_dir: PathBuf,
310    pub show_all: bool,
311    pub log_detail: bool,
312    pub log_level: IssueSeverity,
313}
314
315/// Tracks the state of currently seen issues.
316///
317/// An issue is considered seen as long as a single source has pulled the issue.
318/// When a source repulls emitted issues due to a recomputation somewhere in its
319/// graph, there are a few possibilities:
320///
321/// 1. An issue from this pull is brand new to all sources, in which case it will be logged and the
322///    issue's count is inremented.
323/// 2. An issue from this pull is brand new to this source but another source has already pulled it,
324///    in which case it will be logged and the issue's count is incremented.
325/// 3. The previous pull from this source had already seen the issue, in which case the issue will
326///    be skipped and the issue's count remains constant.
327/// 4. An issue seen in a previous pull was not repulled, and the issue's count is decremented.
328///
329/// Once an issue's count reaches zero, it's removed. If it is ever seen again,
330/// it is considered new and will be relogged.
331#[derive(Default)]
332struct SeenIssues {
333    /// Keeps track of all issue pulled from the source. Used so that we can
334    /// decrement issues that are not pulled in the current synchronization.
335    source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
336
337    /// Counts the number of times a particular issue is seen across all
338    /// sources. As long as the count is positive, an issue is considered
339    /// "seen" and will not be relogged. Once the count reaches zero, the
340    /// issue is removed and the next time its seen it will be considered new.
341    issues_count: FxHashMap<u64, usize>,
342}
343
344impl SeenIssues {
345    fn new() -> Self {
346        Default::default()
347    }
348
349    /// Synchronizes state between the issues previously pulled from this
350    /// source, to the issues now pulled.
351    fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
352        let old = self.source_to_issue_ids.entry(source).or_default();
353
354        // difference is the issues that were never counted before.
355        let difference = issue_ids
356            .iter()
357            .filter(|id| match self.issues_count.entry(**id) {
358                Entry::Vacant(e) => {
359                    // If the issue not currently counted, then it's new and should be logged.
360                    e.insert(1);
361                    true
362                }
363                Entry::Occupied(mut e) => {
364                    if old.contains(*id) {
365                        // If old contains the id, then we don't need to change the count, but we
366                        // do need to remove the entry. Doing so allows us to iterate the final old
367                        // state and decrement old issues.
368                        old.remove(*id);
369                    } else {
370                        // If old didn't contain the entry, then this issue was already counted
371                        // from a difference source.
372                        *e.get_mut() += 1;
373                    }
374                    false
375                }
376            })
377            .cloned()
378            .collect::<FxHashSet<_>>();
379
380        // Old now contains only the ids that were not present in the new issue_ids.
381        for id in old.iter() {
382            match self.issues_count.entry(*id) {
383                Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
384                Entry::Occupied(mut e) => {
385                    let v = e.get_mut();
386                    if *v == 1 {
387                        // If this was the last counter of the issue, then we need to prune the
388                        // value to free memory.
389                        e.remove();
390                    } else {
391                        // Another source counted the issue, and it must not be relogged until all
392                        // sources remove it.
393                        *v -= 1;
394                    }
395                }
396            }
397        }
398
399        *old = issue_ids;
400        difference
401    }
402}
403
404/// Logs emitted issues to console logs, deduplicating issues between peeks of
405/// the collected issues.
406///
407/// The ConsoleUi can be shared and capture issues from multiple sources, with deduplication
408/// operating across all issues.
409#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
410#[derive(Clone)]
411pub struct ConsoleUi {
412    options: LogOptions,
413
414    #[turbo_tasks(trace_ignore, debug_ignore)]
415    seen: Arc<Mutex<SeenIssues>>,
416}
417
418impl PartialEq for ConsoleUi {
419    fn eq(&self, other: &Self) -> bool {
420        self.options == other.options
421    }
422}
423
424#[turbo_tasks::value_impl]
425impl ConsoleUi {
426    #[turbo_tasks::function]
427    pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
428        ConsoleUi {
429            options: (*options).clone(),
430            seen: Arc::new(Mutex::new(SeenIssues::new())),
431        }
432        .cell()
433    }
434}
435
436#[turbo_tasks::value_impl]
437impl IssueReporter for ConsoleUi {
438    #[turbo_tasks::function]
439    async fn report_issues(
440        &self,
441        issues: TransientInstance<CapturedIssues>,
442        source: TransientValue<RawVc>,
443        min_failing_severity: IssueSeverity,
444    ) -> Result<Vc<bool>> {
445        let issues = &*issues;
446        let LogOptions {
447            ref current_dir,
448            ref project_dir,
449            show_all,
450            log_detail,
451            log_level,
452            ..
453        } = self.options;
454        let mut grouped_issues: GroupedIssues = FxHashMap::default();
455
456        let plain_issues = issues.get_plain_issues().await?;
457        let issues = plain_issues
458            .iter()
459            .map(|plain_issue| {
460                let id = plain_issue.internal_hash_ref(false);
461                (plain_issue, id)
462            })
463            .collect::<Vec<_>>();
464
465        let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
466        let mut new_ids = self
467            .seen
468            .lock()
469            .unwrap()
470            .new_ids(source.into_value(), issue_ids);
471
472        let mut has_fatal = false;
473        for (plain_issue, id) in issues {
474            if !new_ids.remove(&id) {
475                continue;
476            }
477
478            let severity = plain_issue.severity;
479            if severity <= min_failing_severity {
480                has_fatal = true;
481            }
482
483            let context_path =
484                make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
485            let stage = plain_issue.stage.to_string();
486            let processing_path = &*plain_issue.processing_path;
487            let severity_map = grouped_issues.entry(severity).or_default();
488            let category_map = severity_map.entry(stage.clone()).or_default();
489            let issues = category_map.entry(context_path.to_string()).or_default();
490
491            let mut styled_issue = style_issue_source(plain_issue, &context_path);
492            let description = &plain_issue.description;
493            if let Some(description) = description {
494                writeln!(
495                    &mut styled_issue,
496                    "\n{}",
497                    render_styled_string_to_ansi(description)
498                )?;
499            }
500
501            if log_detail {
502                styled_issue.push('\n');
503                let detail = &plain_issue.detail;
504                if let Some(detail) = detail {
505                    for line in render_styled_string_to_ansi(detail).split('\n') {
506                        writeln!(&mut styled_issue, "| {line}")?;
507                    }
508                }
509                let documentation_link = &plain_issue.documentation_link;
510                if !documentation_link.is_empty() {
511                    writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
512                }
513                format_optional_path(processing_path, &mut styled_issue)?;
514            }
515            issues.push(styled_issue);
516        }
517
518        for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
519            if let Some(severity_map) = grouped_issues.get_mut(&severity) {
520                let severity_map_size = severity_map.len();
521                let indent = if severity_map_size == 1 {
522                    print!("{} - ", severity.style(severity_to_style(severity)));
523                    ""
524                } else {
525                    println!("{} -", severity.style(severity_to_style(severity)));
526                    "  "
527                };
528                let severity_map_take_count = if show_all {
529                    severity_map_size
530                } else {
531                    DEFAULT_SHOW_COUNT
532                };
533                let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
534                categories.sort();
535                for category in categories.iter().take(severity_map_take_count) {
536                    let category_issues = severity_map.get_mut(category).unwrap();
537                    let category_issues_size = category_issues.len();
538                    let indent = if category_issues_size == 1 && indent.is_empty() {
539                        print!("[{category}] ");
540                        "".to_string()
541                    } else {
542                        println!("{indent}[{category}]");
543                        format!("{indent}  ")
544                    };
545                    let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
546                        .iter_mut()
547                        .partition(|(context, _)| !context.contains("node_modules"));
548                    contexts.sort_by_key(|(c, _)| *c);
549                    if show_all {
550                        vendor_contexts.sort_by_key(|(c, _)| *c);
551                        contexts.extend(vendor_contexts);
552                    }
553                    let category_issues_take_count = if show_all {
554                        category_issues_size
555                    } else {
556                        min(contexts.len(), DEFAULT_SHOW_COUNT)
557                    };
558                    for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
559                        issues.sort();
560                        println!("{indent}{}", context.bright_blue());
561                        let issues_size = issues.len();
562                        let issues_take_count = if show_all {
563                            issues_size
564                        } else {
565                            DEFAULT_SHOW_COUNT
566                        };
567                        for issue in issues.iter().take(issues_take_count) {
568                            let mut i = 0;
569                            for line in issue.lines() {
570                                println!("{indent}  {line}");
571                                i += 1;
572                            }
573                            if i > 1 {
574                                // Spacing after multi line issues
575                                println!();
576                            }
577                        }
578                        if issues_size > issues_take_count {
579                            println!("{indent}  {}", show_all_message("issues", issues_size));
580                        }
581                    }
582                    if category_issues_size > category_issues_take_count {
583                        println!(
584                            "{indent}{}",
585                            show_all_message_with_shown_count(
586                                "paths",
587                                category_issues_size,
588                                category_issues_take_count
589                            )
590                        );
591                    }
592                }
593                if severity_map_size > severity_map_take_count {
594                    println!(
595                        "{indent}{}",
596                        show_all_message("categories", severity_map_size)
597                    )
598                }
599            }
600        }
601
602        Ok(Vc::cell(has_fatal))
603    }
604}
605
606fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
607    if let Some(path_in_project) = path.strip_prefix("[project]/") {
608        let abs_path = if std::path::MAIN_SEPARATOR != '/' {
609            project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
610        } else {
611            project_dir.join(path_in_project)
612        };
613        let relative = abs_path
614            .strip_prefix(cwd)
615            .unwrap_or(&abs_path)
616            .to_string_lossy()
617            .to_string();
618        relative.into()
619    } else {
620        path.into()
621    }
622}
623
624fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
625    show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
626}
627
628fn show_all_message_with_shown_count(
629    label: &str,
630    size: usize,
631    shown: usize,
632) -> StyledContent<String> {
633    if shown == 0 {
634        format!(
635            "... [{} {label}] are hidden, run with {} to show them",
636            size,
637            "--show-all".bright_green()
638        )
639        .bold()
640    } else {
641        format!(
642            "... [{} more {label}] are hidden, run with {} to show all",
643            size - shown,
644            "--show-all".bright_green()
645        )
646        .bold()
647    }
648}
649
650fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
651    match styled_string {
652        StyledString::Line(parts) => {
653            let mut string = String::new();
654            for part in parts {
655                string.push_str(&render_styled_string_to_ansi(part));
656            }
657            string.push('\n');
658            string
659        }
660        StyledString::Stack(parts) => {
661            let mut string = String::new();
662            for part in parts {
663                string.push_str(&render_styled_string_to_ansi(part));
664                string.push('\n');
665            }
666            string
667        }
668        StyledString::Text(string) => string.to_string(),
669        StyledString::Code(string) => string.blue().to_string(),
670        StyledString::Strong(string) => string.bold().to_string(),
671    }
672}
673
674fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
675    let title = &plain_issue.title;
676    let formatted_title = match title {
677        StyledString::Text(text) => text.bold().to_string(),
678        _ => render_styled_string_to_ansi(title),
679    };
680
681    if let Some(source) = &plain_issue.source {
682        let mut styled_issue = match source.range {
683            Some((start, _)) => format!(
684                "{}:{}:{}  {}",
685                context_path,
686                start.line + 1,
687                start.column,
688                formatted_title
689            ),
690            None => format!("{context_path}  {formatted_title}"),
691        };
692        styled_issue.push('\n');
693        format_source_content(source, &mut styled_issue);
694        styled_issue
695    } else {
696        format!("{context_path}  {formatted_title}\n")
697    }
698}