mago 1.57.0

A comprehensive suite of PHP tooling inspired by Rust’s approach, providing parsing, linting, formatting, and more through a unified CLI and library interface.
use std::path::Path;
use std::process::Command;
use std::process::Output;

const BEFORE: &str = include_str!("fixtures/fix/multipass.before.php");
const AFTER: &str = include_str!("fixtures/fix/multipass.after.php");

fn workspace(code: &str) -> tempfile::TempDir {
    let directory = tempfile::tempdir().unwrap();
    std::fs::create_dir(directory.path().join("src")).unwrap();
    std::fs::write(directory.path().join("mago.toml"), include_str!("fixtures/fix/mago.toml")).unwrap();
    std::fs::write(directory.path().join("src/test.php"), code).unwrap();
    directory
}

fn run(workspace: &Path, command: &str, arguments: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_mago"))
        .args(["--no-version-check", "--colors", "never", "--threads", "2", command])
        .args(arguments)
        .env("MAGO_LOG", "info")
        .current_dir(workspace)
        .output()
        .unwrap()
}

fn contents(workspace: &Path) -> String {
    std::fs::read_to_string(workspace.join("src/test.php")).unwrap()
}

#[test]
fn fixes_dependent_rules_in_order_and_is_idempotent() {
    let directory = workspace(BEFORE);
    let output = run(directory.path(), "fix", &[]);
    let log = String::from_utf8_lossy(&output.stderr);
    assert!(output.status.success(), "{log}");
    assert_eq!(contents(directory.path()), AFTER);
    let steps: Vec<_> = log.lines().filter(|line| line.contains("Running ")).collect();
    assert_eq!(steps.len(), 12, "{log}");
    for pass in steps.as_chunks::<4>().0 {
        for (line, tool) in pass.iter().zip(["guard", "analyze", "lint", "format"]) {
            assert!(line.contains(&format!("Running {tool}")), "{line}");
        }
    }

    let output = run(directory.path(), "fix", &[]);
    assert!(output.status.success());
    assert_eq!(contents(directory.path()), AFTER);
    assert!(!String::from_utf8_lossy(&output.stderr).contains("fix pass 2"));
}

#[test]
fn can_disable_any_or_all_tools() {
    let directory = workspace(BEFORE);
    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-lint", "--no-fmt"]);
    assert!(output.status.success());
    assert_eq!(contents(directory.path()), BEFORE);
    assert!(!String::from_utf8_lossy(&output.stderr).contains("Running "));

    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-lint"]);
    assert!(output.status.success());
    assert!(contents(directory.path()).contains("is_null($value)"));
    assert_ne!(contents(directory.path()), BEFORE);

    std::fs::write(directory.path().join("src/test.php"), BEFORE).unwrap();
    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-fmt"]);
    assert!(output.status.success());
    assert_eq!(contents(directory.path()), BEFORE.replace("is_null($value)", "$value === null"));
}

#[test]
fn uses_the_selected_safety_level() {
    let code = include_str!("fixtures/fix/safety.php");
    let directory = workspace(code);
    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-fmt"]);
    assert!(output.status.success());
    assert_eq!(contents(directory.path()), code);

    for flag in ["--potentially-unsafe", "--unsafe"] {
        std::fs::write(directory.path().join("src/test.php"), code).unwrap();
        let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-fmt", flag]);
        assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
        assert!(contents(directory.path()).contains("declare(strict_types=1);"));
    }
}

#[test]
fn applies_analyzer_fixes_before_formatting() {
    let directory = workspace(include_str!("fixtures/fix/analyzer.php"));
    let output = run(directory.path(), "fix", &["--no-guard", "--no-lint"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert!(contents(directory.path()).contains("return $value;"));
    assert!(!contents(directory.path()).contains("=== true"));
}

#[test]
fn unfixable_issues_do_not_stop_other_fixes() {
    for fail_on_remaining in [false, true] {
        let directory = workspace(include_str!("fixtures/fix/unfixable.php"));
        let arguments = if fail_on_remaining { vec!["--fail-on-remaining"] } else { vec![] };
        let output = run(directory.path(), "fix", &arguments);
        assert_eq!(output.status.success(), !fail_on_remaining, "{}", String::from_utf8_lossy(&output.stderr));
        assert!(contents(directory.path()).contains("return $value === null;"));
        assert!(contents(directory.path()).contains("missing_function();"));
    }
}

#[test]
fn limits_fixes_to_requested_paths() {
    let directory = workspace(BEFORE);
    std::fs::write(directory.path().join("src/other.php"), BEFORE).unwrap();
    let output = run(directory.path(), "fix", &["src/test.php", "--no-guard", "--no-analyze"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert_eq!(contents(directory.path()), AFTER);
    assert_eq!(std::fs::read_to_string(directory.path().join("src/other.php")).unwrap(), BEFORE);
}

#[test]
fn refuses_to_claim_success_before_fixes_settle() {
    let directory = workspace(BEFORE);
    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--max-passes", "1"]);
    assert_eq!(output.status.code(), Some(1));
    assert!(String::from_utf8_lossy(&output.stderr).contains("did not settle"));

    for limit in ["0", "257"] {
        let output = run(directory.path(), "fix", &["--max-passes", limit]);
        assert_eq!(output.status.code(), Some(2));
        assert!(String::from_utf8_lossy(&output.stderr).contains("1..=256"));
    }

    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--max-passes", "256"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert_eq!(contents(directory.path()), AFTER);
}

#[test]
fn reports_formatter_parse_failures() {
    let code = include_str!("fixtures/fix/invalid.php");
    let directory = workspace(code);
    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-lint"]);
    assert_eq!(output.status.code(), Some(1));
    assert_eq!(contents(directory.path()), code);
}

#[test]
fn respects_tool_exclusions() {
    let directory = workspace(BEFORE);
    std::fs::write(directory.path().join("mago.toml"), include_str!("fixtures/fix/exclusions.toml")).unwrap();
    for name in ["lint_excluded.php", "format_excluded.php", "source_excluded.php"] {
        std::fs::write(directory.path().join("src").join(name), BEFORE).unwrap();
    }

    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert_eq!(contents(directory.path()), AFTER);
    assert_eq!(
        std::fs::read_to_string(directory.path().join("src/lint_excluded.php")).unwrap(),
        AFTER.replace("$value === null", "is_null($value)")
    );
    assert_eq!(
        std::fs::read_to_string(directory.path().join("src/format_excluded.php")).unwrap(),
        BEFORE.replace("is_null($value)", "$value === null")
    );
    assert_eq!(std::fs::read_to_string(directory.path().join("src/source_excluded.php")).unwrap(), BEFORE);
}

#[test]
fn respects_baselines_unless_asked_to_ignore_them() {
    let directory = workspace(BEFORE);
    std::fs::write(directory.path().join("mago.toml"), include_str!("fixtures/fix/baseline.toml")).unwrap();
    let output = run(directory.path(), "lint", &["--generate-baseline"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));

    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--no-fmt"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert_eq!(contents(directory.path()), BEFORE);

    let output = run(directory.path(), "fix", &["--no-guard", "--no-analyze", "--ignore-baseline"]);
    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    assert_eq!(contents(directory.path()), AFTER);
}