diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index baa4e8c9..4c60822c 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -23,6 +23,42 @@ describe("run config → launch config", () => { expect(launchConfigFor(rc({}), "/p")).toBeNull(); }); + it("php-debug: the script runs on the PHP from Settings with Xdebug's debug mode, on a free port", () => { + const xdebug = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"]; + // `artisan serve` with PHP at Default: the script is a file of the project. + const serve = launchConfigFor( + rc({ kind: "php", command: "php", args: ["artisan", "serve"], debugAdapter: "php-debug" }), + "/app", + "/opt/php@8.3/bin/php", + )!; + expect(serve).toMatchObject({ adapter: "php-debug", program: "/app/artisan", args: ["serve"], cwd: "/app" }); + expect(extra(serve)).toEqual({ port: 0, runtimeArgs: xdebug, runtimeExecutable: "/opt/php@8.3/bin/php" }); + // The PHP the command names wins, and its options stay with it. + const file = launchConfigFor( + rc({ + kind: "php", + command: "/usr/bin/php8.4", + args: ["-dmemory_limit=1G", "src/a.php", "x"], + debugAdapter: "php-debug", + }), + "/app", + "/opt/php@8.3/bin/php", + )!; + expect(file).toMatchObject({ program: "/app/src/a.php", args: ["x"] }); + expect(extra(file)).toEqual({ + port: 0, + runtimeArgs: [...xdebug, "-dmemory_limit=1G"], + runtimeExecutable: "/usr/bin/php8.4", + }); + // PHPUnit runs its script directly; without a PHP in Settings, php-debug uses the `php` on PATH. + const unit = launchConfigFor( + rc({ kind: "phpunit", command: "vendor/bin/phpunit", args: ["--filter", "testA"], debugAdapter: "php-debug" }), + "/app", + )!; + expect(unit).toMatchObject({ program: "/app/vendor/bin/phpunit", args: ["--filter", "testA"] }); + expect(extra(unit)).toEqual({ port: 0, runtimeArgs: xdebug }); + }); + it("cargo run builds the binary through codelldb's cargo integration", () => { const l = launchConfigFor( rc({ diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 759c6fe3..944dda9f 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -299,7 +299,10 @@ async function startConfig(arg: unknown) { } const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg"; const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config; - const launch = launchConfigFor(prepared, root); + // php-debug starts the PHP chosen in Settings unless the command names one. + const php = + config.debugAdapter === "php-debug" && isTauri() ? await call(commands.sdkPhpEffective()).catch(() => null) : null; + const launch = launchConfigFor(prepared, root, php); if (!launch) { notifications().notify({ group: "run", diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 1137cdd6..e5fb36da 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -356,11 +356,44 @@ function flutterAttach(rc: RunConfig, cwd: string, adapter: string): LaunchConfi }; } +const PHP_NAME = /^php(\d+(\.\d+)*)?(\.exe)?$/i; + +/** + * php-debug runs `runtimeExecutable runtimeArgs program args` and doesn't enable Xdebug: debug mode and + * the connection back come from `-d` options, to the free port php-debug picks for `port: 0` (another + * IDE may listen on 9003). `php` is the PHP chosen in Settings, used when the command doesn't name one. + */ +function phpDebugLaunch(rc: RunConfig, cwd: string, php: string | null): LaunchConfig { + const name = rc.command.split(/[\\/]/).pop() ?? rc.command; + const runtimeArgs = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"]; + let runtime = php; + let program: string; + let args: string[]; + if (PHP_NAME.test(name)) { + // `php [options] script args`: the options stay with PHP; the script is a file (`artisan`). + if (name !== rc.command) runtime = resolveCommand(rc.command, cwd); + const at = rc.args.findIndex((a) => !a.startsWith("-")); + runtimeArgs.push(...(at < 0 ? rc.args : rc.args.slice(0, at))); + program = at < 0 ? "" : join(cwd, rc.args[at]!); + args = at < 0 ? [] : rc.args.slice(at + 1); + } else { + // A PHP script run directly (`vendor/bin/phpunit`; on Windows its `.bat` shim sits beside it). + program = resolveCommand(rc.command.replace(/\.bat$/i, ""), cwd); + args = [...rc.args]; + } + return { + ...base(rc, "php-debug", cwd), + program, + args, + extraJson: JSON.stringify({ port: 0, runtimeArgs, ...(runtime ? { runtimeExecutable: runtime } : {}) }), + }; +} + /** * The launch configuration that debugs `rc`, or `null` when it has no adapter. * `root` is the project root (default working directory). */ -export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | null { +export function launchConfigFor(rc: RunConfig, root: string, php: string | null = null): LaunchConfig | null { const adapter = rc.debugAdapter; if (!adapter || debugUnsupportedReason(rc)) return null; const cwd = rc.cwd ? join(root, rc.cwd) : root; @@ -374,6 +407,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); if (adapter === "delve") return delveLaunch(rc, cwd, root); if (adapter === "godot") return godotLaunch(rc, cwd); + if (adapter === "php-debug") return phpDebugLaunch(rc, cwd, php); if (adapter === "netcoredbg" && rc.kind === "dotnet") { if (!rc.params?.targetPath) return null; return { diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index c9c2ef8f..e684aa1d 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -50,7 +50,7 @@ import styles from "./Run.module.css"; /** * Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`), - * verified against small programs. Others (dart, php-debug, …) only got + * verified against small programs. Others (dart, …) only got * generic launch arguments and are left out until they have one; a configuration that * already names one keeps it. */ @@ -63,6 +63,7 @@ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [ { id: "delve", label: "Delve (Go)" }, { id: "codelldb", label: "CodeLLDB (Rust, C, C++)" }, { id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" }, + { id: "php-debug", label: "php-debug (PHP)" }, ]; /** Options "Modify options" adds to the form: value fields (shown once added) and flags (chips). */ diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 194982d7..2487c44e 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -226,9 +226,12 @@ describe("single-file producers (scratches)", () => { it("Ruby, PHP, Perl, Lua, R, Swift, Dart, Elixir, Scala CLI, Kotlin script", () => { expect(run(scratch("a.rb"))).toMatchObject({ command: "/bin/ruby", args: ["a.rb"] }); expect(run(scratch("a.php"))).toMatchObject({ command: "/bin/php", args: ["a.php"] }); + // Debuggable with php-debug, like a PHP file in PhpStorm. + expect(produceContext(scratch("a.php"), ALL)!.config!.debugAdapter).toBe("php-debug"); // The PHP chosen in Settings wins over the PATH one, also with no PHP on PATH (#1555). const php81 = "/opt/homebrew/opt/php@8.1/bin/php"; expect(run(scratch("a.php"), { ...ALL, php: php81 })).toMatchObject({ command: php81, args: ["a.php"] }); + expect(produceContext(scratch("a.php"), { ...ALL, php: php81 })!.config!.debugAdapter).toBe("php-debug"); expect(run(scratch("a.php"), env({}, { php: php81 }))).toMatchObject({ command: php81, args: ["a.php"] }); expect(run(scratch("tool", "#!/usr/bin/env php\n [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]); case "php": - if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg]) }; - return simple(lang, s, env, lang, () => [s.arg]); + if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg], "php-debug") }; + return simple(lang, s, env, lang, () => [s.arg], "php-debug"); case "perl": case "lua": case "swift": diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 3ec10d8e..6ff3df72 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -86,6 +86,7 @@ const BUILT_AT_RUNTIME = new Set([]); const UNTRANSLATED = new Set([ "dotnet test", "netcoredbg (.NET)", + "php-debug (PHP)", // Product names "Cargo", "CMake",