Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30fe7a4
feat(php): consolidated PHP tooling (php, artisan, phpunit, phpstan, …
iliaal Apr 30, 2026
c37eed9
fix(test): adapt new rewrite_command signature in php-tooling tests
iliaal May 17, 2026
214a79a
feat(pipe): expose PHP tool filters as stdin pipe filters
EliW Jun 18, 2026
0b75581
fix(php): align pint/phpstan parsers with current tool schemas
iliaal Jun 22, 2026
88e56ce
fix(php): rewrite ./vendor/bin/<tool> form for phpunit/pest/paratest/…
iliaal Jun 23, 2026
4a37246
fix(php): classify php subcommands in the PASSTHROUGH list
iliaal Jun 26, 2026
8eae6b7
fix(php): route php_tool_command through resolved_command
iliaal Jun 26, 2026
07c231e
fix(php): address phpstan review feedback (resolution, text fallback,…
iliaal Jun 27, 2026
128d04f
fix(php): anchor phpunit failure-heading detection to the "N) " format
iliaal Jun 27, 2026
b5c3f7f
perf(php): resolve cwd once in pint output instead of per file
iliaal Jun 27, 2026
6117bb4
refactor(php): standardize tool regexes and normalize invocation in r…
iliaal Jun 28, 2026
0cc15dc
fix(php): default pint applied_fixers when key is absent
iliaal Jun 29, 2026
8825480
fix(php): strip ANSI in phpunit filter and split errors from failures
iliaal Jun 30, 2026
7cc46b4
fix(php): detect phpstan analyse after global flags; avoid duplicate …
iliaal Jun 30, 2026
28366ce
fix(php): drop bogus pest.php check in test-runner detection
iliaal Jun 30, 2026
50a8743
fix(pipe): anchor phpunit auto-detection to the leading banner
iliaal Jun 30, 2026
5d2928c
perf(php): cache composer_bin_dirs to avoid per-segment file reads
iliaal Jun 30, 2026
fe75595
test(php): add paratest unit tests to satisfy test-presence gate
iliaal Jul 1, 2026
5e34f61
Merge remote-tracking branch 'origin/develop' into feat/php-tooling
iliaal Jul 1, 2026
d67fc28
test(php): add token-savings assertion to paratest per cli-testing guide
iliaal Jul 1, 2026
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");
108 changes: 108 additions & 0 deletions src/cmds/php/paratest_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! 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(),
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_paratest_strips_banner_seed_and_progress() {
// ParaTest prints its own banner and a "Random Seed:" line on top of
// PHPUnit-style dot progress; only the result summary should survive.
let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\
Random Seed: 1234567890\n\
.......... 10 / 10 (100%)\n\n\
OK (10 tests, 25 assertions)\n";
let filtered = filter_test_runner_output(output);
assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered);
assert!(!filtered.contains("Random Seed:"), "got: {}", filtered);
assert!(!filtered.contains("10 / 10 (100%)"), "got: {}", filtered);
assert!(
filtered.contains("OK (10 tests, 25 assertions)"),
"got: {}",
filtered
);
}

#[test]
fn test_paratest_keeps_failures() {
let output = "ParaTest v7.3.0 upon PHPUnit 10.5.0\n\
..F.\n\
There was 1 failure:\n\
1) App\\Tests\\UserTest::testEmail\n\
Failed asserting that false is true.\n";
let filtered = filter_test_runner_output(output);
assert!(!filtered.contains("ParaTest v7.3.0"), "got: {}", filtered);
assert!(
filtered.contains("App\\Tests\\UserTest::testEmail"),
"got: {}",
filtered
);
assert!(
filtered.contains("Failed asserting that false is true."),
"got: {}",
filtered
);
}

#[test]
fn test_paratest_token_savings() {
use crate::core::utils::count_tokens;

// A realistic passing run: banner + config + many parallel-worker
// progress lines, collapsing to a one-line summary.
let mut output = String::from(
"ParaTest v7.3.0 upon PHPUnit 10.5.0 by Sebastian Bergmann and contributors.\n\
Runtime: PHP 8.3.10\n\
Configuration: /var/www/html/phpunit.xml\n\
Random Seed: 1234567890\n\n",
);
for i in 1..=40 {
output.push_str(&format!(
".......................................... {} / 40 ({}%)\n",
i,
i * 100 / 40
));
}
output.push_str("\nOK (400 tests, 1200 assertions)\n");

let filtered = filter_test_runner_output(&output);
let savings =
100.0 - (count_tokens(&filtered) as f64 / count_tokens(&output) as f64 * 100.0);
assert!(
savings >= 60.0,
"expected ≥60% savings, got {:.1}%\n{}",
savings,
filtered
);
}
}
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"));
}
}
Loading
Loading