1use std::{
2 borrow::Cow,
3 cmp::min,
4 collections::hash_map::Entry,
5 fmt::Write as _,
6 path::{Path, PathBuf},
7 str::FromStr,
8 sync::{Arc, Mutex},
9};
10
11use anyhow::{Result, anyhow};
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, TransientValue, Vc};
17use turbo_tasks_fs::{FileLinesContent, source_context::get_source_context};
18use turbopack_core::issue::{
19 CapturedIssues, IssueReporter, IssueSeverity, PlainIssue, PlainIssueProcessingPathItem,
20 PlainIssueSource, PlainTraceItem, StyledString,
21};
22
23use crate::source_context::format_source_context_lines;
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub struct IssueSeverityCliOption(pub IssueSeverity);
27
28impl serde::Serialize for IssueSeverityCliOption {
29 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
30 serializer.serialize_str(&self.0.to_string())
31 }
32}
33
34impl<'de> serde::Deserialize<'de> for IssueSeverityCliOption {
35 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
36 let s = String::deserialize(deserializer)?;
37 IssueSeverityCliOption::from_str(&s).map_err(serde::de::Error::custom)
38 }
39}
40
41impl clap::ValueEnum for IssueSeverityCliOption {
42 fn value_variants<'a>() -> &'a [Self] {
43 const VARIANTS: [IssueSeverityCliOption; 8] = [
44 IssueSeverityCliOption(IssueSeverity::Bug),
45 IssueSeverityCliOption(IssueSeverity::Fatal),
46 IssueSeverityCliOption(IssueSeverity::Error),
47 IssueSeverityCliOption(IssueSeverity::Warning),
48 IssueSeverityCliOption(IssueSeverity::Hint),
49 IssueSeverityCliOption(IssueSeverity::Note),
50 IssueSeverityCliOption(IssueSeverity::Suggestion),
51 IssueSeverityCliOption(IssueSeverity::Info),
52 ];
53 &VARIANTS
54 }
55
56 fn to_possible_value<'a>(&self) -> Option<clap::builder::PossibleValue> {
57 Some(clap::builder::PossibleValue::new(self.0.as_str()).help(self.0.as_help_str()))
58 }
59}
60
61impl FromStr for IssueSeverityCliOption {
62 type Err = anyhow::Error;
63
64 fn from_str(s: &str) -> Result<Self, Self::Err> {
65 <IssueSeverityCliOption as clap::ValueEnum>::from_str(s, true).map_err(|s| anyhow!("{}", s))
66 }
67}
68
69fn severity_to_style(severity: IssueSeverity) -> Style {
70 match severity {
71 IssueSeverity::Bug => Style::new().bright_red().underline(),
72 IssueSeverity::Fatal => Style::new().bright_red().underline(),
73 IssueSeverity::Error => Style::new().bright_red(),
74 IssueSeverity::Warning => Style::new().bright_yellow(),
75 IssueSeverity::Hint => Style::new().bold(),
76 IssueSeverity::Note => Style::new().bold(),
77 IssueSeverity::Suggestion => Style::new().bright_green().underline(),
78 IssueSeverity::Info => Style::new().bright_green(),
79 }
80}
81
82fn format_source_content(source: &PlainIssueSource, formatted_issue: &mut String) {
83 if let FileLinesContent::Lines(lines) = source.asset.content.lines_ref()
84 && let Some((start, end)) = source.range
85 {
86 let lines = lines.iter().map(|l| l.content.as_str());
87 let ctx = get_source_context(lines, start.line, start.column, end.line, end.column);
88 format_source_context_lines(&ctx, formatted_issue);
89 }
90}
91
92fn format_optional_path(
93 path: &Option<Vec<ReadRef<PlainIssueProcessingPathItem>>>,
94 formatted_issue: &mut String,
95) -> Result<()> {
96 if let Some(path) = path {
97 let mut last_context = None;
98 for item in path.iter().rev() {
99 let PlainIssueProcessingPathItem {
100 file_path: ref context,
101 ref description,
102 } = **item;
103 if let Some(context) = context {
104 let option_context = Some(context.clone());
105 if last_context == option_context {
106 writeln!(formatted_issue, " at {description}")?;
107 } else {
108 writeln!(
109 formatted_issue,
110 " at {} ({})",
111 context.to_string().bright_blue(),
112 description
113 )?;
114 last_context = option_context;
115 }
116 } else {
117 writeln!(formatted_issue, " at {description}")?;
118 last_context = None;
119 }
120 }
121 }
122 Ok(())
123}
124
125pub fn format_issue(
126 plain_issue: &PlainIssue,
127 path: Option<String>,
128 options: &LogOptions,
129) -> String {
130 let &LogOptions {
131 ref current_dir,
132 log_detail,
133 ..
134 } = options;
135
136 let mut issue_text = String::new();
137
138 let severity = plain_issue.severity;
139 let context_path = plain_issue
141 .file_path
142 .replace("[project]", ¤t_dir.to_string_lossy())
143 .replace("/./", "/")
144 .replace("\\\\?\\", "");
145 let stage = plain_issue.stage.to_string();
146
147 let mut styled_issue = style_issue_source(plain_issue, &context_path);
148 let description = &plain_issue.description;
149 if let Some(description) = description {
150 writeln!(
151 styled_issue,
152 "\n{}",
153 render_styled_string_to_ansi(description)
154 )
155 .unwrap();
156 }
157
158 if log_detail {
159 styled_issue.push('\n');
160 let detail = &plain_issue.detail;
161 if let Some(detail) = detail {
162 for line in render_styled_string_to_ansi(detail).split('\n') {
163 writeln!(styled_issue, "| {line}").unwrap();
164 }
165 }
166 let documentation_link = &plain_issue.documentation_link;
167 if !documentation_link.is_empty() {
168 writeln!(styled_issue, "\ndocumentation: {documentation_link}").unwrap();
169 }
170 if let Some(path) = path {
171 writeln!(styled_issue, "{path}").unwrap();
172 }
173 }
174 let traces = &*plain_issue.import_traces;
175 if !traces.is_empty() {
176 fn leaf_layer_name(items: &[PlainTraceItem]) -> Option<RcStr> {
178 items
179 .iter()
180 .find(|t| t.layer.is_some())
181 .and_then(|t| t.layer.clone())
182 }
183 fn are_layers_identical(items: &[PlainTraceItem]) -> bool {
186 let Some(first_present_layer) = items.iter().position(|t| t.layer.is_some()) else {
187 return true; };
189 let layer = &items[first_present_layer].layer;
190 items
191 .iter()
192 .skip(first_present_layer + 1)
193 .all(|t| t.layer.is_none() || &t.layer == layer)
194 }
195 fn format_trace_items(
196 out: &mut String,
197 indent: &'static str,
198 print_layers: bool,
199 items: &[PlainTraceItem],
200 ) {
201 for item in items {
202 out.push_str(indent);
203 if item.fs_name != "project" {
211 out.push('[');
212 out.push_str(&item.fs_name);
213 out.push_str("]/");
214 } else {
215 out.push_str("./");
217 }
218 out.push_str(&item.path);
219 if let Some(ref label) = item.layer
220 && print_layers
221 {
222 out.push_str(" [");
223 out.push_str(label);
224 out.push(']');
225 }
226 out.push('\n');
227 }
228 }
229
230 writeln!(
234 styled_issue,
235 "Import trace{}:",
236 if traces.len() > 1 { "s" } else { "" }
237 )
238 .unwrap();
239 let every_trace_has_a_distinct_root_layer = traces
240 .iter()
241 .filter_map(|t| leaf_layer_name(t))
242 .collect::<FxHashSet<RcStr>>()
243 .len()
244 == traces.len();
245 for (index, trace) in traces.iter().enumerate() {
246 let layer = leaf_layer_name(trace);
247 let mut trace_indent = " ";
248 if every_trace_has_a_distinct_root_layer {
249 writeln!(styled_issue, " {}:", layer.unwrap()).unwrap();
250 } else if traces.len() > 1 {
251 write!(styled_issue, " #{}", index + 1).unwrap();
252 if let Some(layer) = layer {
253 write!(styled_issue, " [{layer}]").unwrap();
254 }
255 writeln!(styled_issue, ":").unwrap();
256 } else if let Some(layer) = layer {
257 write!(styled_issue, " [{layer}]").unwrap();
258 } else {
259 trace_indent = " ";
261 }
262
263 format_trace_items(
264 &mut styled_issue,
265 trace_indent,
266 !are_layers_identical(trace),
267 trace,
268 );
269 }
270 }
271
272 let severity = severity.style(severity_to_style(severity));
273 write!(issue_text, "{severity} - [{stage}] ").unwrap();
274 for (index, line) in styled_issue.lines().enumerate() {
275 if index > 0 {
277 issue_text.push_str(" ");
278 }
279 issue_text.push_str(line);
280 issue_text.push('\n');
281 }
282
283 issue_text
284}
285
286pub type GroupedIssues =
287 FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
288
289const DEFAULT_SHOW_COUNT: usize = 3;
290
291const ORDERED_GROUPS: &[IssueSeverity] = &[
292 IssueSeverity::Bug,
293 IssueSeverity::Fatal,
294 IssueSeverity::Error,
295 IssueSeverity::Warning,
296 IssueSeverity::Hint,
297 IssueSeverity::Note,
298 IssueSeverity::Suggestion,
299 IssueSeverity::Info,
300];
301
302#[turbo_tasks::value(shared)]
303#[derive(Debug, Clone)]
304pub struct LogOptions {
305 pub current_dir: PathBuf,
306 pub project_dir: PathBuf,
307 pub show_all: bool,
308 pub log_detail: bool,
309 pub log_level: IssueSeverity,
310}
311
312#[derive(Default)]
329struct SeenIssues {
330 source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
333
334 issues_count: FxHashMap<u64, usize>,
339}
340
341impl SeenIssues {
342 fn new() -> Self {
343 Default::default()
344 }
345
346 fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
349 let old = self.source_to_issue_ids.entry(source).or_default();
350
351 let difference = issue_ids
353 .iter()
354 .filter(|id| match self.issues_count.entry(**id) {
355 Entry::Vacant(e) => {
356 e.insert(1);
358 true
359 }
360 Entry::Occupied(mut e) => {
361 if old.contains(*id) {
362 old.remove(*id);
366 } else {
367 *e.get_mut() += 1;
370 }
371 false
372 }
373 })
374 .cloned()
375 .collect::<FxHashSet<_>>();
376
377 for id in old.iter() {
379 match self.issues_count.entry(*id) {
380 Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
381 Entry::Occupied(mut e) => {
382 let v = e.get_mut();
383 if *v == 1 {
384 e.remove();
387 } else {
388 *v -= 1;
391 }
392 }
393 }
394 }
395
396 *old = issue_ids;
397 difference
398 }
399}
400
401#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
407#[derive(Clone)]
408pub struct ConsoleUi {
409 options: LogOptions,
410
411 #[turbo_tasks(trace_ignore, debug_ignore)]
412 seen: Arc<Mutex<SeenIssues>>,
413}
414
415impl PartialEq for ConsoleUi {
416 fn eq(&self, other: &Self) -> bool {
417 self.options == other.options
418 }
419}
420
421#[turbo_tasks::value_impl]
422impl ConsoleUi {
423 #[turbo_tasks::function]
424 pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
425 ConsoleUi {
426 options: (*options).clone(),
427 seen: Arc::new(Mutex::new(SeenIssues::new())),
428 }
429 .cell()
430 }
431}
432
433#[turbo_tasks::value_impl]
434impl IssueReporter for ConsoleUi {
435 #[turbo_tasks::function]
436 async fn report_issues(
437 &self,
438 issues: TransientInstance<CapturedIssues>,
439 source: TransientValue<RawVc>,
440 min_failing_severity: IssueSeverity,
441 ) -> Result<Vc<bool>> {
442 let issues = &*issues;
443 let LogOptions {
444 ref current_dir,
445 ref project_dir,
446 show_all,
447 log_detail,
448 log_level,
449 ..
450 } = self.options;
451 let mut grouped_issues: GroupedIssues = FxHashMap::default();
452
453 let plain_issues = issues.get_plain_issues().await?;
454 let issues = plain_issues
455 .iter()
456 .map(|plain_issue| {
457 let id = plain_issue.internal_hash_ref(false);
458 (plain_issue, id)
459 })
460 .collect::<Vec<_>>();
461
462 let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
463 let mut new_ids = self
464 .seen
465 .lock()
466 .unwrap()
467 .new_ids(source.into_value(), issue_ids);
468
469 let mut has_fatal = false;
470 for (plain_issue, id) in issues {
471 if !new_ids.remove(&id) {
472 continue;
473 }
474
475 let severity = plain_issue.severity;
476 if severity <= min_failing_severity {
477 has_fatal = true;
478 }
479
480 let context_path =
481 make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
482 let stage = plain_issue.stage.to_string();
483 let processing_path = &*plain_issue.processing_path;
484 let severity_map = grouped_issues.entry(severity).or_default();
485 let category_map = severity_map.entry(stage.clone()).or_default();
486 let issues = category_map.entry(context_path.to_string()).or_default();
487
488 let mut styled_issue = style_issue_source(plain_issue, &context_path);
489 let description = &plain_issue.description;
490 if let Some(description) = description {
491 writeln!(
492 &mut styled_issue,
493 "\n{}",
494 render_styled_string_to_ansi(description)
495 )?;
496 }
497
498 if log_detail {
499 styled_issue.push('\n');
500 let detail = &plain_issue.detail;
501 if let Some(detail) = detail {
502 for line in render_styled_string_to_ansi(detail).split('\n') {
503 writeln!(&mut styled_issue, "| {line}")?;
504 }
505 }
506 let documentation_link = &plain_issue.documentation_link;
507 if !documentation_link.is_empty() {
508 writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
509 }
510 format_optional_path(processing_path, &mut styled_issue)?;
511 }
512 issues.push(styled_issue);
513 }
514
515 for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
516 if let Some(severity_map) = grouped_issues.get_mut(&severity) {
517 let severity_map_size = severity_map.len();
518 let indent = if severity_map_size == 1 {
519 print!("{} - ", severity.style(severity_to_style(severity)));
520 ""
521 } else {
522 println!("{} -", severity.style(severity_to_style(severity)));
523 " "
524 };
525 let severity_map_take_count = if show_all {
526 severity_map_size
527 } else {
528 DEFAULT_SHOW_COUNT
529 };
530 let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
531 categories.sort();
532 for category in categories.iter().take(severity_map_take_count) {
533 let category_issues = severity_map.get_mut(category).unwrap();
534 let category_issues_size = category_issues.len();
535 let indent = if category_issues_size == 1 && indent.is_empty() {
536 print!("[{category}] ");
537 "".to_string()
538 } else {
539 println!("{indent}[{category}]");
540 format!("{indent} ")
541 };
542 let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
543 .iter_mut()
544 .partition(|(context, _)| !context.contains("node_modules"));
545 contexts.sort_by_key(|(c, _)| *c);
546 if show_all {
547 vendor_contexts.sort_by_key(|(c, _)| *c);
548 contexts.extend(vendor_contexts);
549 }
550 let category_issues_take_count = if show_all {
551 category_issues_size
552 } else {
553 min(contexts.len(), DEFAULT_SHOW_COUNT)
554 };
555 for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
556 issues.sort();
557 println!("{indent}{}", context.bright_blue());
558 let issues_size = issues.len();
559 let issues_take_count = if show_all {
560 issues_size
561 } else {
562 DEFAULT_SHOW_COUNT
563 };
564 for issue in issues.iter().take(issues_take_count) {
565 let mut i = 0;
566 for line in issue.lines() {
567 println!("{indent} {line}");
568 i += 1;
569 }
570 if i > 1 {
571 println!();
573 }
574 }
575 if issues_size > issues_take_count {
576 println!("{indent} {}", show_all_message("issues", issues_size));
577 }
578 }
579 if category_issues_size > category_issues_take_count {
580 println!(
581 "{indent}{}",
582 show_all_message_with_shown_count(
583 "paths",
584 category_issues_size,
585 category_issues_take_count
586 )
587 );
588 }
589 }
590 if severity_map_size > severity_map_take_count {
591 println!(
592 "{indent}{}",
593 show_all_message("categories", severity_map_size)
594 )
595 }
596 }
597 }
598
599 Ok(Vc::cell(has_fatal))
600 }
601}
602
603fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
604 if let Some(path_in_project) = path.strip_prefix("[project]/") {
605 let abs_path = if std::path::MAIN_SEPARATOR != '/' {
606 project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
607 } else {
608 project_dir.join(path_in_project)
609 };
610 let relative = abs_path
611 .strip_prefix(cwd)
612 .unwrap_or(&abs_path)
613 .to_string_lossy()
614 .to_string();
615 relative.into()
616 } else {
617 path.into()
618 }
619}
620
621fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
622 show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
623}
624
625fn show_all_message_with_shown_count(
626 label: &str,
627 size: usize,
628 shown: usize,
629) -> StyledContent<String> {
630 if shown == 0 {
631 format!(
632 "... [{} {label}] are hidden, run with {} to show them",
633 size,
634 "--show-all".bright_green()
635 )
636 .bold()
637 } else {
638 format!(
639 "... [{} more {label}] are hidden, run with {} to show all",
640 size - shown,
641 "--show-all".bright_green()
642 )
643 .bold()
644 }
645}
646
647fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
648 match styled_string {
649 StyledString::Line(parts) => {
650 let mut string = String::new();
651 for part in parts {
652 string.push_str(&render_styled_string_to_ansi(part));
653 }
654 string.push('\n');
655 string
656 }
657 StyledString::Stack(parts) => {
658 let mut string = String::new();
659 for part in parts {
660 string.push_str(&render_styled_string_to_ansi(part));
661 string.push('\n');
662 }
663 string
664 }
665 StyledString::Text(string) => string.to_string(),
666 StyledString::Code(string) => string.blue().to_string(),
667 StyledString::Strong(string) => string.bold().to_string(),
668 }
669}
670
671fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
672 let title = &plain_issue.title;
673 let formatted_title = match title {
674 StyledString::Text(text) => text.bold().to_string(),
675 _ => render_styled_string_to_ansi(title),
676 };
677
678 if let Some(source) = &plain_issue.source {
679 let mut styled_issue = match source.range {
680 Some((start, _)) => format!(
681 "{}:{}:{} {}",
682 context_path,
683 start.line + 1,
684 start.column,
685 formatted_title
686 ),
687 None => format!("{context_path} {formatted_title}"),
688 };
689 styled_issue.push('\n');
690 format_source_content(source, &mut styled_issue);
691 styled_issue
692 } else {
693 format!("{context_path} {formatted_title}\n")
694 }
695}