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