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",