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 if traces.len() == 1 {
230 let trace = &traces[0];
231 writeln!(styled_issue, "Import trace:").unwrap();
235 format_trace_items(&mut styled_issue, " ", !are_layers_identical(trace), trace);
236 } else {
237 styled_issue.push_str("Import traces:\n");
241 let every_trace_has_a_distinct_root_layer = traces
242 .iter()
243 .filter_map(|t| leaf_layer_name(t))
244 .collect::<FxHashSet<RcStr>>()
245 .len()
246 == traces.len();
247 if every_trace_has_a_distinct_root_layer {
248 for trace in traces {
249 writeln!(styled_issue, " {}:", leaf_layer_name(trace).unwrap()).unwrap();
250 format_trace_items(&mut styled_issue, " ", false, trace);
251 }
252 } else {
253 for (index, trace) in traces.iter().enumerate() {
254 let printed_layer = match leaf_layer_name(trace) {
255 Some(layer) => {
256 writeln!(styled_issue, " #{} [{layer}]:", index + 1).unwrap();
257 false
258 }
259 None => {
260 writeln!(styled_issue, " #{}:", index + 1).unwrap();
261 true
262 }
263 };
264 format_trace_items(
265 &mut styled_issue,
266 " ",
267 !printed_layer || !are_layers_identical(trace),
268 trace,
269 );
270 }
271 }
272 }
273 }
274
275 let severity = severity.style(severity_to_style(severity));
276 write!(issue_text, "{severity} - [{stage}] ").unwrap();
277 for (index, line) in styled_issue.lines().enumerate() {
278 if index > 0 {
280 issue_text.push_str(" ");
281 }
282 issue_text.push_str(line);
283 issue_text.push('\n');
284 }
285
286 issue_text
287}
288
289pub type GroupedIssues =
290 FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
291
292const DEFAULT_SHOW_COUNT: usize = 3;
293
294const ORDERED_GROUPS: &[IssueSeverity] = &[
295 IssueSeverity::Bug,
296 IssueSeverity::Fatal,
297 IssueSeverity::Error,
298 IssueSeverity::Warning,
299 IssueSeverity::Hint,
300 IssueSeverity::Note,
301 IssueSeverity::Suggestion,
302 IssueSeverity::Info,
303];
304
305#[turbo_tasks::value(shared)]
306#[derive(Debug, Clone)]
307pub struct LogOptions {
308 pub current_dir: PathBuf,
309 pub project_dir: PathBuf,
310 pub show_all: bool,
311 pub log_detail: bool,
312 pub log_level: IssueSeverity,
313}
314
315#[derive(Default)]
332struct SeenIssues {
333 source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
336
337 issues_count: FxHashMap<u64, usize>,
342}
343
344impl SeenIssues {
345 fn new() -> Self {
346 Default::default()
347 }
348
349 fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
352 let old = self.source_to_issue_ids.entry(source).or_default();
353
354 let difference = issue_ids
356 .iter()
357 .filter(|id| match self.issues_count.entry(**id) {
358 Entry::Vacant(e) => {
359 e.insert(1);
361 true
362 }
363 Entry::Occupied(mut e) => {
364 if old.contains(*id) {
365 old.remove(*id);
369 } else {
370 *e.get_mut() += 1;
373 }
374 false
375 }
376 })
377 .cloned()
378 .collect::<FxHashSet<_>>();
379
380 for id in old.iter() {
382 match self.issues_count.entry(*id) {
383 Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
384 Entry::Occupied(mut e) => {
385 let v = e.get_mut();
386 if *v == 1 {
387 e.remove();
390 } else {
391 *v -= 1;
394 }
395 }
396 }
397 }
398
399 *old = issue_ids;
400 difference
401 }
402}
403
404#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
410#[derive(Clone)]
411pub struct ConsoleUi {
412 options: LogOptions,
413
414 #[turbo_tasks(trace_ignore, debug_ignore)]
415 seen: Arc<Mutex<SeenIssues>>,
416}
417
418impl PartialEq for ConsoleUi {
419 fn eq(&self, other: &Self) -> bool {
420 self.options == other.options
421 }
422}
423
424#[turbo_tasks::value_impl]
425impl ConsoleUi {
426 #[turbo_tasks::function]
427 pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
428 ConsoleUi {
429 options: (*options).clone(),
430 seen: Arc::new(Mutex::new(SeenIssues::new())),
431 }
432 .cell()
433 }
434}
435
436#[turbo_tasks::value_impl]
437impl IssueReporter for ConsoleUi {
438 #[turbo_tasks::function]
439 async fn report_issues(
440 &self,
441 issues: TransientInstance<CapturedIssues>,
442 source: TransientValue<RawVc>,
443 min_failing_severity: IssueSeverity,
444 ) -> Result<Vc<bool>> {
445 let issues = &*issues;
446 let LogOptions {
447 ref current_dir,
448 ref project_dir,
449 show_all,
450 log_detail,
451 log_level,
452 ..
453 } = self.options;
454 let mut grouped_issues: GroupedIssues = FxHashMap::default();
455
456 let plain_issues = issues.get_plain_issues().await?;
457 let issues = plain_issues
458 .iter()
459 .map(|plain_issue| {
460 let id = plain_issue.internal_hash_ref(false);
461 (plain_issue, id)
462 })
463 .collect::<Vec<_>>();
464
465 let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
466 let mut new_ids = self
467 .seen
468 .lock()
469 .unwrap()
470 .new_ids(source.into_value(), issue_ids);
471
472 let mut has_fatal = false;
473 for (plain_issue, id) in issues {
474 if !new_ids.remove(&id) {
475 continue;
476 }
477
478 let severity = plain_issue.severity;
479 if severity <= min_failing_severity {
480 has_fatal = true;
481 }
482
483 let context_path =
484 make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
485 let stage = plain_issue.stage.to_string();
486 let processing_path = &*plain_issue.processing_path;
487 let severity_map = grouped_issues.entry(severity).or_default();
488 let category_map = severity_map.entry(stage.clone()).or_default();
489 let issues = category_map.entry(context_path.to_string()).or_default();
490
491 let mut styled_issue = style_issue_source(plain_issue, &context_path);
492 let description = &plain_issue.description;
493 if let Some(description) = description {
494 writeln!(
495 &mut styled_issue,
496 "\n{}",
497 render_styled_string_to_ansi(description)
498 )?;
499 }
500
501 if log_detail {
502 styled_issue.push('\n');
503 let detail = &plain_issue.detail;
504 if let Some(detail) = detail {
505 for line in render_styled_string_to_ansi(detail).split('\n') {
506 writeln!(&mut styled_issue, "| {line}")?;
507 }
508 }
509 let documentation_link = &plain_issue.documentation_link;
510 if !documentation_link.is_empty() {
511 writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
512 }
513 format_optional_path(processing_path, &mut styled_issue)?;
514 }
515 issues.push(styled_issue);
516 }
517
518 for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
519 if let Some(severity_map) = grouped_issues.get_mut(&severity) {
520 let severity_map_size = severity_map.len();
521 let indent = if severity_map_size == 1 {
522 print!("{} - ", severity.style(severity_to_style(severity)));
523 ""
524 } else {
525 println!("{} -", severity.style(severity_to_style(severity)));
526 " "
527 };
528 let severity_map_take_count = if show_all {
529 severity_map_size
530 } else {
531 DEFAULT_SHOW_COUNT
532 };
533 let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
534 categories.sort();
535 for category in categories.iter().take(severity_map_take_count) {
536 let category_issues = severity_map.get_mut(category).unwrap();
537 let category_issues_size = category_issues.len();
538 let indent = if category_issues_size == 1 && indent.is_empty() {
539 print!("[{category}] ");
540 "".to_string()
541 } else {
542 println!("{indent}[{category}]");
543 format!("{indent} ")
544 };
545 let (mut contexts, mut vendor_contexts): (Vec<_>, Vec<_>) = category_issues
546 .iter_mut()
547 .partition(|(context, _)| !context.contains("node_modules"));
548 contexts.sort_by_key(|(c, _)| *c);
549 if show_all {
550 vendor_contexts.sort_by_key(|(c, _)| *c);
551 contexts.extend(vendor_contexts);
552 }
553 let category_issues_take_count = if show_all {
554 category_issues_size
555 } else {
556 min(contexts.len(), DEFAULT_SHOW_COUNT)
557 };
558 for (context, issues) in contexts.into_iter().take(category_issues_take_count) {
559 issues.sort();
560 println!("{indent}{}", context.bright_blue());
561 let issues_size = issues.len();
562 let issues_take_count = if show_all {
563 issues_size
564 } else {
565 DEFAULT_SHOW_COUNT
566 };
567 for issue in issues.iter().take(issues_take_count) {
568 let mut i = 0;
569 for line in issue.lines() {
570 println!("{indent} {line}");
571 i += 1;
572 }
573 if i > 1 {
574 println!();
576 }
577 }
578 if issues_size > issues_take_count {
579 println!("{indent} {}", show_all_message("issues", issues_size));
580 }
581 }
582 if category_issues_size > category_issues_take_count {
583 println!(
584 "{indent}{}",
585 show_all_message_with_shown_count(
586 "paths",
587 category_issues_size,
588 category_issues_take_count
589 )
590 );
591 }
592 }
593 if severity_map_size > severity_map_take_count {
594 println!(
595 "{indent}{}",
596 show_all_message("categories", severity_map_size)
597 )
598 }
599 }
600 }
601
602 Ok(Vc::cell(has_fatal))
603 }
604}
605
606fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
607 if let Some(path_in_project) = path.strip_prefix("[project]/") {
608 let abs_path = if std::path::MAIN_SEPARATOR != '/' {
609 project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
610 } else {
611 project_dir.join(path_in_project)
612 };
613 let relative = abs_path
614 .strip_prefix(cwd)
615 .unwrap_or(&abs_path)
616 .to_string_lossy()
617 .to_string();
618 relative.into()
619 } else {
620 path.into()
621 }
622}
623
624fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
625 show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
626}
627
628fn show_all_message_with_shown_count(
629 label: &str,
630 size: usize,
631 shown: usize,
632) -> StyledContent<String> {
633 if shown == 0 {
634 format!(
635 "... [{} {label}] are hidden, run with {} to show them",
636 size,
637 "--show-all".bright_green()
638 )
639 .bold()
640 } else {
641 format!(
642 "... [{} more {label}] are hidden, run with {} to show all",
643 size - shown,
644 "--show-all".bright_green()
645 )
646 .bold()
647 }
648}
649
650fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
651 match styled_string {
652 StyledString::Line(parts) => {
653 let mut string = String::new();
654 for part in parts {
655 string.push_str(&render_styled_string_to_ansi(part));
656 }
657 string.push('\n');
658 string
659 }
660 StyledString::Stack(parts) => {
661 let mut string = String::new();
662 for part in parts {
663 string.push_str(&render_styled_string_to_ansi(part));
664 string.push('\n');
665 }
666 string
667 }
668 StyledString::Text(string) => string.to_string(),
669 StyledString::Code(string) => string.blue().to_string(),
670 StyledString::Strong(string) => string.bold().to_string(),
671 }
672}
673
674fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
675 let title = &plain_issue.title;
676 let formatted_title = match title {
677 StyledString::Text(text) => text.bold().to_string(),
678 _ => render_styled_string_to_ansi(title),
679 };
680
681 if let Some(source) = &plain_issue.source {
682 let mut styled_issue = match source.range {
683 Some((start, _)) => format!(
684 "{}:{}:{} {}",
685 context_path,
686 start.line + 1,
687 start.column,
688 formatted_title
689 ),
690 None => format!("{context_path} {formatted_title}"),
691 };
692 styled_issue.push('\n');
693 format_source_content(source, &mut styled_issue);
694 styled_issue
695 } else {
696 format!("{context_path} {formatted_title}\n")
697 }
698}