diff --git a/bin/yerd/src/cli_shim.rs b/bin/yerd/src/cli_shim.rs index 6f1c7955..8bf2836e 100644 --- a/bin/yerd/src/cli_shim.rs +++ b/bin/yerd/src/cli_shim.rs @@ -6,8 +6,14 @@ //! generated ini (`{data}/php-cli-.ini`, which carries the user's global //! settings **and** that version's registered extensions), and `exec`s PHP. //! Pointing `PHPRC` per version is what lets a custom extension load in the CLI, -//! and `PHPRC` (rather than `-d`) is inherited by any child PHP the exec'd one -//! spawns. Unix-only: these wrappers are never created on other platforms. +//! and `PHPRC` (rather than `-d`) is inherited by a child PHP the exec'd one +//! spawns through its absolute interpreter path; a child that resolves `php` from +//! `PATH` re-enters this shim instead and gets its own `PHPRC`. To keep coverage +//! alive across that second kind of hop, the shim honours `YERD_COVER=1` from the +//! environment (exported by the cover shims) by pointing `PHPRC` at the cover ini +//! for the version *it* resolves, falling back to the clean ini with a stderr +//! notice when pcov isn't available. Unix-only: these wrappers are never created +//! on other platforms. use std::os::unix::process::CommandExt as _; use std::path::Path; @@ -59,6 +65,13 @@ fn parse_cli_name(name: &str) -> Option { Some(CliSpec::Version(major, minor)) } +/// Whether `YERD_COVER`'s value turns coverage on. Deliberately narrow: only the +/// exact value `1` counts, so an unset, empty, `0`, or stray value left in a +/// long-lived shell never silently instruments every `php` run. +fn cover_env_enabled(value: Option<&std::ffi::OsStr>) -> bool { + value.is_some_and(|v| v == "1") +} + fn run(spec: &CliSpec) -> ExitCode { let dirs = match ActivePaths::new().resolve() { Ok(d) => d, @@ -70,7 +83,18 @@ fn run(spec: &CliSpec) -> ExitCode { }; let mut cmd = std::process::Command::new(&php_bin); - if let Some(phprc) = cli_phprc(&dirs, &minor) { + let cover_ini = if cover_env_enabled(std::env::var_os("YERD_COVER").as_deref()) { + match crate::cover_shim::prepare_cover_ini(&dirs, &minor) { + Ok(path) => Some(path), + Err(msg) => { + eprintln!("yerd: YERD_COVER=1 ignored ({msg}); running without coverage"); + None + } + } + } else { + None + }; + if let Some(phprc) = cover_ini.or_else(|| cli_phprc(&dirs, &minor)) { cmd.env("PHPRC", phprc); } let err = cmd.args(std::env::args_os().skip(1)).exec(); @@ -129,4 +153,19 @@ mod tests { assert!(parse_cli_name("php8.").is_none()); assert!(parse_cli_name("php.5").is_none()); } + + #[test] + fn cover_env_enabled_only_for_exactly_one() { + for (value, want) in [ + (None, false), + (Some(""), false), + (Some("0"), false), + (Some("true"), false), + (Some("1 "), false), + (Some("1"), true), + ] { + let got = cover_env_enabled(value.map(std::ffi::OsStr::new)); + assert_eq!(got, want, "YERD_COVER={value:?}"); + } + } } diff --git a/bin/yerd/src/cover_shim.rs b/bin/yerd/src/cover_shim.rs index bf28845c..3cf1d4ce 100644 --- a/bin/yerd/src/cover_shim.rs +++ b/bin/yerd/src/cover_shim.rs @@ -6,10 +6,14 @@ //! `PHPRC` at a pcov-augmented copy of Yerd's CLI ini, and `exec`s PHP with //! coverage enabled - leaving the clean `php`/`php` shims untouched. //! `PHPRC` (rather than `-d` flags) is what it is: those flags are process-local, -//! but this env var is inherited by any PHP process the exec'd one spawns in -//! turn (e.g. `artisan test`'s child PHPUnit/Pest/paratest run), so coverage -//! stays enabled across that hop too. Unix-only: cover shims are never created -//! on other platforms. +//! but this env var is inherited by a child PHP the exec'd one spawns through its +//! absolute interpreter path (`PHP_BINARY`, what `artisan test`'s child +//! PHPUnit/Pest/paratest run does), so coverage stays enabled across that hop. A +//! child that instead resolves `php` from `PATH` re-enters Yerd's plain CLI shim, +//! which sets its own `PHPRC`; for that hop these shims also export +//! `YERD_COVER=1`, which the plain shim honours by deriving the cover ini for the +//! version *it* resolves. Unix-only: cover shims are never created on other +//! platforms. use std::ffi::OsString; use std::io::Write as _; @@ -77,44 +81,70 @@ fn run(spec: &CoverSpec, forward: &[OsString]) -> ExitCode { Ok(t) => t, Err(msg) => return fail(msg), }; + let cover_ini_path = match prepare_cover_ini(&dirs, &minor) { + Ok(p) => p, + Err(msg) => return fail(msg), + }; + + let err = Command::new(&php_bin) + .env("PHPRC", &cover_ini_path) + .env("YERD_COVER", "1") + .args(forward) + .exec(); + if err.kind() == std::io::ErrorKind::NotFound { + return fail(format!( + "PHP binary not found at {} ({err}) — reinstall with `yerd install php {minor}`", + php_bin.display() + )); + } + fail(format!("failed to exec {}: {err}", php_bin.display())) +} + +/// Derive the pcov-enabled ini for `minor` and return its path +/// (`{data}/php-ext/php-/cover.ini`), writing it atomically. +/// +/// Shared by the cover shims, which treat an `Err` as fatal, and by the plain +/// CLI shim under `YERD_COVER=1`, which treats it as a reason to run without +/// coverage. Every failure - no `pcov.so` for that version, an unreadable base +/// ini, a path that can't be rendered as an ini value, or a failed write - +/// returns `Err(message)` rather than exiting. A legacy minor gets its own +/// wording, since pcov is never built for < 8.2 and no amount of waiting for the +/// background fetch will produce one. +pub(crate) fn prepare_cover_ini(dirs: &PlatformDirs, minor: &str) -> Result { let ext_dir = dirs.data.join("php-ext").join(format!("php-{minor}")); let pcov = ext_dir.join("pcov.so"); if !pcov.is_file() { - return fail(format!( - "pcov not installed for PHP {minor} — reinstall PHP or wait for the background fetch" - )); + return Err(if crate::shim::minor_is_legacy(minor) { + format!( + "pcov is not built for out-of-support legacy PHP {minor} (< 8.2), so coverage is \ + never available there" + ) + } else { + format!( + "pcov not installed for PHP {minor} — reinstall PHP or wait for the background fetch" + ) + }); } - let base = match crate::shim::cli_phprc(&dirs, &minor) { + let base = match crate::shim::cli_phprc(dirs, minor) { Some(ini) => match std::fs::read_to_string(&ini) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return fail(format!("cannot read Yerd's CLI php.ini: {e}")), + Err(e) => return Err(format!("cannot read Yerd's CLI php.ini: {e}")), }, None => String::new(), }; let Some(cover_ini) = php_settings::render_cover_ini(&base, &pcov) else { - return fail(format!( + return Err(format!( "cannot enable pcov: {} isn't safe to use as an ini value (no control characters, `;`, or `#`, and it must be valid UTF-8) - move Yerd's data directory to a path without those", pcov.display() )); }; let cover_ini_path = ext_dir.join("cover.ini"); if let Err(e) = atomic_write(&cover_ini_path, cover_ini.as_bytes()) { - return fail(format!("cannot write {}: {e}", cover_ini_path.display())); - } - - let err = Command::new(&php_bin) - .env("PHPRC", &cover_ini_path) - .args(forward) - .exec(); - if err.kind() == std::io::ErrorKind::NotFound { - return fail(format!( - "PHP binary not found at {} ({err}) — reinstall with `yerd install php {minor}`", - php_bin.display() - )); + return Err(format!("cannot write {}: {e}", cover_ini_path.display())); } - fail(format!("failed to exec {}: {err}", php_bin.display())) + Ok(cover_ini_path) } /// Write `bytes` to `path` atomically (tempfile in the same directory + @@ -185,16 +215,40 @@ mod tests { assert!(parse_cover_name("phpx.4cover").is_none()); } + fn dirs_at(tmp: &Path) -> PlatformDirs { + PlatformDirs { + config: tmp.join("c"), + data: tmp.join("d"), + state: tmp.join("s"), + cache: tmp.join("ca"), + runtime: tmp.join("r"), + } + } + + /// The plain CLI shim reaches [`prepare_cover_ini`] without a legacy gate in + /// front of it, so the missing-pcov message has to tell a legacy minor the + /// truth rather than sending the user off to wait for a fetch that will + /// never produce a build. + #[test] + fn missing_pcov_message_distinguishes_legacy_from_not_yet_fetched() { + let tmp = tempfile::tempdir().unwrap(); + let dirs = dirs_at(tmp.path()); + + let legacy = prepare_cover_ini(&dirs, "8.1").unwrap_err(); + assert!(legacy.contains("legacy"), "got {legacy}"); + assert!( + !legacy.contains("background fetch"), + "legacy must not promise a fetch, got {legacy}" + ); + + let supported = prepare_cover_ini(&dirs, "8.4").unwrap_err(); + assert!(supported.contains("background fetch"), "got {supported}"); + } + #[test] fn resolve_target_rejects_legacy_before_checking_install() { let tmp = tempfile::tempdir().unwrap(); - let dirs = PlatformDirs { - config: tmp.path().join("c"), - data: tmp.path().join("d"), - state: tmp.path().join("s"), - cache: tmp.path().join("ca"), - runtime: tmp.path().join("r"), - }; + let dirs = dirs_at(tmp.path()); // No 7.4 installed, yet the legacy gate fires first with a pcov message, // not "not installed". match resolve_target(&dirs, &CoverSpec::Version(7, 4)) { diff --git a/bin/yerd/src/shim.rs b/bin/yerd/src/shim.rs index b77f547e..36382a1b 100644 --- a/bin/yerd/src/shim.rs +++ b/bin/yerd/src/shim.rs @@ -11,7 +11,7 @@ use yerd_platform::PlatformDirs; /// Whether a `"major.minor"` minor string names a legacy version (< 8.2). A /// minor that doesn't parse is treated as non-legacy (it will fail elsewhere). -fn minor_is_legacy(minor: &str) -> bool { +pub(crate) fn minor_is_legacy(minor: &str) -> bool { minor.parse::().is_ok_and(PhpVersion::is_legacy) } diff --git a/bin/yerd/tests/cover_shim_e2e.rs b/bin/yerd/tests/cover_shim_e2e.rs index a3e1976a..ab8819c5 100644 --- a/bin/yerd/tests/cover_shim_e2e.rs +++ b/bin/yerd/tests/cover_shim_e2e.rs @@ -3,7 +3,10 @@ //! actual mechanism that lets coverage survive `artisan test`'s child //! PHPUnit/Pest/paratest hop. Covers both front doors that reach the same //! cover-shim logic: the `phpcover` argv[0] shim and the `yerd coverage` -//! subcommand. Spawns the real built `yerd` binary against a fully faked +//! subcommand. Also covers the other hop, where a child resolves `php` from +//! `PATH` and re-enters the plain CLI shim: there the exported `YERD_COVER=1` is +//! what keeps coverage alive, with the child deriving the cover ini for its own +//! PHP version. Spawns the real built `yerd` binary against a fully faked //! `PlatformDirs` layout (a stub shell script standing in for the PHP //! interpreter), rather than calling `cover_shim::dispatch()` in-process, //! because it resolves `ActivePaths::new().resolve()` internally with no @@ -25,68 +28,110 @@ mod tests { use yerd_platform::PlatformDirs; /// A `#!/bin/sh` stand-in for the PHP CLI binary. On every process it prints - /// `phprc=$PHPRC`; on the top-level pass (before the hop) it also prints - /// `args=$*` so a test can assert the launcher forwarded the caller's - /// arguments verbatim. It then re-execs itself once with `--grandchild` - /// (the actual hop under test - a plain re-exec inherits the parent's - /// environment, same as Symfony `Process` spawning `PHPUnit` via + /// `phprc=$PHPRC` and `yerd_cover=$YERD_COVER`; on the top-level pass (before + /// the hop) it also prints `args=$*` so a test can assert the launcher + /// forwarded the caller's arguments verbatim. It then re-execs itself once + /// with `--grandchild` (the actual hop under test - a plain re-exec inherits + /// the parent's environment, same as Symfony `Process` spawning `PHPUnit` via /// `PHP_BINARY`) and exits. The grandchild deliberately carries only /// `--grandchild`, so it exercises `PHPRC` inheritance, not arg forwarding. const STUB_PHP: &str = "#!/bin/sh\n\ printf 'phprc=%s\\n' \"$PHPRC\"\n\ + printf 'yerd_cover=%s\\n' \"$YERD_COVER\"\n\ case \"$1\" in\n\ --grandchild) exit 0 ;;\n\ esac\n\ printf 'args=%s\\n' \"$*\"\n\ exec \"$0\" --grandchild\n"; + /// A stub PHP that prints where its ini came from and exits, with no hop. + /// Stands in for the child interpreter at the far end of a `PATH` hop. + const STUB_PHP_PRINT_ONLY: &str = "#!/bin/sh\n\ + printf 'phprc=%s\\n' \"$PHPRC\"\n"; + + /// A stub PHP that prints its own ini, then spawns `php8.4` resolved from + /// `PATH` - the hop that re-enters Yerd's plain CLI shim rather than + /// inheriting `PHPRC` from an absolute-interpreter spawn. + const STUB_PHP_PATH_HOP: &str = "#!/bin/sh\n\ + printf 'phprc=%s\\n' \"$PHPRC\"\n\ + exec php8.4 --child\n"; + + /// Install a stub PHP CLI binary for `minor`, plus that version's stub + /// `pcov.so`. The binary lands at exactly `shim::cli_binary`'s path + /// (`{data}/php/php-/bin/php`), which is the only location any shim + /// ever execs - a stub written elsewhere would silently exercise nothing. + fn install_stub_php(dirs: &PlatformDirs, minor: &str, body: &str) { + let php_bin_dir = dirs + .data + .join("php") + .join(format!("php-{minor}")) + .join("bin"); + fs::create_dir_all(&php_bin_dir).expect("mkdir php bin"); + let php_bin = php_bin_dir.join("php"); + fs::write(&php_bin, body).expect("write stub php"); + fs::set_permissions(&php_bin, fs::Permissions::from_mode(0o755)).expect("chmod +x"); + + let ext_dir = dirs.data.join("php-ext").join(format!("php-{minor}")); + fs::create_dir_all(&ext_dir).expect("mkdir php-ext"); + fs::write(ext_dir.join("pcov.so"), b"").expect("write stub pcov.so"); + } + /// Build a faked `PlatformDirs` layout under a fresh tempdir: a stub PHP 8.4 /// CLI binary and a stub `pcov.so`. Returns `(tempdir, home, expected cover.ini)`; /// the tempdir is kept alive by the caller. + /// + /// 8.4 must stay the only installed version here: the default-resolution + /// tests rely on it winning `highest_installed`. fn faked_php_8_4_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { let tmp = tempfile::tempdir().expect("tempdir"); let home = tmp.path().join("home"); fs::create_dir_all(&home).expect("mkdir home"); let dirs = PlatformDirs::for_user(&home, 0); - let php_bin_dir = dirs.data.join("php").join("php-8.4").join("bin"); - fs::create_dir_all(&php_bin_dir).expect("mkdir php bin"); - let php_bin = php_bin_dir.join("php"); - fs::write(&php_bin, STUB_PHP).expect("write stub php"); - fs::set_permissions(&php_bin, fs::Permissions::from_mode(0o755)).expect("chmod +x"); - - let ext_dir = dirs.data.join("php-ext").join("php-8.4"); - fs::create_dir_all(&ext_dir).expect("mkdir php-ext"); - fs::write(ext_dir.join("pcov.so"), b"").expect("write stub pcov.so"); + install_stub_php(&dirs, "8.4", STUB_PHP); - let expected_phprc = ext_dir.join("cover.ini"); + let expected_phprc = dirs.data.join("php-ext").join("php-8.4").join("cover.ini"); (tmp, home, expected_phprc) } /// Invoke `program` with `args` under the faked home's XDG environment and - /// return its captured output. - fn run_in_home( + /// return its captured output. The environment is otherwise cleared, so a + /// test that needs `YERD_COVER` or a `PATH` passes it in `extra_env`. + fn run_in_home_with_env( program: &std::path::Path, args: &[&str], home: &std::path::Path, + extra_env: &[(&str, &str)], ) -> std::process::Output { - Command::new(program) - .args(args) + let mut cmd = Command::new(program); + cmd.args(args) .env_clear() .env("HOME", home) .env("XDG_DATA_HOME", home.join(".local").join("share")) .env("XDG_CONFIG_HOME", home.join(".config")) .env("XDG_STATE_HOME", home.join(".local").join("state")) - .env("XDG_CACHE_HOME", home.join(".cache")) - .output() - .expect("run yerd") + .env("XDG_CACHE_HOME", home.join(".cache")); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.output().expect("run yerd") } - /// Assert the stub PHP saw `PHPRC` pointing at the cover ini on both the - /// top-level process and its re-exec'd grandchild (coverage surviving the - /// hop), that the cover ini was written, and that `expected_args` reached the - /// top-level PHP verbatim - i.e. the launcher forwarded the caller's args and - /// leaked no shim or subcommand name into them. + /// [`run_in_home_with_env`] with nothing beyond the faked home's XDG vars. + fn run_in_home( + program: &std::path::Path, + args: &[&str], + home: &std::path::Path, + ) -> std::process::Output { + run_in_home_with_env(program, args, home, &[]) + } + + /// Assert the stub PHP saw `PHPRC` pointing at the cover ini and + /// `YERD_COVER=1` exported on both the top-level process and its re-exec'd + /// grandchild (coverage surviving the hop), that the cover ini was written, + /// and that `expected_args` reached the top-level PHP verbatim - i.e. the + /// launcher forwarded the caller's args and leaked no shim or subcommand name + /// into them. fn assert_cover_run( output: &std::process::Output, expected_phprc: &std::path::Path, @@ -108,6 +153,15 @@ mod tests { vec![want_phprc, want_phprc], "PHPRC must be identical across the re-exec hop (top-level process and its grandchild)" ); + let cover: Vec<&str> = stdout + .lines() + .filter_map(|l| l.strip_prefix("yerd_cover=")) + .collect(); + assert_eq!( + cover, + vec!["1", "1"], + "the cover launcher must export YERD_COVER=1 into the PHP process tree" + ); let args: Vec<&str> = stdout .lines() .filter_map(|l| l.strip_prefix("args=")) @@ -205,4 +259,124 @@ mod tests { ); assert_cover_run(&output, &expected_phprc, "artisan test --coverage"); } + + /// Collect the `phprc=` values the stub PHP printed, in order. + fn phprc_lines(output: &std::process::Output) -> Vec { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|l| l.strip_prefix("phprc=").map(str::to_owned)) + .collect() + } + + /// With no cover shim anywhere in the picture, the plain `php` shim honours + /// `YERD_COVER=1` from the environment by deriving the cover ini itself. + #[test] + fn plain_php_shim_derives_a_cover_ini_under_yerd_cover() { + let (tmp, home, expected_phprc) = faked_php_8_4_layout(); + + let php_shim = tmp.path().join("php"); + symlink(env!("CARGO_BIN_EXE_yerd"), &php_shim).expect("symlink php shim"); + + let output = run_in_home_with_env(&php_shim, &["--version"], &home, &[("YERD_COVER", "1")]); + assert!( + output.status.success(), + "plain php shim failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let want = expected_phprc.to_str().expect("utf8 path"); + assert_eq!( + phprc_lines(&output), + vec![want, want], + "the plain shim must point PHPRC at the cover ini it derived" + ); + let ini = fs::read_to_string(&expected_phprc).expect("read cover.ini"); + assert!( + ini.contains("pcov.enabled = 1"), + "cover ini must enable pcov, got: {ini}" + ); + } + + /// The `PATH` hop from issue #221, across two PHP minors: `php8.5cover` execs + /// the 8.5 interpreter, which spawns `php8.4` resolved from `PATH`. That child + /// re-enters the plain CLI shim, which must derive the cover ini for ITS OWN + /// version (8.4's, built from 8.4's ABI-specific `pcov.so`) rather than + /// inheriting or reusing the parent's 8.5 one. + #[test] + fn path_hop_child_derives_the_cover_ini_for_its_own_version() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).expect("mkdir home"); + let dirs = PlatformDirs::for_user(&home, 0); + + install_stub_php(&dirs, "8.5", STUB_PHP_PATH_HOP); + install_stub_php(&dirs, "8.4", STUB_PHP_PRINT_ONLY); + + let path_dir = tmp.path().join("path"); + fs::create_dir_all(&path_dir).expect("mkdir path dir"); + symlink(env!("CARGO_BIN_EXE_yerd"), path_dir.join("php8.4")).expect("symlink php8.4 shim"); + + let cover_shim_bin = tmp.path().join("php8.5cover"); + symlink(env!("CARGO_BIN_EXE_yerd"), &cover_shim_bin).expect("symlink cover shim"); + + let path = format!("{}:/usr/bin:/bin", path_dir.display()); + let output = run_in_home_with_env( + &cover_shim_bin, + &["artisan", "test"], + &home, + &[("PATH", path.as_str())], + ); + assert!( + output.status.success(), + "cross-version PATH hop failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let parent_ini = dirs.data.join("php-ext").join("php-8.5").join("cover.ini"); + let child_ini = dirs.data.join("php-ext").join("php-8.4").join("cover.ini"); + assert_eq!( + phprc_lines(&output), + vec![ + parent_ini.to_str().expect("utf8 path"), + child_ini.to_str().expect("utf8 path"), + ], + "the child must use its own version's cover ini, not the parent's" + ); + } + + /// `YERD_COVER=1` with no `pcov.so` for the resolved version must not fail: + /// the shim prints one notice and runs PHP on the normal per-version ini + /// (here unset, since the faked layout generates no CLI ini). + #[test] + fn yerd_cover_falls_back_with_a_notice_when_pcov_is_missing() { + let (tmp, home, expected_phprc) = faked_php_8_4_layout(); + fs::remove_file(expected_phprc.with_file_name("pcov.so")).expect("remove stub pcov.so"); + + let php_shim = tmp.path().join("php"); + symlink(env!("CARGO_BIN_EXE_yerd"), &php_shim).expect("symlink php shim"); + + let output = run_in_home_with_env(&php_shim, &["--version"], &home, &[("YERD_COVER", "1")]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "the fallback must still run PHP, got: {stderr}" + ); + let notices: Vec<&str> = stderr + .lines() + .filter(|l| l.contains("YERD_COVER")) + .collect(); + assert_eq!(notices.len(), 1, "expected one notice line, got: {stderr}"); + assert!( + notices[0].contains("pcov") && notices[0].contains("coverage"), + "the notice must name pcov and coverage, got: {stderr}" + ); + assert_eq!( + phprc_lines(&output), + vec!["", ""], + "PHPRC must fall back to the normal CLI ini path" + ); + assert!( + !expected_phprc.exists(), + "no cover ini may be written when pcov is missing" + ); + } } diff --git a/docs/developer/binaries/yerd.md b/docs/developer/binaries/yerd.md index f908352b..6665292d 100644 --- a/docs/developer/binaries/yerd.md +++ b/docs/developer/binaries/yerd.md @@ -44,7 +44,7 @@ the `tests/cli_e2e.rs` integration test can drive the same code paths. | `src/wp_shim.rs` | Multi-call dispatch: when `argv[0]` is `wp`, exec WP-CLI - site-aware if cwd is inside a registered site (pins that site's PHP, scopes via `--path=`) (Unix-only). Runs before clap. | | `src/laravel_shim.rs` | Multi-call dispatch: when `argv[0]` is `laravel`, exec the Laravel installer under the resolved PHP (Unix-only). Runs before clap. | | `src/shim.rs` | Shared PHP-resolution helpers for the cover, composer, `wp`, and `laravel` multi-call shims (default-version resolution, highest-installed fallback). | -| `src/cli_shim.rs` | Multi-call dispatch: when `argv[0]` is `php` or `php.`, exec that version's PHP CLI with `PHPRC` pointed at its generated ini (Unix-only). Runs before clap. | +| `src/cli_shim.rs` | Multi-call dispatch: when `argv[0]` is `php` or `php.`, exec that version's PHP CLI with `PHPRC` pointed at its generated ini (Unix-only). Runs before clap. If the environment carries exactly `YERD_COVER=1` it instead derives *its own* resolved version's pcov-enabled `cover.ini` (via `cover_shim::prepare_cover_ini`), falling back to the clean ini with a stderr notice when pcov isn't available. | | `src/site_scope.rs` | Resolve the site owning a directory (or a `--site` name) to its pinned PHP version - the shared lookup behind `exec`, `which`, and the site-aware `wp` shim. | | `src/exec_cmd.rs` | `yerd exec` / `yerd which`: run a tool under a site's pinned PHP, or print the binary that would be used (local exec, no IPC beyond the site lookup; Unix-only). | | `src/path_cmd.rs` | `yerd path install`/`uninstall`/`print`: edit the user's shell startup file to add `{data}/bin` to `PATH` (local, no IPC; Unix-only). | @@ -118,8 +118,9 @@ the target means "all three". maintains a set of *cover* symlinks - `phpcover` (the default PHP version) and `php.cover` (e.g. `php8.4cover`) - each pointing back at the `yerd` binary itself. Running one of those names makes `yerd` behave as a thin PHP -wrapper that turns coverage on, while the clean `php` / `php` shims stay -untouched so ordinary PHP carries **no coverage overhead**. +wrapper that turns coverage on, while the `php` / `php` shims stay clean so +ordinary PHP carries **no coverage overhead** - unless they are told otherwise by +the `YERD_COVER=1` the cover shims export, described below. This is dispatched in `main.rs` **before clap ever parses**: @@ -156,17 +157,34 @@ if let Some(code) = yerd::cover_shim::dispatch() { `ExitCode::FAILURE`. `PHPRC` rather than `-d` flags is deliberate: `-d` only affects the exec'd -process itself, but `PHPRC` is an environment variable, so it's inherited by -any PHP process that process spawns in turn (e.g. `php artisan test`'s child -PHPUnit/Pest/paratest run, launched via `PHP_BINARY` by a Symfony `Process` -that inherits the parent's environment) - which is what actually needs pcov -loaded to produce a coverage report. Setting `PHPRC` on the `Command` overrides -that one key and leaves the rest of the environment inherited (no -`env_clear()` is called), so if the caller's shell already has its own `PHPRC` -(for example from the `yerd path install` rc-block, which points the plain -`php` shim at `{data}/php-cli.ini`), the cover shim's value wins for the +process itself, but `PHPRC` is an environment variable, so it's inherited by any +PHP process that process spawns **by absolute interpreter path** (e.g. `php +artisan test`'s child PHPUnit/Pest/paratest run, launched via `PHP_BINARY` by a +Symfony `Process` that inherits the parent's environment) - which is what +actually needs pcov loaded to produce a coverage report. Setting `PHPRC` on the +`Command` overrides that one key and leaves the rest of the environment +inherited (no `env_clear()` is called), so if the caller's shell already has its +own `PHPRC` (for example from the `yerd path install` rc-block, which points the +plain `php` shim at `{data}/php-cli.ini`), the cover shim's value wins for the exec'd process - intentional, not a conflict to resolve. +Inheritance alone is not enough, though: a child that resolves `php` from `PATH` +(a `#!/usr/bin/env php` shebang, phpunit-watcher, a `Process` handed a bare +`php`) re-enters `cli_shim`, which sets its **own** `PHPRC` and so clobbers the +inherited cover ini. The cover shims therefore also set `YERD_COVER=1` on the +`Command`. `cli_shim` honours that flag - exact value `1` only - by calling +`cover_shim::prepare_cover_ini` for the minor *it* just resolved, so the child +loads the pcov build matching its own PHP version rather than reusing the +parent's ABI-specific `pcov.so`. When that derivation fails (no `pcov.so` for +the resolved version, including every legacy minor) `cli_shim` prints one +`yerd:` notice on stderr and execs with the clean per-version ini, so an +inherited `YERD_COVER` can never break an unrelated `php` invocation. The `wp` +shim and `yerd exec` do not participate: they still point `PHPRC` at +`cli_phprc`'s clean per-version ini. `composer_shim` and `laravel_shim` set no +`PHPRC` at all, so they inherit the caller's - which means a `composer` nested +inside a cover shim keeps the parent's cover ini while resolving the *default* +PHP version, and loads a mismatched `pcov.so` when those versions differ. + The same logic also backs the `yerd coverage ` **subcommand**, which reaches it from the other direction. Rather than being keyed on `argv[0]`, `lib.rs::run` intercepts `Command::Coverage` locally (like `elevate`/`path`, diff --git a/docs/guide/code-coverage.md b/docs/guide/code-coverage.md index 901e130b..93be42a1 100644 --- a/docs/guide/code-coverage.md +++ b/docs/guide/code-coverage.md @@ -18,11 +18,13 @@ for a specific one. They live in the same `{data}/bin` directory as the regular (default PHP + pcov); use a `phpcover` shim when you need to pin coverage to a specific version. -::: info Zero overhead by default -The plain `php` and `php` shims **never** load pcov, so normal CLI -scripts and your `.test` sites run with no coverage instrumentation. pcov is -loaded only when you invoke a `…cover` shim - coverage is strictly opt-in, -per command. +::: info Zero overhead unless you ask for it +The plain `php` and `php` shims don't load pcov on their own, so normal +CLI scripts and your `.test` sites run with no coverage instrumentation. pcov is +loaded when you invoke a `…cover` shim, or when the environment carries +[`YERD_COVER=1`](#enabling-coverage-with-yerd-cover) - which those shims export +so nested PHP runs keep coverage, and which you can set yourself. Either way +coverage is opt-in: nothing loads pcov unless something asked for it. ::: ## Running tests with coverage @@ -51,10 +53,17 @@ passed to PHP rather than producing a JSON response. ::: Each cover shim points `PHPRC` at a pcov-enabled copy of Yerd's CLI ini, then -hands off to your script. Because `PHPRC` is an environment variable rather -than a CLI flag, it's inherited by any PHP process your script spawns in -turn - which is what makes `artisan test`'s child PHPUnit/Pest/paratest run -see a working coverage driver too, not just the top-level `artisan` process. +hands off to your script. Because `PHPRC` is an environment variable rather than +a CLI flag, it is inherited by any PHP process your script spawns **by absolute +interpreter path** - the `PHP_BINARY` style used by Symfony's `Process`, +paratest, and `artisan test`'s child PHPUnit/Pest run, so those see the coverage +driver too, not just the top-level `artisan` process. + +A child that resolves `php` from your `PATH` instead - a `#!/usr/bin/env php` +shebang, phpunit-watcher, a `Process` handed a bare `php` - re-enters Yerd's own +`php` shim, which sets its own `PHPRC` and would otherwise drop the coverage +driver on the way through. For those, the cover shims also export +`YERD_COVER=1`, described next. ::: tip Add the shim dir to your PATH The cover shims sit in the same `{data}/bin` directory as `php` (Yerd prints the @@ -63,6 +72,46 @@ available everywhere, right next to the version shims described in [PHP Versions](./php-versions). ::: +## Enabling coverage with `YERD_COVER` + +`yerd coverage` and the `phpcover` / `phpcover` shims set +`YERD_COVER=1` in the environment of the PHP process they exec, and every +process that one spawns inherits it. The plain `php` and `php` shims +read it: when the environment carries exactly `1`, the shim loads pcov for the +PHP version **it** resolves, rather than the clean per-version ini it would +normally use. Coverage therefore survives a `PATH` hop, and a child running on a +different PHP version gets that version's own pcov build. + +You can also set it yourself, which is the answer for test runners that spawn +their own PHP processes instead of being launched under a cover shim: + +```sh +# Pest / PHPUnit watch mode - the watcher spawns the php runs itself +YERD_COVER=1 vendor/bin/phpunit-watcher watch + +# Or turn it on for a whole shell session +export YERD_COVER=1 +``` + +::: warning Exactly `1`, nothing else +Only the literal value `1` enables coverage. Unset, empty, `0`, `true` or any +other value means off, so a stray `YERD_COVER` can't quietly instrument +everything you run. +::: + +Two limits worth knowing: + +- **It applies to the `php` and `php` shims.** The `wp` shim and `yerd + exec` point `PHPRC` at the clean per-version ini regardless, so they do not + pick up `YERD_COVER`. The `composer` and `laravel` shims set no `PHPRC` of + their own at all: they simply inherit whatever the environment carries, so a + `composer` run nested inside a cover shim keeps the *parent's* cover ini even + though it resolves your default PHP version. +- **It never fails your command.** If pcov isn't available for the version the + shim resolves - a [legacy version](./php-versions#legacy-php-versions), or a + build that hasn't been fetched yet - the shim prints a one-line notice on + stderr and runs your command as normal, without coverage. + ## Automatic, per version You don't install or enable anything. Whenever you install a PHP version, Yerd diff --git a/docs/reference/cli/coverage.md b/docs/reference/cli/coverage.md index 37d61b66..40ed6bfb 100644 --- a/docs/reference/cli/coverage.md +++ b/docs/reference/cli/coverage.md @@ -52,8 +52,41 @@ belong to your script or test runner, not to `yerd`: daemon response, and `--json` has no effect. This is the one command where the "`--json` on every command" note in the [overview](./) does not apply. +## Environment + +The three coverage front doors - `yerd coverage`, `phpcover` and +`phpcover` - set `YERD_COVER=1` in the environment of the PHP process +they `exec`, and every process it spawns inherits it. + +| Variable | Value | Effect | +| --- | --- | --- | +| `YERD_COVER` | `1` | The plain `php` / `php` shims load pcov for the version **they** resolve, instead of the clean per-version ini. | +| `YERD_COVER` | unset, empty, `0`, anything else | No effect - the plain shims behave normally. | + +Only the literal value `1` enables coverage; the reading is deliberately narrow +so a stray value can't silently instrument every PHP run. Set it yourself for +test runners that spawn their own PHP processes rather than running under a +cover shim: + +```sh +YERD_COVER=1 vendor/bin/phpunit-watcher watch +``` + +If pcov isn't available for the version a plain shim resolves - a legacy version, +or a build not fetched yet - the shim prints a one-line notice on stderr and +**runs the command normally, without coverage**. It does not fail. Only the +`php` / `php` shims honour the variable: the `wp` shim and `yerd exec` +set their own `PHPRC` regardless, while the `composer` and `laravel` shims set +none and inherit whatever the environment already carries. See +[Code Coverage · Enabling coverage with `YERD_COVER`](../../guide/code-coverage#enabling-coverage-with-yerd-cover). + ## Failure modes +These apply to the coverage front doors - `yerd coverage` and the `phpcover` / +`phpcover` shims - which fail rather than run without coverage. They do +**not** describe the `YERD_COVER` path above, where a plain shim falls back to a +normal run instead. + - If the resolved default version has no published pcov build for your OS and architecture yet, `yerd coverage` reports that pcov isn't installed for that version rather than running without coverage. The background fetch is