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    #[turbo_tasks(unsafe_ignore, debug_ignore)]
357    seen: Arc<Mutex<SeenIssues>>,
358}
359
360impl PartialEq for ConsoleUi {
361    fn eq(&self, other: &Self) -> bool {
362        self.options == other.options
363    }
364}
365
366#[turbo_tasks::value_impl]
367impl ConsoleUi {
368    #[turbo_tasks::function(root)]
369    pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
370        ConsoleUi {
371            options: (*options).clone(),
372            seen: Arc::new(Mutex::new(SeenIssues::new())),
373        }
374        .cell()
375    }
376}
377
378#[async_trait]
379#[turbo_tasks::value_impl]
380impl IssueReporter for ConsoleUi {
381    async fn report_issues(
382        &self,
383        issues: ReadRef<PlainIssues>,
384        source: RawVc,
385        min_failing_severity: IssueSeverity,
386    ) -> Result<bool> {
387        let LogOptions {
388            ref current_dir,
389            ref project_dir,
390            show_all,
391            log_detail,
392            log_level,
393            ..
394        } = self.options;
395        let mut grouped_issues: GroupedIssues = FxHashMap::default();
396
397        let plain_issues = &issues.0;
398        let issues = plain_issues
399            .iter()
400            .map(|plain_issue| {
401                let id = plain_issue.internal_hash_ref(false);
402                (plain_issue, id)
403            })
404            .collect::<Vec<_>>();
405
406        let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
407        let mut new_ids = self.seen.lock().unwrap().new_ids(source, issue_ids);
408
409        let mut has_fatal = false;
410        for (plain_issue, id) in issues {
411            if !new_ids.remove(&id) {
412                continue;
413            }
414
415            let severity = plain_issue.severity;
416            if severity <= min_failing_severity {
417                has_fatal = true;
418            }
419
420            let context_path =
421                make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
422            let stage = plain_issue.stage.to_string();
423            let severity_map = grouped_issues.entry(severity).or_default();
424            let category_map = severity_map.entry(stage.clone()).or_default();
425            let issues = category_map.entry(context_path.to_string()).or_default();
426
427            let mut styled_issue = style_issue_source(plain_issue, &context_path);
428            let description = &plain_issue.description;
429            if let Some(description) = description {
430                writeln!(
431                    &mut styled_issue,
432                    "\n{}",
433                    render_styled_string_to_ansi(description)
434                )?;
435            }
436
437            if log_detail {
438                styled_issue.push('\n');
439                let detail = &plain_issue.detail;
440                if let Some(detail) = detail {
441                    for line in render_styled_string_to_ansi(detail).split('\n') {
442                        writeln!(&mut styled_issue, "| {line}")?;
443                    }
444                }
445                let documentation_link = &plain_issue.documentation_link;
446                if !documentation_link.is_empty() {
447                    writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
448                }
449            }
450            issues.push(styled_issue);
451        }
452
453        for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
454            if let Some(severity_map) = grouped_issues.get_mut(&severity) {
455                let severity_map_size = severity_map.len();
456                let indent = if severity_map_size == 1 {
457                    print!("{} - ", severity.style(severity_to_style(severity)));
458                    ""
459                } else {
460                    println!("{} -", severity.style(severity_to_style(severity)));
461                    "  "
462                };
463                let severity_map_take_count = if show_all {
464                    severity_map_size
465                } else {
466                    DEFAULT_SHOW_COUNT
467                };
468                let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
469                categories.sort();
470                for category in categories.iter().take(severity_map_take_count) {
471                    let category_issues = severity_map.get_mut(category).unwrap();
472                    let category_issues_size = category_issues.len();
473                    let indent = if category_issues_size == 1 && indent.is_empty() {
474                        print!("[{category}] ");
475                        "".to_string()
476                    } else {
477                        println!("{indent}[{category}]");
478                        format!("{indent}  ")
479                    };
480                    let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
481                        .iter_mut()
482                        .partition(|(context, _)| !context.contains("node_modules"));
483                    contexts.sort_by_key(|(c, _)| *c);
484                    if show_all {
485                        vendor_contexts.sort_by_key(|(c, _)| *c);
486                        contexts.extend(vendor_contexts);
487                    }
488                    let category_issues_take_count = if show_all {
489                        category_issues_size
490                    } else {
491                        min(contexts.len(), DEFAULT_SHOW_COUNT)
492                    };
493                    for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
494                        issues.sort();
495                        println!("{indent}{}", context.bright_blue());
496                        let issues_size = issues.len();
497                        let issues_take_count = if show_all {
498                            issues_size
499                        } else {
500                            DEFAULT_SHOW_COUNT
501                        };
502                        for issue in issues.iter().take(issues_take_count) {
503                            let mut i = 0;
504                            for line in issue.lines() {
505                                println!("{indent}  {line}");
506                                i += 1;
507                            }
508                            if i > 1 {
509                                // Spacing after multi line issues
510                                println!();
511                            }
512                        }
513                        if issues_size > issues_take_count {
514                            println!("{indent}  {}", show_all_message("issues", issues_size));
515                        }
516                    }
517                    if category_issues_size > category_issues_take_count {
518                        println!(
519                            "{indent}{}",
520                            show_all_message_with_shown_count(
521                                "paths",
522                                category_issues_size,
523                                category_issues_take_count
524                            )
525                        );
526                    }
527                }
528                if severity_map_size > severity_map_take_count {
529                    println!(
530                        "{indent}{}",
531                        show_all_message("categories", severity_map_size)
532                    )
533                }
534            }
535        }
536
537        Ok(has_fatal)
538    }
539}
540
541fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
542    if let Some(path_in_project) = path.strip_prefix("[project]/") {
543        let abs_path = if std::path::MAIN_SEPARATOR != '/' {
544            project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
545        } else {
546            project_dir.join(path_in_project)
547        };
548        let relative = abs_path
549            .strip_prefix(cwd)
550            .unwrap_or(&abs_path)
551            .to_string_lossy()
552            .to_string();
553        relative.into()
554    } else {
555        path.into()
556    }
557}
558
559fn show_all_message(label: &str, size: usize) -> String {
560    show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
561}
562
563fn show_all_message_with_shown_count(label: &str, size: usize, shown: usize) -> String {
564    if shown == 0 {
565        format!(
566            "... [{} {label}] are hidden, run with {} to show them",
567            size,
568            "--show-all".bright_green()
569        )
570        .bold()
571        .to_string()
572    } else {
573        format!(
574            "... [{} more {label}] are hidden, run with {} to show all",
575            size - shown,
576            "--show-all".bright_green()
577        )
578        .bold()
579        .to_string()
580    }
581}
582
583fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
584    match styled_string {
585        StyledString::Line(parts) => {
586            let mut string = String::new();
587            for part in parts {
588                string.push_str(&render_styled_string_to_ansi(part));
589            }
590            string.push('\n');
591            string
592        }
593        StyledString::Stack(parts) => {
594            let mut string = String::new();
595            for part in parts {
596                string.push_str(&render_styled_string_to_ansi(part));
597                string.push('\n');
598            }
599            string
600        }
601        StyledString::Text(string) => string.to_string(),
602        StyledString::Code(string) => string.blue().to_string(),
603        StyledString::Strong(string) => string.bold().to_string(),
604    }
605}
606
607fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
608    let title = &plain_issue.title;
609    let formatted_title = match title {
610        StyledString::Text(text) => text.bold().to_string(),
611        _ => render_styled_string_to_ansi(title),
612    };
613
614    if let Some(source) = &plain_issue.source {
615        let mut styled_issue = match source.range {
616            Some((start, _)) => format!(
617                "{}:{}:{}  {}",
618                context_path,
619                start.line + 1,
620                start.column,
621                formatted_title
622            ),
623            None => format!("{context_path}  {formatted_title}"),
624        };
625        styled_issue.push('\n');
626        format_source_content(source, &mut styled_issue);
627        styled_issue
628    } else {
629        format!("{context_path}  {formatted_title}\n")
630    }
631}