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_tasks::{RawVc, ReadRef, TransientInstance, TransientValue, TryJoinIterExt, Vc};
16use turbo_tasks_fs::{FileLinesContent, source_context::get_source_context};
17use turbopack_core::issue::{
18 CapturedIssues, Issue, IssueReporter, IssueSeverity, PlainIssue, PlainIssueProcessingPathItem,
19 PlainIssueSource, StyledString,
20};
21
22use crate::source_context::format_source_context_lines;
23
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub struct IssueSeverityCliOption(pub IssueSeverity);
26
27impl serde::Serialize for IssueSeverityCliOption {
28 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
29 serializer.serialize_str(&self.0.to_string())
30 }
31}
32
33impl<'de> serde::Deserialize<'de> for IssueSeverityCliOption {
34 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
35 let s = String::deserialize(deserializer)?;
36 IssueSeverityCliOption::from_str(&s).map_err(serde::de::Error::custom)
37 }
38}
39
40impl clap::ValueEnum for IssueSeverityCliOption {
41 fn value_variants<'a>() -> &'a [Self] {
42 const VARIANTS: [IssueSeverityCliOption; 8] = [
43 IssueSeverityCliOption(IssueSeverity::Bug),
44 IssueSeverityCliOption(IssueSeverity::Fatal),
45 IssueSeverityCliOption(IssueSeverity::Error),
46 IssueSeverityCliOption(IssueSeverity::Warning),
47 IssueSeverityCliOption(IssueSeverity::Hint),
48 IssueSeverityCliOption(IssueSeverity::Note),
49 IssueSeverityCliOption(IssueSeverity::Suggestion),
50 IssueSeverityCliOption(IssueSeverity::Info),
51 ];
52 &VARIANTS
53 }
54
55 fn to_possible_value<'a>(&self) -> Option<clap::builder::PossibleValue> {
56 Some(clap::builder::PossibleValue::new(self.0.as_str()).help(self.0.as_help_str()))
57 }
58}
59
60impl FromStr for IssueSeverityCliOption {
61 type Err = anyhow::Error;
62
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 <IssueSeverityCliOption as clap::ValueEnum>::from_str(s, true).map_err(|s| anyhow!("{}", s))
65 }
66}
67
68fn severity_to_style(severity: IssueSeverity) -> Style {
69 match severity {
70 IssueSeverity::Bug => Style::new().bright_red().underline(),
71 IssueSeverity::Fatal => Style::new().bright_red().underline(),
72 IssueSeverity::Error => Style::new().bright_red(),
73 IssueSeverity::Warning => Style::new().bright_yellow(),
74 IssueSeverity::Hint => Style::new().bold(),
75 IssueSeverity::Note => Style::new().bold(),
76 IssueSeverity::Suggestion => Style::new().bright_green().underline(),
77 IssueSeverity::Info => Style::new().bright_green(),
78 }
79}
80
81fn format_source_content(source: &PlainIssueSource, formatted_issue: &mut String) {
82 if let FileLinesContent::Lines(lines) = source.asset.content.lines_ref() {
83 if let Some((start, end)) = source.range {
84 let lines = lines.iter().map(|l| l.content.as_str());
85 let ctx = get_source_context(lines, start.line, start.column, end.line, end.column);
86 format_source_context_lines(&ctx, formatted_issue);
87 }
88 }
89}
90
91fn format_optional_path(
92 path: &Option<Vec<ReadRef<PlainIssueProcessingPathItem>>>,
93 formatted_issue: &mut String,
94) -> Result<()> {
95 if let Some(path) = path {
96 let mut last_context = None;
97 for item in path.iter().rev() {
98 let PlainIssueProcessingPathItem {
99 file_path: ref context,
100 ref description,
101 } = **item;
102 if let Some(context) = context {
103 let option_context = Some(context.clone());
104 if last_context == option_context {
105 writeln!(formatted_issue, " at {description}")?;
106 } else {
107 writeln!(
108 formatted_issue,
109 " at {} ({})",
110 context.to_string().bright_blue(),
111 description
112 )?;
113 last_context = option_context;
114 }
115 } else {
116 writeln!(formatted_issue, " at {description}")?;
117 last_context = None;
118 }
119 }
120 }
121 Ok(())
122}
123
124pub fn format_issue(
125 plain_issue: &PlainIssue,
126 path: Option<String>,
127 options: &LogOptions,
128) -> String {
129 let &LogOptions {
130 ref current_dir,
131 log_detail,
132 ..
133 } = options;
134
135 let mut issue_text = String::new();
136
137 let severity = plain_issue.severity;
138 let context_path = plain_issue
140 .file_path
141 .replace("[project]", ¤t_dir.to_string_lossy())
142 .replace("/./", "/")
143 .replace("\\\\?\\", "");
144 let stgae = plain_issue.stage.to_string();
145
146 let mut styled_issue = style_issue_source(plain_issue, &context_path);
147 let description = &plain_issue.description;
148 if let Some(description) = description {
149 writeln!(
150 styled_issue,
151 "\n{}",
152 render_styled_string_to_ansi(description)
153 )
154 .unwrap();
155 }
156
157 if log_detail {
158 styled_issue.push('\n');
159 let detail = &plain_issue.detail;
160 if let Some(detail) = detail {
161 for line in render_styled_string_to_ansi(detail).split('\n') {
162 writeln!(styled_issue, "| {line}").unwrap();
163 }
164 }
165 let documentation_link = &plain_issue.documentation_link;
166 if !documentation_link.is_empty() {
167 writeln!(styled_issue, "\ndocumentation: {documentation_link}").unwrap();
168 }
169 if let Some(path) = path {
170 writeln!(styled_issue, "{path}").unwrap();
171 }
172 }
173
174 write!(
175 issue_text,
176 "{} - [{}] {}",
177 severity.style(severity_to_style(severity)),
178 stgae,
179 plain_issue.file_path
180 )
181 .unwrap();
182
183 for line in styled_issue.lines() {
184 writeln!(issue_text, " {line}").unwrap();
185 }
186
187 issue_text
188}
189
190pub type GroupedIssues =
191 FxHashMap<IssueSeverity, FxHashMap<String, FxHashMap<String, Vec<String>>>>;
192
193const DEFAULT_SHOW_COUNT: usize = 3;
194
195const ORDERED_GROUPS: &[IssueSeverity] = &[
196 IssueSeverity::Bug,
197 IssueSeverity::Fatal,
198 IssueSeverity::Error,
199 IssueSeverity::Warning,
200 IssueSeverity::Hint,
201 IssueSeverity::Note,
202 IssueSeverity::Suggestion,
203 IssueSeverity::Info,
204];
205
206#[turbo_tasks::value(shared)]
207#[derive(Debug, Clone)]
208pub struct LogOptions {
209 pub current_dir: PathBuf,
210 pub project_dir: PathBuf,
211 pub show_all: bool,
212 pub log_detail: bool,
213 pub log_level: IssueSeverity,
214}
215
216#[derive(Default)]
233struct SeenIssues {
234 source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
237
238 issues_count: FxHashMap<u64, usize>,
243}
244
245impl SeenIssues {
246 fn new() -> Self {
247 Default::default()
248 }
249
250 fn new_ids(&mut self, source: RawVc, issue_ids: FxHashSet<u64>) -> FxHashSet<u64> {
253 let old = self.source_to_issue_ids.entry(source).or_default();
254
255 let difference = issue_ids
257 .iter()
258 .filter(|id| match self.issues_count.entry(**id) {
259 Entry::Vacant(e) => {
260 e.insert(1);
262 true
263 }
264 Entry::Occupied(mut e) => {
265 if old.contains(*id) {
266 old.remove(*id);
270 } else {
271 *e.get_mut() += 1;
274 }
275 false
276 }
277 })
278 .cloned()
279 .collect::<FxHashSet<_>>();
280
281 for id in old.iter() {
283 match self.issues_count.entry(*id) {
284 Entry::Vacant(_) => unreachable!("issue must already be tracked to appear in old"),
285 Entry::Occupied(mut e) => {
286 let v = e.get_mut();
287 if *v == 1 {
288 e.remove();
291 } else {
292 *v -= 1;
295 }
296 }
297 }
298 }
299
300 *old = issue_ids;
301 difference
302 }
303}
304
305#[turbo_tasks::value(shared, serialization = "none", eq = "manual")]
311#[derive(Clone)]
312pub struct ConsoleUi {
313 options: LogOptions,
314
315 #[turbo_tasks(trace_ignore, debug_ignore)]
316 seen: Arc<Mutex<SeenIssues>>,
317}
318
319impl PartialEq for ConsoleUi {
320 fn eq(&self, other: &Self) -> bool {
321 self.options == other.options
322 }
323}
324
325#[turbo_tasks::value_impl]
326impl ConsoleUi {
327 #[turbo_tasks::function]
328 pub fn new(options: TransientInstance<LogOptions>) -> Vc<Self> {
329 ConsoleUi {
330 options: (*options).clone(),
331 seen: Arc::new(Mutex::new(SeenIssues::new())),
332 }
333 .cell()
334 }
335}
336
337#[turbo_tasks::value_impl]
338impl IssueReporter for ConsoleUi {
339 #[turbo_tasks::function]
340 async fn report_issues(
341 &self,
342 issues: TransientInstance<CapturedIssues>,
343 source: TransientValue<RawVc>,
344 min_failing_severity: Vc<IssueSeverity>,
345 ) -> Result<Vc<bool>> {
346 let issues = &*issues;
347 let LogOptions {
348 ref current_dir,
349 ref project_dir,
350 show_all,
351 log_detail,
352 log_level,
353 ..
354 } = self.options;
355 let mut grouped_issues: GroupedIssues = FxHashMap::default();
356
357 let issues = issues
358 .iter_with_shortest_path()
359 .map(|(issue, path)| async move {
360 let plain_issue = issue.into_plain(path);
361 let id = plain_issue.internal_hash(false).await?;
362 Ok((plain_issue.await?, *id))
363 })
364 .try_join()
365 .await?;
366
367 let issue_ids = issues.iter().map(|(_, id)| *id).collect::<FxHashSet<_>>();
368 let mut new_ids = self
369 .seen
370 .lock()
371 .unwrap()
372 .new_ids(source.into_value(), issue_ids);
373
374 let mut has_fatal = false;
375 for (plain_issue, id) in issues {
376 if !new_ids.remove(&id) {
377 continue;
378 }
379
380 let severity = plain_issue.severity;
381 if severity <= *min_failing_severity.await? {
382 has_fatal = true;
383 }
384
385 let context_path =
386 make_relative_to_cwd(&plain_issue.file_path, project_dir, current_dir);
387 let stage = plain_issue.stage.to_string();
388 let processing_path = &*plain_issue.processing_path;
389 let severity_map = grouped_issues.entry(severity).or_default();
390 let category_map = severity_map.entry(stage.clone()).or_default();
391 let issues = category_map.entry(context_path.to_string()).or_default();
392
393 let mut styled_issue = style_issue_source(&plain_issue, &context_path);
394 let description = &plain_issue.description;
395 if let Some(description) = description {
396 writeln!(
397 &mut styled_issue,
398 "\n{}",
399 render_styled_string_to_ansi(description)
400 )?;
401 }
402
403 if log_detail {
404 styled_issue.push('\n');
405 let detail = &plain_issue.detail;
406 if let Some(detail) = detail {
407 for line in render_styled_string_to_ansi(detail).split('\n') {
408 writeln!(&mut styled_issue, "| {line}")?;
409 }
410 }
411 let documentation_link = &plain_issue.documentation_link;
412 if !documentation_link.is_empty() {
413 writeln!(&mut styled_issue, "\ndocumentation: {documentation_link}")?;
414 }
415 format_optional_path(processing_path, &mut styled_issue)?;
416 }
417 issues.push(styled_issue);
418 }
419
420 for severity in ORDERED_GROUPS.iter().copied().filter(|l| *l <= log_level) {
421 if let Some(severity_map) = grouped_issues.get_mut(&severity) {
422 let severity_map_size = severity_map.len();
423 let indent = if severity_map_size == 1 {
424 print!("{} - ", severity.style(severity_to_style(severity)));
425 ""
426 } else {
427 println!("{} -", severity.style(severity_to_style(severity)));
428 " "
429 };
430 let severity_map_take_count = if show_all {
431 severity_map_size
432 } else {
433 DEFAULT_SHOW_COUNT
434 };
435 let mut categories = severity_map.keys().cloned().collect::<Vec<_>>();
436 categories.sort();
437 for category in categories.iter().take(severity_map_take_count) {
438 let category_issues = severity_map.get_mut(category).unwrap();
439 let category_issues_size = category_issues.len();
440 let indent = if category_issues_size == 1 && indent.is_empty() {
441 print!("[{category}] ");
442 "".to_string()
443 } else {
444 println!("{indent}[{category}]");
445 format!("{indent} ")
446 };
447 let (mut contextes, mut vendor_contextes): (Vec<_>, Vec<_>) = category_issues
448 .iter_mut()
449 .partition(|(context, _)| !context.contains("node_modules"));
450 contextes.sort_by_key(|(c, _)| *c);
451 if show_all {
452 vendor_contextes.sort_by_key(|(c, _)| *c);
453 contextes.extend(vendor_contextes);
454 }
455 let category_issues_take_count = if show_all {
456 category_issues_size
457 } else {
458 min(contextes.len(), DEFAULT_SHOW_COUNT)
459 };
460 for (context, issues) in contextes.into_iter().take(category_issues_take_count)
461 {
462 issues.sort();
463 println!("{indent}{}", context.bright_blue());
464 let issues_size = issues.len();
465 let issues_take_count = if show_all {
466 issues_size
467 } else {
468 DEFAULT_SHOW_COUNT
469 };
470 for issue in issues.iter().take(issues_take_count) {
471 let mut i = 0;
472 for line in issue.lines() {
473 println!("{indent} {line}");
474 i += 1;
475 }
476 if i > 1 {
477 println!();
479 }
480 }
481 if issues_size > issues_take_count {
482 println!("{indent} {}", show_all_message("issues", issues_size));
483 }
484 }
485 if category_issues_size > category_issues_take_count {
486 println!(
487 "{indent}{}",
488 show_all_message_with_shown_count(
489 "paths",
490 category_issues_size,
491 category_issues_take_count
492 )
493 );
494 }
495 }
496 if severity_map_size > severity_map_take_count {
497 println!(
498 "{indent}{}",
499 show_all_message("categories", severity_map_size)
500 )
501 }
502 }
503 }
504
505 Ok(Vc::cell(has_fatal))
506 }
507}
508
509fn make_relative_to_cwd<'a>(path: &'a str, project_dir: &Path, cwd: &Path) -> Cow<'a, str> {
510 if let Some(path_in_project) = path.strip_prefix("[project]/") {
511 let abs_path = if std::path::MAIN_SEPARATOR != '/' {
512 project_dir.join(path_in_project.replace('/', std::path::MAIN_SEPARATOR_STR))
513 } else {
514 project_dir.join(path_in_project)
515 };
516 let relative = abs_path
517 .strip_prefix(cwd)
518 .unwrap_or(&abs_path)
519 .to_string_lossy()
520 .to_string();
521 relative.into()
522 } else {
523 path.into()
524 }
525}
526
527fn show_all_message(label: &str, size: usize) -> StyledContent<String> {
528 show_all_message_with_shown_count(label, size, DEFAULT_SHOW_COUNT)
529}
530
531fn show_all_message_with_shown_count(
532 label: &str,
533 size: usize,
534 shown: usize,
535) -> StyledContent<String> {
536 if shown == 0 {
537 format!(
538 "... [{} {label}] are hidden, run with {} to show them",
539 size,
540 "--show-all".bright_green()
541 )
542 .bold()
543 } else {
544 format!(
545 "... [{} more {label}] are hidden, run with {} to show all",
546 size - shown,
547 "--show-all".bright_green()
548 )
549 .bold()
550 }
551}
552
553fn render_styled_string_to_ansi(styled_string: &StyledString) -> String {
554 match styled_string {
555 StyledString::Line(parts) => {
556 let mut string = String::new();
557 for part in parts {
558 string.push_str(&render_styled_string_to_ansi(part));
559 }
560 string.push('\n');
561 string
562 }
563 StyledString::Stack(parts) => {
564 let mut string = String::new();
565 for part in parts {
566 string.push_str(&render_styled_string_to_ansi(part));
567 string.push('\n');
568 }
569 string
570 }
571 StyledString::Text(string) => string.to_string(),
572 StyledString::Code(string) => string.blue().to_string(),
573 StyledString::Strong(string) => string.bold().to_string(),
574 }
575}
576
577fn style_issue_source(plain_issue: &PlainIssue, context_path: &str) -> String {
578 let title = &plain_issue.title;
579 let formatted_title = match title {
580 StyledString::Text(text) => text.bold().to_string(),
581 _ => render_styled_string_to_ansi(title),
582 };
583
584 if let Some(source) = &plain_issue.source {
585 let mut styled_issue = match source.range {
586 Some((start, _)) => format!(
587 "{}:{}:{} {}",
588 context_path,
589 start.line + 1,
590 start.column,
591 formatted_title
592 ),
593 None => format!("{context_path} {formatted_title}"),
594 };
595 styled_issue.push('\n');
596 format_source_content(source, &mut styled_issue);
597 styled_issue
598 } else {
599 formatted_title
600 }
601}