From ca4edd72ebee3d31ca22374acc5299b0a70c989b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 5 Jul 2026 12:41:01 +0400 Subject: [PATCH 1/2] Debug test runnables through the Xdebug adapter 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. --- debug_adapter_schemas/Xdebug.json | 11 ++ extension.toml | 2 + languages/php/config.toml | 1 + src/php.rs | 12 +- src/xdebug.rs | 217 +++++++++++++++++++++++++++++- 5 files changed, 239 insertions(+), 4 deletions(-) diff --git a/debug_adapter_schemas/Xdebug.json b/debug_adapter_schemas/Xdebug.json index 47e5b9a..938f060 100644 --- a/debug_adapter_schemas/Xdebug.json +++ b/debug_adapter_schemas/Xdebug.json @@ -20,6 +20,17 @@ "description": "The PHP script to debug (typically a path to a file)", "default": "${file}" }, + "runtimeExecutable": { + "type": "string", + "description": "Absolute path to the PHP executable the adapter spawns (default: the `php` on your PATH). Set this to a real `php.exe` when `php` on the PATH is a shell shim (a `.bat`/`.cmd` cannot be launched directly)." + }, + "runtimeArgs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments passed to the PHP executable before the program (for example `-dxdebug.mode=debug`)" + }, "cwd": { "type": "string", "description": "Working directory for the debugged program" diff --git a/extension.toml b/extension.toml index 787d098..c17afd0 100644 --- a/extension.toml +++ b/extension.toml @@ -22,6 +22,8 @@ language = "PHP" [debug_adapters.Xdebug] +[debug_locators.php] + [grammars.php] repository = "https://github.com/tree-sitter/tree-sitter-php" commit = "5b5627faaa290d89eb3d01b9bf47c3bb9e797dea" diff --git a/languages/php/config.toml b/languages/php/config.toml index d60735e..3648c8c 100644 --- a/languages/php/config.toml +++ b/languages/php/config.toml @@ -20,3 +20,4 @@ prettier_parser_name = "php" prettier_plugins = ["@prettier/plugin-php"] completion_query_characters = ["$"] word_characters = ["$"] +debuggers = ["Xdebug"] diff --git a/src/php.rs b/src/php.rs index 9a591f4..20d3480 100644 --- a/src/php.rs +++ b/src/php.rs @@ -5,7 +5,7 @@ use std::fs; use zed::CodeLabel; use zed_extension_api::{ self as zed, serde_json, DebugConfig, DebugScenario, LanguageServerId, Result, - StartDebuggingRequestArgumentsRequest, + StartDebuggingRequestArgumentsRequest, TaskTemplate, }; use crate::{ @@ -158,6 +158,16 @@ impl zed::Extension for PhpExtension { self.xdebug .get_binary(config, user_provided_debug_adapter_path, worktree) } + fn dap_locator_create_scenario( + &mut self, + _locator_name: String, + build_task: TaskTemplate, + resolved_label: String, + debug_adapter_name: String, + ) -> Option { + self.xdebug + .dap_locator_create_scenario(build_task, resolved_label, debug_adapter_name) + } } zed::register_extension!(PhpExtension); diff --git a/src/xdebug.rs b/src/xdebug.rs index 8b60a1b..d95bf9c 100644 --- a/src/xdebug.rs +++ b/src/xdebug.rs @@ -5,13 +5,35 @@ use zed_extension_api::{ serde_json::{self, json, Value}, DebugAdapterBinary, DebugConfig, DebugRequest, DebugScenario, DownloadedFileType, GithubReleaseAsset, GithubReleaseOptions, StartDebuggingRequestArguments, - StartDebuggingRequestArgumentsRequest, TcpArguments, TcpArgumentsTemplate, + StartDebuggingRequestArgumentsRequest, TaskTemplate, TcpArguments, TcpArgumentsTemplate, }; pub(super) struct XDebug { current_version: OnceLock, } +/// Drop the double quotes that `tasks.json` adds for the "Run" shell. PHP +/// identifiers and file paths never contain `"`, so removing every quote is +/// safe and leaves a clean argv element for the debug adapter to spawn. +fn strip_shell_quotes(arg: &str) -> String { + arg.replace('"', "") +} + +/// The PHP binary the adapter should spawn, when the scenario doesn't already +/// pin a `runtimeExecutable`. Prefer the `PHP_BINARY` environment variable so a +/// project can point at a real `php.exe` when `php` on the PATH is a shell shim +/// (a `.bat`/`.cmd` the debug adapter can't spawn directly); otherwise fall back +/// to whatever `php` resolves to on the PATH. +fn resolve_php_runtime(worktree: &zed_extension_api::Worktree) -> Option { + worktree + .shell_env() + .into_iter() + .find(|(key, _)| key == "PHP_BINARY") + .map(|(_, value)| value) + .filter(|value| !value.is_empty()) + .or_else(|| worktree.which("php")) +} + impl XDebug { pub(super) const NAME: &'static str = "Xdebug"; const ADAPTER_PATH: &'static str = "extension/out/phpDebug.js"; @@ -65,6 +87,65 @@ impl XDebug { tcp_connection: None, }) } + /// Turn a runnable task (a PHPUnit/Pest/Testo command from `tasks.json`) into + /// an Xdebug launch scenario, so the gutter offers a "Debug" counterpart to + /// its "Run". Zed runs this against every task; we only claim the ones that + /// boil down to launching a PHP entrypoint. + pub(crate) fn dap_locator_create_scenario( + &self, + build_task: TaskTemplate, + resolved_label: String, + adapter: String, + ) -> Option { + if adapter != Self::NAME { + return None; + } + + // `php vendor/bin/testo …` launches the script in the first argument; + // `./vendor/bin/phpunit …` is itself the PHP entrypoint. Skip inline code + // like `php -r `, which has no program to debug. + let (program, args) = match build_task.command.as_str() { + "php" => { + let program = build_task.args.first()?; + if program.starts_with('-') { + return None; + } + (program.clone(), build_task.args[1..].to_vec()) + } + command => (command.to_string(), build_task.args.clone()), + }; + // `tasks.json` quotes values for the shell that runs "Run" + // (e.g. `--path="$ZED_RELATIVE_FILE"`). The debug adapter spawns the + // process directly (argv, no shell), so those quotes must come off. + let program = strip_shell_quotes(&program); + let args: Vec = args.iter().map(|a| strip_shell_quotes(a)).collect(); + + let env = Value::Object( + build_task + .env + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + ); + + let config = json!({ + "request": "launch", + "program": program, + "args": args, + "cwd": build_task.cwd, + "env": env, + "stopOnEntry": false, + }); + + Some(DebugScenario { + adapter, + label: resolved_label, + build: None, + config: config.to_string(), + tcp_connection: None, + }) + } + fn fetch_latest_adapter_version() -> Result<(GithubReleaseAsset, String), String> { let release = latest_github_release( "xdebug/vscode-php-debug", @@ -123,8 +204,49 @@ impl XDebug { let mut configuration = Value::from_str(&task_definition.config) .map_err(|e| format!("Invalid JSON configuration: {e}"))?; if let Some(obj) = configuration.as_object_mut() { - obj.entry("cwd") - .or_insert_with(|| worktree.root_path().into()); + // Tasks carry no `cwd`, so a locator-built scenario has `"cwd": null`; + // fill it with the worktree root. `entry(..).or_insert` wouldn't fire + // here — the key is present, just null. + if obj.get("cwd").is_none_or(Value::is_null) { + obj.insert("cwd".to_string(), worktree.root_path().into()); + } + // The adapter spawns PHP itself; on Windows `spawn("php")` won't find + // `php.exe` on the PATH, so hand it the absolute path we resolve here + // (honoring a `PHP_BINARY` override for shell-shim setups). + if !obj.contains_key("runtimeExecutable") { + if let Some(php) = resolve_php_runtime(worktree) { + obj.insert("runtimeExecutable".to_string(), php.into()); + } + } + // The adapter forwards `runtimeArgs` to PHP verbatim; it does NOT enable + // Xdebug on its own. Without these `-dxdebug…` overrides the launched + // script never connects back and breakpoints never bind. `${port}` is + // replaced by the adapter with the DBGp port it listens on, so it always + // matches. Users can override by setting their own `runtimeArgs`. + // (Xdebug must still be loaded in PHP via `zend_extension`.) + if !obj.contains_key("runtimeArgs") { + obj.insert( + "runtimeArgs".to_string(), + json!([ + "-dxdebug.mode=debug", + "-dxdebug.start_with_request=yes", + "-dxdebug.client_port=${port}" + ]), + ); + } + // The debug adapter spawns PHP directly (no shell), and Node refuses to + // launch `.bat`/`.cmd` files (CVE-2024-27980), failing with a cryptic + // `spawn EINVAL`. Turn that into an actionable message. + if let Some(runtime) = obj.get("runtimeExecutable").and_then(Value::as_str) { + let ext = runtime.to_ascii_lowercase(); + if ext.ends_with(".bat") || ext.ends_with(".cmd") { + return Err(format!( + "Cannot debug through the shell shim `{runtime}`: the debug adapter \ + spawns PHP directly and Windows batch files can't be launched that way. \ + Point `PHP_BINARY` (or a `runtimeExecutable` in your debug config) at a real `php.exe`." + )); + } + } } Ok(DebugAdapterBinary { @@ -191,3 +313,92 @@ impl XDebug { self.get_installed_binary(config, user_provided_debug_adapter_path, worktree) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn task(command: &str, args: &[&str]) -> TaskTemplate { + TaskTemplate { + label: "run".into(), + command: command.into(), + args: args.iter().map(|a| (*a).into()).collect(), + env: vec![], + cwd: None, + } + } + + fn scenario_config(build_task: TaskTemplate) -> Value { + let scenario = XDebug::new() + .dap_locator_create_scenario(build_task, "label".into(), XDebug::NAME.into()) + .expect("locator claims the task"); + assert_eq!(scenario.adapter, XDebug::NAME); + Value::from_str(&scenario.config).expect("scenario config is JSON") + } + + #[test] + fn testo_command_launches_the_script_after_php() { + // `php vendor/bin/testo …` → program is the script, php drops out. + let config = scenario_config(task( + "php", + &["vendor/bin/testo", "--path=tests/Foo.php", "--filter=bar"], + )); + assert_eq!(config["request"], "launch"); + assert_eq!(config["program"], "vendor/bin/testo"); + assert_eq!( + config["args"], + json!(["--path=tests/Foo.php", "--filter=bar"]) + ); + } + + #[test] + fn phpunit_binary_is_itself_the_program() { + // `./vendor/bin/phpunit …` is already a PHP entrypoint; args pass through. + let config = scenario_config(task( + "./vendor/bin/phpunit", + &["--filter", "bar", "tests/Foo.php"], + )); + assert_eq!(config["program"], "./vendor/bin/phpunit"); + assert_eq!(config["args"], json!(["--filter", "bar", "tests/Foo.php"])); + } + + #[test] + fn shell_quotes_are_stripped_from_argv() { + // `tasks.json` wraps values for the shell; argv must be quote-free. + let config = scenario_config(task( + "php", + &[ + "vendor/bin/testo", + "--path=\"tests/Foo.php\"", + "--filter=\"bar\"", + ], + )); + assert_eq!( + config["args"], + json!(["--path=tests/Foo.php", "--filter=bar"]) + ); + } + + #[test] + fn inline_php_code_is_not_debuggable() { + // `php -r ` has no program to break in. + assert!(XDebug::new() + .dap_locator_create_scenario( + task("php", &["-r", "echo 1;"]), + "label".into(), + XDebug::NAME.into(), + ) + .is_none()); + } + + #[test] + fn other_adapters_are_declined() { + assert!(XDebug::new() + .dap_locator_create_scenario( + task("php", &["vendor/bin/testo"]), + "label".into(), + "SomethingElse".into(), + ) + .is_none()); + } +} From 54bb1af634f59b86bc1cfb0584c93224648f187e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 5 Jul 2026 17:09:14 +0400 Subject: [PATCH 2/2] Support Xdebug listen mode for incoming connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" }] --- debug_adapter_schemas/Xdebug.json | 2 +- src/xdebug.rs | 80 ++++++++++++++++++------------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/debug_adapter_schemas/Xdebug.json b/debug_adapter_schemas/Xdebug.json index 938f060..8b0269e 100644 --- a/debug_adapter_schemas/Xdebug.json +++ b/debug_adapter_schemas/Xdebug.json @@ -176,5 +176,5 @@ } } }, - "required": ["request", "program"] + "required": ["request"] } diff --git a/src/xdebug.rs b/src/xdebug.rs index d95bf9c..9f224dd 100644 --- a/src/xdebug.rs +++ b/src/xdebug.rs @@ -210,41 +210,53 @@ impl XDebug { if obj.get("cwd").is_none_or(Value::is_null) { obj.insert("cwd".to_string(), worktree.root_path().into()); } - // The adapter spawns PHP itself; on Windows `spawn("php")` won't find - // `php.exe` on the PATH, so hand it the absolute path we resolve here - // (honoring a `PHP_BINARY` override for shell-shim setups). - if !obj.contains_key("runtimeExecutable") { - if let Some(php) = resolve_php_runtime(worktree) { - obj.insert("runtimeExecutable".to_string(), php.into()); + // A launch config with a `program` runs a PHP script (CLI debugging); + // one without just listens for incoming Xdebug connections (web + // debugging). Only the former spawns PHP, so the PHP binary and the + // Xdebug-enabling args belong there only — injecting them into a + // listener would make the adapter try to spawn `php` with no script + // instead of listening. + let launches_program = obj + .get("program") + .and_then(Value::as_str) + .is_some_and(|program| !program.is_empty()); + if launches_program { + // The adapter spawns PHP itself; on Windows `spawn("php")` won't find + // `php.exe` on the PATH, so hand it the absolute path we resolve here + // (honoring a `PHP_BINARY` override for shell-shim setups). + if !obj.contains_key("runtimeExecutable") { + if let Some(php) = resolve_php_runtime(worktree) { + obj.insert("runtimeExecutable".to_string(), php.into()); + } } - } - // The adapter forwards `runtimeArgs` to PHP verbatim; it does NOT enable - // Xdebug on its own. Without these `-dxdebug…` overrides the launched - // script never connects back and breakpoints never bind. `${port}` is - // replaced by the adapter with the DBGp port it listens on, so it always - // matches. Users can override by setting their own `runtimeArgs`. - // (Xdebug must still be loaded in PHP via `zend_extension`.) - if !obj.contains_key("runtimeArgs") { - obj.insert( - "runtimeArgs".to_string(), - json!([ - "-dxdebug.mode=debug", - "-dxdebug.start_with_request=yes", - "-dxdebug.client_port=${port}" - ]), - ); - } - // The debug adapter spawns PHP directly (no shell), and Node refuses to - // launch `.bat`/`.cmd` files (CVE-2024-27980), failing with a cryptic - // `spawn EINVAL`. Turn that into an actionable message. - if let Some(runtime) = obj.get("runtimeExecutable").and_then(Value::as_str) { - let ext = runtime.to_ascii_lowercase(); - if ext.ends_with(".bat") || ext.ends_with(".cmd") { - return Err(format!( - "Cannot debug through the shell shim `{runtime}`: the debug adapter \ - spawns PHP directly and Windows batch files can't be launched that way. \ - Point `PHP_BINARY` (or a `runtimeExecutable` in your debug config) at a real `php.exe`." - )); + // The adapter forwards `runtimeArgs` to PHP verbatim; it does NOT + // enable Xdebug on its own. Without these `-dxdebug…` overrides the + // launched script never connects back and breakpoints never bind. + // `${port}` is replaced by the adapter with the DBGp port it listens + // on, so it always matches. Users can override with their own + // `runtimeArgs`. (Xdebug must still be loaded via `zend_extension`.) + if !obj.contains_key("runtimeArgs") { + obj.insert( + "runtimeArgs".to_string(), + json!([ + "-dxdebug.mode=debug", + "-dxdebug.start_with_request=yes", + "-dxdebug.client_port=${port}" + ]), + ); + } + // The debug adapter spawns PHP directly (no shell), and Node refuses + // to launch `.bat`/`.cmd` files (CVE-2024-27980), failing with a + // cryptic `spawn EINVAL`. Turn that into an actionable message. + if let Some(runtime) = obj.get("runtimeExecutable").and_then(Value::as_str) { + let ext = runtime.to_ascii_lowercase(); + if ext.ends_with(".bat") || ext.ends_with(".cmd") { + return Err(format!( + "Cannot debug through the shell shim `{runtime}`: the debug adapter \ + spawns PHP directly and Windows batch files can't be launched that way. \ + Point `PHP_BINARY` (or a `runtimeExecutable` in your debug config) at a real `php.exe`." + )); + } } } }