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