дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(debugger): tell when the PHP has no Xdebug (#1801)
A PHP without the Xdebug extension ignores the -dxdebug.* options php-debug starts it with, so a PHP debug session ran past every breakpoint and nothing said why. Starting one now checks the PHP that will run (with XDEBUG_MODE=off, in the session's folder, where a version manager's php shim picks the PHP) and warns when Xdebug is not loaded, with Open PHP Settings; the session still starts. Settings > PHP > CLI Interpreter shows the Xdebug version of the selected PHP, or Not installed. Co-authored-by: arookieofc <[email protected]>
Цей коміт міститься в:
1 джерело
4dd51a1101
коміт
7a6b1601d5
14 files changed
+413
-12
No files matched your search
@@ -71,6 +71,19 @@ pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core
|
||||
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
|
||||
}
|
||||
|
||||
/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
|
||||
/// project root).
|
||||
pub async fn sdk_php_xdebug(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
php: PathBuf,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> IdeResult<ide_sdk::php::PhpXdebug> {
|
||||
host.require_trust(window)?;
|
||||
let cwd = cwd.or_else(|| host.project(window).ok().map(|p| p.root.clone()));
|
||||
tokio::task::spawn_blocking(move || ide_sdk::php::xdebug(&php, cwd.as_deref())).await.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
@@ -245,6 +258,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
|
||||
@@ -55,12 +55,16 @@ pub(crate) fn query_in(
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
if !env.iter().any(|(k, _)| *k == "PATH") {
|
||||
cmd.args(args).envs(env.iter().copied());
|
||||
query_command(cmd, timeout)
|
||||
}
|
||||
|
||||
/// [`query`] of a command built by the caller; `PATH` is the search path unless it sets one.
|
||||
pub(crate) fn query_command(mut cmd: Command, timeout: Duration) -> Option<String> {
|
||||
if !cmd.get_envs().any(|(k, _)| k == "PATH") {
|
||||
cmd.env("PATH", search_path());
|
||||
}
|
||||
let mut child = ide_core::process::prepare(&mut cmd)
|
||||
.args(args)
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
|
||||
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
|
||||
//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes;
|
||||
//! and the PHPStan run that checks saved files.
|
||||
//! the PHPStan run that checks saved files; and whether an interpreter loads Xdebug.
|
||||
|
||||
use crate::apply::{merge, merge_init_options, setting_path};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
@@ -205,6 +207,60 @@ pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option<C
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// A PHP interpreter's version and the Xdebug it loads.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpXdebug {
|
||||
pub php: ide_core::IpcPath,
|
||||
/// `PHP_VERSION`.
|
||||
pub php_version: String,
|
||||
/// The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets.
|
||||
pub xdebug: Option<String>,
|
||||
}
|
||||
|
||||
const XDEBUG_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// `php` printing its version and the loaded Xdebug's (nothing without one), with Xdebug off: a
|
||||
/// php.ini with `xdebug.start_with_request=yes` would connect to a listening debugger. Xdebug stays
|
||||
/// loaded in mode `off`, so it still reports its version. Run in `cwd`, where a version manager's
|
||||
/// `php` shim (asdf, mise, phpenv) picks the PHP it starts.
|
||||
fn xdebug_command(php: &Path, cwd: Option<&Path>) -> Command {
|
||||
let mut cmd = Command::new(php);
|
||||
cmd.args(["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#]).env("XDEBUG_MODE", "off");
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The version of `php` and of the Xdebug it loads, run in `cwd`. An error when `php` doesn't run,
|
||||
/// times out or prints something else.
|
||||
pub fn xdebug(php: &Path, cwd: Option<&Path>) -> IdeResult<PhpXdebug> {
|
||||
let out = crate::exec::query_command(xdebug_command(php, cwd), XDEBUG_TIMEOUT)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("cannot run {}", php.display())))?;
|
||||
let (php_version, xdebug) = parse_xdebug(&out)
|
||||
.ok_or_else(|| IdeError::invalid(format!("{} printed no PHP version: {out}", php.display())))?;
|
||||
Ok(PhpXdebug { php: php.to_path_buf().into(), php_version, xdebug })
|
||||
}
|
||||
|
||||
/// The PHP version, then the Xdebug version when one is loaded: the last lines of the output, after
|
||||
/// the startup warnings PHP prints first with `display_startup_errors` (as a broken Xdebug build does).
|
||||
fn parse_xdebug(out: &str) -> Option<(String, Option<String>)> {
|
||||
let lines: Vec<&str> = out.lines().map(str::trim).collect();
|
||||
match lines[..] {
|
||||
[.., php, xdebug] if is_version(php) && is_version(xdebug) => Some((php.into(), Some(xdebug.into()))),
|
||||
[.., php] if is_version(php) => Some((php.into(), None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `8.3.14`, `8.1.2-1ubuntu2.14`, `3.5.0alpha3`.
|
||||
fn is_version(s: &str) -> bool {
|
||||
s.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& s.contains('.')
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || ".-+~_".contains(c))
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
impl PhpLanguageLevel {
|
||||
@@ -521,6 +577,43 @@ mod tests {
|
||||
assert!(!args(" ").iter().any(|a| a.starts_with("--configuration")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xdebug_probe_runs_with_xdebug_off() {
|
||||
let php = Path::new("/opt/homebrew/opt/[email protected]/bin/php");
|
||||
let project = Path::new("/work/app");
|
||||
let cmd = xdebug_command(php, Some(project));
|
||||
assert_eq!(cmd.get_program(), php.as_os_str());
|
||||
assert_eq!(cmd.get_current_dir(), Some(project), "where a php shim picks the version");
|
||||
assert_eq!(
|
||||
cmd.get_args().map(text).collect::<Vec<_>>(),
|
||||
["-r", r#"echo PHP_VERSION, "\n", phpversion("xdebug") ?: "";"#]
|
||||
);
|
||||
assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]);
|
||||
assert_eq!(xdebug_command(php, None).get_current_dir(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xdebug_probe_reads_both_versions() {
|
||||
let found = |php: &str, xdebug: Option<&str>| Some((php.to_string(), xdebug.map(str::to_string)));
|
||||
assert_eq!(parse_xdebug("8.3.14\n3.4.7"), found("8.3.14", Some("3.4.7")));
|
||||
assert_eq!(parse_xdebug("8.1.33"), found("8.1.33", None));
|
||||
assert_eq!(parse_xdebug("8.1.2-1ubuntu2.14\r\n3.5.0alpha3"), found("8.1.2-1ubuntu2.14", Some("3.5.0alpha3")));
|
||||
assert_eq!(parse_xdebug("5.6.40\n2.5.5"), found("5.6.40", Some("2.5.5")));
|
||||
// display_startup_errors prints startup warnings before the script's output.
|
||||
let warning = "\nWarning: PHP Startup: Unable to load dynamic library 'xdebug.so' in Unknown on line 0";
|
||||
assert_eq!(parse_xdebug(&format!("{warning}\n8.1.33")), found("8.1.33", None));
|
||||
assert_eq!(parse_xdebug(&format!("{warning}\n8.3.14\n3.4.7")), found("8.3.14", Some("3.4.7")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xdebug_probe_fails_on_other_output() {
|
||||
for out in ["", " ", "Could not open input file: -r", "8.3.14\nnot a version", "PHP", "8"] {
|
||||
assert_eq!(parse_xdebug(out), None, "{out:?}");
|
||||
}
|
||||
let missing = Path::new("/nonexistent/bin/php");
|
||||
assert!(xdebug(missing, None).is_err(), "no PHP is an error, not a PHP without Xdebug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_level_names_its_source() {
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
|
||||
@@ -407,6 +407,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
sdk::sdk_detect,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_effective,
|
||||
sdk::sdk_php_xdebug,
|
||||
sdk::sdk_php_language_level,
|
||||
sdk::sdk_php_composer_install,
|
||||
sdk::sdk_project_libraries,
|
||||
|
||||
@@ -35,6 +35,19 @@ pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResul
|
||||
ide_host::ops::sdk::sdk_php_effective(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
|
||||
/// project root).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_php_xdebug(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
php: PathBuf,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> IdeResult<ide_sdk::php::PhpXdebug> {
|
||||
ide_host::ops::sdk::sdk_php_xdebug(&state.host, window.label(), php, cwd).await
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -799,6 +799,11 @@ export const commands = {
|
||||
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
|
||||
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
|
||||
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
|
||||
/**
|
||||
* The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
|
||||
* project root).
|
||||
*/
|
||||
sdkPhpXdebug: (php: string, cwd: string | null) => typedError<PhpXdebug, IdeError>(__TAURI_INVOKE("sdk_php_xdebug", { php, cwd })),
|
||||
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
|
||||
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
|
||||
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
|
||||
@@ -5673,6 +5678,15 @@ export type PhpSettings = {
|
||||
phpstanConfig?: string,
|
||||
};
|
||||
|
||||
/** A PHP interpreter's version and the Xdebug it loads. */
|
||||
export type PhpXdebug = {
|
||||
php: IpcPath,
|
||||
/** `PHP_VERSION`. */
|
||||
phpVersion: string,
|
||||
/** The Xdebug version; `None`: not loaded, so PHP ignores the `xdebug.*` options a debug session sets. */
|
||||
xdebug: string | null,
|
||||
};
|
||||
|
||||
/** An entry of a plugin's `pick` or `pick-many`. */
|
||||
export type PickItem = {
|
||||
key: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
const ipc = vi.hoisted(() => {
|
||||
@@ -20,6 +20,16 @@ const ipc = vi.hoisted(() => {
|
||||
debugStackTrace: vi.fn(() => ok([])),
|
||||
debugThreads: vi.fn(() => ok([])),
|
||||
debugScopes: vi.fn(() => ok([])),
|
||||
debugStart: vi.fn((_launch: { extraJson: string | null }) => ok(42)),
|
||||
debugSetBreakpoints: vi.fn(() => ok([])),
|
||||
debugBreakpointStates: vi.fn(() => ok([])),
|
||||
sdkPhpEffective: vi.fn(() => ok<string | null>("/opt/php81/bin/php")),
|
||||
sdkLocateTools: vi.fn((_names: string[], _near: string | null) =>
|
||||
ok({ tools: [{ name: "php", path: "/usr/bin/php" }], compileDir: "/tmp" }),
|
||||
),
|
||||
sdkPhpXdebug: vi.fn((php: string, _cwd: string | null) =>
|
||||
ok({ php, phpVersion: "8.1.33", xdebug: null as string | null }),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,9 +59,11 @@ vi.mock("@/workbench/contributions", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { executeAction, getAction, registerActions } from "@/platform/actions";
|
||||
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
|
||||
import type { DebugCapabilities } from "@/platform/ipc";
|
||||
import type { DebugCapabilities, RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { registerDebugActions } from "../actions";
|
||||
import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store";
|
||||
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
|
||||
@@ -313,6 +325,82 @@ describe("debugger actions (IDEA parity)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PHP debugging without Xdebug", () => {
|
||||
const php: RunConfig = {
|
||||
id: "php:index",
|
||||
name: "index.php",
|
||||
kind: "php",
|
||||
command: "php",
|
||||
args: ["index.php"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: "php-debug",
|
||||
};
|
||||
const notify = vi.fn();
|
||||
const settings = vi.fn();
|
||||
let unregister: () => void;
|
||||
beforeEach(() => {
|
||||
useProject.setState({ project: { root: "/p" } as never });
|
||||
provideNotificationService({ notify });
|
||||
unregister = registerActions([{ id: "file.settings", title: "Settings", run: ({ arg }) => settings(arg) }]);
|
||||
});
|
||||
afterEach(() => {
|
||||
unregister();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
const runtime = () => JSON.parse(ipc.debugStart.mock.calls[0]![0].extraJson!).runtimeExecutable;
|
||||
|
||||
it("warns that breakpoints are skipped and still starts the session", async () => {
|
||||
await executeAction("debug.startConfig", php);
|
||||
// In the session's folder, where a version manager's shim picks the PHP.
|
||||
expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/opt/php81/bin/php", "/p");
|
||||
expect(ipc.debugStart).toHaveBeenCalledOnce();
|
||||
expect(runtime()).toBe("/opt/php81/bin/php");
|
||||
expect(notify).toHaveBeenCalledOnce();
|
||||
const n = notify.mock.calls[0]![0];
|
||||
expect(n).toMatchObject({ group: "run", level: "warning", key: "debug.phpXdebug" });
|
||||
expect(n.title).toBe("Xdebug is not loaded in PHP 8.1.33");
|
||||
expect(n.body).toContain("/opt/php81/bin/php");
|
||||
expect(n.body).toContain("Xdebug 3");
|
||||
expect(n.actions.map((a: { label: string }) => a.label)).toEqual(["Open PHP Settings"]);
|
||||
n.actions[0].run();
|
||||
expect(settings).toHaveBeenCalledWith("languages.php");
|
||||
});
|
||||
|
||||
it("does not warn when the PHP loads Xdebug or the check fails", async () => {
|
||||
ipc.sdkPhpXdebug.mockImplementationOnce((p: string) => ipc.ok({ php: p, phpVersion: "8.3.14", xdebug: "3.4.7" }));
|
||||
await executeAction("debug.startConfig", php);
|
||||
ipc.sdkPhpXdebug.mockImplementationOnce(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
status: "error",
|
||||
error: { kind: "unavailable", message: "cannot run", data: null },
|
||||
}) as never,
|
||||
);
|
||||
await executeAction("debug.startConfig", php);
|
||||
expect(ipc.sdkPhpXdebug).toHaveBeenCalledTimes(2);
|
||||
expect(ipc.debugStart).toHaveBeenCalledTimes(2);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the php on the search path for the Default interpreter", async () => {
|
||||
ipc.sdkPhpEffective.mockImplementationOnce(() => ipc.ok(null));
|
||||
await executeAction("debug.startConfig", php);
|
||||
expect(ipc.sdkLocateTools).toHaveBeenCalledWith(["php"], null);
|
||||
expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/usr/bin/php", "/p");
|
||||
expect(runtime()).toBeUndefined();
|
||||
expect(notify.mock.calls[0]![0].body).toContain("/usr/bin/php");
|
||||
});
|
||||
|
||||
it("checks the PHP the command names, in the configuration's working directory", async () => {
|
||||
await executeAction("debug.startConfig", { ...php, command: "/opt/php83/bin/php", cwd: "web" });
|
||||
expect(ipc.sdkPhpXdebug).toHaveBeenCalledWith("/opt/php83/bin/php", "/p/web");
|
||||
expect(ipc.sdkLocateTools).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("output class breakpoint restrictions", () => {
|
||||
it.each([
|
||||
"debug.toggleBreakpoint",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** Debugger actions (ids match `keymaps/intellij-macos.ts`). */
|
||||
import { executeAction, getAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isCancelled,
|
||||
isTauri,
|
||||
nextRequestId,
|
||||
type LaunchConfig,
|
||||
type RunConfig,
|
||||
type RunId,
|
||||
} from "@/platform/ipc";
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
@@ -313,10 +322,34 @@ async function startConfig(arg: unknown) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const xdebugCheck = config.debugAdapter === "php-debug" && isTauri() ? warnWithoutXdebug(launch) : null;
|
||||
await maybeEditorService()
|
||||
?.saveAll()
|
||||
.catch(() => undefined);
|
||||
await startSession(launch, { runConfig: origin ?? config });
|
||||
await xdebugCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* A PHP without the Xdebug extension ignores the `-dxdebug.*` options php-debug starts it with: the
|
||||
* script runs past every breakpoint. Warns, without holding the session back (a command may load
|
||||
* Xdebug itself with `-dzend_extension=`).
|
||||
*/
|
||||
async function warnWithoutXdebug(launch: LaunchConfig) {
|
||||
const runtime = (JSON.parse(launch.extraJson ?? "{}") as { runtimeExecutable?: string }).runtimeExecutable;
|
||||
// Without one, php-debug runs the `php` on the search path.
|
||||
const php = runtime ?? (await call(commands.sdkLocateTools(["php"], null)).catch(() => null))?.tools[0]?.path;
|
||||
// In the session's folder: a version manager's `php` shim picks the PHP from it.
|
||||
const probe = php ? await call(commands.sdkPhpXdebug(php, launch.cwd)).catch(() => null) : null;
|
||||
if (!probe || probe.xdebug) return;
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
key: "debug.phpXdebug",
|
||||
title: `Xdebug is not loaded in PHP ${probe.phpVersion}`,
|
||||
body: tx`${probe.php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.`,
|
||||
actions: [{ label: "Open PHP Settings", run: () => void executeAction("file.settings", "languages.php") }],
|
||||
});
|
||||
}
|
||||
|
||||
export function registerDebugActions(): () => void {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { Sdk } from "@/platform/ipc";
|
||||
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
const sdk = (path: string, version: string): Sdk => ({
|
||||
path,
|
||||
name: `PHP ${version}`,
|
||||
version,
|
||||
source: "system",
|
||||
home: null,
|
||||
extra: null,
|
||||
});
|
||||
const XDEBUG: Record<string, string | null> = { "/opt/php83/bin/php": "3.4.7", "/opt/php81/bin/php": null };
|
||||
const mocks = vi.hoisted(() => ({ xdebug: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const actual = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
...actual.commands,
|
||||
sdkDetect: () => ok([sdk("/opt/php83/bin/php", "8.3.14"), sdk("/opt/php81/bin/php", "8.1.33")]),
|
||||
sdkLocateTools: () => ok({ tools: [{ name: "php", path: "/opt/php81/bin/php" }], compileDir: "/tmp" }),
|
||||
sdkPhpXdebug: mocks.xdebug,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { PageContext } from "../components/context";
|
||||
import { PhpPage } from "../languages/LanguagePages";
|
||||
import { detectSdks } from "../languages/api";
|
||||
import { DEFAULT_SETTINGS } from "../model/schema";
|
||||
import { openSettings, stage } from "../model/staging";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.xdebug.mockImplementation((php: string) =>
|
||||
php in XDEBUG
|
||||
? ok({ php, phpVersion: php.includes("83") ? "8.3.14" : "8.1.33", xdebug: XDEBUG[php] })
|
||||
: Promise.resolve({ status: "error", error: { kind: "unavailable", message: "cannot run", data: null } }),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function page(interpreter: string) {
|
||||
const settings = structuredClone(DEFAULT_SETTINGS);
|
||||
settings.languages.php.interpreter = interpreter;
|
||||
useProject.setState({ settings, project: null });
|
||||
openSettings("languages.php");
|
||||
render(
|
||||
<PageContext.Provider value={{ pageId: "languages.php", scope: "global" }}>
|
||||
<PhpPage />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The value of the CLI Interpreter row labelled `label`. */
|
||||
const row = (label: string) => screen.queryByText(label, { selector: "label *" })?.closest("div")?.textContent;
|
||||
|
||||
describe("PHP settings: Xdebug row", () => {
|
||||
it("shows the Xdebug version of the selected PHP, re-checked when the interpreter changes", async () => {
|
||||
page("/opt/php83/bin/php");
|
||||
await waitFor(() => expect(row("Xdebug")).toContain("3.4.7"));
|
||||
expect(row("Version")).toContain("8.3.14");
|
||||
expect(mocks.xdebug).toHaveBeenCalledWith("/opt/php83/bin/php", null);
|
||||
|
||||
let finish: () => void = () => {};
|
||||
mocks.xdebug.mockImplementationOnce(
|
||||
(php: string) =>
|
||||
new Promise((r) => (finish = () => r({ status: "ok", data: { php, phpVersion: "8.1.33", xdebug: null } }))),
|
||||
);
|
||||
act(() => stage("languages.php", "global", "languages.php.interpreter", "/opt/php81/bin/php"));
|
||||
expect(row("Version")).toContain("8.1.33");
|
||||
expect(row("Xdebug"), "not the previous PHP's Xdebug").toBeUndefined();
|
||||
await act(async () => finish());
|
||||
expect(row("Xdebug")).toContain("Not installed");
|
||||
});
|
||||
|
||||
it("checks again when the interpreters are detected again", async () => {
|
||||
page("/opt/php81/bin/php");
|
||||
await waitFor(() => expect(row("Xdebug")).toContain("Not installed"));
|
||||
XDEBUG["/opt/php81/bin/php"] = "3.4.7";
|
||||
try {
|
||||
await act(() => detectSdks("php", true));
|
||||
await waitFor(() => expect(row("Xdebug")).toContain("3.4.7"));
|
||||
} finally {
|
||||
XDEBUG["/opt/php81/bin/php"] = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("checks the php on the search path for the Default interpreter", async () => {
|
||||
page("");
|
||||
await waitFor(() => expect(row("Xdebug")).toContain("Not installed"));
|
||||
expect(mocks.xdebug).toHaveBeenCalledWith("/opt/php81/bin/php", null);
|
||||
});
|
||||
|
||||
it("hides the row while checking and when the check fails", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
mocks.xdebug.mockImplementationOnce(() => new Promise((r) => (finish = r)));
|
||||
page("/opt/php83/bin/php");
|
||||
await waitFor(() => expect(row("Version")).toContain("8.3.14"));
|
||||
expect(row("Xdebug")).toBeUndefined();
|
||||
await act(async () =>
|
||||
finish({ status: "error", error: { kind: "unavailable", message: "timed out", data: null } }),
|
||||
);
|
||||
expect(row("Xdebug")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
* use (applied by `ide-sdk` when language servers start and when run configurations load).
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { IconButton, Input, type SelectOption } from "@/ui";
|
||||
import { call, commands, type Sdk } from "@/platform/ipc";
|
||||
import { IconButton, Input, type SelectOption, T } from "@/ui";
|
||||
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
|
||||
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
|
||||
import { choosePath } from "./api";
|
||||
@@ -134,6 +134,25 @@ function usePathPhp(enabled: boolean): string | null {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Xdebug version `php` loads, `null` for none; `undefined` while it is checked or when the check
|
||||
* fails. Checked again when detection runs again (after installing Xdebug).
|
||||
*/
|
||||
function useXdebug(php: Sdk | undefined): string | null | undefined {
|
||||
const [found, setFound] = useState<{ php: Sdk; xdebug: string | null } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!php) return;
|
||||
let live = true;
|
||||
call(commands.sdkPhpXdebug(php.path, null))
|
||||
.then((r) => live && setFound({ php, xdebug: r.xdebug }))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [php]);
|
||||
return found && found.php === php ? found.xdebug : undefined;
|
||||
}
|
||||
|
||||
export function PhpPage() {
|
||||
const [interpreter] = useSetting<string>("languages.php.interpreter");
|
||||
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
|
||||
@@ -143,6 +162,7 @@ export function PhpPage() {
|
||||
const pathPhp = usePathPhp(!interpreter);
|
||||
// Default runs the `php` on PATH: the list is newest first, so it isn't necessarily `list[0]`.
|
||||
const selected = list.find((s) => s.path === (interpreter || pathPhp));
|
||||
const xdebug = useXdebug(selected);
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Section>
|
||||
@@ -203,6 +223,7 @@ export function PhpPage() {
|
||||
hint="Runs PHP scripts, PHPUnit, Composer and artisan, and the PHP-based language servers (phpactor). Its folder is put first on PATH, so vendor/bin/phpunit and other #!/usr/bin/env php scripts use it too."
|
||||
/>
|
||||
{selected?.version && <Row label="Version">{selected.version}</Row>}
|
||||
{xdebug !== undefined && <Row label="Xdebug">{xdebug ?? <T>Not installed</T>}</Row>}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -168,6 +168,7 @@ export const LANGUAGE_PAGES: PageSpec[] = [
|
||||
"cli interpreter",
|
||||
"language level",
|
||||
"php version",
|
||||
"xdebug",
|
||||
"php",
|
||||
"composer",
|
||||
"phpunit",
|
||||
|
||||
@@ -46,5 +46,8 @@
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.",
|
||||
"Automatic": "Automatic",
|
||||
"CNB (China mirror)": "CNB (China mirror)",
|
||||
"Remote connection cancelled": "Remote connection cancelled"
|
||||
"Remote connection cancelled": "Remote connection cancelled",
|
||||
"Xdebug is not loaded in PHP {version}": "Xdebug is not loaded in PHP {version}",
|
||||
"{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.",
|
||||
"Open PHP Settings": "Open PHP Settings"
|
||||
}
|
||||
@@ -6401,5 +6401,8 @@
|
||||
"Update source": "更新源",
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
|
||||
"CNB (China mirror)": "CNB(国内镜像)",
|
||||
"Remote connection cancelled": "远程连接已取消"
|
||||
"Remote connection cancelled": "远程连接已取消",
|
||||
"Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug",
|
||||
"{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。",
|
||||
"Open PHP Settings": "打开 PHP 设置"
|
||||
}
|
||||
@@ -96,6 +96,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"IntelliJ IDEA",
|
||||
"GitHub",
|
||||
"PHPStan",
|
||||
"Xdebug",
|
||||
// Docker run target placeholder values
|
||||
"compose.yaml",
|
||||
"python:3.12",
|
||||
|
||||
Reference in new issue
Заблокувати користувача