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 let context_path = plain_issue
64 .file_path
65 .replace("[project]", ¤t_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 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 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 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; };
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 if item.fs_name != "project" {
158 out.push('[');
159 out.push_str(&item.fs_name);
160 out.push_str("]/");
161 } else {
162 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 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 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 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#[derive(Default)]
276struct SeenIssues {
277 source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
280
281 issues_count: FxHashMap<u64, usize>,
286}
287
288impl SeenIssues {
289 fn new() -> Self {
290 Default::default()
291 }
292
293 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 let difference = issue_ids
300 .iter()
301 .filter(|id| match self.issues_count.entry(**id) {
302 Entry::Vacant(e) => {
303 e.insert(1);
305 true
306 }
307 Entry::Occupied(mut e) => {
308 if old.contains(*id) {
309 old.remove(*id);
313 } else {
314 *e.get_mut() += 1;
317 }
318 false
319 }
320 })
321 .cloned()
322 .collect::<FxHashSet<_>>();
323
324 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 e.remove();
334 } else {
335 *v -= 1;
338 }
339 }
340 }
341 }
342
343 *old = issue_ids;
344 difference
345 }
346}
347
348#[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 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}