react-perf-analyzer 0.2.0

Static analysis CLI for React performance anti-patterns
/// reporter.rs — Output formatting for lint results.
///
/// Supports two output modes:
///
/// 1. **Text** (default): Human-readable columnar format, modelled after ESLint:
///
///    ```
///    src/App.tsx:12:5   warning  no_inline_jsx_fn   Inline function in JSX...
///    src/Dashboard.tsx:1:1   warning  large_component   Component is 420 lines...
///
///    ✖ 2 warnings found
///    ```
///
/// 2. **JSON**: Machine-readable array of issue objects, suitable for CI
///    tooling, editors, or piping into `jq`:
///
///    ```json
///    [
///      {
///        "rule": "no_inline_jsx_fn",
///        "message": "...",
///        "file": "src/App.tsx",
///        "line": 12,
///        "column": 5,
///        "severity": "warning"
///      }
///    ]
///    ```
use std::path::Path;

use crate::rules::Issue;

// ─── Text reporter ────────────────────────────────────────────────────────────

/// Print issues to stdout in human-readable columnar format.
///
/// Issues are sorted by file path then line number so the output is
/// predictable and easy to scan.
///
/// Returns the total number of issues printed (used for the summary line).
pub fn report_text(issues: &[Issue]) -> usize {
    if issues.is_empty() {
        println!("✓ No performance issues found.");
        return 0;
    }

    // Sort a local copy: by file path, then line, then column.
    let mut sorted = issues.to_vec();
    sorted.sort_by(|a, b| {
        a.file
            .cmp(&b.file)
            .then(a.line.cmp(&b.line))
            .then(a.column.cmp(&b.column))
    });

    let mut current_file: Option<&Path> = None;

    for issue in &sorted {
        // Print a blank-line-separated file header on first occurrence.
        if current_file != Some(&issue.file) {
            if current_file.is_some() {
                println!(); // blank line between files
            }
            current_file = Some(&issue.file);
        }

        // Format:  path:line:col   severity   rule_name   message
        // Column widths chosen to mirror ESLint's default output.
        println!(
            "{file}:{line}:{col}  {severity}  {rule:<22}  {message}",
            file     = issue.file.display(),
            line     = issue.line,
            col      = issue.column,
            severity = issue.severity,
            rule     = issue.rule,
            message  = issue.message,
        );
    }

    // Summary line.
    println!();
    let count = sorted.len();
    let label = if count == 1 { "issue" } else { "issues" };
    println!("{count} {label} found");

    count
}

// ─── JSON reporter ────────────────────────────────────────────────────────────

/// Serialize issues to a pretty-printed JSON array on stdout.
///
/// Uses `serde_json` for serialization. File paths are serialized as
/// their display strings (forward-slash on Unix, backslash on Windows).
pub fn report_json(issues: &[Issue]) -> usize {
    // We need to serialize the file path as a string, not a PathBuf.
    // Build a simple wrapper struct for serde.
    #[derive(serde::Serialize)]
    struct JsonIssue<'a> {
        rule:     &'a str,
        message:  &'a str,
        file:     String,
        line:     u32,
        column:   u32,
        severity: &'a crate::rules::Severity,
    }

    let json_issues: Vec<JsonIssue<'_>> = issues
        .iter()
        .map(|i| JsonIssue {
            rule:     &i.rule,
            message:  &i.message,
            file:     i.file.display().to_string(),
            line:     i.line,
            column:   i.column,
            severity: &i.severity,
        })
        .collect();

    match serde_json::to_string_pretty(&json_issues) {
        Ok(json) => println!("{json}"),
        Err(err) => eprintln!("Error serializing JSON output: {err}"),
    }

    issues.len()
}

// ─── Summary helpers ──────────────────────────────────────────────────────────

/// Print a concise summary of per-rule issue counts to stderr.
///
/// Useful when `--format json` is used and you still want a human summary.
pub fn print_summary(issues: &[Issue]) {
    use std::collections::HashMap;

    if issues.is_empty() {
        return;
    }

    let mut counts: HashMap<&str, usize> = HashMap::new();
    for issue in issues {
        *counts.entry(issue.rule.as_str()).or_insert(0) += 1;
    }

    let mut pairs: Vec<(&&str, &usize)> = counts.iter().collect();
    pairs.sort_by_key(|(rule, _)| **rule);

    eprintln!("\nSummary:");
    for (rule, count) in pairs {
        eprintln!("  {rule:<24} {count} issue(s)");
    }
}