Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cmds/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/cmds/php/README.md
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions src/cmds/php/artisan_cmd.rs
Original file line number Diff line number Diff line change
@@ -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)"));
}
}
81 changes: 81 additions & 0 deletions src/cmds/php/ecs_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<i32> {
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"));
}
}
1 change: 1 addition & 0 deletions src/cmds/php/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
automod::dir!(pub "src/cmds/php");
31 changes: 31 additions & 0 deletions src/cmds/php/paratest_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<i32> {
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(),
)
}
45 changes: 45 additions & 0 deletions src/cmds/php/pest_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<i32> {
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"));
}
}
114 changes: 114 additions & 0 deletions src/cmds/php/php_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<i32> {
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"));
}
}
Loading