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, 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 let traces = &*plain_issue.import_traces;
97 if !traces.is_empty() {
98 fn leaf_layer_name(items: &[PlainTraceItem]) -> Option<RcStr> {
100 items
101 .iter()
102 .find(|t| t.layer.is_some())
103 .and_then(|t| t.layer.clone())
104 }
105 fn are_layers_identical(items: &[PlainTraceItem]) -> bool {
108 let Some(first_present_layer) = items.iter().position(|t| t.layer.is_some()) else {
109 return true; };
111 let layer = &items[first_present_layer].layer;
112 items
113 .iter()
114 .skip(first_present_layer + 1)
115 .all(|t| t.layer.is_none() || &t.layer == layer)
116 }
117 fn format_trace_items(
118 out: &mut String,
119 indent: &'static str,
120 print_layers: bool,
121 items: &[PlainTraceItem],
122 ) {
123 for item in items {
124 out.push_str(indent);
125 if item.fs_name != "project" {
133 out.push('[');
134 out.push_str(&item.fs_name);
135 out.push_str("]/");
136 } else {
137 out.push_str("./");
139 }
140 out.push_str(&item.path);
141 if let Some(ref label) = item.layer
142 && print_layers
143 {
144 out.push_str(" [");
145 out.push_str(label);
146 out.push(']');
147 }
148 out.push('\n');
149 }
150 }
151
152 writeln!(
156 styled_issue,
157 "Import trace{}:",
158 if traces.len() > 1 { "s" } else { "" }
159 )
160 .unwrap();
161 let every_trace_has_a_distinct_root_layer = traces
162 .iter()
163 .filter_map(|t| leaf_layer_name(t))
164 .collect::<FxHashSet<RcStr>>()
165 .len()
166 == traces.len();
167 for (index, trace) in traces.iter().enumerate() {
168 let layer = leaf_layer_name(trace);
169 let mut trace_indent = " ";
170 if every_trace_has_a_distinct_root_layer {
171 writeln!(styled_issue, " {}:", layer.unwrap()).unwrap();
172 } else if traces.len() > 1 {
173 write!(styled_issue, " #{}", index + 1).unwrap();
174 if let Some(layer) = layer {
175 write!(styled_issue, " [{layer}]").unwrap();
176 }
177 writeln!(styled_issue, ":").unwrap();
178 } else if let Some(layer) = layer {
179 write!(styled_issue, " [{layer}]").unwrap();
180 } else {
181 trace_indent = " ";
183 }
184
185 format_trace_items(
186 &mut styled_issue,
187 trace_indent,
188 !are_layers_identical(trace),
189 trace,
190 );
191 }
192 }
193
194 let severity = severity.style(severity_to_style(severity));
195 write!(issue_text, "{severity} - [{stage}] ").unwrap();
196 for (index, line) in styled_issue.lines().enumerate() {
197 if index > 0 {
199 issue_text.push_str(" ");
200 }
201 issue_text.push_str(line);
202 issue_text.push('\n');
203 }
204
205 issue_text
206}
207
208pub type GroupedIssues =
209 FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
210
211const DEFAULT_SHOW_COUNT: usize = 3;
212
213const ORDERED_GROUPS: &[IssueSeverity] = &[
214 IssueSeverity::Bug,
215 IssueSeverity::Fatal,
216 IssueSeverity::Error,
217 IssueSeverity::Warning,
218 IssueSeverity::Hint,
219 IssueSeverity::Note,
220 IssueSeverity::Suggestion,
221 IssueSeverity::Info,
222];
223
224#[turbo_tasks::value(shared)]
225#[derive(Debug, Clone)]
226pub struct LogOptions {
227 pub current_dir: PathBuf,
228 pub project_dir: PathBuf,
229 pub show_all: bool,
230 pub log_detail: bool,
231 pub log_level: IssueSeverity,
232}
233
234#[derive(Default)]
251struct SeenIssues {
252 source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
255
256 issues_count: FxHashMap<u64, usize>,
261}
262
263impl SeenIssues {
264 fn new() -> Self {
265 Default::default()
266 }
267
268 fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
271 let old = self.source_to_issue_ids.entry(source).or_default();
272
273 let difference = issue_ids
275 .iter()
276 .filter(|id| match self.issues_count.entry(**id) {
277 Entry::Vacant(e) => {
278 e.insert(1);
280 true
281 }
282 Entry::Occupied(mut e) => {
283 if old.contains(*id) {
284 old.remove(*id);
288 } else {
289 *e.get_mut() += 1;
292 }
293 false
294 }
295 })
296 .cloned()
297 .collect::<FxHashSet<_>>();
298
299 for id in old.iter() {
301 match self.issues_count.entry(*id) {
302 Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
303 Entry::Occupied(mut e) => {
304 let v = e.get_mut();
305 if *v == 1 {
306 e.remove();
309 } else {
310 *v -= 1;
313 }
314 }
315 }
316 }
317
318 *old = issue_ids;
319 difference
320 }
321}
322
323#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
329#[derive(Clone)]
330pub struct ConsoleUi {
331 options: LogOptions,
332
333 #[turbo_tasks(trace_ignore, debug_ignore)]
334 seen: Arc<Mutex<SeenIssues>>,
335}
336
337impl PartialEq for ConsoleUi {
338 fn eq(&self, other: &Self) -> bool {
339 self.options == other.options
340 }
341}
342
343#[turbo_tasks::value_impl]
344impl ConsoleUi {
345 #[turbo_tasks::function]
346 pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
347 ConsoleUi {
348 options: (*options).clone(),
349 seen: Arc::new(Mutex::new(SeenIssues::new())),
350 }
351 .cell()
352 }
353}
354
355#[turbo_tasks::value_impl]
356impl IssueReporter for ConsoleUi {
357 #[turbo_tasks::function]
358 async fn report_issues(
359 &self,
360 source: TransientValue<RawVc>,
361 min_failing_severity: IssueSeverity,
362 ) -> Result<Vc<bool>> {
363 let issues = source.peek_issues();
364 let LogOptions {
365 ref current_dir,
366 ref project_dir,
367 show_all,
368 log_detail,
369 log_level,
370 ..
371 } = self.options;
372 let mut grouped_issues: GroupedIssues = FxHashMap::default();
373
374 let plain_issues = issues.get_plain_issues().await?;
375 let issues = plain_issues
376 .iter()
377 .map(|plain_issue| {
378 let id = plain_issue.internal_hash_ref(false);
379 (plain_issue, id)
380 })
381 .collect::<Vec<_>>();
382
383 let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
384 let mut new_ids = self
385 .seen
386 .lock()
387 .unwrap()
388 .new_ids(source.into_value(), issue_ids);
389
390 let mut has_fatal = false;
391 for (plain_issue, id) in issues {
392 if !new_ids.remove(&id) {
393 continue;
394 }
395
396 let severity = plain_issue.severity;
397 if severity <= min_failing_severity {
398 has_fatal = true;
399 }
400
401 let context_path =
402 make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
403 let stage = plain_issue.stage.to_string();
404 let severity_map = grouped_issues.entry(severity).or_default();
405 let category_map = severity_map.entry(stage.clone()).or_default();
406 let issues = category_map.entry(context_path.to_string()).or_default();
407
408 let mut styled_issue = style_issue_source(plain_issue, &context_path);
409 let description = &plain_issue.description;
410 if let Some(description) = description {
411 writeln!(
412 &mut styled_issue,
413 "\n{}",
414 render_styled_string_to_ansi(description)
415 )?;
416 }
417
418 if log_detail {
419 styled_issue.push('\n');
420 let detail = &plain_issue.detail;
421 if let Some(detail) = detail {
422 for line in render_styled_string_to_ansi(detail).split('\n') {
423 writeln!(&mut styled_issue, "| {line}")?;
424 }
425 }
426 let documentation_link = &plain_issue.documentation_link;
427 if !documentation_link.is_empty() {
428 writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
429 }
430 }
431 issues.push(styled_issue);
432 }
433
434 for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
435 if let Some(severity_map) = grouped_issues.get_mut(&severity) {
436 let severity_map_size = severity_map.len();
437 let indent = if severity_map_size == 1 {
438 print!("{} - ", severity.style(severity_to_style(severity)));
439 ""
440 } else {
441 println!("{} -", severity.style(severity_to_style(severity)));
442 " "
443 };
444 let severity_map_take_count = if show_all {
445 severity_map_size
446 } else {
447 DEFAULT_SHOW_COUNT
448 };
449 let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
450 categories.sort();
451 for category in categories.iter().take(severity_map_take_count) {
452 let category_issues = severity_map.get_mut(category).unwrap();
453 let category_issues_size = category_issues.len();
454 let indent = if category_issues_size == 1 && indent.is_empty() {
455 print!("[{category}] ");
456 "".to_string()
457 } else {
458 println!("{indent}[{category}]");
459 format!("{indent} ")
460 };
461 let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
462 .iter_mut()
463 .partition(|(context, _)| !context.contains("node_modules"));
464 contexts.sort_by_key(|(c, _)| *c);
465 if show_all {
466 vendor_contexts.sort_by_key(|(c, _)| *c);
467 contexts.extend(vendor_contexts);
468 }
469 let category_issues_take_count = if show_all {
470 category_issues_size
471 } else {
472 min(contexts.len(), DEFAULT_SHOW_COUNT)
473 };
474 for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
475 issues.sort();
476 println!("{indent}{}", context.bright_blue());
477 let issues_size = issues.len();
478 let issues_take_count = if show_all {
479 issues_size
480 } else {
481 DEFAULT_SHOW_COUNT
482 };
483 for issue in issues.iter().take(issues_take_count) {
484 let mut i = 0;
485 for line in issue.lines() {
486 println!("{indent} {line}");
487 i += 1;
488 }
489 if i > 1 {
490 println!();
492 }
493 }
494 if issues_size > issues_take_count {
495 println!("{indent} {}", show_all_message("issues", issues_size));
496 }
497 }
498 if category_issues_size > category_issues_take_count {
499 println!(
500 "{indent}{}",
501 show_all_message_with_shown_count(
502 "paths",
503 category_issues_size,
504 category_issues_take_count
505 )
506 );
507 }
508 }
509 if severity_map_size > severity_map_take_count {
510 println!(
511 "{indent}{}",
512 show_all_message("categories", severity_map_size)
513 )
514 }
515 }
516 }
517
518 Ok(Vc::cell(has_fatal))
519 }
520}
521
522fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
523 if let Some(path_in_project) = path.strip_prefix("[project]/") {
524 let abs_path = if std::path::MAIN_SEPARATOR != '/' {
525 project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
526 } else {
527 project_dir.join(path_in_project)
528 };
529 let relative = abs_path
530 .strip_prefix(cwd)
531 .unwrap_or(&abs_path)
532 .to_string_lossy()
533 .to_string();
534 relative.into()
535 } else {
536 path.into()
537 }
538}
539
540fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
541 show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
542}
543
544fn show_all_message_with_shown_count(
545 label: &str,
546 size: usize,
547 shown: usize,
548) -> StyledContent<String> {
549 if shown == 0 {
550 format!(
551 "... [{} {label}] are hidden, run with {} to show them",
552 size,
553 "--show-all".bright_green()
554 )
555 .bold()
556 } else {
557 format!(
558 "... [{} more {label}] are hidden, run with {} to show all",
559 size - shown,
560 "--show-all".bright_green()
561 )
562 .bold()
563 }
564}
565
566fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
567 match styled_string {
568 StyledString::Line(parts) => {
569 let mut string = String::new();
570 for part in parts {
571 string.push_str(&render_styled_string_to_ansi(part));
572 }
573 string.push('\n');
574 string
575 }
576 StyledString::Stack(parts) => {
577 let mut string = String::new();
578 for part in parts {
579 string.push_str(&render_styled_string_to_ansi(part));
580 string.push('\n');
581 }
582 string
583 }
584 StyledString::Text(string) => string.to_string(),
585 StyledString::Code(string) => string.blue().to_string(),
586 StyledString::Strong(string) => string.bold().to_string(),
587 }
588}
589
590fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
591 let title = &plain_issue.title;
592 let formatted_title = match title {
593 StyledString::Text(text) => text.bold().to_string(),
594 _ => render_styled_string_to_ansi(title),
595 };
596
597 if let Some(source) = &plain_issue.source {
598 let mut styled_issue = match source.range {
599 Some((start, _)) => format!(
600 "{}:{}:{} {}",
601 context_path,
602 start.line + 1,
603 start.column,
604 formatted_title
605 ),
606 None => format!("{context_path} {formatted_title}"),
607 };
608 styled_issue.push('\n');
609 format_source_content(source, &mut styled_issue);
610 styled_issue
611 } else {
612 format!("{context_path} {formatted_title}\n")
613 }
614}