Skip to main content

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