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