From 83d2f67b4fe4021a26df2df145d5016b512460ad Mon Sep 17 00:00:00 2001 From: Vadim Lazuk Date: Fri, 6 Nov 2026 01:28:02 +0300 Subject: [PATCH] feat(debugger): add PHP Remote Debug (Xdebug) run configuration (#1635) A debug-only PHP Remote Debug (Xdebug) configuration: php-debug listens on the port (9003 by default) with optional path mappings, and the form shows how to start the remote side. php-debug is also offered as a debug adapter in the configuration form. Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com> --- .../debugger/__tests__/remote.test.ts | 25 ++++++- src/features/debugger/remote.ts | 69 ++++++++++++++++--- src/features/run/ConfigForm.tsx | 5 +- src/platform/locales/text/en.json | 5 +- src/platform/locales/text/zh.json | 3 + src/platform/locales/textCatalog.test.ts | 1 + 6 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts index 9336ac74..d70ff6fb 100644 --- a/src/features/debugger/__tests__/remote.test.ts +++ b/src/features/debugger/__tests__/remote.test.ts @@ -30,7 +30,9 @@ const { goRemoteType, pythonRemoteType, nodeRemoteType, + phpRemoteType, jdwpAgentArgs, + xdebugCommand, remoteAttachConfig, remoteJvmTarget, } = remote; @@ -67,7 +69,9 @@ describe("remote debug configuration types", () => { port: "Port must be a number from 1 to 65535", }); expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined(); - expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" }); + // PHP Remote Debug has no host: php-debug listens itself, Xdebug connects to it. + if (type.fields.some((f) => f.key === "host")) + expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" }); }); it("Remote JVM Debug defaults to attaching to localhost:5005", () => { @@ -157,6 +161,25 @@ describe("remote attach requests", () => { }); }); + it("PHP Remote Debug defaults to port 9003 and shows the xdebug command line", () => { + expect(configOf(phpRemoteType).params).toEqual({ port: "9003" }); + const field = phpRemoteType.fields.find((f) => f.key === "hint")!; + expect(field.control).toBe("output"); + expect(field.value!({ port: "9004" })).toContain("-dxdebug.client_port=9004"); + expect(xdebugCommand({})).toBe( + "php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=9003 script.php", + ); + }); + + it("PHP Remote Debug launches php-debug without a program and maps the roots", () => { + const l = remoteAttachConfig(configOf(phpRemoteType, { localRoot: ".", remoteRoot: "/var/www/html" }), "/p")!; + expect(l).toMatchObject({ adapter: "php-debug", request: "launch", cwd: "/p" }); + expect(l.program).toBeNull(); + expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings: { "/var/www/html": "/p" } }); + const plain = JSON.parse(remoteAttachConfig(configOf(phpRemoteType), "/p")!.extraJson!); + expect(plain).toEqual({ port: 9003 }); + }); + it("start-the-remote-side hints follow the port", () => { expect(remote.delveServerCommand({ port: "4000" })).toBe( "dlv debug --headless --listen=:4000 --api-version=2 --accept-multiclient", diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts index 5a4828bc..4dc7a6b5 100644 --- a/src/features/debugger/remote.ts +++ b/src/features/debugger/remote.ts @@ -2,6 +2,7 @@ * Remote debug run configurations: the debugger attaches to a process started * elsewhere (another machine, a container) over host:port, like IDEA's Remote JVM * Debug, GoLand's Go Remote, PyCharm's remote debugpy and IDEA's Attach to Node.js. + * PHP Remote Debug is the exception: its php-debug listens and Xdebug connects to it. * Only Debug applies to them (`debugOnly`); they launch no process. */ import type { LaunchConfig, RunConfig } from "@/platform/ipc"; @@ -13,6 +14,7 @@ export const GO_REMOTE = "go-remote"; export const PYTHON_REMOTE = "python-remote"; export const NODE_REMOTE = "node-remote"; export const GODOT_ATTACH = "godot-attach"; +export const PHP_REMOTE = "php-remote"; type Params = Readonly>; @@ -221,6 +223,38 @@ export const nodeRemoteType: RunConfigTypeContribution = { commandLine: () => null, }; +// --------------------------------------------------------------------------- +// PHP Remote Debug (Xdebug): php-debug listens, the remote PHP connects to it + +/** How to start the remote side, so Xdebug connects back to the listening php-debug. */ +export function xdebugCommand(p: Params): string { + return `php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=${portOf(p, 9003)} script.php`; +} + +export const phpRemoteType: RunConfigTypeContribution = { + id: PHP_REMOTE, + label: "PHP Remote Debug (Xdebug)", + icon: "file-php", + description: "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)", + defaults: { port: "9003" }, + debugOnly: true, + debugAdapter: "php-debug", + fields: [ + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: xdebugCommand, + hint: "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container", + }, + localRootField, + remoteRootField("e.g. /var/www/html"), + ], + commandLine: () => null, +}; + // --------------------------------------------------------------------------- // Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter) @@ -250,13 +284,14 @@ export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [ goRemoteType, pythonRemoteType, nodeRemoteType, + phpRemoteType, godotAttachType, ]; export const isRemoteConfig = (c: Pick) => REMOTE_TYPES.some((t) => t.id === c.kind); // --------------------------------------------------------------------------- -// Attach requests +// Attach / listen requests function isAbsolute(p: string) { return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); @@ -281,11 +316,17 @@ export function serverAddress(host: string, port: number): string { return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; } -function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record): LaunchConfig { +function requestBase( + rc: RunConfig, + adapter: string, + root: string, + request: "attach" | "launch", + extra: Record, +): LaunchConfig { return { name: rc.name, adapter, - request: "attach", + request, program: null, args: [], cwd: root, @@ -295,9 +336,11 @@ function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record< } /** - * The attach request of a Go / Python / Node.js remote configuration. Delve's headless + * The request of a Go / Python / Node.js / PHP remote configuration. Delve's headless * server and `debugpy --listen` speak DAP themselves (`debugServer` connects there); - * js-debug runs here and connects to the inspector. + * js-debug runs here and connects to the inspector. PHP Remote Debug is the one + * `launch`: php-debug is the side that listens, and each Xdebug connection becomes + * a thread, so nothing attaches and no program is named. */ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null { const p = rc.params ?? {}; @@ -306,27 +349,35 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | const skip = workflowSettings().debugger.skipLibraryCode; switch (rc.kind) { case GO_REMOTE: - return attachBase(rc, "delve", root, { + return requestBase(rc, "delve", root, "attach", { debugServer: serverAddress(host, portOf(p, 2345)), mode: "remote", ...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}), }); case PYTHON_REMOTE: - return attachBase(rc, "debugpy", root, { + return requestBase(rc, "debugpy", root, "attach", { debugServer: serverAddress(host, portOf(p, 5678)), justMyCode: skip, ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), }); case GODOT_ATTACH: - return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root }); + return requestBase(rc, "godot", root, "attach", { + debugServer: serverAddress(host, portOf(p, 6006)), + project: root, + }); case NODE_REMOTE: - return attachBase(rc, "js-debug", root, { + return requestBase(rc, "js-debug", root, "attach", { type: "pwa-node", address: host, port: portOf(p, 9229), ...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}), skipFiles: skip ? ["/**", "**/node_modules/**"] : [], }); + case PHP_REMOTE: + return requestBase(rc, "php-debug", root, "launch", { + port: portOf(p, 9003), + ...(map ? { pathMappings: { [map.remote]: map.local } } : {}), + }); default: return null; } diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index c9c2ef8f..d00a285e 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -49,8 +49,8 @@ import { TomcatSettings } from "./TomcatSettings"; 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 + * Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`, + * `debugger/remote.ts`), 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/platform/locales/text/en.json b/src/platform/locales/text/en.json index 0beb9e74..df41de2f 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -30,5 +30,8 @@ "{added} line added": "{added} line added", "{added} lines added": "{added} lines added", "{removed} line deleted": "{removed} line deleted", - "{removed} lines deleted": "{removed} lines deleted" + "{removed} lines deleted": "{removed} lines deleted", + "PHP Remote Debug (Xdebug)": "PHP Remote Debug (Xdebug)", + "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)": "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)", + "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 50a54d8b..8064365b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3653,6 +3653,9 @@ "pip install debugpy on the remote machine": "在远程机器上执行 pip install debugpy", "Attach to Node.js": "附加到 Node.js", "Attach to a Node.js process started with --inspect here or on another machine": "附加到以 --inspect 启动的 Node.js 进程(本机或其他机器)", + "PHP Remote Debug (Xdebug)": "PHP 远程调试 (Xdebug)", + "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)": "监听 Xdebug 连接:调试在其他地方(php-fpm、容器中)提供服务的 PHP 请求", + "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container": "对于 Web 请求:设置 XDEBUG_SESSION cookie 或查询参数;当 PHP 运行在容器中时,将 xdebug.client_host 指向本机", "Copied": "已复制", "'{name}' can only be debugged": "'{name}' 只能调试", "It attaches the debugger to a process started elsewhere.": "它将调试器附加到在别处启动的进程。", 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",