turbopack_cli_utils/
issue.rs

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    // TODO CLICKABLE PATHS
139    let context_path = plain_issue
140        .file_path
141        .replace("[project]", &current_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/// Tracks the state of currently seen issues.
217///
218/// An issue is considered seen as long as a single source has pulled the issue.
219/// When a source repulls emitted issues due to a recomputation somewhere in its
220/// graph, there are a few possibilities:
221///
222/// 1. An issue from this pull is brand new to all sources, in which case it will be logged and the
223///    issue's count is inremented.
224/// 2. An issue from this pull is brand new to this source but another source has already pulled it,
225///    in which case it will be logged and the issue's count is incremented.
226/// 3. The previous pull from this source had already seen the issue, in which case the issue will
227///    be skipped and the issue's count remains constant.
228/// 4. An issue seen in a previous pull was not repulled, and the issue's count is decremented.
229///
230/// Once an issue's count reaches zero, it's removed. If it is ever seen again,
231/// it is considered new and will be relogged.
232#[derive(Default)]
233struct SeenIssues {
234    /// Keeps track of all issue pulled from the source. Used so that we can
235    /// decrement issues that are not pulled in the current synchronization.
236    source_to_issue_ids: FxHashMap<RawVc, FxHashSet<u64>>,
237
238    /// Counts the number of times a particular issue is seen across all
239    /// sources. As long as the count is positive, an issue is considered
240    /// "seen" and will not be relogged. Once the count reaches zero, the
241    /// issue is removed and the next time its seen it will be considered new.
242    issues_count: FxHashMap<u64, usize>,
243}
244
245impl SeenIssues {
246    fn new() -> Self {
247        Default::default()
248    }
249
250    /// Synchronizes state between the issues previously pulled from this
251    /// source, to the issues now pulled.
252    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        // difference is the issues that were never counted before.
256        let difference = issue_ids
257            .iter()
258            .filter(|id| match self.issues_count.entry(**id) {
259                Entry::Vacant(e) => {
260                    // If the issue not currently counted, then it's new and should be logged.
261                    e.insert(1);
262                    true
263                }
264                Entry::Occupied(mut e) => {
265                    if old.contains(*id) {
266                        // If old contains the id, then we don't need to change the count, but we
267                        // do need to remove the entry. Doing so allows us to iterate the final old
268                        // state and decrement old issues.
269                        old.remove(*id);
270                    } else {
271                        // If old didn't contain the entry, then this issue was already counted
272                        // from a difference source.
273                        *e.get_mut() += 1;
274                    }
275                    false
276                }
277            })
278            .cloned()
279            .collect::<FxHashSet<_>>();
280
281        // Old now contains only the ids that were not present in the new issue_ids.
282        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                        // If this was the last counter of the issue, then we need to prune the
289                        // value to free memory.
290                        e.remove();
291                    } else {
292                        // Another source counted the issue, and it must not be relogged until all
293                        // sources remove it.
294                        *v -= 1;
295                    }
296                }
297            }
298        }
299
300        *old = issue_ids;
301        difference
302    }
303}
304
305/// Logs emitted issues to console logs, deduplicating issues between peeks of
306/// the collected issues.
307///
308/// The ConsoleUi can be shared and capture issues from multiple sources, with deduplication
309/// operating across all issues.
310#[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                                // Spacing after multi line issues
478                                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}