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