Skip to content

Debug test runnables through the Xdebug adapter#130

Open
roxblnfk wants to merge 2 commits into
zed-extensions:mainfrom
roxblnfk:debug-runnables
Open

Debug test runnables through the Xdebug adapter#130
roxblnfk wants to merge 2 commits into
zed-extensions:mainfrom
roxblnfk:debug-runnables

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Jul 5, 2026

Copy link
Copy Markdown

What

Lets you debug PHP test runnables with the bundled Xdebug adapter: the gutter now offers Debug next to Run for the PHPUnit/Pest runnables (and any php <script> task), launching the exact test under the cursor with breakpoints. It also enables Xdebug's listen mode for debugging incoming web requests.

The extension already ships the Xdebug debug adapter, but nothing connected it to the runnable tasks, and the PHP language wasn't associated with any debugger — so no debug affordance ever appeared. This PR wires that up.

image image

How

  • Language ↔ adapter. languages/php/config.toml gains debuggers = ["Xdebug"]. Without this, code_actions.rs::debug_scenarios bails out early (no default debug adapter for the buffer) and never reaches a locator.
  • Debug locator. A new [debug_locators.php] and dap_locator_create_scenario turn a runnable task into an Xdebug launch scenario. It drops the php wrapper to find the program, strips the double quotes tasks.json adds for the "Run" shell (the adapter spawns argv directly, without a shell), and skips inline php -r code.
  • Launch-config fixups at spawn time (get_installed_binary, the single choke point every scenario passes through — gutter, debug.json, F4). These apply only when the config has a program (a CLI launch); see listen mode below:
    • cwd → worktree root (a locator scenario carries "cwd": null, which entry(..).or_insert wouldn't fill);
    • runtimeExecutable → absolute php, via a PHP_BINARY env override then which("php"), because spawn("php") can't resolve a bare name on Windows;
    • runtimeArgs-dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=${port}. The adapter forwards runtimeArgs to PHP verbatim and never enables Xdebug itself, so without these the launched script never connects back. ${port} is replaced by the adapter with the DBGp port it listens on, so it always matches. A user's own runtimeArgs take precedence.
    • When php resolves to a .bat/.cmd shim, fail with an actionable message instead of the cryptic spawn EINVAL Node throws for batch files (CVE-2024-27980).
  • Schema. debug_adapter_schemas/Xdebug.json documents runtimeExecutable and runtimeArgs, so they autocomplete when editing .zed/debug.json.

Listen mode (debugging web requests)

The same adapter listens for incoming Xdebug connections when a launch config has no program — the usual workflow for debugging a request triggered from the browser or an external CLI run. Because the spawn-time fixups above are gated on program, a program-less config is left untouched: the adapter listens instead of trying to spawn php with no script, and the .bat/.cmd guard doesn't reject a session that never launches PHP.

Set it up:

  1. Add a program-less scenario to .zed/debug.json:

    [{ "adapter": "Xdebug", "label": "Listen for Xdebug", "request": "launch" }]
  2. Open debugger: start, pick Listen for Xdebug from the list and start it. The session now listens on the DBGp port (9003 by default; set "port" in the config to change it) — no executable to fill in.

  3. Set your breakpoints.

  4. Run PHP with Xdebug pointed at the listener, e.g.:

    php -dxdebug.mode=debug -dxdebug.start_with_request=yes \
        -dxdebug.client_host=127.0.0.1 -dxdebug.client_port=9003 \
        vendor/bin/phpunit
    

    or configure the same via php.ini / XDEBUG_MODE=debug and just trigger a web request. Xdebug connects back to the listening session and breakpoints bind.

Requirements

Breakpoints require Xdebug to be loaded in the PHP that runs (via zend_extension); the -dxdebug.* overrides only configure an already-installed Xdebug, they don't load it.

Tests

Unit tests in src/xdebug.rs cover the locator: the testo/phpunit program split, quote stripping, declining inline code, and declining other adapters. cargo test, cargo fmt --check and cargo clippy --all-features -- -D warnings (wasm target) are green.

Register a debug locator so the gutter offers "Debug" next to "Run" for
PHPUnit/Pest/Testo runnables, and associate the Xdebug adapter with the PHP
language (debuggers = ["Xdebug"]) so Zed reaches the locator at all.

The locator turns a runnable task into an Xdebug launch scenario: it drops the
`php` wrapper to find the program, strips the shell quotes tasks.json adds for
"Run" (the adapter spawns argv without a shell), and skips inline `php -r` code.

At launch time (get_installed_binary) fill in what the adapter needs but a
generated scenario lacks:
- cwd -> worktree root (a locator scenario carries "cwd": null);
- runtimeExecutable -> absolute php (a PHP_BINARY env override, else
  which("php")), since spawn("php") can not resolve it on Windows;
- runtimeArgs -> -dxdebug.mode=debug -dxdebug.start_with_request=yes
  -dxdebug.client_port=${port}, because the adapter forwards runtimeArgs
  verbatim and never enables Xdebug itself;
- fail with an actionable message when php resolves to a .bat/.cmd shim, which
  Node refuses to spawn (CVE-2024-27980) with a cryptic EINVAL.

Also document runtimeExecutable/runtimeArgs in the Xdebug config schema.
@cla-bot cla-bot Bot added the cla-signed label Jul 5, 2026
An Xdebug launch config without a `program` should listen for incoming Xdebug
connections (debugging a web request) rather than spawn PHP. Gate the CLI-launch
fixups — resolving the PHP binary, injecting the `-dxdebug…` runtime args, and
the `.bat`/`.cmd` guard — on the presence of a non-empty `program`, and drop
`program` from the config schema's required list so a program-less (listen)
config validates.

Without this, injecting `runtimeArgs` flipped a listener into a launch (the
adapter would spawn `php` with no script instead of listening), and the schema
rejected the program-less config.

Listen by adding a program-less scenario to `.zed/debug.json`:

    [{ "label": "Listen for Xdebug", "adapter": "Xdebug", "request": "launch" }]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant