diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f56fa3749..8fc199d011 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -119,6 +119,14 @@ jobs:
MYSQL_DATABASE: test_db
MYSQL_USER: user
MYSQL_PASSWORD: password
+ test-php-wasm-cli:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: ./.github/actions/prepare-playground
+ - run: npx nx test php-wasm-cli
test-playground-cli:
strategy:
matrix:
diff --git a/packages/php-wasm/cli/src/main.ts b/packages/php-wasm/cli/src/main.ts
index 06f509627c..ff587f56bd 100644
--- a/packages/php-wasm/cli/src/main.ts
+++ b/packages/php-wasm/cli/src/main.ts
@@ -9,12 +9,16 @@ import {
LatestSupportedPHPVersion,
SupportedPHPVersionsList,
} from '@php-wasm/universal';
+/* eslint-disable no-console */
import type { SupportedPHPVersion } from '@php-wasm/universal';
-
import { FileLockManagerForNode } from '@php-wasm/node';
import { PHP } from '@php-wasm/universal';
import { loadNodeRuntime, useHostFilesystem } from '@php-wasm/node';
import { startBridge } from '@php-wasm/xdebug-bridge';
+import {
+ addXdebugIDEConfig,
+ clearXdebugIDEConfig,
+} from './xdebug-path-mappings';
import path from 'path';
let args = process.argv.slice(2);
@@ -22,6 +26,15 @@ if (!args.length) {
args = ['--help'];
}
+const bold = (text: string) =>
+ process.stdout.isTTY ? '\x1b[1m' + text + '\x1b[0m' : text;
+
+const italic = (text: string) =>
+ process.stdout.isTTY ? `\x1b[3m${text}\x1b[0m` : text;
+
+const highlight = (text: string) =>
+ process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text;
+
const baseUrl = (import.meta || {}).url;
// Write the ca-bundle.crt file to disk so that PHP can find it.
@@ -51,6 +64,23 @@ async function run() {
args = args.filter((arg) => arg !== '--experimental-devtools');
}
+ const experimentalUnsafeIDEIntegrationOptions =
+ args
+ .filter((arg) =>
+ arg.startsWith('--experimental-unsafe-ide-integration')
+ )
+ .map((arg) => {
+ const value = arg.split('=')[1];
+ if (value === undefined) return ['vscode', 'phpstorm'];
+ if (value.includes(',')) return value.split(',');
+ return [value];
+ })[0] ?? false;
+ if (experimentalUnsafeIDEIntegrationOptions) {
+ args = args.filter(
+ (arg) => !arg.startsWith('--experimental-unsafe-ide-integration')
+ );
+ }
+
// npm scripts set the TMPDIR env variable
// PHP accepts a TMPDIR env variable and expects it to
// be a writable directory within the PHP filesystem.
@@ -105,7 +135,89 @@ ${process.argv[0]} ${process.execArgv.join(' ')} ${process.argv[1]}
useHostFilesystem(php);
- if (hasDevtoolsOption && hasXdebugOption) {
+ // If xdebug, and experimental IDE are enabled,
+ // add the new IDE config.
+ if (hasXdebugOption && experimentalUnsafeIDEIntegrationOptions) {
+ try {
+ const IDEConfigName = 'PHP.wasm CLI - Listen for Xdebug';
+ const ides = experimentalUnsafeIDEIntegrationOptions;
+
+ // NOTE: Both the 'clear' and 'add' operations can throw errors.
+ await clearXdebugIDEConfig(IDEConfigName, process.cwd());
+
+ const modifiedConfig = await addXdebugIDEConfig({
+ name: IDEConfigName,
+ host: 'example.com',
+ port: 443,
+ ides: ides,
+ cwd: process.cwd(),
+ });
+
+ // Display IDE-specific instructions
+ const hasVSCode = ides.includes('vscode');
+ const hasPhpStorm = ides.includes('phpstorm');
+
+ console.log('');
+
+ if (modifiedConfig.length > 0) {
+ console.log(bold(`Xdebug configured successfully`));
+ console.log(
+ highlight(`Updated IDE config: `) + modifiedConfig.join(' ')
+ );
+ } else {
+ console.log(bold(`Xdebug configuration failed.`));
+ console.log(
+ 'No IDE-specific project settings directory was found in the current working directory.'
+ );
+ }
+
+ console.log('');
+
+ if (hasVSCode) {
+ console.log(bold('VS Code / Cursor instructions:'));
+ console.log(
+ ' 1. Ensure you have installed an IDE extension for PHP Debugging'
+ );
+ console.log(
+ ` (The ${bold('PHP Debug')} extension by ${bold(
+ 'Xdebug'
+ )} has been a solid option)`
+ );
+ console.log(
+ ' 2. Open the Run and Debug panel on the left sidebar'
+ );
+ console.log(
+ ` 3. Select "${italic(IDEConfigName)}" from the dropdown`
+ );
+ console.log(' 3. Click "start debugging"');
+ console.log(' 5. Set a breakpoint.');
+ console.log(' 6. Run your command with PHP.wasm CLI.');
+ if (hasPhpStorm) {
+ console.log('');
+ }
+ }
+
+ if (hasPhpStorm) {
+ console.log(bold('PhpStorm instructions:'));
+ console.log(
+ ` 1. Choose "${italic(
+ IDEConfigName
+ )}" debug configuration in the toolbar`
+ );
+ console.log(' 2. Click the debug button (bug icon)`');
+ console.log(' 3. Set a breakpoint.');
+ console.log(' 4. Run your command with PHP.wasm CLI.');
+ }
+
+ console.log('');
+ } catch (error) {
+ throw new Error('Could not configure Xdebug', {
+ cause: error,
+ });
+ }
+ }
+
+ if (hasXdebugOption && hasDevtoolsOption) {
const bridge = await startBridge({ breakOnFirstLine: true });
bridge.start();
diff --git a/packages/php-wasm/cli/src/test/xdebug-path-mappings.spec.ts b/packages/php-wasm/cli/src/test/xdebug-path-mappings.spec.ts
new file mode 100644
index 0000000000..7e55ed4d2e
--- /dev/null
+++ b/packages/php-wasm/cli/src/test/xdebug-path-mappings.spec.ts
@@ -0,0 +1,868 @@
+import {
+ updatePhpStormConfig,
+ updateVSCodeConfig,
+ type PhpStormConfigOptions,
+ type VSCodeConfigOptions,
+} from '../xdebug-path-mappings';
+import { XMLParser } from 'fast-xml-parser';
+import * as JSONC from 'jsonc-parser';
+
+/**
+ * Helper to compare two XML documents structurally.
+ * Normalizes whitespace and compares the parsed structure.
+ *
+ * This validates that the XML has the same semantic structure,
+ * regardless of formatting, attribute order, or whitespace.
+ *
+ * Uses the same parser options as the source code for consistency.
+ */
+function expectXMLEquals(actualXML: string, expectedXML: string) {
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '',
+ preserveOrder: true, // Match source code
+ trimValues: true,
+ parseAttributeValue: false,
+ parseTagValue: false,
+ cdataPropName: '__cdata',
+ commentPropName: '__xmlComment',
+ allowBooleanAttributes: true,
+ });
+
+ let actual: any;
+ let expected: any;
+
+ try {
+ actual = parser.parse(actualXML, true);
+ } catch (error) {
+ throw new Error(`Failed to parse actual XML: ${error}`);
+ }
+
+ try {
+ expected = parser.parse(expectedXML, true);
+ } catch (error) {
+ throw new Error(`Failed to parse expected XML: ${error}`);
+ }
+
+ expect(actual).toEqual(expected);
+}
+
+/**
+ * Helper to compare two JSON documents structurally.
+ * Parses and compares the structures, ignoring formatting differences.
+ *
+ * Uses JSONC parser to handle comments and trailing commas.
+ */
+function expectJSONEquals(actualJSON: string, expectedJSON: string) {
+ let actual: any;
+ let expected: any;
+
+ try {
+ // Use JSONC parser to handle comments and trailing commas
+ const errors: JSONC.ParseError[] = [];
+ actual = JSONC.parse(actualJSON, errors, { allowTrailingComma: true });
+ if (errors.length > 0) {
+ throw new Error(
+ `JSONC parse errors: ${errors.map((e) => e.error).join(', ')}`
+ );
+ }
+ } catch (error) {
+ throw new Error(`Failed to parse actual JSON: ${error}`);
+ }
+
+ try {
+ const errors: JSONC.ParseError[] = [];
+ expected = JSONC.parse(expectedJSON, errors, {
+ allowTrailingComma: true,
+ });
+ if (errors.length > 0) {
+ throw new Error(
+ `JSONC parse errors: ${errors.map((e) => e.error).join(', ')}`
+ );
+ }
+ } catch (error) {
+ throw new Error(`Failed to parse expected JSON: ${error}`);
+ }
+
+ expect(actual).toEqual(expected);
+}
+
+describe('updatePhpStormConfig', () => {
+ const defaultOptions: PhpStormConfigOptions = {
+ name: 'Test Server',
+ host: 'localhost',
+ port: 8080,
+ projectDir: process.cwd(),
+ ideKey: 'PHPWASMCLI',
+ };
+
+ describe('valid configurations', () => {
+ it('should add server and run configuration to minimal valid XML', () => {
+ const xml =
+ '\n\n';
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle empty project element', () => {
+ const xml =
+ '\n';
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should preserve existing components', () => {
+ const xml = `
+
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should not duplicate server if it already exists', () => {
+ const xml =
+ '\n\n';
+ const result1 = updatePhpStormConfig(xml, defaultOptions);
+ const result2 = updatePhpStormConfig(result1, defaultOptions);
+
+ // Count server elements in PhpServers component - should only be 1
+ const serverMatches =
+ result2.match(/]*name="Test Server"/g) || [];
+ expect(serverMatches.length).toBe(1);
+
+ // Count configuration elements in RunManager component - should only be 1
+ const configMatches =
+ result2.match(/]*name="Test Server"/g) || [];
+ expect(configMatches.length).toBe(1);
+ });
+
+ it('should handle existing PhpServers component', () => {
+ const xml = `
+
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle existing RunManager component', () => {
+ const xml = `
+
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should use custom IDE key', () => {
+ const options: PhpStormConfigOptions = {
+ ...defaultOptions,
+ ideKey: 'CUSTOM_KEY',
+ };
+
+ const xml =
+ '\n\n';
+ const result = updatePhpStormConfig(xml, options);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle complex host and port combinations', () => {
+ const options: PhpStormConfigOptions = {
+ ...defaultOptions,
+ host: '192.168.1.100',
+ port: 3000,
+ };
+
+ const xml =
+ '\n\n';
+ const result = updatePhpStormConfig(xml, options);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+ });
+
+ describe('error handling', () => {
+ it('should throw error for invalid XML', () => {
+ const invalidXml = 'not valid xml';
+
+ expect(() => {
+ updatePhpStormConfig(invalidXml, defaultOptions);
+ }).toThrow('PhpStorm configuration file is not valid XML.');
+ });
+
+ it('should throw error for malformed XML', () => {
+ const malformedXml = '';
+
+ expect(() => {
+ updatePhpStormConfig(malformedXml, defaultOptions);
+ }).toThrow('PhpStorm configuration file is not valid XML.');
+ });
+
+ it('should throw error for project element without version', () => {
+ const xml =
+ '\n\n';
+
+ expect(() => {
+ updatePhpStormConfig(xml, defaultOptions);
+ }).toThrow(
+ 'PhpStorm IDE integration only supports in workspace.xml, ' +
+ 'but the configuration has no version number.'
+ );
+ });
+
+ it('should throw error for unsupported project version', () => {
+ const xml =
+ '\n\n';
+
+ expect(() => {
+ updatePhpStormConfig(xml, defaultOptions);
+ }).toThrow(
+ 'PhpStorm IDE integration only supports in workspace.xml, ' +
+ 'but we found a configuration with version "5".'
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle XML with comments', () => {
+ const xml = `
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle XML with CDATA sections', () => {
+ const xml = `
+
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle special characters in server name', () => {
+ const options: PhpStormConfigOptions = {
+ ...defaultOptions,
+ name: 'Test & Server "With" Quotes',
+ };
+
+ const xml =
+ '\n\n';
+ const result = updatePhpStormConfig(xml, options);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle deeply nested existing structure', () => {
+ const xml = `
+
+
+
+
+
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+
+ it('should handle project element with additional attributes', () => {
+ const xml = `
+
+
+`;
+ const result = updatePhpStormConfig(xml, defaultOptions);
+
+ const expected = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ expectXMLEquals(result, expected);
+ });
+ });
+});
+
+describe('updateVSCodeConfig', () => {
+ const defaultOptions: VSCodeConfigOptions = {
+ name: 'Test Configuration',
+ workspaceDir: process.cwd(),
+ };
+
+ describe('valid configurations', () => {
+ it('should add configuration to minimal valid JSON', () => {
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should create configurations array if missing', () => {
+ const json = '{}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should handle empty JSON object', () => {
+ const json = '{\n}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should preserve existing configurations', () => {
+ const json = `{
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php",
+ "request": "launch",
+ "port": 9000
+ }
+ ]
+}`;
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php",
+ "request": "launch",
+ "port": 9000
+ },
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should not duplicate configuration if it already exists', () => {
+ const json = '{\n "configurations": []\n}';
+ const result1 = updateVSCodeConfig(json, defaultOptions);
+ const result2 = updateVSCodeConfig(result1, defaultOptions);
+
+ // Count occurrences of the configuration name
+ const matches = result2.match(/"name": "Test Configuration"/g);
+ expect(matches?.length).toBe(1);
+ });
+
+ it('should handle JSON with comments (JSONC)', () => {
+ const json = `{
+ // This is a comment
+ "configurations": [
+ // Another comment
+ ]
+}`;
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ // Comments are not preserved in output, so just check structure
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should handle JSON with trailing commas (JSONC)', () => {
+ const json = `{
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php",
+ },
+ ],
+}`;
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ // Trailing commas are normalized in output
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php"
+ },
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should preserve other properties in the JSON', () => {
+ const json = `{
+ "version": "0.2.0",
+ "configurations": [],
+ "compounds": []
+}`;
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ],
+ "compounds": []
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should maintain proper JSON formatting', () => {
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ // Also verify it's valid JSON
+ expect(() => JSON.parse(result)).not.toThrow();
+ });
+ });
+
+ describe('error handling', () => {
+ it('should throw error for invalid JSON', () => {
+ const invalidJson = 'not valid json';
+
+ expect(() => {
+ updateVSCodeConfig(invalidJson, defaultOptions);
+ }).toThrow('VS Code configuration file is not valid JSON.');
+ });
+
+ it('should throw error for malformed JSON', () => {
+ const malformedJson = '{"configurations": [}';
+
+ expect(() => {
+ updateVSCodeConfig(malformedJson, defaultOptions);
+ }).toThrow('VS Code configuration file is not valid JSON.');
+ });
+
+ it('should throw error for JSON with unclosed brackets', () => {
+ const unclosedJson = '{"configurations": [';
+
+ expect(() => {
+ updateVSCodeConfig(unclosedJson, defaultOptions);
+ }).toThrow('VS Code configuration file is not valid JSON.');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle special characters in configuration name', () => {
+ const options: VSCodeConfigOptions = {
+ ...defaultOptions,
+ name: 'Test & Configuration "With" Quotes',
+ };
+
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, options);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test & Configuration \\"With\\" Quotes",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should handle deeply nested existing structure', () => {
+ const json = `{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php",
+ "request": "launch",
+ "port": 9000
+ }
+ ]
+}`;
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Existing Config",
+ "type": "php",
+ "request": "launch",
+ "port": 9000
+ },
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should handle configurations as first element in array', () => {
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ const parsed = JSON.parse(result);
+ expect(parsed.configurations[0].name).toBe('Test Configuration');
+ });
+
+ it('should handle whitespace variations', () => {
+ const json = '{"configurations":[]}';
+ const result = updateVSCodeConfig(json, defaultOptions);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+
+ it('should handle very long configuration names', () => {
+ const options: VSCodeConfigOptions = {
+ ...defaultOptions,
+ name: 'A'.repeat(200),
+ };
+
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, options);
+
+ const expected = {
+ configurations: [
+ {
+ name: 'A'.repeat(200),
+ type: 'php',
+ request: 'launch',
+ port: 9003,
+ },
+ ],
+ };
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toEqual(expected);
+ });
+
+ it('should handle Unicode characters in configuration', () => {
+ const options: VSCodeConfigOptions = {
+ ...defaultOptions,
+ name: 'Test 🚀 Configuration',
+ };
+
+ const json = '{\n "configurations": []\n}';
+ const result = updateVSCodeConfig(json, options);
+
+ const expected = `{
+ "configurations": [
+ {
+ "name": "Test 🚀 Configuration",
+ "type": "php",
+ "request": "launch",
+ "port": 9003,
+ }
+ ]
+}`;
+
+ expectJSONEquals(result, expected);
+ });
+ });
+});
diff --git a/packages/php-wasm/cli/src/xdebug-path-mappings.ts b/packages/php-wasm/cli/src/xdebug-path-mappings.ts
new file mode 100644
index 0000000000..b9dc4d231a
--- /dev/null
+++ b/packages/php-wasm/cli/src/xdebug-path-mappings.ts
@@ -0,0 +1,595 @@
+import fs from 'fs';
+import path from 'path';
+import {
+ type X2jOptions,
+ type XmlBuilderOptions,
+ XMLParser,
+ XMLBuilder,
+} from 'fast-xml-parser';
+import * as JSONC from 'jsonc-parser';
+
+export type IDEConfig = {
+ /**
+ * The name of the configuration within the IDE configuration.
+ */
+ name: string;
+ /**
+ * The IDEs to configure.
+ */
+ ides: string[];
+ /**
+ * The web server host.
+ */
+ host: string;
+ /**
+ * The web server port.
+ */
+ port: number;
+ /**
+ * The current working directory to consider for debugger path mapping.
+ */
+ cwd: string;
+ /**
+ * The IDE key to use for the debug configuration. Defaults to 'PHPWASMCLI'.
+ */
+ ideKey?: string;
+};
+
+type PhpStormConfigMetaData = {
+ name?: string;
+ version?: string;
+ host?: string;
+ /**
+ * The type of the server.
+ */
+ type?: 'PhpRemoteDebugRunConfigurationType';
+ factoryName?: string;
+ filter_connections?: 'FILTER';
+ server_name?: string;
+ session_id?: string;
+ v?: string;
+};
+
+type PhpStormConfigNode = {
+ ':@'?: PhpStormConfigMetaData;
+ project?: PhpStormConfigNode[];
+ component?: PhpStormConfigNode[];
+ servers?: PhpStormConfigNode[];
+ server?: PhpStormConfigNode[];
+ configuration?: PhpStormConfigNode[];
+ method?: PhpStormConfigNode[];
+};
+
+type VSCodeConfigNode = {
+ name: string;
+ type: string;
+ request: string;
+ port: number;
+};
+
+const xmlParserOptions: X2jOptions = {
+ ignoreAttributes: false,
+ attributeNamePrefix: '',
+ preserveOrder: true,
+ cdataPropName: '__cdata',
+ commentPropName: '__xmlComment',
+ allowBooleanAttributes: true,
+ trimValues: true,
+};
+const xmlBuilderOptions: XmlBuilderOptions = {
+ ignoreAttributes: xmlParserOptions.ignoreAttributes,
+ attributeNamePrefix: xmlParserOptions.attributeNamePrefix,
+ preserveOrder: xmlParserOptions.preserveOrder,
+ cdataPropName: xmlParserOptions.cdataPropName,
+ commentPropName: xmlParserOptions.commentPropName,
+ suppressBooleanAttributes: !xmlParserOptions.allowBooleanAttributes,
+ format: true,
+ indentBy: '\t',
+};
+
+const jsoncParseOptions: JSONC.ParseOptions = {
+ allowEmptyContent: true,
+ allowTrailingComma: true,
+};
+
+export type PhpStormConfigOptions = {
+ name: string;
+ host: string;
+ port: number;
+ projectDir: string;
+ ideKey: string;
+};
+
+/**
+ * Pure function to update PHPStorm XML config with XDebug server and run configuration.
+ *
+ * @param xmlContent The original XML content of workspace.xml
+ * @param options Configuration options for the server
+ * @returns Updated XML content
+ * @throws Error if XML is invalid or configuration is incompatible
+ */
+export function updatePhpStormConfig(
+ xmlContent: string,
+ options: PhpStormConfigOptions
+): string {
+ const { name, host, port, ideKey } = options;
+
+ const xmlParser = new XMLParser(xmlParserOptions);
+
+ // Parse the XML
+ const config: PhpStormConfigNode[] = (() => {
+ try {
+ return xmlParser.parse(xmlContent, true);
+ } catch {
+ throw new Error('PhpStorm configuration file is not valid XML.');
+ }
+ })();
+
+ // Create the server element with path mappings
+ const serverElement: PhpStormConfigNode = {
+ server: [],
+ ':@': {
+ name,
+ // NOTE: PhpStorm quirk: Xdebug only works when the full URL (including port)
+ // is provided in `host`. The separate `port` field is ignored or misinterpreted,
+ // so we rely solely on host: "host:port".
+ host: `${host}:${port}`,
+ },
+ };
+
+ // Find or create project element
+ let projectElement = config?.find((c: PhpStormConfigNode) => !!c?.project);
+ if (projectElement) {
+ const projectVersion = projectElement[':@']?.version;
+ if (projectVersion === undefined) {
+ throw new Error(
+ 'PhpStorm IDE integration only supports in workspace.xml, ' +
+ 'but the configuration has no version number.'
+ );
+ } else if (projectVersion !== '4') {
+ throw new Error(
+ 'PhpStorm IDE integration only supports in workspace.xml, ' +
+ `but we found a configuration with version "${projectVersion}".`
+ );
+ }
+ }
+ if (projectElement === undefined) {
+ projectElement = {
+ project: [],
+ ':@': { version: '4' },
+ };
+ config.push(projectElement);
+ }
+
+ // Find or create PhpServers component
+ let componentElement = projectElement.project?.find(
+ (c: PhpStormConfigNode) =>
+ !!c?.component && c?.[':@']?.name === 'PhpServers'
+ );
+ if (componentElement === undefined) {
+ componentElement = {
+ component: [],
+ ':@': { name: 'PhpServers' },
+ };
+
+ if (projectElement.project === undefined) {
+ projectElement.project = [];
+ }
+
+ projectElement.project.push(componentElement);
+ }
+
+ // Find or create servers element
+ let serversElement = componentElement.component?.find(
+ (c: PhpStormConfigNode) => !!c?.servers
+ );
+ if (serversElement === undefined) {
+ serversElement = { servers: [] };
+
+ if (componentElement.component === undefined) {
+ componentElement.component = [];
+ }
+
+ componentElement.component.push(serversElement);
+ }
+
+ // Check if server already exists
+ const serverElementIndex = serversElement.servers?.findIndex(
+ (c: PhpStormConfigNode) => !!c?.server && c?.[':@']?.name === name
+ );
+
+ // Only add server if it doesn't exist
+ if (serverElementIndex === undefined || serverElementIndex < 0) {
+ if (serversElement.servers === undefined) {
+ serversElement.servers = [];
+ }
+
+ serversElement.servers.push(serverElement);
+ }
+
+ // Find or create RunManager component
+ let runManagerElement = projectElement.project?.find(
+ (c: PhpStormConfigNode) =>
+ !!c?.component && c?.[':@']?.name === 'RunManager'
+ );
+ if (runManagerElement === undefined) {
+ runManagerElement = {
+ component: [],
+ ':@': { name: 'RunManager' },
+ };
+
+ if (projectElement.project === undefined) {
+ projectElement.project = [];
+ }
+
+ projectElement.project.push(runManagerElement);
+ }
+
+ // Check if run configuration already exists
+ const existingConfigIndex =
+ runManagerElement.component?.findIndex(
+ (c: PhpStormConfigNode) =>
+ !!c?.configuration && c?.[':@']?.name === name
+ ) ?? -1;
+
+ // Only add run configuration if it doesn't exist
+ if (existingConfigIndex < 0) {
+ const runConfigElement: PhpStormConfigNode = {
+ configuration: [
+ {
+ method: [],
+ ':@': { v: '2' },
+ },
+ ],
+ ':@': {
+ name: name,
+ type: 'PhpRemoteDebugRunConfigurationType',
+ factoryName: 'PHP Remote Debug',
+ filter_connections: 'FILTER',
+ server_name: name,
+ session_id: ideKey,
+ },
+ };
+
+ if (runManagerElement.component === undefined) {
+ runManagerElement.component = [];
+ }
+
+ runManagerElement.component.push(runConfigElement);
+ }
+
+ // Build the updated XML
+ const xmlBuilder = new XMLBuilder(xmlBuilderOptions);
+ const xml = xmlBuilder.build(config);
+
+ // Validate the generated XML
+ try {
+ xmlParser.parse(xml, true);
+ } catch {
+ throw new Error(
+ 'The resulting PhpStorm configuration file is not valid XML.'
+ );
+ }
+
+ return xml;
+}
+
+export type VSCodeConfigOptions = {
+ name: string;
+ workspaceDir: string;
+};
+
+/**
+ * Pure function to update VS Code launch.json config with XDebug configuration.
+ *
+ * @param jsonContent The original JSON content of launch.json
+ * @param options Configuration options
+ * @returns Updated JSON content
+ * @throws Error if JSON is invalid
+ */
+export function updateVSCodeConfig(
+ jsonContent: string,
+ options: VSCodeConfigOptions
+): string {
+ const { name } = options;
+
+ const errors: JSONC.ParseError[] = [];
+
+ let content = jsonContent;
+ let root = JSONC.parseTree(content, errors, jsoncParseOptions);
+
+ if (root === undefined || errors.length) {
+ throw new Error('VS Code configuration file is not valid JSON.');
+ }
+
+ // Find or create configurations array
+ let configurationsNode = JSONC.findNodeAtLocation(root, ['configurations']);
+
+ if (
+ configurationsNode === undefined ||
+ configurationsNode.children === undefined
+ ) {
+ const edits = JSONC.modify(content, ['configurations'], [], {});
+ content = JSONC.applyEdits(content, edits);
+
+ root = JSONC.parseTree(content, [], jsoncParseOptions);
+ configurationsNode = JSONC.findNodeAtLocation(root!, [
+ 'configurations',
+ ]);
+ }
+
+ // Check if configuration already exists
+ const configurationIndex = configurationsNode?.children?.findIndex(
+ (child) => JSONC.findNodeAtLocation(child, ['name'])?.value === name
+ );
+
+ // Only add configuration if it doesn't exist
+ if (configurationIndex === undefined || configurationIndex < 0) {
+ const configuration: VSCodeConfigNode = {
+ name: name,
+ type: 'php',
+ request: 'launch',
+ port: 9003,
+ };
+
+ // Get the current length to append at the end
+ const currentLength = configurationsNode?.children?.length || 0;
+
+ const edits = JSONC.modify(
+ content,
+ ['configurations', currentLength],
+ configuration,
+ {
+ formattingOptions: {
+ insertSpaces: true,
+ tabSize: 4,
+ eol: '\n',
+ },
+ }
+ );
+
+ content = jsoncApplyEdits(content, edits);
+ }
+
+ return content;
+}
+
+/**
+ * Implement necessary parameters in IDE configuration files.
+ *
+ * @param name The configuration name.
+ * @param mounts The PHP.wasm CLI mount options.
+ */
+export async function addXdebugIDEConfig({
+ name,
+ ides,
+ host,
+ port,
+ cwd,
+ ideKey = 'PHPWASMCLI',
+}: IDEConfig) {
+ const modifiedConfig: string[] = [];
+
+ // PHPstorm
+ if (ides.includes('phpstorm')) {
+ const phpStormRelativeConfigFilePath = '.idea/workspace.xml';
+ const phpStormConfigFilePath = path.join(
+ cwd,
+ phpStormRelativeConfigFilePath
+ );
+
+ // Create a template config file if the IDE directory exists,
+ // or throw an error if IDE integration is requested but the directory is missing.
+ if (!fs.existsSync(phpStormConfigFilePath)) {
+ if (fs.existsSync(path.dirname(phpStormConfigFilePath))) {
+ fs.writeFileSync(
+ phpStormConfigFilePath,
+ '\n\n'
+ );
+ } else if (ides.length == 1) {
+ throw new Error(
+ `PhpStorm IDE integration requested, but no '.idea' directory was found in the current working directory.`
+ );
+ }
+ }
+
+ if (fs.existsSync(phpStormConfigFilePath)) {
+ const contents = fs.readFileSync(phpStormConfigFilePath, 'utf8');
+ const updatedXml = updatePhpStormConfig(contents, {
+ name,
+ host,
+ port,
+ projectDir: cwd,
+ ideKey,
+ });
+ fs.writeFileSync(phpStormConfigFilePath, updatedXml);
+ modifiedConfig.push(phpStormRelativeConfigFilePath);
+ }
+ }
+
+ // VSCode
+ if (ides.includes('vscode')) {
+ const vsCodeRelativeConfigFilePath = '.vscode/launch.json';
+ const vsCodeConfigFilePath = path.join(
+ cwd,
+ vsCodeRelativeConfigFilePath
+ );
+
+ // Create a template config file if the IDE directory exists,
+ // or throw an error if IDE integration is requested but the directory is missing.
+ if (!fs.existsSync(vsCodeConfigFilePath)) {
+ if (fs.existsSync(path.dirname(vsCodeConfigFilePath))) {
+ fs.writeFileSync(
+ vsCodeConfigFilePath,
+ '{\n "configurations": []\n}'
+ );
+ } else if (ides.length == 1) {
+ throw new Error(
+ `VS Code IDE integration requested, but no '.vscode' directory was found in the current working directory.`
+ );
+ }
+ }
+
+ if (fs.existsSync(vsCodeConfigFilePath)) {
+ const content = fs.readFileSync(vsCodeConfigFilePath, 'utf-8');
+ const updatedJson = updateVSCodeConfig(content, {
+ name,
+ workspaceDir: cwd,
+ });
+
+ // Only write and track the file if changes were made
+ if (updatedJson !== content) {
+ fs.writeFileSync(vsCodeConfigFilePath, updatedJson);
+ modifiedConfig.push(vsCodeRelativeConfigFilePath);
+ }
+ }
+ }
+
+ return modifiedConfig;
+}
+
+/**
+ * Remove stale parameters in IDE configuration files.
+ *
+ * @param name The configuration name.
+ * @param cwd The current working directory.
+ */
+export async function clearXdebugIDEConfig(name: string, cwd: string) {
+ const phpStormConfigFilePath = path.join(cwd, '.idea/workspace.xml');
+ // PhpStorm
+ if (fs.existsSync(phpStormConfigFilePath)) {
+ const contents = fs.readFileSync(phpStormConfigFilePath, 'utf8');
+ const xmlParser = new XMLParser(xmlParserOptions);
+ // NOTE: Using an IIFE so `config` can remain const.
+ const config: PhpStormConfigNode[] = (() => {
+ try {
+ return xmlParser.parse(contents, true);
+ } catch {
+ throw new Error(
+ 'PhpStorm configuration file is not valid XML.'
+ );
+ }
+ })();
+
+ const projectElement = config.find(
+ (c: PhpStormConfigNode) => !!c?.project
+ );
+ const componentElement = projectElement?.project?.find(
+ (c: PhpStormConfigNode) =>
+ !!c?.component && c?.[':@']?.name === 'PhpServers'
+ );
+ const serversElement = componentElement?.component?.find(
+ (c: PhpStormConfigNode) => !!c?.servers
+ );
+ const serverElementIndex = serversElement?.servers?.findIndex(
+ (c: PhpStormConfigNode) => !!c?.server && c?.[':@']?.name === name
+ );
+
+ if (serverElementIndex !== undefined && serverElementIndex >= 0) {
+ serversElement!.servers!.splice(serverElementIndex, 1);
+
+ const xmlBuilder = new XMLBuilder(xmlBuilderOptions);
+ const xml = xmlBuilder.build(config);
+
+ try {
+ xmlParser.parse(xml, true);
+ } catch {
+ throw new Error(
+ 'The resulting PhpStorm configuration file is not valid XML.'
+ );
+ }
+
+ if (
+ xml ===
+ '\n\n \n \n \n'
+ ) {
+ fs.unlinkSync(phpStormConfigFilePath);
+ } else {
+ fs.writeFileSync(phpStormConfigFilePath, xml);
+ }
+ }
+ }
+
+ const vsCodeConfigFilePath = path.join(cwd, '.vscode/launch.json');
+ // VSCode
+ if (fs.existsSync(vsCodeConfigFilePath)) {
+ const errors: JSONC.ParseError[] = [];
+
+ const content = fs.readFileSync(vsCodeConfigFilePath, 'utf-8');
+ const root = JSONC.parseTree(content, errors, jsoncParseOptions);
+
+ if (root === undefined || errors.length) {
+ throw new Error('VS Code configuration file is not valid JSON.');
+ }
+
+ const configurationsNode = JSONC.findNodeAtLocation(root, [
+ 'configurations',
+ ]);
+
+ const configurationIndex = configurationsNode?.children?.findIndex(
+ (child) => JSONC.findNodeAtLocation(child, ['name'])?.value === name
+ );
+
+ if (configurationIndex !== undefined && configurationIndex >= 0) {
+ const edits = JSONC.modify(
+ content,
+ ['configurations', configurationIndex],
+ undefined,
+ {
+ formattingOptions: {
+ insertSpaces: true,
+ tabSize: 4,
+ eol: '\n',
+ },
+ }
+ );
+
+ const json = jsoncApplyEdits(content, edits);
+ if (json === '{\n "configurations": []\n}') {
+ fs.unlinkSync(vsCodeConfigFilePath);
+ } else {
+ fs.writeFileSync(vsCodeConfigFilePath, json);
+ }
+ }
+ }
+}
+
+function jsoncApplyEdits(content: string, edits: JSONC.Edit[]) {
+ const errors: JSONC.ParseError[] = [];
+ const json = JSONC.applyEdits(content, edits);
+
+ errors.length = 0;
+
+ JSONC.parseTree(json, errors, jsoncParseOptions);
+
+ if (errors.length) {
+ const formattedErrors = errors
+ .map((error) => {
+ return {
+ message: JSONC.printParseErrorCode(error.error),
+ offset: error.offset,
+ length: error.length,
+ fragment: json.slice(
+ Math.max(0, error.offset - 20),
+ Math.min(json.length, error.offset + error.length + 10)
+ ),
+ };
+ })
+ .map(
+ (error) =>
+ `${error.message} at ${error.offset}:${error.length} (${error.fragment})`
+ );
+ const formattedEdits = edits.map(
+ (edit) => `At ${edit.offset}:${edit.length} - (${edit.content})`
+ );
+ throw new Error(
+ `VS Code configuration file (.vscode/launch.json) is not valid a JSONC after PHP.wasm CLI modifications. This is likely ` +
+ `a PHP.wasm CLI bug. Please report it at https://github.com/WordPress/wordpress-playground/issues and include the contents ` +
+ `of your ".vscode/launch.json" file. \n\n Applied edits: ${formattedEdits.join(
+ '\n'
+ )}\n\n The errors are: ${formattedErrors.join('\n')}`
+ );
+ }
+
+ return json;
+}
diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts
index f1ffbb914b..c13cc3f2d4 100644
--- a/packages/playground/cli/src/run-cli.ts
+++ b/packages/playground/cli/src/run-cli.ts
@@ -689,8 +689,6 @@ export async function runCLI(args: RunCLIArgs): Promise {
await createPlaygroundCliTempDir(tempDirNameDelimiter);
logger.debug(`Native temp dir for VFS root: ${nativeDir.path}`);
- const IDEConfigName = 'WP Playground CLI - Listen for Xdebug';
-
// Always clean up any existing Playground files symlink in the project root.
const symlinkName = '.playground-xdebug-root';
const symlinkPath = path.join(process.cwd(), symlinkName);
@@ -713,6 +711,8 @@ export async function runCLI(args: RunCLIArgs): Promise {
};
try {
+ const IDEConfigName =
+ 'WP Playground CLI - Listen for Xdebug';
// NOTE: Both the 'clear' and 'add' operations can throw errors.
await clearXdebugIDEConfig(IDEConfigName, process.cwd());
@@ -740,11 +740,20 @@ export async function runCLI(args: RunCLIArgs): Promise {
const hasPhpStorm = ides.includes('phpstorm');
console.log('');
- console.log(bold(`Xdebug configured successfully`));
- console.log(
- highlight(`Updated IDE config: `) +
- modifiedConfig.join(' ')
- );
+
+ if (modifiedConfig.length > 0) {
+ console.log(bold(`Xdebug configured successfully`));
+ console.log(
+ highlight(`Updated IDE config: `) +
+ modifiedConfig.join(' ')
+ );
+ } else {
+ console.log(bold(`Xdebug configuration failed.`));
+ console.log(
+ 'No IDE-specific project settings directory was found in the current working directory.'
+ );
+ }
+
console.log(
highlight('Playground source root: ') +
`.playground-xdebug-root` +
diff --git a/packages/playground/cli/src/xdebug-path-mappings.ts b/packages/playground/cli/src/xdebug-path-mappings.ts
index 391f0d4371..c5ae4a9216 100644
--- a/packages/playground/cli/src/xdebug-path-mappings.ts
+++ b/packages/playground/cli/src/xdebug-path-mappings.ts
@@ -26,9 +26,9 @@ export async function createPlaygroundCliTempDirSymlink(
const type =
platform === 'win32'
? // On Windows, creating a 'dir' symlink can require elevated permissions.
- // In this case, let's make junction points because they function like
- // symlinks and do not require elevated permissions.
- 'junction'
+ // In this case, let's make junction points because they function like
+ // symlinks and do not require elevated permissions.
+ 'junction'
: 'dir';
fs.symlinkSync(nativeDirPath, symlinkPath, type);
}
@@ -500,9 +500,8 @@ export async function addXdebugIDEConfig({
ideKey,
});
fs.writeFileSync(phpStormConfigFilePath, updatedXml);
+ modifiedConfig.push(phpStormRelativeConfigFilePath);
}
-
- modifiedConfig.push(phpStormRelativeConfigFilePath);
}
// VSCode