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