diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3..ce1443c2d 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -6,6 +6,7 @@ pub mod git; pub mod go; pub mod js; pub mod jvm; +pub mod php; pub mod python; pub mod ruby; pub mod rust; diff --git a/src/cmds/php/README.md b/src/cmds/php/README.md new file mode 100644 index 000000000..003de1420 --- /dev/null +++ b/src/cmds/php/README.md @@ -0,0 +1,15 @@ +# PHP + +> Part of [`src/cmds/`](../README.md) — see also [docs/contributing/TECHNICAL.md](../../../docs/contributing/TECHNICAL.md) + +## Specifics + +- `php_cmd.rs` summarizes `php -l` syntax checks and routes `php artisan*` to specialized helpers +- `artisan_cmd.rs` cleans Artisan output and applies runner-aware filtering for `php artisan test` +- `phpunit_cmd.rs` strips progress/header noise and keeps failure details + final summary +- `phpstan_cmd.rs` injects JSON output by default and emits compact file/line error summaries +- `ecs_cmd.rs` condenses EasyCodingStandard output while preserving file paths and error lines +- `pest_cmd.rs` runs Pest with compact progress suppression and test-focused output +- `paratest_cmd.rs` runs ParaTest with compact progress suppression and test-focused output +- `test_output.rs` provides shared PHPUnit/Pest/ParaTest output filtering logic +- `utils.rs` resolves local `vendor/bin/*` tools first, then falls back to global binaries diff --git a/src/cmds/php/artisan_cmd.rs b/src/cmds/php/artisan_cmd.rs new file mode 100644 index 000000000..88543515d --- /dev/null +++ b/src/cmds/php/artisan_cmd.rs @@ -0,0 +1,54 @@ +//! Laravel Artisan output cleanup helpers. + +use super::test_output::filter_test_runner_output; +use super::utils::{strip_ansi_and_controls, PhpTestRunner}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref BOX_CHARS_RE: Regex = + Regex::new(r"[\u{2500}-\u{257F}\u{2580}-\u{259F}\u{25A0}-\u{25FF}\u{27A0}-\u{27BF}]+") + .unwrap(); + static ref DOTS_RE: Regex = Regex::new(r"\.{3,}").unwrap(); + static ref MULTI_SPACE_RE: Regex = Regex::new(r"[ \t]{2,}").unwrap(); + static ref MULTI_BLANK_RE: Regex = Regex::new(r"\n{3,}").unwrap(); +} + +pub fn filter_artisan_output(output: &str) -> String { + let mut cleaned = strip_ansi_and_controls(output); + cleaned = BOX_CHARS_RE.replace_all(&cleaned, "").to_string(); + cleaned = DOTS_RE.replace_all(&cleaned, "..").to_string(); + cleaned = MULTI_SPACE_RE.replace_all(&cleaned, " ").to_string(); + cleaned = MULTI_BLANK_RE.replace_all(&cleaned, "\n\n").to_string(); + cleaned.trim().to_string() +} + +pub fn filter_artisan_test_output(output: &str, runner: PhpTestRunner) -> String { + match runner { + PhpTestRunner::Pest | PhpTestRunner::Phpunit => filter_test_runner_output(output), + PhpTestRunner::Unknown => filter_artisan_output(output), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_artisan_cleanup() { + let out = + "\u{1b}[32mEnvironment .....\u{1b}[0m\n\u{2502} Laravel Version \u{2502} 13.0.0 \u{2502}\n\n\n"; + let filtered = filter_artisan_output(out); + assert!(!filtered.contains('\u{1b}')); + assert!(!filtered.contains('\u{2502}')); + assert!(filtered.contains("Environment ..")); + } + + #[test] + fn test_artisan_test_prefers_runner_filter() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_artisan_test_output(output, PhpTestRunner::Phpunit); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } +} diff --git a/src/cmds/php/ecs_cmd.rs b/src/cmds/php/ecs_cmd.rs new file mode 100644 index 000000000..8b02fb71e --- /dev/null +++ b/src/cmds/php/ecs_cmd.rs @@ -0,0 +1,81 @@ +//! EasyCodingStandard output filter. + +use super::utils::{php_tool_command, strip_ansi_and_controls}; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("ecs"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: ecs {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "ecs", + &args.join(" "), + filter_ecs_output, + runner::RunOptions::default(), + ) +} + +pub(crate) fn filter_ecs_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + if cleaned.contains("No errors found") { + return "✓ ecs: no issues".to_string(); + } + + let mut lines = Vec::new(); + for line in cleaned.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.contains(".php") + || trimmed.contains("ERROR") + || trimmed.contains("FAIL") + || trimmed.contains("Fixed") + || trimmed.contains("checked") + || trimmed.contains("files") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = cleaned.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ecs_success_output() { + assert_eq!( + filter_ecs_output("[OK] No errors found. Great job!"), + "✓ ecs: no issues" + ); + } + + #[test] + fn test_ecs_keeps_file_errors() { + let output = "src/Foo.php\n 10 | ERROR | Something bad\n"; + let filtered = filter_ecs_output(output); + assert!(filtered.contains("src/Foo.php")); + assert!(filtered.contains("ERROR")); + } +} diff --git a/src/cmds/php/mod.rs b/src/cmds/php/mod.rs new file mode 100644 index 000000000..90c027f68 --- /dev/null +++ b/src/cmds/php/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/php"); diff --git a/src/cmds/php/paratest_cmd.rs b/src/cmds/php/paratest_cmd.rs new file mode 100644 index 000000000..3585f3ffa --- /dev/null +++ b/src/cmds/php/paratest_cmd.rs @@ -0,0 +1,31 @@ +//! ParaTest runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("paratest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: paratest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "paratest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} diff --git a/src/cmds/php/pest_cmd.rs b/src/cmds/php/pest_cmd.rs new file mode 100644 index 000000000..559beb167 --- /dev/null +++ b/src/cmds/php/pest_cmd.rs @@ -0,0 +1,45 @@ +//! Pest test runner filter. + +use super::test_output::filter_test_runner_output; +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pest"); + + let has_no_progress = args.iter().any(|a| a == "--no-progress"); + if !has_no_progress { + cmd.arg("--no-progress"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "pest", + &args.join(" "), + filter_test_runner_output, + runner::RunOptions::default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pest_filters_progress_noise() { + let output = "Pest 5.0.0\n.....\nPASS Tests\\Unit\\ExampleTest\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("Pest 5.0.0")); + assert!(!filtered.contains(".....")); + assert!(filtered.contains("PASS Tests\\Unit\\ExampleTest")); + } +} diff --git a/src/cmds/php/php_cmd.rs b/src/cmds/php/php_cmd.rs new file mode 100644 index 000000000..3bbca9942 --- /dev/null +++ b/src/cmds/php/php_cmd.rs @@ -0,0 +1,114 @@ +//! PHP command filter: syntax-check summaries and generic cleanup. + +use super::artisan_cmd::{filter_artisan_output, filter_artisan_test_output}; +use super::utils::{detect_php_test_runner, strip_ansi_and_controls, PhpTestRunner}; +use crate::core::runner; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("php"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: php {}", args.join(" ")); + } + + let is_artisan = args.first().map(String::as_str) == Some("artisan"); + let is_artisan_test = is_artisan && args.get(1).map(String::as_str) == Some("test"); + let is_lint = args.iter().any(|a| a == "-l" || a == "--syntax-check"); + let detected_runner = if is_artisan_test { + detect_php_test_runner() + } else { + PhpTestRunner::Unknown + }; + + if verbose > 0 && is_artisan_test { + eprintln!("Detected artisan test runner: {:?}", detected_runner); + } + + runner::run_filtered( + cmd, + "php", + &args.join(" "), + move |raw| { + if is_lint { + return filter_php_lint_output(raw); + } + if is_artisan_test { + return filter_artisan_test_output(raw, detected_runner); + } + if is_artisan { + return filter_artisan_output(raw); + } + filter_php_output(raw) + }, + runner::RunOptions::default(), + ) +} + +fn filter_php_lint_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(file) = trimmed.strip_prefix("No syntax errors detected in ") { + lines.push(format!("ok {}", file.trim())); + continue; + } + + if trimmed.starts_with("Errors parsing ") + || trimmed.contains("Parse error") + || trimmed.contains("Fatal error") + || trimmed.contains("syntax error") + { + lines.push(trimmed.to_string()); + } + } + + if lines.is_empty() { + let fallback = output.trim(); + if fallback.is_empty() { + "ok".to_string() + } else { + fallback.to_string() + } + } else { + lines.join("\n") + } +} + +fn filter_php_output(output: &str) -> String { + let cleaned = strip_ansi_and_controls(output); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "ok".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_php_lint_summary() { + let out = "No syntax errors detected in app/Http/Controller.php\n"; + assert_eq!(filter_php_lint_output(out), "ok app/Http/Controller.php"); + } + + #[test] + fn test_php_lint_error_preserved() { + let out = "Errors parsing app/Foo.php\nParse error: syntax error, unexpected ')' in app/Foo.php on line 10\n"; + let filtered = filter_php_lint_output(out); + assert!(filtered.contains("Errors parsing app/Foo.php")); + assert!(filtered.contains("Parse error")); + } +} diff --git a/src/cmds/php/phpstan_cmd.rs b/src/cmds/php/phpstan_cmd.rs new file mode 100644 index 000000000..9d4d57037 --- /dev/null +++ b/src/cmds/php/phpstan_cmd.rs @@ -0,0 +1,502 @@ +//! PHPStan static analysis filter. +//! +//! Injects `--error-format=json` for structured output, parses errors grouped by +//! file and sorted by error count. Falls back to text parsing when the user +//! specifies a custom format or when injected JSON output fails to parse. + +use crate::core::runner; +use crate::core::utils::{exit_code_from_status, resolved_command}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +// ── JSON structures matching PHPStan's --error-format=json output ─────────── + +#[derive(Deserialize)] +struct PhpstanOutput { + totals: PhpstanTotals, + files: HashMap, + #[serde(default)] + errors: Vec, +} + +#[derive(Deserialize)] +struct PhpstanTotals { + // `errors` counts only non-file-specific (global/config) errors; per-file + // errors live in `file_errors`. Gating "ok" on `errors` alone hides real + // failures, since a normal failing run reports errors=0, file_errors=N. + errors: usize, + file_errors: usize, +} + +#[derive(Deserialize)] +struct PhpstanFile { + errors: usize, + messages: Vec, +} + +#[derive(Deserialize)] +struct PhpstanMessage { + message: String, + line: usize, + #[serde(default)] + #[allow(dead_code)] + ignorable: bool, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result { + // Check for vendor/bin/phpstan first + let mut cmd = if Path::new("vendor/bin/phpstan").exists() { + resolved_command("vendor/bin/phpstan") + } else { + resolved_command("phpstan") + }; + + // Utility commands (--version, list, clear-result-cache, worker, …): real passthrough. + // Only analyse/analyze subcommands get filtered and token-tracked. + let is_analyse = args + .first() + .map(|a| a == "analyse" || a == "analyze") + .unwrap_or(false); + + if !is_analyse { + if verbose > 0 { + eprintln!("Running: phpstan {} (passthrough)", args.join(" ")); + } + cmd.args(args); + let status = cmd.status().context("Failed to run phpstan")?; + return Ok(exit_code_from_status(&status, "phpstan")); + } + + // Detect if user specified a custom output format (not json). + // Handles both `--error-format=table` and `--error-format table` forms. + let has_custom_format = { + let mut it = args.iter().peekable(); + let mut found = false; + while let Some(a) = it.next() { + if a == "--error-format" { + if it.peek().map(|v| v.as_str()) != Some("json") { + found = true; + } + break; + } + if a.starts_with("--error-format=") && a != "--error-format=json" { + found = true; + break; + } + } + found + }; + + // Pass user args first (subcommand must come before global flags for PHPStan), + // then append --error-format=json unless the user specified a custom format. + cmd.args(args); + if !has_custom_format { + cmd.arg("--error-format").arg("json"); + } + + if verbose > 0 { + eprintln!("Running: phpstan {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "phpstan", + &args.join(" "), + move |stdout| { + if has_custom_format { + filter_phpstan_text(stdout) + } else { + filter_phpstan_json(stdout) + } + }, + runner::RunOptions::stdout_only().tee("phpstan"), + ) +} + +// ── JSON filtering ─────────────────────────────────────────────────────────── + +pub(crate) fn filter_phpstan_json(output: &str) -> String { + if output.trim().is_empty() { + return "PHPStan: No output".to_string(); + } + + let parsed: Result = serde_json::from_str(output); + let phpstan = match parsed { + Ok(p) => p, + Err(e) => { + eprintln!("[rtk] phpstan: JSON parse failed ({})", e); + return crate::core::utils::fallback_tail(output, "phpstan (JSON parse error)", 5); + } + }; + + // No errors case: both file-specific and global error counts must be zero. + if phpstan.totals.file_errors == 0 && phpstan.totals.errors == 0 { + return "phpstan: ok".to_string(); + } + + let mut result = format!( + "phpstan: {} errors in {} files\n", + phpstan.totals.file_errors, + phpstan.files.len() + ); + + // Add global errors first if any + if !phpstan.errors.is_empty() { + result.push_str("\nGlobal errors:\n"); + for error in &phpstan.errors { + result.push_str(&format!(" {}\n", error)); + } + result.push('\n'); + } + + // Build list of files with errors, sorted by error count descending + let mut files_vec: Vec<(&String, &PhpstanFile)> = phpstan.files.iter().collect(); + files_vec.sort_by(|a, b| b.1.errors.cmp(&a.1.errors).then(a.0.cmp(b.0))); + + let max_files = 10; + let max_messages_per_file = 5; + + for (path, file) in files_vec.iter().take(max_files) { + let short = compact_php_path(path); + result.push_str(&format!("\n{} ({} errors)\n", short, file.errors)); + + for message in file.messages.iter().take(max_messages_per_file) { + let first_line = message.message.lines().next().unwrap_or(""); + result.push_str(&format!(" :{} {}\n", message.line, first_line)); + } + + if file.messages.len() > max_messages_per_file { + result.push_str(&format!( + " ... +{} more\n", + file.messages.len() - max_messages_per_file + )); + } + } + + if files_vec.len() > max_files { + result.push_str(&format!( + "\n... +{} more files\n", + files_vec.len() - max_files + )); + } + + result.trim().to_string() +} + +// ── Text fallback ──────────────────────────────────────────────────────────── + +pub(crate) fn filter_phpstan_text(output: &str) -> String { + // Check for errors first + for line in output.lines() { + let t = line.trim(); + if t.contains("cannot load such file") + || t.contains("not found") + || t.starts_with("phpstan: command not found") + || t.starts_with("phpstan: No such file") + { + let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); + let truncated = error_lines.join("\n"); + let total_lines = output.trim().lines().count(); + if total_lines > 20 { + return format!( + "PHPStan error:\n{}\n... ({} more lines)", + truncated, + total_lines - 20 + ); + } + return format!("PHPStan error:\n{}", truncated); + } + } + + // Extract summary if present. Match case-insensitively: phpstan prints + // "[ERROR] Found N errors" / "[OK] No errors" with varying capitalization. + for line in output.lines().rev() { + let t = line.trim(); + let lower = t.to_lowercase(); + if lower.contains("[ok]") || lower.contains("no errors") { + return "phpstan: ok".to_string(); + } + if lower.contains("error") && (lower.contains("found") || lower.contains("in")) { + return format!("PHPStan: {}", t); + } + } + + // Last resort: last 20 lines + crate::core::utils::fallback_tail(output, "phpstan", 20) +} + +/// Compact PHP file path by finding the nearest conventional directory +/// and stripping the absolute path prefix. +fn compact_php_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + for prefix in &[ + "app/Models/", + "app/Http/Controllers/", + "app/Http/Middleware/", + "app/Services/", + "app/Repositories/", + "src/", + "tests/", + "config/", + "database/", + ] { + if let Some(pos) = path.find(prefix) { + return path[pos..].to_string(); + } + } + + // Generic: strip up to last known directory marker + if let Some(pos) = path.rfind("/app/") { + return path[pos + 1..].to_string(); + } + if let Some(pos) = path.rfind("/src/") { + return path[pos + 1..].to_string(); + } + // Keep last 2 path components to preserve context (dir/File.php) + if let Some(pos) = path.rfind('/') { + if let Some(prev) = path[..pos].rfind('/') { + return path[prev + 1..].to_string(); + } + return path[pos + 1..].to_string(); + } + path +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::utils::count_tokens; + + fn no_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 0, "file_errors": 0}, + "files": {}, + "errors": [] + }"# + } + + fn with_errors_json() -> &'static str { + r#"{ + "totals": {"errors": 5, "file_errors": 5}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method Model::find().", "line": 25, "ignorable": false} + ] + }, + "app/Http/Controllers/UserController.php": { + "errors": 2, + "messages": [ + {"message": "Parameter $id of anonymous function has no typehint.", "line": 45, "ignorable": false}, + {"message": "Variable $user might not be defined.", "line": 67, "ignorable": false} + ] + }, + "app/Services/AuthService.php": { + "errors": 1, + "messages": [ + {"message": "Return type missing.", "line": 12, "ignorable": false} + ] + } + }, + "errors": [] + }"# + } + + fn large_json_for_truncation() -> String { + let mut files = HashMap::new(); + + // Create 12 files with varying error counts + for i in 1..=12 { + let filename = format!("app/Models/Model{}.php", i); + let error_count = if i <= 3 { 10 } else { i % 5 + 1 }; + + let mut messages = Vec::new(); + for j in 1..=error_count { + messages.push(format!( + r#"{{"message": "Error {} in file {}", "line": {}, "ignorable": false}}"#, + j, i, j * 10 + )); + } + + files.insert( + filename, + format!( + r#"{{"errors": {}, "messages": [{}]}}"#, + error_count, + messages.join(",") + ), + ); + } + + let files_json: Vec = files + .iter() + .map(|(k, v)| format!(r#""{}": {}"#, k, v)) + .collect(); + + format!( + r#"{{"totals": {{"errors": 50, "file_errors": 50}}, "files": {{{}}}, "errors": []}}"#, + files_json.join(",") + ) + } + + #[test] + fn test_filter_phpstan_json_no_errors() { + let result = filter_phpstan_json(no_errors_json()); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_file_errors_not_hidden() { + // Real failing runs report errors=0 (no global errors) with the count + // in file_errors. Gating "ok" on `errors` alone silently hid failures. + let json = r#"{ + "totals": {"errors": 0, "file_errors": 2}, + "files": { + "app/Models/User.php": { + "errors": 2, + "messages": [ + {"message": "Property $id does not accept null.", "line": 10, "ignorable": true}, + {"message": "Call to undefined method.", "line": 25, "ignorable": false} + ] + } + }, + "errors": [] + }"#; + let result = filter_phpstan_json(json); + assert!(result.starts_with("phpstan: 2 errors in 1 files"), "got: {}", result); + assert!(result.contains("app/Models/User.php (2 errors)"), "got: {}", result); + } + + #[test] + fn test_filter_phpstan_json_with_errors() { + let result = filter_phpstan_json(with_errors_json()); + + // Check summary line + assert!(result.contains("5 errors in 3 files")); + + // Check file names are present + assert!(result.contains("app/Models/User.php")); + assert!(result.contains("app/Http/Controllers/UserController.php")); + assert!(result.contains("app/Services/AuthService.php")); + + // Check line numbers and messages + assert!(result.contains(":10 Property $id does not accept null.")); + assert!(result.contains(":25 Call to undefined method Model::find().")); + assert!(result.contains(":45 Parameter $id of anonymous function has no typehint.")); + } + + #[test] + fn test_filter_phpstan_json_truncation() { + let result = filter_phpstan_json(&large_json_for_truncation()); + + // Should show max 10 files + assert!(result.contains("+2 more files")); + + // Should not show all 12 files inline + let file_count = result.matches("app/Models/Model").count(); + assert_eq!(file_count, 10, "Should show exactly 10 files"); + } + + #[test] + fn test_filter_phpstan_token_savings() { + // Use the realistic fixture with many files, long paths, and JSON metadata + // to verify the ≥75% savings claim in rules.rs + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "PHPStan: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_phpstan_empty_input() { + let result = filter_phpstan_json(""); + assert_eq!(result, "PHPStan: No output"); + } + + #[test] + fn test_filter_phpstan_malformed_json() { + let garbage = "some php warning\n{broken json"; + let result = filter_phpstan_json(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + #[test] + fn test_compact_php_path() { + assert_eq!( + compact_php_path("/var/www/project/app/Models/User.php"), + "app/Models/User.php" + ); + assert_eq!( + compact_php_path("app/Http/Controllers/UserController.php"), + "app/Http/Controllers/UserController.php" + ); + assert_eq!( + compact_php_path("/home/user/project/src/Service.php"), + "src/Service.php" + ); + assert_eq!( + compact_php_path("tests/Unit/UserTest.php"), + "tests/Unit/UserTest.php" + ); + } + + #[test] + fn test_filter_phpstan_text_fallback() { + let text = r#"PHPStan analysis complete +[OK] No errors found"#; + let result = filter_phpstan_text(text); + assert_eq!(result, "phpstan: ok"); + } + + #[test] + fn test_filter_phpstan_text_with_errors() { + let text = r#"PHPStan analysis complete + +Found 5 errors in 3 files"#; + let result = filter_phpstan_text(text); + assert!(result.starts_with("PHPStan:"), "should have PHPStan: prefix"); + assert!(result.contains("5 errors"), "should contain error count"); + assert!(result.contains("3 files"), "should contain file count"); + } + + #[test] + fn test_filter_phpstan_text_error_summary_case_insensitive() { + // phpstan prints "[ERROR] Found N errors" — capital F and no " in ", + // so the summary must be matched case-insensitively (regression guard). + let text = " ------\n 42 problem\n ------\n\n [ERROR] Found 2 errors\n"; + let result = filter_phpstan_text(text); + assert_eq!(result, "PHPStan: [ERROR] Found 2 errors"); + } + + #[test] + fn test_filter_phpstan_fixture_structure() { + // Verify output structure on the realistic fixture (14 files, 47 errors) + let input = include_str!("../../../tests/fixtures/phpstan_raw.json"); + let output = filter_phpstan_json(input); + + assert!(output.contains("47 errors in 14 files")); + // Files are sorted by error count descending — User.php has 6, comes first + assert!(output.contains("app/Models/User.php (6 errors)")); + // 14 files → only 10 shown, 4 more + assert!(output.contains("+4 more files")); + } +} diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs new file mode 100644 index 000000000..8706cf0cc --- /dev/null +++ b/src/cmds/php/phpunit_cmd.rs @@ -0,0 +1,337 @@ +//! PHPUnit output filter. +//! +//! Parses PHPUnit's plain-text runner output and emits a compact summary: +//! aggregate counts from the `Tests: X, Assertions: Y, Failures: Z.` line +//! plus a bounded list of failures with their first two detail lines. +//! Dot-progress lines and headers are stripped entirely. + +use super::utils::php_tool_command; +use crate::core::runner; +use anyhow::Result; + +const MAX_FAILURES_SHOWN: usize = 10; +const MAX_DETAIL_LINES_PER_FAILURE: usize = 2; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("phpunit"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: phpunit {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "phpunit", + &args.join(" "), + filter_phpunit_output, + runner::RunOptions::stdout_only().tee("phpunit"), + ) +} + +pub(crate) fn filter_phpunit_output(output: &str) -> String { + let mut failures: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut in_failures = false; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("OK (") { + return format!("PHPUnit: {}", trimmed); + } + + if trimmed.starts_with("OK, but") { + return build_success_with_skipped(output); + } + + if (trimmed.starts_with("There was") || trimmed.starts_with("There were")) + && (trimmed.contains("failure") || trimmed.contains("error")) + { + in_failures = true; + continue; + } + + if trimmed == "FAILURES!" || trimmed == "ERRORS!" { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + in_failures = false; + continue; + } + + if in_failures { + if is_numbered_failure_heading(trimmed) { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + current.push(trimmed.to_string()); + } else if !trimmed.is_empty() { + current.push(trimmed.to_string()); + } + } + } + + if !current.is_empty() { + failures.push(current); + } + + if failures.is_empty() { + let (tests, assertions, _, _) = parse_counts(output); + if tests > 0 { + return format!("PHPUnit: {} tests, {} assertions", tests, assertions); + } + return "PHPUnit: ok".to_string(); + } + + build_phpunit_summary(output, &failures) +} + +fn is_numbered_failure_heading(line: &str) -> bool { + // PHPUnit formats each failure as "N) Class::method" + let mut chars = line.chars(); + let first_digit = chars.next().is_some_and(|c| c.is_ascii_digit()); + first_digit && line.contains(')') +} + +fn build_success_with_skipped(output: &str) -> String { + let (tests, assertions, _, skipped) = parse_counts(output); + if skipped > 0 { + format!( + "PHPUnit: {} tests, {} assertions, {} skipped", + tests, assertions, skipped + ) + } else { + format!("PHPUnit: {} tests, {} assertions", tests, assertions) + } +} + +fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { + let (tests, assertions, failures_count, _skipped) = parse_counts(output); + + let mut result = format!( + "PHPUnit: {} tests, {} assertions, {} failures\n", + tests, assertions, failures_count + ); + + for failure_lines in failures.iter().take(MAX_FAILURES_SHOWN) { + if let Some(first) = failure_lines.first() { + result.push_str(&format!("\n{}\n", first)); + } + for detail in failure_lines + .iter() + .skip(1) + .take(MAX_DETAIL_LINES_PER_FAILURE) + { + result.push_str(&format!(" {}\n", detail)); + } + } + + if failures.len() > MAX_FAILURES_SHOWN { + result.push_str(&format!( + "\n... +{} more failures\n", + failures.len() - MAX_FAILURES_SHOWN + )); + } + + result.trim().to_string() +} + +fn parse_counts(output: &str) -> (usize, usize, usize, usize) { + let mut tests = 0; + let mut assertions = 0; + let mut failures = 0; + let mut skipped = 0; + + for line in output.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("Tests:") { + continue; + } + + for part in trimmed.split(',') { + let mut it = part.split_whitespace(); + let key = it.next().unwrap_or(""); + let val = it + .next() + .unwrap_or("") + .trim_end_matches('.') + .parse() + .unwrap_or(0); + + match key { + "Tests:" => tests = val, + "Assertions:" => assertions = val, + k if k.starts_with("Failures") || k.starts_with("Errors") => failures += val, + k if k.starts_with("Skipped") => skipped = val, + _ => {} + } + } + } + + (tests, assertions, failures, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REAL_PHPUNIT_FAILURE: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.27 with Xdebug 3.3.1 +Configuration: /var/www/html/phpunit.xml + +........................................ 40 / 40 (100%) +.................................................. 80 / 80 (100%) +.F................................................ 100 / 100 (100%) +.......... 110 / 110 (100%) + +Time: 00:01:23.456, Memory: 48.00 MB + +There was 1 failure: + +1) App\Tests\UserTest::testEmailValidation +Failed asserting that false is true. + +#0 /var/www/html/src/User.php:142 (App\User::validate) +#1 /var/www/html/tests/UserTest.php:38 (App\Tests\UserTest::testEmailValidation) + +FAILURES! +Tests: 110, Assertions: 340, Failures: 1."#; + + const REAL_PHPUNIT_SUCCESS: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.0 + +......... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK (9 tests, 20 assertions)"#; + + const REAL_PHPUNIT_MULTIPLE_FAILURES: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +FF....... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +There were 2 failures: + +1) UserTest::testEmail +Failed asserting that false is true. + +/home/user/tests/UserTest.php:42 + +2) OrderTest::testTotal +Failed asserting that 42 matches expected 100. + +/home/user/tests/OrderTest.php:17 + +FAILURES! +Tests: 9, Assertions: 15, Failures: 2."#; + + #[test] + fn test_phpunit_success() { + let result = filter_phpunit_output(REAL_PHPUNIT_SUCCESS); + assert!(result.contains("PHPUnit"), "got: {}", result); + assert!(result.contains("OK (9 tests, 20 assertions)"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_captures_test_name() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!( + result.contains("UserTest::testEmailValidation"), + "got: {}", + result + ); + assert!( + result.contains("Failed asserting that false is true"), + "got: {}", + result + ); + } + + #[test] + fn test_phpunit_failure_summary_counts() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!(result.contains("110 tests"), "got: {}", result); + assert!(result.contains("340 assertions"), "got: {}", result); + assert!(result.contains("1 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_multiple_failures() { + let result = filter_phpunit_output(REAL_PHPUNIT_MULTIPLE_FAILURES); + assert!(result.contains("UserTest::testEmail"), "got: {}", result); + assert!(result.contains("OrderTest::testTotal"), "got: {}", result); + assert!(result.contains("2 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_ok_with_skipped() { + let output = r#"OK, but incomplete, skipped, or risky tests! +Tests: 5, Assertions: 10, Skipped: 2."#; + let result = filter_phpunit_output(output); + assert!(result.contains("5 tests"), "got: {}", result); + assert!(result.contains("2 skipped"), "got: {}", result); + } + + #[test] + fn test_phpunit_errors_summary() { + let output = r#"There was 1 error: + +1) FooTest::testBar +RuntimeException: boom + +ERRORS! +Tests: 1, Assertions: 0, Errors: 1."#; + let result = filter_phpunit_output(output); + assert!(result.contains("FooTest::testBar"), "got: {}", result); + assert!(result.contains("1 failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_failure_truncation() { + let mut output = String::from("There were 15 failures:\n\n"); + for i in 1..=15 { + output.push_str(&format!( + "{}) Suite::test{}\nFailed asserting thing {}.\n\n", + i, i, i + )); + } + output.push_str("FAILURES!\nTests: 15, Assertions: 15, Failures: 15.\n"); + + let result = filter_phpunit_output(&output); + assert!(result.contains("Suite::test1"), "got: {}", result); + assert!(result.contains("Suite::test10"), "got: {}", result); + assert!(!result.contains("Suite::test11"), "got: {}", result); + assert!(result.contains("+5 more failures"), "got: {}", result); + } + + #[test] + fn test_phpunit_empty_ok_fallback() { + let result = filter_phpunit_output(""); + assert_eq!(result, "PHPUnit: ok"); + } + + #[test] + fn test_phpunit_only_summary_line() { + let result = filter_phpunit_output("Tests: 4, Assertions: 4.\n"); + assert!(result.contains("4 tests"), "got: {}", result); + } + + #[test] + fn test_phpunit_compression() { + let raw_len = REAL_PHPUNIT_FAILURE.len(); + let filtered_len = filter_phpunit_output(REAL_PHPUNIT_FAILURE).len(); + assert!( + filtered_len < raw_len / 2, + "expected >50% reduction, raw={}, filtered={}", + raw_len, + filtered_len + ); + } +} diff --git a/src/cmds/php/pint_cmd.rs b/src/cmds/php/pint_cmd.rs new file mode 100644 index 000000000..397eb8233 --- /dev/null +++ b/src/cmds/php/pint_cmd.rs @@ -0,0 +1,218 @@ +//! Laravel Pint (PHP-CS-Fixer wrapper) output filter. +//! +//! Pint emits verbose per-rule progress and config chatter on its default +//! text output. It also supports `--format=json`, which gives a structured +//! list of files with their applied rules. We inject `--format=json` when +//! the user hasn't picked a format, parse it, and emit a compact summary +//! grouped by file and sorted by rule count. + +use super::utils::php_tool_command; +use crate::core::runner; +use crate::core::utils::fallback_tail; +use anyhow::Result; +use serde::Deserialize; + +const MAX_FILES_SHOWN: usize = 15; +const MAX_RULES_PER_FILE: usize = 5; + +#[derive(Deserialize)] +struct PintOutput { + #[serde(default)] + files: Vec, +} + +#[derive(Deserialize)] +struct PintFile { + // Pint ≥ ~1.14 renamed the JSON keys: name→path, appliedFixers→fixers. + // Aliases keep both schemas parsing so output stays compressed across versions. + #[serde(alias = "path")] + name: String, + #[serde(rename = "appliedFixers", alias = "fixers")] + applied_fixers: Vec, +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = php_tool_command("pint"); + + let has_format = args + .iter() + .any(|a| a == "--format" || a.starts_with("--format=")); + let is_utility_cmd = args + .first() + .map(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) + .unwrap_or(false); + + if !has_format && !is_utility_cmd { + cmd.arg("--format=json"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: pint {}", args.join(" ")); + } + + let filter = move |stdout: &str| -> String { + if has_format || is_utility_cmd { + fallback_tail(stdout, "pint", 60) + } else { + filter_pint_json(stdout) + } + }; + + runner::run_filtered( + cmd, + "pint", + &args.join(" "), + filter, + runner::RunOptions::stdout_only().tee("pint"), + ) +} + +pub(crate) fn filter_pint_json(output: &str) -> String { + let trimmed = output.trim(); + if trimmed.is_empty() { + return "pint: ok".to_string(); + } + + let parsed: Result = serde_json::from_str(trimmed); + let pint = match parsed { + Ok(p) => p, + Err(_) => return fallback_tail(output, "pint (JSON parse error)", 20), + }; + + if pint.files.is_empty() { + return "pint: ok".to_string(); + } + + let total_files = pint.files.len(); + let total_rules: usize = pint.files.iter().map(|f| f.applied_fixers.len()).sum(); + + let mut files = pint.files; + files.sort_by(|a, b| { + b.applied_fixers + .len() + .cmp(&a.applied_fixers.len()) + .then(a.name.cmp(&b.name)) + }); + + let mut result = format!("pint: {} changes in {} files\n", total_rules, total_files); + + for file in files.iter().take(MAX_FILES_SHOWN) { + let name = short_path(&file.name); + result.push_str(&format!("\n{} ({})\n", name, file.applied_fixers.len())); + for rule in file.applied_fixers.iter().take(MAX_RULES_PER_FILE) { + result.push_str(&format!(" - {}\n", rule)); + } + if file.applied_fixers.len() > MAX_RULES_PER_FILE { + result.push_str(&format!( + " ... +{} more rules\n", + file.applied_fixers.len() - MAX_RULES_PER_FILE + )); + } + } + + if files.len() > MAX_FILES_SHOWN { + result.push_str(&format!( + "\n... +{} more files\n", + files.len() - MAX_FILES_SHOWN + )); + } + + result.trim().to_string() +} + +fn short_path(path: &str) -> String { + if let Ok(cwd) = std::env::current_dir() { + if let Ok(cwd_str) = cwd.into_os_string().into_string() { + let with_sep = format!("{}/", cwd_str); + if let Some(rest) = path.strip_prefix(&with_sep) { + return rest.to_string(); + } + } + } + path.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pint_empty_is_ok() { + assert_eq!(filter_pint_json(""), "pint: ok"); + } + + #[test] + fn test_pint_no_files_is_ok() { + assert_eq!(filter_pint_json(r#"{"files":[]}"#), "pint: ok"); + } + + #[test] + fn test_pint_single_file() { + let json = r#"{"files":[{"name":"app/Foo.php","appliedFixers":["no_unused_imports","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("no_unused_imports"), "got: {}", result); + assert!(result.contains("ordered_imports"), "got: {}", result); + } + + #[test] + fn test_pint_current_schema_path_fixers() { + // Pint ≥ ~1.14 emits path/fixers instead of name/appliedFixers. + // Without aliases this fell back to raw output (no compression). + let json = r#"{"result":"fail","files":[{"path":"app/Foo.php","fixers":["concat_space","ordered_imports"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains("2 changes in 1 files"), "got: {}", result); + assert!(result.contains("app/Foo.php (2)"), "got: {}", result); + assert!(result.contains("concat_space"), "got: {}", result); + } + + #[test] + fn test_pint_sorted_by_count_desc() { + let json = r#"{"files":[ + {"name":"a.php","appliedFixers":["x"]}, + {"name":"b.php","appliedFixers":["x","y","z"]}, + {"name":"c.php","appliedFixers":["x","y"]} + ]}"#; + let result = filter_pint_json(json); + let pos_b = result.find("b.php").unwrap(); + let pos_c = result.find("c.php").unwrap(); + let pos_a = result.find("a.php").unwrap(); + assert!(pos_b < pos_c && pos_c < pos_a, "got: {}", result); + } + + #[test] + fn test_pint_file_truncation() { + let mut files = Vec::new(); + for i in 1..=20 { + files.push(format!( + r#"{{"name":"f{}.php","appliedFixers":["x"]}}"#, + i + )); + } + let json = format!(r#"{{"files":[{}]}}"#, files.join(",")); + let result = filter_pint_json(&json); + assert!(result.contains("20 changes in 20 files"), "got: {}", result); + assert!(result.contains("+5 more files"), "got: {}", result); + } + + #[test] + fn test_pint_rule_truncation() { + let json = r#"{"files":[{"name":"f.php","appliedFixers":["a","b","c","d","e","f","g"]}]}"#; + let result = filter_pint_json(json); + assert!(result.contains(" - a\n"), "got: {}", result); + assert!(result.contains(" - e\n"), "got: {}", result); + assert!(!result.contains(" - f\n"), "got: {}", result); + assert!(result.contains("+2 more rules"), "got: {}", result); + } + + #[test] + fn test_pint_invalid_json_falls_back() { + let result = filter_pint_json("Laravel Pint v1.13.6\n\n... some text ..."); + assert!(!result.contains("pint: ok"), "got: {}", result); + } +} diff --git a/src/cmds/php/test_output.rs b/src/cmds/php/test_output.rs new file mode 100644 index 000000000..a77ab8961 --- /dev/null +++ b/src/cmds/php/test_output.rs @@ -0,0 +1,84 @@ +//! Shared compact output filtering for PHP test runners. + +use super::utils::strip_ansi_and_controls; + +fn is_progress_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + + let has_dot = trimmed.contains('.'); + let progress_charset = trimmed.chars().all(|c| { + matches!( + c, + '.' | 'F' | 'E' | 'W' | 'R' | 'S' | 'I' | 'D' | 'N' | 'O' | 'K' | '0' + ..='9' | ' ' | '/' | '%' | '(' | ')' | '-' + ) + }); + + has_dot && progress_charset +} + +pub fn filter_test_runner_output(output: &str) -> String { + let mut lines = Vec::new(); + + for line in strip_ansi_and_controls(output).lines() { + let trimmed = line.trim_end(); + if trimmed.trim().is_empty() { + continue; + } + + if trimmed.starts_with("PHPUnit ") + || trimmed.starts_with("Pest ") + || trimmed.starts_with("ParaTest ") + || trimmed.starts_with("Runtime:") + || trimmed.starts_with("Configuration:") + || trimmed.starts_with("Random Seed:") + { + continue; + } + + if is_progress_line(trimmed) { + continue; + } + + lines.push(trimmed.to_string()); + } + + if lines.is_empty() { + return "ok".to_string(); + } + + if lines.len() > 120 { + let mut reduced = Vec::new(); + reduced.extend(lines.iter().take(80).cloned()); + reduced.push(format!("... +{} more lines", lines.len() - 120)); + reduced.extend(lines.iter().skip(lines.len() - 40).cloned()); + return reduced.join("\n"); + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filters_phpunit_headers_and_progress() { + let output = "PHPUnit 12.2.0\n....\nOK (4 tests, 4 assertions)\n"; + let filtered = filter_test_runner_output(output); + assert!(!filtered.contains("PHPUnit 12.2.0")); + assert!(!filtered.contains("....")); + assert!(filtered.contains("OK (4 tests, 4 assertions)")); + } + + #[test] + fn test_keeps_failures() { + let output = "..F\nThere was 1 failure:\nFailed asserting true is false\n"; + let filtered = filter_test_runner_output(output); + assert!(filtered.contains("There was 1 failure")); + assert!(filtered.contains("Failed asserting true is false")); + } +} diff --git a/src/cmds/php/utils.rs b/src/cmds/php/utils.rs new file mode 100644 index 000000000..dee8b05cf --- /dev/null +++ b/src/cmds/php/utils.rs @@ -0,0 +1,59 @@ +use crate::core::utils::{composer_tool_paths, resolve_binary, resolved_command}; +use lazy_static::lazy_static; +use regex::Regex; +use std::path::Path; +use std::process::Command; + +lazy_static! { + static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap(); + static ref CONTROL_RE: Regex = Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]").unwrap(); +} + +pub fn php_tool_command(tool: &str) -> Command { + for local_tool in composer_tool_paths(tool) { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + if let Ok(resolved_tool) = resolve_binary(&local_tool_name) { + return Command::new(resolved_tool); + } + + if local_tool.exists() { + return Command::new(local_tool); + } + } + + resolved_command(tool) +} + +fn composer_tool_exists(tool: &str) -> bool { + composer_tool_paths(tool).into_iter().any(|local_tool| { + let local_tool_name = local_tool.to_string_lossy().into_owned(); + resolve_binary(&local_tool_name).is_ok() || local_tool.exists() + }) +} + +pub fn strip_ansi_and_controls(input: &str) -> String { + let no_ansi = ANSI_RE.replace_all(input, ""); + CONTROL_RE.replace_all(&no_ansi, "").to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhpTestRunner { + Pest, + Phpunit, + Unknown, +} + +pub fn detect_php_test_runner() -> PhpTestRunner { + if composer_tool_exists("pest") || Path::new("pest.php").exists() { + return PhpTestRunner::Pest; + } + + if composer_tool_exists("phpunit") + || Path::new("phpunit.xml").exists() + || Path::new("phpunit.xml.dist").exists() + { + return PhpTestRunner::Phpunit; + } + + PhpTestRunner::Unknown +} diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 6dcc4cdb6..b26138484 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -26,6 +26,13 @@ pub fn resolve_filter(name: &str) -> Option String> { "ruff-check" => Some(crate::cmds::python::ruff_cmd::filter_ruff_check_json), "ruff-format" => Some(crate::cmds::python::ruff_cmd::filter_ruff_format), "prettier" => Some(crate::cmds::js::prettier_cmd::filter_prettier_output), + "phpunit" => Some(crate::cmds::php::phpunit_cmd::filter_phpunit_output), + "pest" | "paratest" | "php-test" => { + Some(crate::cmds::php::test_output::filter_test_runner_output) + } + "ecs" => Some(crate::cmds::php::ecs_cmd::filter_ecs_output), + "phpstan" => Some(phpstan_wrapper), + "pint" => Some(pint_wrapper), _ => None, } } @@ -46,6 +53,24 @@ fn git_diff_wrapper(input: &str) -> String { crate::cmds::git::git::compact_diff(input, 200) } +fn phpstan_wrapper(input: &str) -> String { + // Runner forces --format=json; piped output may be either JSON or the + // default human table. Pick by content. + if input.trim_start().starts_with('{') { + crate::cmds::php::phpstan_cmd::filter_phpstan_json(input) + } else { + crate::cmds::php::phpstan_cmd::filter_phpstan_text(input) + } +} + +fn pint_wrapper(input: &str) -> String { + if input.trim_start().starts_with('{') { + crate::cmds::php::pint_cmd::filter_pint_json(input) + } else { + crate::core::utils::fallback_tail(input, "pint", 60) + } +} + fn vitest_wrapper(input: &str) -> String { use crate::cmds::js::vitest_cmd::VitestParser; use crate::parser::{FormatMode, OutputParser, TokenFormatter}; @@ -153,6 +178,11 @@ pub fn auto_detect_filter(input: &str) -> fn(&str) -> String { return crate::cmds::python::pytest_cmd::filter_pytest_output; } + // phpunit banner: "PHPUnit X.Y.Z by Sebastian Bergmann and contributors." + if first_1k.contains("by Sebastian Bergmann") { + return crate::cmds::php::phpunit_cmd::filter_phpunit_output; + } + let first_trimmed = first_1k.trim_start(); if first_trimmed.starts_with('{') && first_1k.contains("\"Action\"") { return go_test_wrapper; @@ -230,7 +260,8 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::anyhow!( "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - log, mypy, ruff-check, ruff-format, prettier", + log, mypy, ruff-check, ruff-format, prettier, phpunit, pest, \ + paratest, php-test, ecs, phpstan, pint", name ) })?, @@ -246,6 +277,21 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { mod tests { use super::*; + #[test] + fn test_auto_detect_phpunit_banner() { + // The phpunit banner must route to the phpunit filter with no -f flag. + let input = "PHPUnit 11.0.0 by Sebastian Bergmann and contributors.\n\n\ + ... 3 / 3 (100%)\n\nOK (3 tests, 5 assertions)\n"; + let f = auto_detect_filter(input); + let out = f(input); + assert!(out.starts_with("PHPUnit:"), "out={}", out); + } + + #[test] + fn test_resolve_filter_phpunit() { + assert!(resolve_filter("phpunit").is_some()); + } + #[test] fn test_resolve_filter_cargo_test() { let f = resolve_filter("cargo-test").expect("cargo-test filter must exist"); diff --git a/src/core/utils.rs b/src/core/utils.rs index a3bc84fe0..12e37b757 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -7,6 +7,8 @@ use anyhow::{Context, Result}; use regex::Regex; +use serde_json::Value; +use std::fs; use std::path::PathBuf; use std::process::Command; @@ -355,6 +357,54 @@ pub fn resolved_command(name: &str) -> Command { } } +/// Return Composer bin directories in precedence order. +/// +/// Composer allows overriding the default `vendor/bin` via `COMPOSER_BIN_DIR` +/// or `composer.json` `config.bin-dir`. Keep the default as a fallback so we +/// continue recognizing the common layout even when the repo is not configured. +pub fn composer_bin_dirs() -> Vec { + let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok(); + let composer_json = fs::read_to_string("composer.json").ok(); + composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref()) +} + +pub fn composer_tool_paths(tool: &str) -> Vec { + composer_bin_dirs() + .into_iter() + .map(|dir| dir.join(tool)) + .collect() +} + +fn composer_bin_dirs_from(env_bin_dir: Option<&str>, composer_json: Option<&str>) -> Vec { + let mut dirs = Vec::new(); + + if let Some(dir) = env_bin_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .or_else(|| composer_json.and_then(read_composer_bin_dir)) + { + dirs.push(dir); + } + + let default_dir = PathBuf::from("vendor/bin"); + if !dirs.iter().any(|dir| dir == &default_dir) { + dirs.push(default_dir); + } + + dirs +} + +fn read_composer_bin_dir(composer_json: &str) -> Option { + let parsed: Value = serde_json::from_str(composer_json).ok()?; + let bin_dir = parsed.get("config")?.get("bin-dir")?.as_str()?.trim(); + if bin_dir.is_empty() { + None + } else { + Some(PathBuf::from(bin_dir)) + } +} + /// Check if a tool exists on PATH (PATHEXT-aware on Windows). /// /// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows. @@ -428,6 +478,32 @@ mod tests { assert_eq!(truncate("hello world", 3), "..."); } + #[test] + fn test_composer_bin_dirs_use_default_when_unconfigured() { + assert_eq!( + composer_bin_dirs_from(None, None), + vec![PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_prefer_env_override() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(Some("custom/bin"), Some(composer_json)), + vec![PathBuf::from("custom/bin"), PathBuf::from("vendor/bin")] + ); + } + + #[test] + fn test_composer_bin_dirs_read_composer_config() { + let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#; + assert_eq!( + composer_bin_dirs_from(None, Some(composer_json)), + vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")] + ); + } + #[test] fn test_strip_ansi_simple() { let input = "\x1b[31mError\x1b[0m"; diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 59651a497..c7d7a864b 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1,11 +1,15 @@ //! Matches shell commands against known RTK rewrite rules to decide how to handle them. +use crate::core::utils::composer_bin_dirs; use lazy_static::lazy_static; use regex::{Regex, RegexSet}; +use std::path::Path; use super::lexer::{split_on_operators, tokenize, TokenKind}; use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, RULES}; +const PHP_TOOL_NAMES: [&str; 6] = ["phpunit", "phpstan", "ecs", "pest", "paratest", "pint"]; + /// Result of classifying a command. #[derive(Debug, PartialEq)] pub enum Classification { @@ -120,6 +124,9 @@ pub fn classify_command(cmd: &str) -> Classification { let cmd_normalized = strip_absolute_path(cmd_clean); // Strip git global options: git -C /tmp status → git status (#163) let cmd_normalized = strip_git_global_opts(&cmd_normalized); + // Normalize PHP tool paths: vendor/bin/phpunit, bin/phpunit, or composer + // custom bin-dir → phpunit (so one rule matches every Composer layout). + let cmd_normalized = normalize_php_tool_command(&cmd_normalized); // Strip golangci-lint global options before `run` so classify/rewrite stays // aligned with the runtime wrapper behavior. let cmd_normalized = strip_golangci_global_opts(&cmd_normalized); @@ -247,6 +254,70 @@ pub fn split_command_chain(cmd: &str) -> Vec<&str> { split_on_operators(trimmed, true) } +fn normalize_php_tool_command(cmd: &str) -> String { + normalize_php_tool_command_with_dirs(cmd, &composer_bin_dirs()) +} + +fn normalize_php_tool_command_with_dirs(cmd: &str, bin_dirs: &[std::path::PathBuf]) -> String { + let first_space = cmd.find(char::is_whitespace); + let first_word = match first_space { + Some(pos) => &cmd[..pos], + None => cmd, + }; + + let Some(tool) = normalize_php_tool_word(first_word, bin_dirs) else { + return cmd.to_string(); + }; + + match first_space { + Some(pos) => format!("{}{}", tool, &cmd[pos..]), + None => tool.to_string(), + } +} + +fn normalize_php_tool_word<'a>(word: &str, bin_dirs: &'a [std::path::PathBuf]) -> Option<&'a str> { + let normalized_word = normalize_php_tool_path(word); + + for tool in PHP_TOOL_NAMES { + if normalized_word == tool { + return Some(tool); + } + + if bin_dirs + .iter() + .any(|bin_dir| matches_php_tool_path(&normalized_word, bin_dir, tool)) + { + return Some(tool); + } + } + + None +} + +fn matches_php_tool_path(word: &str, bin_dir: &Path, tool: &str) -> bool { + let normalized_dir = normalize_php_tool_path(&bin_dir.to_string_lossy()); + let candidate = format!("{normalized_dir}/{tool}"); + word == candidate || word.ends_with(&format!("/{candidate}")) +} + +fn normalize_php_tool_path(path: &str) -> String { + let mut normalized = path.trim().replace('\\', "/"); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped.to_string(); + } + + if let Some((stem, ext)) = normalized.rsplit_once('.') { + if ["bat", "cmd", "exe", "ps1"] + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + { + normalized = stem.to_string(); + } + } + + normalized +} + /// Strip git global options before the subcommand (#163). /// `git -C /tmp status` → `git status`, preserving the rest. /// Returns the original string unchanged if not a git command. @@ -4038,7 +4109,7 @@ mod tests { ); } - // --- line-continuation handling (issue #1564) ------------------- + // --- line-continuation handling (issue #1564) --- #[test] fn test_rewrite_leading_backslash_newline() { @@ -4101,4 +4172,177 @@ mod tests { std::borrow::Cow::::Borrowed("git diff HEAD~1"), ); } + + // --- PHP tooling --- + + #[test] + fn test_classify_phpunit() { + assert!(matches!( + classify_command("phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_vendor_bin_phpunit() { + assert!(matches!( + classify_command("vendor/bin/phpunit --filter EmailTest"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_php_vendor_bin_phpunit() { + assert!(matches!( + classify_command("php vendor/bin/phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_rewrite_phpunit() { + assert_eq!( + rewrite_command_no_prefixes("phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_vendor_bin_phpunit() { + assert_eq!( + rewrite_command_no_prefixes("vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_rewrite_dotslash_vendor_bin() { + // `./vendor/bin/` is the common Laravel invocation form. classify + // normalizes the leading `./`, but the rewrite strips literal prefixes, + // so the `./vendor/bin/` prefix must be present or rewrite no-ops. + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pint --test", &[]), + Some("rtk pint --test".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/pest tests/", &[]), + Some("rtk pest tests/".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/paratest", &[]), + Some("rtk paratest".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/ecs check", &[]), + Some("rtk ecs check".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("./vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_classify_phpstan() { + assert!(matches!( + classify_command("vendor/bin/phpstan analyse src/"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_classify_phpstan_direct() { + assert!(matches!( + classify_command("phpstan analyse --level=9"), + Classification::Supported { + rtk_equivalent: "rtk phpstan", + .. + } + )); + } + + #[test] + fn test_rewrite_phpstan_vendor_bin() { + assert_eq!( + rewrite_command_no_prefixes("vendor/bin/phpstan analyse src/", &[]), + Some("rtk phpstan analyse src/".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_php_prefix() { + assert_eq!( + rewrite_command_no_prefixes("php vendor/bin/phpstan analyse", &[]), + Some("rtk phpstan analyse".into()) + ); + } + + #[test] + fn test_rewrite_phpstan_version_not_rewritten() { + assert_eq!(rewrite_command_no_prefixes("phpstan --version", &[]), None); + assert_eq!(rewrite_command_no_prefixes("phpstan list", &[]), None); + assert_eq!( + rewrite_command_no_prefixes("phpstan clear-result-cache", &[]), + None + ); + } + + #[test] + fn test_classify_pest() { + assert!(matches!( + classify_command("vendor/bin/pest tests/"), + Classification::Supported { + rtk_equivalent: "rtk pest", + .. + } + )); + } + + #[test] + fn test_classify_pint() { + assert!(matches!( + classify_command("vendor/bin/pint --test"), + Classification::Supported { + rtk_equivalent: "rtk pint", + .. + } + )); + } + + #[test] + fn test_php_artisan_rewrites() { + assert!(matches!( + classify_command("php artisan migrate"), + Classification::Supported { + rtk_equivalent: "rtk php", + .. + } + )); + } + + #[test] + fn test_normalize_php_tool_command_custom_bin_dir() { + use std::path::PathBuf; + let dirs = vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")]; + assert_eq!( + normalize_php_tool_command_with_dirs("tools/bin/phpunit tests/", &dirs), + "phpunit tests/" + ); + assert_eq!( + normalize_php_tool_command_with_dirs("./tools/bin/pest", &dirs), + "pest" + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 6bb5741c8..36c94e50a 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -521,6 +521,91 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // PHP tooling + RtkRule { + pattern: r"^php\s+artisan(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^php\s+-l(?:\s|$)", + rtk_cmd: "rtk php", + rewrite_prefixes: &["php"], + category: "Build", + savings_pct: 60.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", + rtk_cmd: "rtk phpunit", + rewrite_prefixes: &[ + "php vendor/bin/phpunit", + "php bin/phpunit", + "./vendor/bin/phpunit", + "vendor/bin/phpunit", + "bin/phpunit", + "phpunit", + ], + category: "Tests", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:php\s+)?(?:\.?/?vendor/bin/)?phpstan\s+analy[sz]e\b", + rtk_cmd: "rtk phpstan", + rewrite_prefixes: &[ + "php vendor/bin/phpstan", + "vendor/bin/phpstan", + "./vendor/bin/phpstan", + "phpstan", + ], + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[("analyse", 65.0), ("analyze", 65.0)], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?pest(?:\s|$)", + rtk_cmd: "rtk pest", + rewrite_prefixes: &["./vendor/bin/pest", "vendor/bin/pest", "pest"], + category: "Tests", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?paratest(?:\s|$)", + rtk_cmd: "rtk paratest", + rewrite_prefixes: &["./vendor/bin/paratest", "vendor/bin/paratest", "paratest"], + category: "Tests", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?ecs(?:\s|$)", + rtk_cmd: "rtk ecs", + rewrite_prefixes: &["./vendor/bin/ecs", "vendor/bin/ecs", "ecs"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^(?:vendor/bin/)?pint(?:\s|$)", + rtk_cmd: "rtk pint", + rewrite_prefixes: &["./vendor/bin/pint", "vendor/bin/pint", "pint"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^aws\s+", rtk_cmd: "rtk aws", diff --git a/src/filters/make.toml b/src/filters/make.toml index 63925d438..e781892a0 100644 --- a/src/filters/make.toml +++ b/src/filters/make.toml @@ -6,7 +6,8 @@ strip_lines_matching = [ "^\\s*$", "^Nothing to be done", ] -max_lines = 50 +head_lines = 10 +tail_lines = 40 on_empty = "make: ok" [[tests.make]] @@ -39,3 +40,126 @@ make[1]: Entering directory '/home/user' make[1]: Leaving directory '/home/user' """ expected = "make: ok" + +[[tests.make]] +name = "preserves head + tail summary, drops middle" +input = """ +HEAD line 1 +HEAD line 2 +HEAD line 3 +HEAD line 4 +HEAD line 5 +HEAD line 6 +HEAD line 7 +HEAD line 8 +HEAD line 9 +HEAD line 10 +mid 1 +mid 2 +mid 3 +mid 4 +mid 5 +mid 6 +mid 7 +mid 8 +mid 9 +mid 10 +mid 11 +mid 12 +mid 13 +mid 14 +mid 15 +TAIL line 1 +TAIL line 2 +TAIL line 3 +TAIL line 4 +TAIL line 5 +TAIL line 6 +TAIL line 7 +TAIL line 8 +TAIL line 9 +TAIL line 10 +TAIL line 11 +TAIL line 12 +TAIL line 13 +TAIL line 14 +TAIL line 15 +TAIL line 16 +TAIL line 17 +TAIL line 18 +TAIL line 19 +TAIL line 20 +TAIL line 21 +TAIL line 22 +TAIL line 23 +TAIL line 24 +TAIL line 25 +TAIL line 26 +TAIL line 27 +TAIL line 28 +TAIL line 29 +TAIL line 30 +TAIL line 31 +TAIL line 32 +TAIL line 33 +TAIL line 34 +TAIL line 35 +TAIL line 36 +TAIL line 37 +TAIL line 38 +TAIL line 39 +TAIL line 40 +""" +expected = """ +HEAD line 1 +HEAD line 2 +HEAD line 3 +HEAD line 4 +HEAD line 5 +HEAD line 6 +HEAD line 7 +HEAD line 8 +HEAD line 9 +HEAD line 10 +... (15 lines omitted) +TAIL line 1 +TAIL line 2 +TAIL line 3 +TAIL line 4 +TAIL line 5 +TAIL line 6 +TAIL line 7 +TAIL line 8 +TAIL line 9 +TAIL line 10 +TAIL line 11 +TAIL line 12 +TAIL line 13 +TAIL line 14 +TAIL line 15 +TAIL line 16 +TAIL line 17 +TAIL line 18 +TAIL line 19 +TAIL line 20 +TAIL line 21 +TAIL line 22 +TAIL line 23 +TAIL line 24 +TAIL line 25 +TAIL line 26 +TAIL line 27 +TAIL line 28 +TAIL line 29 +TAIL line 30 +TAIL line 31 +TAIL line 32 +TAIL line 33 +TAIL line 34 +TAIL line 35 +TAIL line 36 +TAIL line 37 +TAIL line 38 +TAIL line 39 +TAIL line 40 +""" diff --git a/src/main.rs b/src/main.rs index a5bde544a..f43f4181c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use cmds::js::{ vitest_cmd, }; use cmds::jvm::{gradlew_cmd, mvn_cmd}; +use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd}; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; @@ -630,7 +631,7 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name (cargo-test, pytest, grep, find, git-log, etc.) + /// Filter name (cargo-test, pytest, phpunit, phpstan, pint, grep, find, git-log, etc.) #[arg(short, long)] filter: Option, @@ -680,6 +681,55 @@ enum Commands { args: Vec, }, + /// PHP command runner with compact output for artisan and syntax checks + Php { + /// PHP arguments (e.g., artisan about, -l app/Http/Controller.php) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// PHPUnit test runner with compact output + Phpunit { + /// PHPUnit arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// PHPStan analyzer with compact output + Phpstan { + /// PHPStan arguments (e.g., analyse src/) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Pest test runner with compact output + Pest { + /// Pest arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// ParaTest parallel test runner with compact output + Paratest { + /// ParaTest arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// EasyCodingStandard (ECS) code style fixer with compact output + Ecs { + /// ECS arguments (e.g., check src/, --fix) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Laravel Pint (PHP-CS-Fixer) code style fixer with compact output + Pint { + /// Pint arguments (e.g., --test, app/) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Rake/Rails test with compact Minitest output (Ruby) Rake { /// Rake arguments (e.g., test, test TEST=path/to/test.rb) @@ -2169,6 +2219,20 @@ fn run_cli() -> Result { Commands::Mypy { args } => mypy_cmd::run(&args, cli.verbose)?, + Commands::Php { args } => php_cmd::run(&args, cli.verbose)?, + + Commands::Phpunit { args } => phpunit_cmd::run(&args, cli.verbose)?, + + Commands::Phpstan { args } => phpstan_cmd::run(&args, cli.verbose)?, + + Commands::Pest { args } => pest_cmd::run(&args, cli.verbose)?, + + Commands::Paratest { args } => paratest_cmd::run(&args, cli.verbose)?, + + Commands::Ecs { args } => ecs_cmd::run(&args, cli.verbose)?, + + Commands::Pint { args } => pint_cmd::run(&args, cli.verbose)?, + Commands::Rake { args } => rake_cmd::run(&args, cli.verbose)?, Commands::Rubocop { args } => rubocop_cmd::run(&args, cli.verbose)?, @@ -2533,6 +2597,13 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Curl { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } + | Commands::Php { .. } + | Commands::Phpunit { .. } + | Commands::Phpstan { .. } + | Commands::Pest { .. } + | Commands::Paratest { .. } + | Commands::Ecs { .. } + | Commands::Pint { .. } | Commands::Rake { .. } | Commands::Rubocop { .. } | Commands::Rspec { .. } diff --git a/tests/fixtures/phpstan_raw.json b/tests/fixtures/phpstan_raw.json new file mode 100644 index 000000000..56eff4709 --- /dev/null +++ b/tests/fixtures/phpstan_raw.json @@ -0,0 +1,380 @@ +{ + "totals": { + "errors": 47, + "file_errors": 47 + }, + "files": { + "/var/www/project/app/Models/User.php": { + "errors": 6, + "messages": [ + { + "message": "Property User::$id (int) does not accept null.", + "line": 15, + "ignorable": false, + "identifier": "property.nonObject", + "tip": "Use ?int if null is a valid value." + }, + { + "message": "Property User::$email (string) does not accept null.", + "line": 18, + "ignorable": false, + "identifier": "property.nonObject", + "tip": null + }, + { + "message": "Method User::find() has no return type specified.", + "line": 45, + "ignorable": true, + "identifier": "missingType.return", + "tip": "Add @return type annotation or declare the return type." + }, + { + "message": "Call to an undefined method Illuminate\\Database\\Eloquent\\Model::unknownMethod().", + "line": 67, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + }, + { + "message": "Parameter #1 $id of method User::find() expects int, string given.", + "line": 89, + "ignorable": false, + "identifier": "argument.type", + "tip": null + }, + { + "message": "Dead catch - Exception is never thrown in the try block.", + "line": 120, + "ignorable": true, + "identifier": "deadCode.catch", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Controllers/UserController.php": { + "errors": 5, + "messages": [ + { + "message": "Parameter $request of method UserController::store() has no type specified.", + "line": 34, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Variable $user might not be defined.", + "line": 56, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Binary operation '+' between string and int results in an error.", + "line": 78, + "ignorable": true, + "identifier": "binaryOp.invalid", + "tip": null + }, + { + "message": "Unreachable statement - code above always terminates.", + "line": 95, + "ignorable": false, + "identifier": "deadCode.unreachable", + "tip": null + }, + { + "message": "Result of method UserRepository::create() (void) is used.", + "line": 112, + "ignorable": false, + "identifier": "return.void", + "tip": null + } + ] + }, + "/var/www/project/app/Services/AuthService.php": { + "errors": 4, + "messages": [ + { + "message": "Method AuthService::login() should return User but returns User|null.", + "line": 23, + "ignorable": false, + "identifier": "return.type", + "tip": null + }, + { + "message": "Comparison operation '>' between int<0, max> and 0 is always true.", + "line": 45, + "ignorable": true, + "identifier": "comparison.alwaysTrue", + "tip": null + }, + { + "message": "Parameter #2 $password of function password_verify expects string, int given.", + "line": 67, + "ignorable": false, + "identifier": "argument.type", + "tip": null + }, + { + "message": "Property AuthService::$logger is never read, only written.", + "line": 89, + "ignorable": true, + "identifier": "deadCode.property", + "tip": null + } + ] + }, + "/var/www/project/app/Repositories/UserRepository.php": { + "errors": 3, + "messages": [ + { + "message": "Access to an undefined property User::$unknownField.", + "line": 28, + "ignorable": false, + "identifier": "property.notFound", + "tip": null + }, + { + "message": "Method UserRepository::all() should return Collection but returns Collection.", + "line": 52, + "ignorable": false, + "identifier": "return.type", + "tip": "Specify the collection type parameter." + }, + { + "message": "Instanceof between User and stdClass will always evaluate to false.", + "line": 78, + "ignorable": false, + "identifier": "instanceof.alwaysFalse", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Middleware/Authenticate.php": { + "errors": 2, + "messages": [ + { + "message": "Method Authenticate::handle() has parameter $next with no type specified.", + "line": 19, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Negated boolean expression is always false.", + "line": 34, + "ignorable": true, + "identifier": "booleanNot.alwaysFalse", + "tip": null + } + ] + }, + "/var/www/project/app/Console/Commands/SyncUsers.php": { + "errors": 3, + "messages": [ + { + "message": "Method SyncUsers::handle() has no return type specified.", + "line": 22, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Variable $users might not be defined.", + "line": 45, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Call to function array_map() with Closure and array|null will result in an error.", + "line": 67, + "ignorable": false, + "identifier": "argument.type", + "tip": null + } + ] + }, + "/var/www/project/app/Events/UserRegistered.php": { + "errors": 2, + "messages": [ + { + "message": "Property UserRegistered::$user has no type specified.", + "line": 12, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + }, + { + "message": "Call to an undefined method Illuminate\\Events\\Dispatcher::dispatch().", + "line": 30, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + } + ] + }, + "/var/www/project/app/Listeners/SendWelcomeEmail.php": { + "errors": 2, + "messages": [ + { + "message": "Parameter $event of method SendWelcomeEmail::handle() has no type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Variable $mailer might not be defined.", + "line": 35, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + } + ] + }, + "/var/www/project/app/Policies/UserPolicy.php": { + "errors": 3, + "messages": [ + { + "message": "Method UserPolicy::view() has no return type specified.", + "line": 25, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method UserPolicy::create() has no return type specified.", + "line": 35, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method UserPolicy::update() has no return type specified.", + "line": 45, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + } + ] + }, + "/var/www/project/app/Observers/UserObserver.php": { + "errors": 2, + "messages": [ + { + "message": "Method UserObserver::created() has parameter $user with no type specified.", + "line": 14, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + }, + { + "message": "Method UserObserver::deleted() has parameter $user with no type specified.", + "line": 24, + "ignorable": false, + "identifier": "missingType.parameter", + "tip": null + } + ] + }, + "/var/www/project/app/Providers/AuthServiceProvider.php": { + "errors": 2, + "messages": [ + { + "message": "Method AuthServiceProvider::boot() has no return type specified.", + "line": 30, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Variable $gate might not be defined.", + "line": 45, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + } + ] + }, + "/var/www/project/app/Jobs/ProcessPayment.php": { + "errors": 4, + "messages": [ + { + "message": "Method ProcessPayment::handle() has no return type specified.", + "line": 35, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Property ProcessPayment::$amount has no type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + }, + { + "message": "Variable $payment might not be defined.", + "line": 58, + "ignorable": false, + "identifier": "variable.undefined", + "tip": null + }, + { + "message": "Call to an undefined method PaymentGateway::charge().", + "line": 72, + "ignorable": false, + "identifier": "method.notFound", + "tip": null + } + ] + }, + "/var/www/project/app/Mail/WelcomeEmail.php": { + "errors": 2, + "messages": [ + { + "message": "Method WelcomeEmail::build() has no return type specified.", + "line": 28, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Property WelcomeEmail::$user has no type specified.", + "line": 15, + "ignorable": false, + "identifier": "missingType.property", + "tip": null + } + ] + }, + "/var/www/project/app/Http/Requests/StoreUserRequest.php": { + "errors": 3, + "messages": [ + { + "message": "Method StoreUserRequest::rules() has no return type specified.", + "line": 18, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Method StoreUserRequest::authorize() has no return type specified.", + "line": 12, + "ignorable": false, + "identifier": "missingType.return", + "tip": null + }, + { + "message": "Strict comparison using === between string and int will always evaluate to false.", + "line": 32, + "ignorable": false, + "identifier": "comparison.strict", + "tip": null + } + ] + } + }, + "errors": [] +}