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 <[email protected]>
Цей коміт міститься в:
Vadim LazukтаInf1mum authored and GitHub committed 2026-11-06 06:28:02 +08:00
1 джерело 3ec30fff3f
коміт 83d2f67b4f
6 files changed
+95 -13

No files matched your search

+24 -1
Переглянути файл
@@ -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",
+60 -9
Переглянути файл
@@ -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<Record<string, string>>;
@@ -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<RunConfig, "kind">) => 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<string, unknown>): LaunchConfig {
function requestBase(
rc: RunConfig,
adapter: string,
root: string,
request: "attach" | "launch",
extra: Record<string, unknown>,
): 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_internals>/**", "**/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;
}