Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion debug_adapter_schemas/Xdebug.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -165,5 +176,5 @@
}
}
},
"required": ["request", "program"]
"required": ["request"]
}
2 changes: 2 additions & 0 deletions extension.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions languages/php/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ prettier_parser_name = "php"
prettier_plugins = ["@prettier/plugin-php"]
completion_query_characters = ["$"]
word_characters = ["$"]
debuggers = ["Xdebug"]
12 changes: 11 additions & 1 deletion src/php.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<DebugScenario> {
self.xdebug
.dap_locator_create_scenario(build_task, resolved_label, debug_adapter_name)
}
}

zed::register_extension!(PhpExtension);
229 changes: 226 additions & 3 deletions src/xdebug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// 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<String> {
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";
Expand Down Expand Up @@ -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<DebugScenario> {
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 <code>`, 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<String> = 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",
Expand Down Expand Up @@ -123,8 +204,61 @@ 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());
}
// 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 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`."
));
}
}
}
}

Ok(DebugAdapterBinary {
Expand Down Expand Up @@ -191,3 +325,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 <code>` 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());
}
}