From 9100187fc86b8e53766bc000c72de4e509d64673 Mon Sep 17 00:00:00 2001 From: Ravuthz Date: Tue, 3 Nov 2026 22:17:52 +0700 Subject: [PATCH] feat(php): PHPUnit and Composer support and a PHP interpreter setting (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer projects and PHPUnit suites are detected (Composer scripts, PHPUnit, artisan serve); PHPUnit runs report into the test tree through --teamcity with gutter runs for classes and methods, file-level runs, rerun failed and a PHPUnit run configuration type. Settings › Languages & Frameworks › PHP selects the CLI interpreter (discovered on PATH, Homebrew, phpbrew, phpenv, Herd, XAMPP, Laragon); it is applied to run configurations and PHP language servers through PATH. TeamCity service messages feed only the test tree and are kept out of the run console, sharing the stdout decoder go test -json uses. Co-authored-by: ravuthz <11207890+ravuthz@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 8 + crates/ide-run/src/detect.rs | 58 ++++++ crates/ide-run/tests/detect.rs | 34 +++ crates/ide-sdk/src/apply.rs | 20 ++ crates/ide-sdk/src/discover.rs | 91 +++++++- crates/ide-sdk/src/lib.rs | 2 + crates/ide-sdk/src/tests.rs | 33 +++ src/bindings.ts | 8 + src/features/run/configs.ts | 5 + src/features/run/context.ts | 5 + src/features/run/phpunitTypes.ts | 77 +++++++ src/features/run/store.ts | 25 ++- src/features/run/targets.test.ts | 41 ++++ src/features/run/targets.ts | 39 +++- src/features/run/testing/go.ts | 27 +-- src/features/run/testing/index.ts | 21 +- src/features/run/testing/phpunit.test.ts | 156 ++++++++++++++ src/features/run/testing/phpunit.ts | 197 ++++++++++++++++++ src/features/run/testing/rerun.ts | 4 + src/features/run/testing/types.ts | 28 +++ src/features/run/types.ts | 7 +- .../settings/__tests__/languages.test.ts | 1 + .../settings/languages/LanguagePages.tsx | 28 ++- src/features/settings/pages/languages.tsx | 24 ++- src/platform/languageSettings.ts | 4 + src/platform/locales/text/zh.json | 16 +- src/platform/locales/textCatalog.test.ts | 7 + 27 files changed, 925 insertions(+), 41 deletions(-) create mode 100644 src/features/run/phpunitTypes.ts create mode 100644 src/features/run/testing/phpunit.test.ts create mode 100644 src/features/run/testing/phpunit.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f93bf1d6..69542e59 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -543,6 +543,7 @@ impl Default for FileSettings { pub struct LanguagesSettings { pub python: PythonSettings, pub node: NodeSettings, + pub php: PhpSettings, pub rust: RustSettings, pub go: GoSettings, pub typescript: TypeScriptSettings, @@ -615,6 +616,13 @@ impl Default for PythonSettings { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct PhpSettings { + /// `php` CLI executable; empty = the one on `PATH`. + pub interpreter: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct RubySettings { diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 7e60b2fd..50bce396 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -33,6 +33,7 @@ pub(crate) fn detect_with_profiles( detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); detect_python(root, &mut out); + detect_php(root, &mut out); detect_ruby(root, &mut out); cmake::detect(root, profiles, &mut out); dotnet::detect(root, &mut out); @@ -906,6 +907,63 @@ fn detect_python(root: &Path, out: &mut Vec) { } } +// ---------------------------------------------------------------- php + +/// Composer script names that are lifecycle events Composer fires itself, not commands to run. +fn is_composer_event(name: &str) -> bool { + name.starts_with("pre-") || name.starts_with("post-") || name == "command" +} + +/// The PHPUnit entry point: the project's `vendor/bin/phpunit`, else a global `phpunit`. +fn phpunit_command(root: &Path) -> String { + let bin = if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" }; + if root.join(bin).is_file() { bin.to_owned() } else { "phpunit".to_owned() } +} + +/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`). +fn detect_php(root: &Path, out: &mut Vec) { + let composer = read_json(&root.join("composer.json")); + let phpunit_xml = first_existing(root, &["phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"]); + if composer.is_none() && phpunit_xml.is_none() { + return; + } + let requires = |name: &str| { + composer.as_ref().is_some_and(|c| { + ["require", "require-dev"] + .iter() + .any(|k| c.get(k).and_then(Json::as_object).is_some_and(|m| m.contains_key(name))) + }) + }; + if root.join("artisan").is_file() { + out.push(config("php:artisan-serve", "artisan serve", "php", "php", &["artisan", "serve"]).debug("php-debug")); + } + let scripts = composer.as_ref().and_then(|c| c.get("scripts")).and_then(Json::as_object); + for name in scripts.into_iter().flat_map(|m| m.keys()) { + if is_composer_event(name) { + continue; + } + let c = config( + format!("composer:{name}"), + format!("composer {name}"), + "composer", + "composer", + &["run-script", name], + ); + out.push(if name == "test" { c.test() } else { c }); + } + if phpunit_xml.is_some() || requires("phpunit/phpunit") || root.join("vendor/bin/phpunit").is_file() { + let mut c = config("phpunit", "PHPUnit", "phpunit", &phpunit_command(root), &[]).test().debug("php-debug"); + // A configuration file with a non-default name must be passed explicitly. + let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()); + if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") { + c.args = vec!["--configuration".into(), file]; + } + // First test configuration is the default for context runs; PHPUnit beats a generic `composer test`. + let at = out.iter().position(|o| o.is_test).unwrap_or(out.len()); + out.insert(at, c); + } +} + // ---------------------------------------------------------------------- ruby /// Tasks a Rakefile defines statically: `task :name`, `task "name" => deps`, diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index e207f2e3..80e54c38 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -729,6 +729,40 @@ fn cmake_profile_build_configures_only_without_a_cache() { } } +#[test] +fn php_phpunit_project() { + let dir = project(&[ + ( + "composer.json", + r#"{"require-dev":{"phpunit/phpunit":"^11"},"scripts":{"test":"phpunit","post-install-cmd":"x","lint":"phpcs"}}"#, + ), + ("phpunit.xml.dist", ""), + ("vendor/bin/phpunit", ""), + ("vendor/bin/phpunit.bat", ""), + ]); + let c = detect(dir.path()); + // PHPUnit comes before the generic `composer test` so it is the default test runner. + assert_eq!(ids(&c), ["composer:lint", "phpunit", "composer:test"]); + let phpunit = find(&c, "phpunit"); + // Windows runs the `.bat` launcher; the extensionless file is a shebang script. + assert_eq!(cmdline(phpunit), if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" }); + assert_eq!(phpunit.kind, "phpunit"); + assert!(phpunit.is_test); + assert_eq!(phpunit.debug_adapter.as_deref(), Some("php-debug")); + assert!(find(&c, "composer:test").is_test); + assert!(!find(&c, "composer:lint").is_test); +} + +#[test] +fn php_detection_needs_a_marker() { + let dir = project(&[("index.php", "")]); + assert_eq!(cmdline(find(&detect(dir.path()), "phpunit")), "phpunit"); + let dir = project(&[("composer.json", "{}")]); + assert!(detect(dir.path()).is_empty()); +} + #[test] fn ruby_projects_list_rake_tasks_rspec_and_rails() { let rakefile = "\ diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index a59dc8c4..2e5e0d3b 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -53,6 +53,13 @@ pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option { } } +/// The directory of the configured `php` executable (it also holds the matching `phpize`, +/// `pecl`…): put on the `PATH` so `#!/usr/bin/env php` scripts such as `vendor/bin/phpunit` +/// and Composer run on the selected interpreter. +pub(crate) fn php_dir(root: &Path, s: &Settings) -> Option { + setting_path(root, &s.languages.php.interpreter).and_then(|p| p.parent().map(Path::to_path_buf)) +} + /// The configured Rust toolchain's `bin` directory and rustup toolchain name. pub(crate) fn rust_bin(root: &Path, s: &Settings) -> Option<(PathBuf, Option)> { let dir = setting_path(root, &s.languages.rust.toolchain)?; @@ -467,6 +474,10 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root if let Some(dir) = node_dir(root, s) { path_dirs.push(dir); } + // PHP servers (phpactor, `php intelephense`-style wrappers) on the selected PHP. + if let Some(dir) = php_dir(root, s) { + path_dirs.push(dir); + } if !path_dirs.is_empty() { let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect(); @@ -624,6 +635,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let python = python_interpreter(root, s); let pm = non_empty(&s.languages.node.package_manager); let node = node_dir(root, s); + let php = setting_path(root, &s.languages.php.interpreter); let rust = rust_bin(root, s); let go = goroot(root, s); let gopath = setting_path(root, &s.languages.go.gopath); @@ -674,6 +686,14 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings path_dirs.push(dir.clone()); } } + "php" | "phpunit" | "composer" => { + if let Some(php) = &php { + if c.kind == "php" && c.detected && c.command == "php" { + c.command = php.to_string_lossy().into_owned(); + } + path_dirs.extend(php.parent().map(Path::to_path_buf)); + } + } "cargo" => { if let Some((bin, toolchain)) = &rust { if c.command == "cargo" && bin.join(exe("cargo")).is_file() { diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index 425795bd..92b58a23 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -15,6 +15,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec { let mut out = match kind { SdkKind::Python => python(root), SdkKind::Node => node(), + SdkKind::Php => php(), SdkKind::PackageManager => package_managers(), SdkKind::Rust => rust(), SdkKind::Go => go(), @@ -37,7 +38,7 @@ fn tool_of(kind: SdkKind) -> Option { SdkKind::Jdk => Some(Tool::Java), SdkKind::Maven => Some(Tool::Maven), SdkKind::Ruby => Some(Tool::Ruby), - SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, + SdkKind::Php | SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, } } @@ -253,6 +254,94 @@ pub(crate) fn node_source(path: &Path, home: Option<&Path>) -> &'static str { }) } +/// Where a PHP on the search path comes from: its version manager or distribution, +/// "system" for one of the system's own bin directories, "path" for anything else. +pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str { + let under = |dir: &str| home.is_some_and(|h| path.starts_with(h.join(dir))); + if under(".phpbrew") { + "phpbrew" + } else if under(".phpenv") { + "phpenv" + } else if under(".config/herd-lite") || under("Library/Application Support/Herd") { + "herd" + } else if path.starts_with("/opt/homebrew") || path.starts_with("/usr/local/opt") { + "homebrew" + } else if let Some(manager) = version_managers::manager_of(path) { + manager + } else if path.parent().is_some_and(ide_core::tool_path::is_system_dir) { + "system" + } else { + "path" + } +} + +/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers, +/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon). +fn php() -> Vec { + let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); + let home = home_dir(); + for p in executables_in("php", std::env::split_paths(&search_path())) { + if version_managers::is_shim(&p) { + continue; + } + let source = php_source(&p, home.as_deref()); + found.push((p, source)); + } + let named = |dir: &Path, prefix: &str| -> Vec { + subdirs(dir) + .into_iter() + .filter(|d| d.file_name().is_some_and(|n| n.to_string_lossy().starts_with(prefix))) + .collect() + }; + for opt in ["/opt/homebrew/opt", "/usr/local/opt"] { + for d in named(Path::new(opt), "php") { + found.push((d.join("bin").join(exe("php")), "homebrew")); + } + } + for d in named(Path::new("/usr/local"), "php") { + found.push((d.join("bin").join(exe("php")), "system")); + } + if let Some(home) = &home { + for v in subdirs(&home.join(".phpbrew/php")).into_iter().rev() { + found.push((v.join("bin").join(exe("php")), "phpbrew")); + } + for v in subdirs(&home.join(".phpenv/versions")).into_iter().rev() { + found.push((v.join("bin").join(exe("php")), "phpenv")); + } + found.push((home.join(".config/herd-lite/bin").join(exe("php")), "herd")); + let herd = home.join("Library/Application Support/Herd/bin"); + for entry in std::fs::read_dir(&herd).into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with("php") && !name.contains('-') && entry.path().is_file() { + found.push((entry.path(), "herd")); + } + } + } + if cfg!(windows) { + found.push((PathBuf::from(r"C:\xampp\php\php.exe"), "xampp")); + found.push((PathBuf::from(r"C:\php\php.exe"), "system")); + for d in subdirs(Path::new(r"C:\laragon\bin\php")) { + found.push((d.join("php.exe"), "laragon")); + } + } + let found = dedupe_by_real_path(found); + par_map(&found, |(path, source)| { + let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?; + let version = version.trim().to_owned(); + if version.is_empty() { + return None; + } + Some(Sdk { + path: path.clone().into(), + name: format!("PHP {version}"), + version: Some(version), + source: (*source).to_owned(), + home: path.parent().and_then(Path::parent).map(Into::into), + extra: None, + }) + }) +} + fn node() -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); // Every Node on the search path, not only the first one `which` finds: an SSH diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 2d90ad2a..ef4efaf6 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -35,6 +35,8 @@ pub use discover::{detect, jdk_sdk}; pub enum SdkKind { Python, Node, + /// `php` CLI executables. + Php, /// npm / pnpm / yarn executables. PackageManager, Rust, diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 75d5011c..167efed1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1085,6 +1085,39 @@ fn node_on_the_search_path_keeps_its_version_manager_as_source() { assert_eq!(source("/usr/bin/node"), "system"); } +#[test] +fn php_sources_and_selected_interpreter() { + let home = Path::new("/home/me"); + let source = |p: &str| crate::discover::php_source(Path::new(p), Some(home)); + assert_eq!(source("/home/me/.phpbrew/php/php-8.3.1/bin/php"), "phpbrew"); + assert_eq!(source("/home/me/.config/herd-lite/bin/php"), "herd"); + assert_eq!(source("/opt/homebrew/opt/php@8.2/bin/php"), "homebrew"); + assert_eq!(source("/home/me/bin/php"), "path"); + + if cfg!(windows) { + return; // Unix paths + } + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + assert_eq!(crate::apply::php_dir(tmp.path(), &s), None); + s.languages.php.interpreter = "/opt/php82/bin/php".into(); + assert_eq!(crate::apply::php_dir(tmp.path(), &s), Some("/opt/php82/bin".into())); + let mut configs = vec![ + run("php", "php", &["artisan", "serve"]), + run("phpunit", "vendor/bin/phpunit", &[]), + run("composer", "composer", &["run-script", "test"]), + run("npm", "npm", &["run", "dev"]), + ]; + apply_to_run_configs(&mut configs, tmp.path(), &s); + assert_eq!(configs[0].command, "/opt/php82/bin/php"); + // Scripts with a `#!/usr/bin/env php` shebang find the selected PHP first on `PATH`. + for c in &configs[..3] { + assert!(c.env["PATH"].starts_with("/opt/php82/bin"), "{}: {:?}", c.kind, c.env.get("PATH")); + } + assert_eq!(configs[1].command, "vendor/bin/phpunit"); + assert!(configs[3].env.get("PATH").is_none_or(|p| !p.contains("php82"))); +} + #[test] fn go_constraints_change_only_protocol_settings() { use serde_json::json; diff --git a/src/bindings.ts b/src/bindings.ts index 58ac9d10..e9bcf986 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4116,6 +4116,7 @@ export type LanguageServerSettings_Serialize = { export type LanguagesSettings = { python?: PythonSettings, node?: NodeSettings, + php?: PhpSettings, rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, @@ -4718,6 +4719,11 @@ export type Permission = /** `insert-text`, `replace-selection`. */ "editor-write"; +export type PhpSettings = { + /** `php` CLI executable; empty = the one on `PATH`. */ + interpreter?: string, +}; + /** Backend → UI notifications (Tauri event `ide://plugin`). */ export type PluginEvent = /** Plugins, their status or their commands changed: re-fetch `plugin_list`. */ @@ -5569,6 +5575,8 @@ export type Sdk = { /** What [`detect`] looks for. */ export type SdkKind = "python" | "node" | +/** `php` CLI executables. */ +"php" | /** npm / pnpm / yarn executables. */ "packageManager" | "rust" | "go" | "typeScript" | /** JDK homes. */ diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 5d94cb2a..11a2e212 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -13,6 +13,8 @@ const KIND_LABELS: Record = { go: "Go", python: "Python", pytest: "pytest", + phpunit: "PHPUnit", + composer: "Composer", make: "Make", cmake: "CMake", just: "Just", @@ -111,6 +113,9 @@ export function configIcon(config: Pick): string { case "python": case "pytest": return "file-python"; + case "phpunit": + case "composer": + return "file-php"; default: return useRunConfigTypes.getState().types.get(config.kind)?.icon ?? "terminal"; } diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 2e7d9e55..69e2050e 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -5,6 +5,7 @@ */ import type { RunConfig } from "@/platform/ipc"; import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; +import { phpunitArgs } from "./testing/phpunit"; import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; @@ -19,6 +20,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( /^test_.*\.py$|_test\.py$/.test(name) || (django && name === "tests.py") || /_test\.go$/.test(name) || + /Test\.php$/.test(name) || /_(spec|test)\.rb$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") @@ -40,6 +42,7 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; + if (/\.php$/.test(relPath)) return ["phpunit"]; if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"]; if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"]; if (/\.go$/.test(relPath)) return ["go"]; @@ -169,6 +172,8 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC case "python": case "rspec": return derived(base, relPath, { args: [...base.args, file] }); + case "phpunit": + return derived(base, relPath, { args: phpunitArgs(base.args, { target: file }) }); case "rake": { // `rake test` (minitest) takes the file as TEST=; `rake spec` as SPEC=. const task = base.args.at(-1) ?? ""; diff --git a/src/features/run/phpunitTypes.ts b/src/features/run/phpunitTypes.ts new file mode 100644 index 00000000..90d941ed --- /dev/null +++ b/src/features/run/phpunitTypes.ts @@ -0,0 +1,77 @@ +/** + * PHPUnit tests (PhpStorm's "PHPUnit" configuration): `vendor/bin/phpunit` with a target (file or directory), + * a configuration file, a test filter and further options. Options go ahead of the target because PHPUnit + * stops reading options at the first path. + */ +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; + +export const phpunitType: RunConfigTypeContribution = { + id: "phpunit", + label: "PHPUnit", + icon: "file-php", + description: "Run PHPUnit tests", + defaults: { command: "vendor/bin/phpunit" }, + debugAdapter: "php-debug", + fields: [ + { + key: "command", + label: "Command", + mono: true, + placeholder: "vendor/bin/phpunit", + required: true, + hint: "e.g. vendor/bin/phpunit, phpunit, vendor/bin/pest", + }, + { + key: "target", + label: "Target", + mono: true, + placeholder: "tests/Unit", + hint: "Test file or directory; empty runs the suites of the configuration file", + }, + { + key: "configuration", + label: "Configuration file", + mono: true, + placeholder: "phpunit.xml", + hint: "Passed as --configuration; empty lets PHPUnit find phpunit.xml(.dist)", + }, + { + key: "filter", + label: "Test filter", + mono: true, + placeholder: "CalculatorTest::testAdd", + hint: "Passed as --filter: a test name or a regular expression", + }, + { key: "options", label: "PHPUnit options", mono: true, placeholder: "e.g. --stop-on-failure --testsuite unit" }, + ], + commandLine(p) { + const args: string[] = []; + if (p.configuration?.trim()) args.push("--configuration", p.configuration.trim()); + if (p.filter?.trim()) args.push("--filter", p.filter.trim()); + args.push(...parseArgs(p.options ?? "")); + if (p.target?.trim()) args.push(...parseArgs(p.target.trim())); + return { command: p.command?.trim() || "vendor/bin/phpunit", args }; + }, + paramsFrom(c) { + if (c.kind !== "phpunit") return null; + const args = [...c.args]; + const take = (flag: string): string => { + const at = args.indexOf(flag); + if (at < 0 || at + 1 >= args.length) return ""; + return args.splice(at, 2)[1]!; + }; + const configuration = take("--configuration") || take("-c"); + const filter = take("--filter"); + // The target is the trailing path, placed after the options. + const last = args[args.length - 1]; + const target = last && !last.startsWith("-") ? args.pop()! : ""; + return { + command: c.command, + target: formatArgs(target ? [target] : []), + configuration, + filter, + options: formatArgs(args), + }; + }, +}; diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 0e038ae6..dfa664bc 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -41,6 +41,8 @@ import { type TestOutputParser, } from "./testing"; import { GoTestOutput } from "./testing/go"; +import { TeamcityOutput } from "./testing/phpunit"; +import type { TestStdout } from "./testing/types"; import { recordTestStates } from "./testing/states"; import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; import { compoundChildren } from "./configs"; @@ -82,7 +84,8 @@ interface SessionRuntime { console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; - goOutput: GoTestOutput | null; + /** Machine-readable stdout (`go test -json`, `--teamcity`): fed to the parser, shown decoded. */ + stdout: TestStdout | null; queue: OutputChunk[]; /** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */ flushHandle: { raf: number } | { timeout: number } | null; @@ -374,7 +377,7 @@ function flush(sessionId: string) { if (rt.queue.length === 0) return; const chunks = rt.queue.splice(0); for (const c of chunks) { - const text = c.kind === "stdout" && rt.goOutput ? rt.goOutput.feed(c.text) : c.text; + const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text; if (text) rt.console.append({ ...c, text }); } bump(sessionId); @@ -441,18 +444,20 @@ export function applyConsoleSettings() { function newRuntime(config: RunConfig): SessionRuntime { const tests = config.isTest ? new TestResults() : null; const parser = config.isTest ? createTestParser(config) : null; - const goOutput = - tests && parser && frameworkFor(config) === "go" - ? new GoTestOutput((line) => tests.apply(parser.feed(line))) - : null; + const feed = (line: string) => { + if (tests && parser) tests.apply(parser.feed(line)); + }; + const framework = tests && parser ? frameworkFor(config) : null; + const stdout = + framework === "go" ? new GoTestOutput(feed) : framework === "phpunit" ? new TeamcityOutput(feed) : null; const runtime: SessionRuntime = { - goOutput, + stdout, tests, parser, console: new ConsoleModel({ maxChars: consoleCharLimit(), onLine: (text, kind) => { - if (parser && tests && kind !== "system" && !(kind === "stdout" && goOutput)) tests.apply(parser.feed(text)); + if (parser && tests && kind !== "system" && !(kind === "stdout" && stdout)) tests.apply(parser.feed(text)); if (kind !== "system") for (const l of runtime.lineListeners) l(text); }, }), @@ -783,8 +788,8 @@ function finishSession(sessionId: string, code: number | null) { void (async () => { if (runtimes.get(sessionId) !== rt) return; flush(sessionId); - if (rt.goOutput) { - const text = rt.goOutput.end(); + if (rt.stdout) { + const text = rt.stdout.end(); if (text) rt.console.append({ kind: "stdout", text }); } rt.console.flush(); diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index 263b34a4..29768847 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -276,3 +276,44 @@ describe("Go test targets", () => { }); }); }); + +describe("php (PHPUnit)", () => { + const src = [ + " { + expect(pick(detectRunTargets(src, "tests/CalcTest.php"))).toEqual([ + [3, "suite", "CalcTest"], + [5, "test", "CalcTest/testAdd"], + [7, "test", "CalcTest/subtracts"], + [9, "test", "CalcTest/multiplies"], + ]); + }); + + it("ignores files that are not tests", () => { + expect(detectRunTargets(src, "src/Calc.php")).toEqual([]); + }); + + it("runs a method with --filter ahead of the file", () => { + const phpunit = cfg({ id: "phpunit", kind: "phpunit", command: "vendor/bin/phpunit" }); + const target = detectRunTargets(src, "tests/CalcTest.php")[1]!; + const run = targetConfig(target, "tests/CalcTest.php", [phpunit], src); + expect(run?.args).toEqual(["--filter", "/(CalcTest::testAdd( with data set .*)?)$/", "tests/CalcTest.php"]); + expect(run?.command).toBe("vendor/bin/phpunit"); + }); +}); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index cb28903d..11bd6d4d 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -14,6 +14,7 @@ import { contextConfig, relToCwd, testConfigFor } from "./context"; import { djangoLabel, djangoTestsOf } from "./djangoTypes"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; +import { phpunitArgs, phpunitFilter } from "./testing/phpunit"; export type TargetKind = "main" | "test" | "suite" | "generate"; @@ -38,12 +39,13 @@ export interface RunTarget { /** Above this size we skip detection (generated / minified files). */ export const MAX_SCAN = 400_000; -type Lang = "rust" | "js" | "python" | "go" | "jvm" | null; +type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | null; export function targetLanguage(relPath: string): Lang { if (/\.rs$/.test(relPath)) return "rust"; if (/\.[cm]?[jt]sx?$/.test(relPath)) return "js"; if (/\.py$/.test(relPath)) return "python"; + if (/\.php$/.test(relPath)) return "php"; if (/\.go$/.test(relPath)) return "go"; if (/\.(java|kt|kts|scala|groovy)$/.test(relPath)) return "jvm"; return null; @@ -144,6 +146,34 @@ function jsTargets(lines: string[]): RunTarget[] { return out; } +/** PHPUnit: classes named `*Test` / extending `TestCase`, and `test*` methods or `#[Test]` / `@test` ones. */ +function phpTargets(lines: string[]): RunTarget[] { + const out: RunTarget[] = []; + let cls: string | null = null; + let marked = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const c = /^\s*(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)(?:\s+extends\s+([\w\\]+))?/.exec(line); + if (c) { + const test = /Test$|TestCase$/.test(c[1]!) || /TestCase$/.test(c[2] ?? ""); + cls = test && !/^\s*abstract\b/.test(line) ? c[1]! : null; + if (cls) out.push({ line: i, kind: "suite", name: cls, path: [cls] }); + marked = false; + continue; + } + if (!cls) continue; + if (/#\[\s*(?:\\?PHPUnit\\Framework\\Attributes\\)?Test\s*[\],(]|@test\b/.test(line)) { + marked = true; + continue; + } + const m = /^\s*public\s+(?:static\s+)?function\s+(\w+)\s*\(/.exec(line); + if (m && (marked || /^test/.test(m[1]!))) + out.push({ line: i, kind: "test", name: `${cls}::${m[1]}`, path: [cls, m[1]!] }); + if (m || /^\s*(?:public|private|protected)\b/.test(line)) marked = false; + } + return out; +} + function pythonTargets(lines: string[]): RunTarget[] { const out: RunTarget[] = []; const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = []; @@ -331,6 +361,8 @@ export function detectRunTargets(text: string, relPath: string, django = false): /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath) || (django && /(^|\/)tests\.py$/.test(relPath)), ); + case "php": + return /Test\.php$|(^|\/)tests?\//.test(relPath) ? phpTargets(lines) : []; case "go": return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); case "jvm": @@ -392,6 +424,11 @@ export function targetConfig( case "pytest": case "python": return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); + case "phpunit": { + const [cls, method] = [target.path[0]!, target.path[1]]; + const filter = phpunitFilter([method ? [cls, method] : [cls]]); + return named(phpunitArgs(file.args, { target: relToCwd(relPath, file), filter })); + } case "django-test": { // unittest's loader would call a module-level `test_*` function (or a plain class) to build the suite. if (!target.unittest) return null; diff --git a/src/features/run/testing/go.ts b/src/features/run/testing/go.ts index acf8117d..ccfe687e 100644 --- a/src/features/run/testing/go.ts +++ b/src/features/run/testing/go.ts @@ -3,7 +3,7 @@ * name only appears in the trailing `ok`/`FAIL` line, so events are buffered * per package and emitted under it once that line arrives. */ -import { parseDuration, type TestEvent, type TestOutputParser } from "./types"; +import { parseDuration, TestStdout, type TestEvent, type TestOutputParser } from "./types"; const RUN_RE = /^=== (?:RUN|CONT|PAUSE)\s+(\S+)/; const RESULT_RE = /^\s*--- (PASS|FAIL|SKIP): (\S+) \(([\d.]+s)\)/; @@ -164,24 +164,11 @@ export function goParser(): TestOutputParser { } /** Decode stdout for the console while feeding complete, unsplit JSON records to the test parser. */ -export class GoTestOutput { - private pending = ""; - constructor(private readonly feedLine: (line: string) => void) {} - feed(text: string): string { - this.pending += text; - const lines = this.pending.split("\n"); - this.pending = lines.pop()!; - return lines.map((line) => this.line(line)).join(""); - } - end(): string { - const pending = this.pending; - this.pending = ""; - return pending ? this.line(pending) : ""; - } - private line(raw: string): string { - const line = raw.replace(/\r$/, ""); - this.feedLine(line); - const event = goJsonEvent(line); - return event ? (event.Output ?? "") : line + "\n"; +export class GoTestOutput extends TestStdout { + constructor(feedLine: (line: string) => void) { + super(feedLine, (line) => { + const event = goJsonEvent(line); + return event ? (event.Output ?? "") : line + "\n"; + }); } } diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 56fcc76c..889e4909 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -4,6 +4,7 @@ import { cargoParser } from "./cargo"; import { goParser } from "./go"; import { jsParser } from "./js"; import { jvmParser } from "./jvm"; +import { phpunitParser, withTeamcity } from "./phpunit"; import { pytestParser } from "./pytest"; import { unittestParser } from "./unittest"; import type { TestEvent, TestOutputParser } from "./types"; @@ -11,7 +12,7 @@ import type { TestEvent, TestOutputParser } from "./types"; export type { TestEvent, TestOutputParser, TestStatus } from "./types"; export { parseDuration } from "./types"; export { TestResults, nodeStatus, type TestNode } from "./results"; -export { cargoParser, goParser, jsParser, jvmParser, pytestParser, unittestParser }; +export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, unittestParser }; /** Runs several parsers side by side (unknown tool: e.g. `make test`). */ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { @@ -21,7 +22,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { }; } -export type Framework = "cargo" | "js" | "pytest" | "unittest" | "go" | "jvm" | "auto"; +export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "auto"; export function frameworkFor(config: Pick): Framework { const cmd = [config.command, ...config.args].join(" "); @@ -39,6 +40,8 @@ export function frameworkFor(config: Pick /^(-v|--verbosity)(=?\d)?$/.test(a)); return chosen ? config : { ...config, args: [...config.args, "-v", "2"] }; @@ -92,12 +97,22 @@ export function createTestParser(config: Pick { + const parser = phpunitParser(); + return lines.flatMap((l) => parser.feed(l)); +}; + +const SUITE = "App\\Tests\\CalcTest"; + +describe("phpunitParser", () => { + it("reports passed, failed and skipped tests with locations", () => { + const events = run([ + "PHPUnit 11.0.0 by Sebastian Bergmann and contributors.", + "##teamcity[testSuiteStarted name='Unit' locationHint='php_qn:///app/phpunit.xml::\\Unit' flowId='1']", + `##teamcity[testSuiteStarted name='${SUITE}' locationHint='php_qn:///app/tests/CalcTest.php::\\${SUITE}' flowId='1']`, + "##teamcity[testStarted name='testAdd' locationHint='php_qn:///app/tests/CalcTest.php::\\App\\Tests\\CalcTest::testAdd' flowId='1']", + "##teamcity[testFinished name='testAdd' duration='3' flowId='1']", + "##teamcity[testStarted name='testSub' locationHint='php_qn:///app/tests/CalcTest.php::\\App\\Tests\\CalcTest::testSub' flowId='1']", + "##teamcity[testFailed name='testSub' message='Failed asserting that 1 matches expected 2.' details=' /app/vendor/phpunit/Assert.php:100|n /app/tests/CalcTest.php:21|n' flowId='1']", + "##teamcity[testFinished name='testSub' duration='1' flowId='1']", + "##teamcity[testStarted name='testSkip' flowId='1']", + "##teamcity[testIgnored name='testSkip' message='not yet' flowId='1']", + "##teamcity[testFinished name='testSkip' duration='0' flowId='1']", + `##teamcity[testSuiteFinished name='${SUITE}' flowId='1']`, + "##teamcity[testSuiteFinished name='Unit' flowId='1']", + ]); + const add = ["Unit", SUITE, "testAdd"]; + const sub = ["Unit", SUITE, "testSub"]; + expect(events).toContainEqual({ type: "result", path: add, status: "passed", durationMs: 3 }); + expect(events).toContainEqual({ type: "result", path: sub, status: "failed" }); + expect(events).toContainEqual({ type: "result", path: ["Unit", SUITE, "testSkip"], status: "skipped" }); + // A failure points at the first frame outside vendor/. + expect(events).toContainEqual({ + type: "location", + path: sub, + location: { path: "/app/tests/CalcTest.php", line: 21, resolve: "path" }, + }); + expect(events).toContainEqual({ type: "output", path: sub, line: "Failed asserting that 1 matches expected 2." }); + // The finish of a failed test doesn't turn it into a pass. + expect(events.filter((e) => e.type === "result" && e.path.join() === sub.join())).toHaveLength(1); + }); + + it("nests data sets under their method", () => { + const events = run([ + `##teamcity[testSuiteStarted name='${SUITE}']`, + `##teamcity[testSuiteStarted name='${SUITE}::testAdd']`, + "##teamcity[testStarted name='testAdd with data set #0']", + "##teamcity[testFinished name='testAdd with data set #0' duration='1']", + `##teamcity[testSuiteFinished name='${SUITE}::testAdd']`, + ]); + expect(events[0]).toEqual({ type: "start", path: [SUITE, "testAdd", "testAdd with data set #0"] }); + }); + + it("unescapes TeamCity values", () => { + const events = run([ + "##teamcity[testSuiteStarted name='S']", + "##teamcity[testStarted name='t']", + "##teamcity[testFailed name='t' message='a |'quoted|' |[x|] b' details='']", + ]); + expect(events).toContainEqual({ type: "output", path: ["S", "t"], line: "a 'quoted' [x] b" }); + }); + + it("ignores everything else", () => { + expect(run(["Time: 00:00.012, Memory: 8.00 MB", "OK (1 test, 1 assertion)", ""])).toEqual([]); + }); + + it("builds a tree", () => { + const results = new TestResults(); + const parser = phpunitParser(); + for (const l of [ + "##teamcity[testSuiteStarted name='S']", + "##teamcity[testStarted name='a']", + "##teamcity[testFinished name='a' duration='1']", + "##teamcity[testStarted name='b']", + "##teamcity[testFailed name='b' message='x' details='']", + "##teamcity[testFinished name='b' duration='1']", + ]) + results.apply(parser.feed(l)); + expect(results.counts()).toMatchObject({ passed: 1, failed: 1 }); + }); +}); + +describe("TeamcityOutput", () => { + it("feeds every line to the tree but keeps service messages out of the console", () => { + const results = new TestResults(); + const parser = phpunitParser(); + const output = new TeamcityOutput((line) => results.apply(parser.feed(line))); + const shown = + output.feed( + "PHPUnit 11.5 by Sebastian Bergmann\n##teamcity[testSuiteStarted name='CalcTest']\n##teamcity[testStar", + ) + + output.feed("ted name='testAdd']\r\n##teamcity[testFinished name='testAdd' duration='1']\nOK (1 test") + + output.end(); + expect(shown).toBe("PHPUnit 11.5 by Sebastian Bergmann\nOK (1 test\n"); + expect(results.counts()).toMatchObject({ passed: 1 }); + }); +}); + +describe("command lines", () => { + it("puts --teamcity before any path, after the runner", () => { + expect(withTeamcity(["tests/FooTest.php"])).toEqual(["--teamcity", "tests/FooTest.php"]); + expect(withTeamcity(["vendor/bin/phpunit", "--stop-on-failure", "tests"])).toEqual([ + "vendor/bin/phpunit", + "--teamcity", + "--stop-on-failure", + "tests", + ]); + expect(withTeamcity(["--testdox"])).toEqual(["--testdox"]); + expect(withTeamcity(["--teamcity"])).toEqual(["--teamcity"]); + }); + + it("detects PHPUnit configurations", () => { + expect(frameworkFor({ kind: "phpunit", command: "vendor/bin/phpunit", args: [] })).toBe("phpunit"); + expect(frameworkFor({ kind: "composer", command: "composer", args: ["run-script", "test"] })).toBe("auto"); + expect(frameworkFor({ kind: "shell", command: "php", args: ["vendor/bin/phpunit"] })).toBe("phpunit"); + expect(createTestParser({ kind: "phpunit", command: "phpunit", args: [] })).toBeDefined(); + expect(withTestReporting({ kind: "phpunit", command: "phpunit", args: ["tests"] }).args).toEqual([ + "--teamcity", + "tests", + ]); + }); + + it("replaces the target and filter, keeping options", () => { + expect(phpunitArgs(["-c", "ci.xml", "tests"], { target: "tests/FooTest.php" })).toEqual([ + "-c", + "ci.xml", + "tests/FooTest.php", + ]); + expect(phpunitArgs(["--filter", "old", "--colors=never", "tests"], { filter: "/x/" })).toEqual([ + "--colors=never", + "--filter", + "/x/", + "tests", + ]); + expect(phpunitArgs(["vendor/bin/phpunit", "tests"], { target: "a.php", filter: "/x/" })).toEqual([ + "vendor/bin/phpunit", + "--filter", + "/x/", + "a.php", + ]); + }); + + it("filters by class and method", () => { + expect(phpunitFilter([["Unit", SUITE, "testAdd"]])).toBe( + "/(App\\\\Tests\\\\CalcTest::testAdd( with data set .*)?)$/", + ); + expect(phpunitFilter([["Unit", SUITE, "testAdd", "testAdd with data set #0"]])).toBe( + "/(App\\\\Tests\\\\CalcTest::testAdd( with data set .*)?)$/", + ); + expect(phpunitFilter([[SUITE]])).toBe("/(App\\\\Tests\\\\CalcTest::.*)$/"); + }); +}); diff --git a/src/features/run/testing/phpunit.ts b/src/features/run/testing/phpunit.ts new file mode 100644 index 00000000..eaf986c8 --- /dev/null +++ b/src/features/run/testing/phpunit.ts @@ -0,0 +1,197 @@ +/** + * PHPUnit (and Pest, which runs on it) through `--teamcity`: one `##teamcity[…]` service message per + * suite and test event, with the exact `Class::method` names and failure details. Tests are reported as + * `[...suites, test]`: the configured suite (`Unit`), the class, then the method (with its data set). + */ +import { TestStdout, type TestEvent, type TestOutputParser } from "./types"; + +const MESSAGE_RE = /^\s*##teamcity\[(\w+)((?:\s+[\w.-]+='(?:[^|']|\|.)*')*)\s*\]\s*$/; +const ATTR_RE = /([\w.-]+)='((?:[^|']|\|.)*)'/g; + +/** TeamCity escapes: `|n` `|r` `|'` `|[` `|]` `||` and `|0xNNNN`. */ +function unescape(value: string): string { + return value.replace(/\|(0x[0-9a-fA-F]{4}|.)/g, (_, c: string) => { + if (c.startsWith("0x")) return String.fromCharCode(Number.parseInt(c, 16)); + return c === "n" ? "\n" : c === "r" ? "\r" : c; + }); +} + +function attributes(text: string): Record { + const out: Record = {}; + for (const m of text.matchAll(ATTR_RE)) out[m[1]!] = unescape(m[2]!); + return out; +} + +/** `php_qn:///app/tests/FooTest.php::\App\FooTest::testBar` → the file. */ +function hintFile(hint: string | undefined): string | null { + const m = /^php_qn:\/\/(.+?\.php)(?:::|$)/.exec(hint ?? ""); + return m ? m[1]! : null; +} + +/** First stack frame inside the project (not `vendor/`): `/app/tests/FooTest.php:12`. */ +function failureLocation(details: string): { path: string; line: number } | null { + for (const line of details.split("\n")) { + const m = /(?:^|\s)((?:[A-Za-z]:)?[^\s:]+\.php):(\d+)\s*$/.exec(line.trim()); + if (m && !/[/\\]vendor[/\\]/.test(m[1]!)) return { path: m[1]!, line: Number(m[2]) }; + } + return null; +} + +export function phpunitParser(): TestOutputParser { + /** Open suites, outermost first (the data-provider suite `Class::method` is one of them). */ + const suites: string[] = []; + const open = new Map(); + + /** A data-provider suite is named `Class::method`; its data sets belong under the method (the class is open already). */ + const suitePath = (): string[] => + suites.map((s) => { + const at = s.lastIndexOf("::"); + return at > 0 ? s.slice(at + 2) : s; + }); + + return { + feed(line) { + const m = MESSAGE_RE.exec(line); + if (!m) return []; + const kind = m[1]!; + const a = attributes(m[2] ?? ""); + const name = a.name ?? ""; + const events: TestEvent[] = []; + switch (kind) { + case "testSuiteStarted": + suites.push(name); + break; + case "testSuiteFinished": + if (suites[suites.length - 1] === name) suites.pop(); + break; + case "testStarted": { + const path = [...suitePath(), name]; + open.set(name, { path, file: hintFile(a.locationHint), done: false }); + events.push({ type: "start", path }); + break; + } + case "testFailed": + case "testIgnored": { + const test = open.get(name); + if (!test) break; + const details = a.details ?? ""; + const text = [a.message, details].filter(Boolean).join("\n"); + for (const l of text.split("\n")) events.push({ type: "output", path: test.path, line: l }); + const at = failureLocation(details) ?? (test.file ? { path: test.file, line: undefined } : null); + if (at) { + events.push({ + type: "location", + path: test.path, + location: { path: at.path, ...(at.line ? { line: at.line } : {}), resolve: "path" }, + }); + } + events.push({ type: "result", path: test.path, status: kind === "testIgnored" ? "skipped" : "failed" }); + test.done = true; + break; + } + case "testFinished": { + const test = open.get(name); + if (!test) break; + open.delete(name); + // A failed or ignored test already has its result; the finish only reports a pass. + if (!test.done) { + events.push({ + type: "result", + path: test.path, + status: "passed", + ...(a.duration ? { durationMs: Number(a.duration) } : {}), + }); + } + break; + } + } + return events; + }, + }; +} + +// ----------------------------------------------------------------- command lines + +/** Options that take a value in a separate argument. */ +const VALUE_OPTIONS = new Set([ + "-c", + "--configuration", + "--bootstrap", + "--filter", + "--testsuite", + "--group", + "--exclude-group", + "--covers", + "--uses", + "--cache-directory", +]); + +/** Number of leading runner arguments (`vendor/bin/phpunit` under `php`); PHPUnit's own options follow. */ +function runnerLength(args: readonly string[]): number { + return args[0] && /(^|[/\\])(phpunit|pest)(\.phar|\.bat)?$/.test(args[0]) ? 1 : 0; +} + +/** Splits the arguments after the runner into options and positional targets. */ +function splitArgs(rest: readonly string[], dropFilter: boolean): { options: string[]; targets: string[] } { + const options: string[] = []; + const targets: string[] = []; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]!; + if (!arg.startsWith("-")) targets.push(arg); + else if (VALUE_OPTIONS.has(arg) && i + 1 < rest.length) { + if (!(dropFilter && arg === "--filter")) options.push(arg, rest[i + 1]!); + i++; + } else if (!(dropFilter && arg.startsWith("--filter="))) options.push(arg); + } + return { options, targets }; +} + +/** + * `args` running `target` (a file or directory) and/or a `--filter` pattern in place of the command's own. + * PHPUnit stops parsing options at the first path, so everything is placed before it. + */ +export function phpunitArgs(args: readonly string[], run: { target?: string; filter?: string }): string[] { + const n = runnerLength(args); + const { options, targets } = splitArgs(args.slice(n), run.filter !== undefined); + return [ + ...args.slice(0, n), + ...options, + ...(run.filter === undefined ? [] : ["--filter", run.filter]), + ...(run.target === undefined ? targets : [run.target]), + ]; +} + +/** `--teamcity` ahead of any path, unless the run already picked a result format. */ +export function withTeamcity(args: readonly string[]): string[] { + if (args.some((a) => a === "--teamcity" || /^--(testdox|log-|no-output)/.test(a))) return [...args]; + const n = runnerLength(args); + return [...args.slice(0, n), "--teamcity", ...args.slice(n)]; +} + +const escapePattern = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + +/** + * `--filter` pattern selecting the tests at `paths` (`[suite, class, method]`, a data set under its method). + * PHPUnit matches `Class::method with data set …`; a class or suite node selects by its name. + */ +export function phpunitFilter(paths: readonly (readonly string[])[]): string { + const parts = new Set(); + for (const path of paths) { + const last = path[path.length - 1] ?? ""; + const dataSet = / with data set .*$/.test(last); + const method = last.replace(/ with data set .*$/, ""); + // A data set sits under a node named after its method (the data-provider suite). + const cls = path[path.length - (dataSet ? 3 : 2)]; + if (cls && /^[a-z_]\w*$/.test(method)) + parts.add(`${escapePattern(cls)}::${escapePattern(method)}( with data set .*)?`); + else parts.add(`${escapePattern(last)}::.*`); + } + return `/(${[...parts].join("|")})$/`; +} + +/** Console text of a `--teamcity` run: the service messages only feed the test tree, as in PhpStorm. */ +export class TeamcityOutput extends TestStdout { + constructor(feedLine: (line: string) => void) { + super(feedLine, (line) => (MESSAGE_RE.test(line) ? "" : line + "\n")); + } +} diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 46415d2a..d03e0f3d 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -2,6 +2,7 @@ * "Rerun Failed Tests": derives a run configuration that runs only the failed * tests of a previous run, using each framework's own filter syntax. */ +import { phpunitArgs, phpunitFilter } from "./phpunit"; import type { RunConfig } from "@/platform/ipc"; import { formatArgs } from "../configs"; import { djangoTestsOf } from "../djangoTypes"; @@ -164,6 +165,9 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): if (!tests) return null; return { ...config, ...tests, name, detected: true, env: { ...config.env } }; } + case "phpunit": + args = phpunitArgs(config.args, { filter: phpunitFilter(paths) }); + break; case "go": args = goArgs(config.args, paths); break; diff --git a/src/features/run/testing/types.ts b/src/features/run/testing/types.ts index 60228a2b..a54f2500 100644 --- a/src/features/run/testing/types.ts +++ b/src/features/run/testing/types.ts @@ -42,3 +42,31 @@ export function parseDuration(text: string | undefined): number | undefined { } return matched ? Math.round(total * 1000) / 1000 : undefined; } + +/** + * Stdout of a runner that prints machine-readable records (`go test -json`, PHPUnit `--teamcity`): + * feeds complete, unsplit lines to the test parser and returns each line's console text. + */ +export class TestStdout { + private pending = ""; + constructor( + private readonly feedLine: (line: string) => void, + private readonly display: (line: string) => string, + ) {} + feed(text: string): string { + this.pending += text; + const lines = this.pending.split("\n"); + this.pending = lines.pop()!; + return lines.map((line) => this.line(line)).join(""); + } + end(): string { + const pending = this.pending; + this.pending = ""; + return pending ? this.line(pending) : ""; + } + private line(raw: string): string { + const line = raw.replace(/\r$/, ""); + this.feedLine(line); + return this.display(line); + } +} diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 50961667..3ebdf749 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -25,6 +25,7 @@ import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; import { rakeType, rspecType, rubyScriptType } from "./rubyTypes"; import { djangoTestType } from "./djangoTypes"; +import { phpunitType } from "./phpunitTypes"; export const COMPOUND = "compound"; @@ -404,6 +405,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ rakeType, rspecType, djangoTestType, + phpunitType, dotnetProjectType, dotnetTestType, goBuildType, @@ -457,7 +459,10 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" + ...(config.kind === "dotnet-test" || + config.kind === "django-test" || + config.kind === "phpunit" || + config.kind === "rspec" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 04070caa..670e882d 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -87,6 +87,7 @@ describe("language pages", () => { "Markdown", "Node.js", "Python Interpreter", + "PHP", "Prettier", "Ruby", "Rust", diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index ca3a14f7..aef56ed2 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -1,5 +1,5 @@ /** - * Settings › Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript and + * Settings › Languages & Frameworks › Go / Java / Node.js / PHP / Rust / TypeScript and * Build Tools › Maven: the toolchain each language's server and run configurations * use (applied by `ide-sdk` when language servers start and when run configurations load). */ @@ -109,6 +109,32 @@ export function NodePage() { ); } +// --------------------------------------------------------------------------- +// PHP + +export function PhpPage() { + const [interpreter] = useSetting("languages.php.interpreter"); + const { list } = useDetected("php"); + const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]); + return ( +
+
+ + {selected?.version && {selected.version}} +
+
+ ); +} + // --------------------------------------------------------------------------- // Ruby diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 60655f4b..e9a49eb4 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -1,9 +1,9 @@ /** * PyCharm's top-level "Python" group (Interpreter), "Project Structure", and - * Languages & Frameworks › Go / Java / Markdown / Node.js / Rust / TypeScript. Pages live in + * Languages & Frameworks › Go / Java / Markdown / Node.js / PHP / Rust / TypeScript. Pages live in * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ -import { GoPage, JavaPage, NodePage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { MarkdownPage } from "../languages/MarkdownPage"; import { PrettierPage } from "../languages/PrettierPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; @@ -128,6 +128,26 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"], component: PrettierPage, }, + { + id: "languages.php", + projectScope: true, + defaultScope: "project", + title: "PHP", + group: LANGUAGES_GROUP, + order: 403.2, + keywords: [ + "php interpreter", + "cli interpreter", + "php", + "composer", + "phpunit", + "xampp", + "herd", + "homebrew", + "phpbrew", + ], + component: PhpPage, + }, { id: "languages.ruby", projectScope: true, diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index f6df5986..edf4c001 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -29,6 +29,7 @@ export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; ruby: { interpreter: string }; node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; + php: { interpreter: string }; rust: { toolchain: string; stdlib: string; @@ -82,6 +83,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, ruby: { interpreter: "" }, node: { interpreter: "", packageManager: "" }, + php: { interpreter: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, typescript: { version: "bundled" }, @@ -168,6 +170,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const py = obj(l.python); const ruby = obj(l.ruby); const node = obj(l.node); + const php = obj(l.php); const rust = obj(l.rust); const go = obj(l.go); const tags = obj(go.buildTags); @@ -188,6 +191,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { interpreter: text(node.interpreter, d.node.interpreter), packageManager: oneOf(node.packageManager, ["", "npm", "pnpm", "yarn", "bun"] as const, d.node.packageManager), }, + php: { interpreter: text(php.interpreter, d.php.interpreter) }, rust: { toolchain: text(rust.toolchain, d.rust.toolchain), stdlib: text(rust.stdlib, d.rust.stdlib), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 12b2c59d..c47f3d11 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4884,8 +4884,6 @@ "No interpreter": "无解释器", "Add New Interpreter…": "添加新的解释器…", "Interpreter Settings…": "解释器设置…", - "Cannot select Python interpreter": "无法选择 Python 解释器", - "Python Interpreter: {label}": "Python 解释器:{label}", "Connection settings for SQL Server.": "SQL Server 的连接设置。", "Microsoft SQL Server": "微软 SQL Server", "Add to .gitignore": "添加到 .gitignore", @@ -5580,9 +5578,23 @@ "Settings module": "设置模块", "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", "e.g. --keepdb --parallel": "例如 --keepdb --parallel", + "Run PHPUnit tests": "运行 PHPUnit 测试", + "e.g. vendor/bin/phpunit, phpunit, vendor/bin/pest": "例如 vendor/bin/phpunit、phpunit、vendor/bin/pest", + "Test file or directory; empty runs the suites of the configuration file": "测试文件或目录;留空则运行配置文件中的测试套件", + "Passed as --configuration; empty lets PHPUnit find phpunit.xml(.dist)": "作为 --configuration 传入;留空则由 PHPUnit 自行查找 phpunit.xml(.dist)", + "Test filter": "测试过滤器", + "Passed as --filter: a test name or a regular expression": "作为 --filter 传入:测试名称或正则表达式", + "PHPUnit options": "PHPUnit 选项", + "e.g. --stop-on-failure --testsuite unit": "例如 --stop-on-failure --testsuite unit", + "CLI Interpreter": "CLI 解释器", + "PHP interpreter": "PHP 解释器", + "Runs PHP scripts, PHPUnit, Composer and artisan, and the PHP-based language servers (phpactor). Its folder is put first on PATH, so vendor/bin/phpunit and other #!/usr/bin/env php scripts use it too.": "运行 PHP 脚本、PHPUnit、Composer 和 artisan,以及基于 PHP 的语言服务器(phpactor)。其所在文件夹会被置于 PATH 最前,因此 vendor/bin/phpunit 等 #!/usr/bin/env php 脚本也会使用它。", + "Select PHP Interpreter": "选择 PHP 解释器", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", "External file changes sync may be slow": "外部文件更改同步可能较慢", + "Cannot select Python interpreter": "无法选择 Python 解释器", + "Python Interpreter: {label}": "Python 解释器:{label}", "recursive": "递归", "Java project import failed": "Java 项目导入失败", "The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server.": "无法导入 Java 项目,因为这些文件使用了 Windows 保留的设备名:{paths}。请删除或重命名这些文件,然后重启 Java 语言服务器。", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 380a72fa..c05ca6d6 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -145,6 +145,8 @@ const UNTRANSLATED = new Set([ "MySQL", "Neon", "Node.js", + "PHP", + "Python", "npm", "OpenIDEA", "Spring Boot", @@ -219,6 +221,11 @@ const UNTRANSLATED = new Set([ "node_modules/typescript", "PascalCase", "polls.tests", + "CalculatorTest::testAdd", + "phpunit.xml", + "PHPUnit", + "tests/Unit", + "vendor/bin/phpunit", "pylyzer --server", "pyproject.toml, .git", "run pytest",