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