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