From ff115a9bf0202c2bca56112407cc5704b465ab21 Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 1 Dec 2025 15:54:35 +0100 Subject: [PATCH 1/3] Add new @php-wasm/cli-util package --- packages/php-wasm/cli-util/.eslintrc.json | 18 + packages/php-wasm/cli-util/package.json | 46 + packages/php-wasm/cli-util/project.json | 81 + packages/php-wasm/cli-util/src/index.ts | 1 + packages/php-wasm/cli-util/src/lib/index.ts | 2 + packages/php-wasm/cli-util/src/lib/mounts.ts | 4 + .../cli-util/src/lib/xdebug-path-mappings.ts | 702 ++++++++ .../src/test/xdebug-path-mappings.spec.ts | 1421 +++++++++++++++++ packages/php-wasm/cli-util/tsconfig.json | 23 + packages/php-wasm/cli-util/tsconfig.lib.json | 10 + packages/php-wasm/cli-util/tsconfig.spec.json | 19 + packages/php-wasm/cli-util/vite.config.ts | 54 + packages/php-wasm/cli/src/main.ts | 113 +- .../cli/src/blueprints-v1/worker-thread-v1.ts | 7 +- packages/playground/cli/src/mounts.ts | 10 +- packages/playground/cli/src/run-cli.ts | 8 +- tsconfig.base.json | 1 + 17 files changed, 2500 insertions(+), 20 deletions(-) create mode 100644 packages/php-wasm/cli-util/.eslintrc.json create mode 100644 packages/php-wasm/cli-util/package.json create mode 100644 packages/php-wasm/cli-util/project.json create mode 100644 packages/php-wasm/cli-util/src/index.ts create mode 100644 packages/php-wasm/cli-util/src/lib/index.ts create mode 100644 packages/php-wasm/cli-util/src/lib/mounts.ts create mode 100644 packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts create mode 100644 packages/php-wasm/cli-util/src/test/xdebug-path-mappings.spec.ts create mode 100644 packages/php-wasm/cli-util/tsconfig.json create mode 100644 packages/php-wasm/cli-util/tsconfig.lib.json create mode 100644 packages/php-wasm/cli-util/tsconfig.spec.json create mode 100644 packages/php-wasm/cli-util/vite.config.ts diff --git a/packages/php-wasm/cli-util/.eslintrc.json b/packages/php-wasm/cli-util/.eslintrc.json new file mode 100644 index 0000000000..79fd7c1d98 --- /dev/null +++ b/packages/php-wasm/cli-util/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/php-wasm/cli-util/package.json b/packages/php-wasm/cli-util/package.json new file mode 100644 index 0000000000..614cae84da --- /dev/null +++ b/packages/php-wasm/cli-util/package.json @@ -0,0 +1,46 @@ +{ + "name": "@php-wasm/cli-util", + "version": "3.0.22", + "description": "Utilities for PHP.wasm related CLIs", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/wordpress-playground" + }, + "homepage": "https://developer.wordpress.org/playground", + "author": "The WordPress contributors", + "contributors": [ + { + "name": "Adam Zielinski", + "email": "adam@adamziel.com", + "url": "https://github.com/adamziel" + } + ], + "exports": { + ".": { + "import": "./index.js", + "require": "./index.cjs" + }, + "./package.json": "./package.json", + "./README.md": "./README.md" + }, + "main": "./index.cjs", + "module": "./index.js", + "type": "module", + "types": "index.d.ts", + "typedoc": { + "entryPoint": "./src/index.ts", + "readmeFile": "./README.md", + "displayName": "@php-wasm/cli-util", + "tsconfig": "./tsconfig.lib.json" + }, + "publishConfig": { + "access": "public", + "directory": "../../../dist/packages/php-wasm/cli-util" + }, + "license": "GPL-2.0-or-later", + "gitHead": "2f8d8f3cea548fbd75111e8659a92f601cddc593", + "engines": { + "node": ">=20.18.3", + "npm": ">=10.1.0" + } +} diff --git a/packages/php-wasm/cli-util/project.json b/packages/php-wasm/cli-util/project.json new file mode 100644 index 0000000000..238fcbe11d --- /dev/null +++ b/packages/php-wasm/cli-util/project.json @@ -0,0 +1,81 @@ +{ + "name": "php-wasm-cli-util", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/php-wasm/cli-util/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:noop", + "dependsOn": ["build:README"] + }, + "build:README": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "cp packages/php-wasm/cli-util/README.md dist/packages/php-wasm/cli-util" + ] + }, + "dependsOn": ["build:package-json"] + }, + "build:package-json": { + "executor": "@wp-playground/nx-extensions:package-json", + "options": { + "tsConfig": "packages/php-wasm/cli-util/tsconfig.lib.json", + "outputPath": "dist/packages/php-wasm/cli-util", + "buildTarget": "php-wasm-cli-util:build:bundle:production" + } + }, + "build:bundle": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "emptyOutDir": false, + "outputPath": "dist/packages/php-wasm/cli-util" + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/publish.mjs php-wasm-cli-util {args.ver} {args.tag}" + }, + "dependsOn": ["build"] + }, + "package-for-self-hosting": { + "executor": "@wp-playground/nx-extensions:package-for-self-hosting", + "dependsOn": ["build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "useFlatConfig": false, + "lintFilePatterns": ["packages/php-wasm/cli-util/**/*.ts"], + "maxWarnings": 0 + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/packages/php-wasm/cli-util"], + "options": { + "reportsDirectory": "../../../coverage/packages/php-wasm/cli-util" + } + }, + "test:esmcjs": { + "executor": "@wp-playground/nx-extensions:assert-built-esm-and-cjs", + "options": { + "outputPath": "dist/packages/php-wasm/cli-util" + }, + "dependsOn": ["build"] + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "tsc -p packages/php-wasm/cli-util/tsconfig.lib.json --noEmit", + "tsc -p packages/php-wasm/cli-util/tsconfig.spec.json --noEmit" + ] + } + } + }, + "tags": ["scope:independent-from-php-binaries"] +} diff --git a/packages/php-wasm/cli-util/src/index.ts b/packages/php-wasm/cli-util/src/index.ts new file mode 100644 index 0000000000..f41a696fd2 --- /dev/null +++ b/packages/php-wasm/cli-util/src/index.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/packages/php-wasm/cli-util/src/lib/index.ts b/packages/php-wasm/cli-util/src/lib/index.ts new file mode 100644 index 0000000000..7ad538c9ef --- /dev/null +++ b/packages/php-wasm/cli-util/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from './mounts'; +export * from './xdebug-path-mappings'; diff --git a/packages/php-wasm/cli-util/src/lib/mounts.ts b/packages/php-wasm/cli-util/src/lib/mounts.ts new file mode 100644 index 0000000000..a74bb02754 --- /dev/null +++ b/packages/php-wasm/cli-util/src/lib/mounts.ts @@ -0,0 +1,4 @@ +export interface Mount { + hostPath: string; + vfsPath: string; +} diff --git a/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts b/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts new file mode 100644 index 0000000000..f978f1651f --- /dev/null +++ b/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts @@ -0,0 +1,702 @@ +import fs from 'fs'; +import path from 'path'; +import type { Mount } from './mounts'; +import { + type X2jOptions, + type XmlBuilderOptions, + XMLParser, + XMLBuilder, +} from 'fast-xml-parser'; +import JSONC from 'jsonc-parser'; + +/** + * Create a symlink to a tempory directory. + * + * The symlink is created to access the system temp dir + * inside the current debugging directory. + * + * @param nativeDirPath The system temp dir path. + * @param symlinkPath The symlink name. + */ +export async function createTempDirSymlink( + nativeDirPath: string, + symlinkPath: string, + platform: string +) { + 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' + : 'dir'; + fs.symlinkSync(nativeDirPath, symlinkPath, type); +} + +/** + * Remove the given temporary directory symlink if it exists. + * + * @param symlinkPath The symlink path. + */ +export async function removeTempDirSymlink(symlinkPath: string) { + try { + const stats = fs.lstatSync(symlinkPath); + if (stats.isSymbolicLink()) { + fs.unlinkSync(symlinkPath); + } + } catch { + // Symlink does not exist or cannot be accessed, nothing to remove + } +} + +/** + * Filters out mounts that are not in the current working directory + * + * @param mounts The mounts list. + */ +function filterLocalMounts(cwd: string, mounts: Mount[]) { + return mounts.filter((mount) => { + const absoluteHostPath = path.resolve(mount.hostPath); + const cwdChildPrefix = path.join(cwd, path.sep); + return ( + // If auto-mounting from the current directory, + // the entire project directory can be mapped. + absoluteHostPath === cwd || + absoluteHostPath.startsWith(cwdChildPrefix) + ); + }); +} + +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 mounts to consider for debugger path mapping. + */ + mounts?: Mount[]; + /** + * The IDE key to use for the debug configuration. Defaults to 'PLAYGROUNDCLI'. + */ + ideKey?: string; +}; + +type PhpStormConfigMetaData = { + name?: string; + version?: string; + host?: string; + use_path_mappings?: string; + 'local-root'?: string; + 'remote-root'?: 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[]; + path_mappings?: PhpStormConfigNode[]; + mapping?: PhpStormConfigNode[]; + configuration?: PhpStormConfigNode[]; + method?: PhpStormConfigNode[]; +}; + +type VSCodeConfigMetaData = { + [key: string]: string; +}; + +type VSCodeConfigNode = { + name: string; + type: string; + request: string; + port: number; + pathMappings?: VSCodeConfigMetaData; +}; + +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; + mappings?: Mount[]; + 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, mappings, 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}`, + use_path_mappings: 'true', + }, + }; + + if (mappings && mappings.length) { + serverElement.server![0].path_mappings = mappings.map((mapping) => ({ + mapping: [], + ':@': { + 'local-root': `$PROJECT_DIR$/${toPosixPath( + path.relative(options.projectDir, mapping.hostPath) + )}`, + 'remote-root': mapping.vfsPath, + }, + })); + } + + // 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; + mappings?: Mount[]; +}; + +/** + * 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, mappings } = 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: any) => + 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, + }; + + if (mappings && mappings.length) { + configuration.pathMappings = mappings.reduce((acc, mount) => { + acc[mount.vfsPath] = `\${workspaceFolder}/${toPosixPath( + path.relative(options.workspaceDir, mount.hostPath) + )}`; + return acc; + }, {} as VSCodeConfigMetaData); + } + + // 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 and path mappings in IDE configuration files. + * + * @param name The configuration name. + * @param mounts The mounts options. + */ +export async function addXdebugIDEConfig({ + name, + ides, + host, + port, + cwd, + mounts, + ideKey = 'PHPWASMCLI', +}: IDEConfig) { + const mappings = mounts ? filterLocalMounts(cwd, mounts) : []; + 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, + mappings, + 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, + mappings, + }); + + // 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 and path mappings 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: any) => + 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 CLI modifications. This is likely ` + + `a 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; +} + +function toPosixPath(pathStr: string) { + return pathStr.replaceAll(path.sep, path.posix.sep); +} diff --git a/packages/php-wasm/cli-util/src/test/xdebug-path-mappings.spec.ts b/packages/php-wasm/cli-util/src/test/xdebug-path-mappings.spec.ts new file mode 100644 index 0000000000..0485d66858 --- /dev/null +++ b/packages/php-wasm/cli-util/src/test/xdebug-path-mappings.spec.ts @@ -0,0 +1,1421 @@ +import { + updatePhpStormConfig, + updateVSCodeConfig, + type PhpStormConfigOptions, + type VSCodeConfigOptions, +} from '../lib/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(), + mappings: [ + { + hostPath: './src', + vfsPath: '/var/www/html/src', + }, + ], + 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 no path mapping', () => { + const options: PhpStormConfigOptions = { + name: 'Test Server', + host: 'localhost', + port: 8080, + projectDir: process.cwd(), + ideKey: 'PHPWASMCLI', + }; + + const xml = + '\n\n'; + const result = updatePhpStormConfig(xml, options); + + const expected = ` + + + + + + + + + + + + +`; + + expectXMLEquals(result, expected); + }); + + it('should handle multiple path mappings', () => { + const options: PhpStormConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './src', + vfsPath: '/var/www/html/src', + }, + { + hostPath: './tests', + vfsPath: '/var/www/html/tests', + }, + { + hostPath: './vendor', + vfsPath: '/var/www/html/vendor', + }, + ], + }; + + const xml = + '\n\n'; + const result = updatePhpStormConfig(xml, options); + + const expected = ` + + + + + + + + + + + + + + + + + +`; + + expectXMLEquals(result, expected); + }); + + it('should strip leading ./ from hostPath', () => { + const options: PhpStormConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './foo/bar', + vfsPath: '/var/www/html/foo/bar', + }, + { + hostPath: 'baz/qux', + vfsPath: '/var/www/html/baz/qux', + }, + ], + }; + + const xml = + '\n\n'; + const result = updatePhpStormConfig(xml, options); + + const expected = ` + + + + + + + + + + + + + + + + +`; + + expectXMLEquals(result, expected); + }); + + it('should make absolute hostPath relative to project directory', () => { + const options: PhpStormConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: `${defaultOptions.projectDir}/foo/bar`, + vfsPath: '/var/www/html/foo/bar', + }, + { + hostPath: `${defaultOptions.projectDir}/baz/qux`, + vfsPath: '/var/www/html/baz/qux', + }, + ], + }; + + const xml = + '\n\n'; + const result = updatePhpStormConfig(xml, options); + + const expected = ` + + + + + + + + + + + + + + + + +`; + + expectXMLEquals(result, expected); + }); + + 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".' + ); + }); + + it('should handle empty mappings array', () => { + const options: PhpStormConfigOptions = { + ...defaultOptions, + mappings: [], + }; + + const xml = + '\n\n'; + const result = updatePhpStormConfig(xml, options); + + const expected = ` + + + + + + + + + + + + +`; + + expectXMLEquals(result, expected); + }); + }); + + 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 paths with special characters', () => { + const options: PhpStormConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './src/my-special-dir', + vfsPath: '/var/www/html/my-special-dir', + }, + ], + }; + + 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(), + mappings: [ + { + hostPath: './src', + vfsPath: '/var/www/html/src', + }, + ], + }; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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 no path mapping', () => { + const options: VSCodeConfigOptions = { + name: 'Test Configuration', + workspaceDir: process.cwd(), + }; + + 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); + }); + + it('should handle multiple path mappings', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './src', + vfsPath: '/var/www/html/src', + }, + { + hostPath: './tests', + vfsPath: '/var/www/html/tests', + }, + { + hostPath: './vendor', + vfsPath: '/var/www/html/vendor', + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src", + "/var/www/html/tests": "\${workspaceFolder}/tests", + "/var/www/html/vendor": "\${workspaceFolder}/vendor" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + + it('should strip leading ./ from hostPath', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './foo/bar', + vfsPath: '/var/www/html/foo/bar', + }, + { + hostPath: 'baz/qux', + vfsPath: '/var/www/html/baz/qux', + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/foo/bar": "\${workspaceFolder}/foo/bar", + "/var/www/html/baz/qux": "\${workspaceFolder}/baz/qux" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + + it('should make absolute hostPath relative to workspace folder', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: `${defaultOptions.workspaceDir}/foo/bar`, + vfsPath: '/var/www/html/foo/bar', + }, + { + hostPath: `${defaultOptions.workspaceDir}/baz/qux`, + vfsPath: '/var/www/html/baz/qux', + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/foo/bar": "\${workspaceFolder}/foo/bar", + "/var/www/html/baz/qux": "\${workspaceFolder}/baz/qux" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ], + "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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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.'); + }); + + it('should handle empty mappings array', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [], + }; + + 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); + }); + }); + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + + it('should handle paths with special characters', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: './src/my-special-dir', + vfsPath: '/var/www/html/my-special-dir', + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/my-special-dir": "\${workspaceFolder}/src/my-special-dir" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/existing": "\${workspaceFolder}/existing" + } + } + ] +}`; + const result = updateVSCodeConfig(json, defaultOptions); + + const expected = `{ + "version": "0.2.0", + "configurations": [ + { + "name": "Existing Config", + "type": "php", + "request": "launch", + "port": 9000, + "pathMappings": { + "/var/www/html/existing": "\${workspaceFolder}/existing" + } + }, + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + 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, + "pathMappings": { + "/var/www/html/src": "\${workspaceFolder}/src" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + + it('should handle mixed single and double quotes in paths', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + mappings: [ + { + hostPath: "./src/path-with-'single'-quotes", + vfsPath: '/var/www/html/path', + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/path": "\${workspaceFolder}/src/path-with-'single'-quotes" + } + } + ] +}`; + + expectJSONEquals(result, expected); + expect(() => JSON.parse(result)).not.toThrow(); + }); + + 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, + pathMappings: { + '/var/www/html/src': '${workspaceFolder}/src', + }, + }, + ], + }; + + const parsed = JSON.parse(result); + expect(parsed).toEqual(expected); + }); + + it('should handle Unicode characters in configuration', () => { + const options: VSCodeConfigOptions = { + ...defaultOptions, + name: 'Test ๐Ÿš€ Configuration', + mappings: [ + { + hostPath: './src', + vfsPath: '/var/www/html/ั‚ะตัั‚', // Cyrillic characters + }, + ], + }; + + const json = '{\n "configurations": []\n}'; + const result = updateVSCodeConfig(json, options); + + const expected = `{ + "configurations": [ + { + "name": "Test ๐Ÿš€ Configuration", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/ั‚ะตัั‚": "\${workspaceFolder}/src" + } + } + ] +}`; + + expectJSONEquals(result, expected); + }); + }); +}); diff --git a/packages/php-wasm/cli-util/tsconfig.json b/packages/php-wasm/cli-util/tsconfig.json new file mode 100644 index 0000000000..3dd0ac8fdc --- /dev/null +++ b/packages/php-wasm/cli-util/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/php-wasm/cli-util/tsconfig.lib.json b/packages/php-wasm/cli-util/tsconfig.lib.json new file mode 100644 index 0000000000..d54256fda2 --- /dev/null +++ b/packages/php-wasm/cli-util/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/php-wasm/cli-util/tsconfig.spec.json b/packages/php-wasm/cli-util/tsconfig.spec.json new file mode 100644 index 0000000000..eb23daacbc --- /dev/null +++ b/packages/php-wasm/cli-util/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/packages/php-wasm/cli-util/vite.config.ts b/packages/php-wasm/cli-util/vite.config.ts new file mode 100644 index 0000000000..d0d8fbb3cc --- /dev/null +++ b/packages/php-wasm/cli-util/vite.config.ts @@ -0,0 +1,54 @@ +/// +import { defineConfig } from 'vite'; + +import dts from 'vite-plugin-dts'; +import { join } from 'path'; + +import viteTsConfigPaths from 'vite-tsconfig-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { getExternalModules } from '../../vite-extensions/vite-external-modules'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import viteGlobalExtensions from '../../vite-extensions/vite-global-extensions'; + +export default defineConfig({ + cacheDir: '../../../node_modules/.vite/php-wasm-cli-util', + + plugins: [ + dts({ + entryRoot: 'src', + tsconfigPath: join(__dirname, 'tsconfig.lib.json'), + pathsToAliases: false, + }), + + viteTsConfigPaths({ + root: '../../../', + }), + + ...viteGlobalExtensions, + ], + + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'php-wasm-cli-util', + fileName: 'index', + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + // External packages that should not be bundled into your library. + external: getExternalModules(), + }, + }, + + test: { + globals: true, + cache: { + dir: '../../../node_modules/.vitest', + }, + environment: 'node', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + }, +}); diff --git a/packages/php-wasm/cli/src/main.ts b/packages/php-wasm/cli/src/main.ts index 06f509627c..2506933122 100644 --- a/packages/php-wasm/cli/src/main.ts +++ b/packages/php-wasm/cli/src/main.ts @@ -9,12 +9,13 @@ 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 '@php-wasm/cli-util'; import path from 'path'; let args = process.argv.slice(2); @@ -22,6 +23,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 +61,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 +132,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/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index 5aecb031fc..dd0dfedc94 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -22,11 +22,6 @@ import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; import { mountResources } from '../mounts'; import { logger } from '@php-wasm/logger'; -export interface Mount { - hostPath: string; - vfsPath: string; -} - export type WorkerBootOptions = { phpVersion: SupportedPHPVersion; siteUrl: string; @@ -192,7 +187,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { ? new File( [sqliteIntegrationPluginZip], 'sqlite-integration-plugin.zip' - ) + ) : undefined, sapiName: 'cli', createFiles: { diff --git a/packages/playground/cli/src/mounts.ts b/packages/playground/cli/src/mounts.ts index 80adda0707..008df20ea2 100644 --- a/packages/playground/cli/src/mounts.ts +++ b/packages/playground/cli/src/mounts.ts @@ -3,11 +3,7 @@ import type { PHP } from '@php-wasm/universal'; import fs, { existsSync } from 'fs'; import path, { basename, join } from 'path'; import type { RunCLIArgs } from './run-cli'; - -export interface Mount { - hostPath: string; - vfsPath: string; -} +import type { Mount } from '@php-wasm/cli-util'; /** * Parse an array of mount argument strings where the host path and VFS path @@ -146,11 +142,11 @@ export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs { ? { step: 'activateTheme', themeDirectoryName: themeName, - } + } : { step: 'activateTheme', themeFolderName: themeName, - } + } ); } else if (containsWpContentDirectories(path)) { /** diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index f1ffbb914b..24e411b6c1 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -28,10 +28,7 @@ import { parseMountWithDelimiterArguments, } from './mounts'; import { startServer } from './start-server'; -import type { - Mount, - PlaygroundCliBlueprintV1Worker, -} from './blueprints-v1/worker-thread-v1'; +import type { PlaygroundCliBlueprintV1Worker } from './blueprints-v1/worker-thread-v1'; import type { PlaygroundCliBlueprintV2Worker } from './blueprints-v2/worker-thread-v2'; import { FileLockManagerForNode } from '@php-wasm/node'; import { LoadBalancer } from './load-balancer'; @@ -54,11 +51,12 @@ import { } from './temp-dir'; import { type WordPressInstallMode } from '@wp-playground/wordpress'; import { + Mount, addXdebugIDEConfig, clearXdebugIDEConfig, createPlaygroundCliTempDirSymlink, removePlaygroundCliTempDirSymlink, -} from './xdebug-path-mappings'; +} from '@php-wasm/cli-util'; // Inlined worker URLs for static analysis by downstream bundlers // These are replaced at build time by the Vite plugin in vite.config.ts diff --git a/tsconfig.base.json b/tsconfig.base.json index 0a4f8d2954..cf1f2680d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,6 +18,7 @@ "baseUrl": ".", "paths": { "@php-wasm/cli": ["packages/php-wasm/cli/src/main.ts"], + "@php-wasm/cli-util": ["packages/php-wasm/cli-util/src/index.ts"], "@php-wasm/fs-journal": [ "packages/php-wasm/fs-journal/src/index.ts" ], From 6bc1397f55f298956ea53d9349444adbbef084ef Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 1 Dec 2025 16:13:51 +0100 Subject: [PATCH 2/3] Update Playground with new CLI utility --- packages/php-wasm/cli-util/README.md | 11 + .../cli-util/src/lib/xdebug-path-mappings.ts | 2 +- .../cli/src/blueprints-v1/worker-thread-v1.ts | 1 + .../cli/src/blueprints-v2/worker-thread-v2.ts | 2 +- packages/playground/cli/src/run-cli.ts | 10 +- .../cli/src/xdebug-path-mappings.ts | 698 --------- .../cli/tests/xdebug-path-mappings.spec.ts | 1369 ----------------- 7 files changed, 19 insertions(+), 2074 deletions(-) create mode 100644 packages/php-wasm/cli-util/README.md delete mode 100644 packages/playground/cli/src/xdebug-path-mappings.ts delete mode 100644 packages/playground/cli/tests/xdebug-path-mappings.spec.ts diff --git a/packages/php-wasm/cli-util/README.md b/packages/php-wasm/cli-util/README.md new file mode 100644 index 0000000000..3ce7246efa --- /dev/null +++ b/packages/php-wasm/cli-util/README.md @@ -0,0 +1,11 @@ +# php-wasm-cli-util + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build php-wasm-cli-util` to build the library. + +## Running unit tests + +Run `nx test php-wasm-cli-util` to execute the unit tests via [Vitest](https://vitest.dev). diff --git a/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts b/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts index f978f1651f..b8321818be 100644 --- a/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts +++ b/packages/php-wasm/cli-util/src/lib/xdebug-path-mappings.ts @@ -7,7 +7,7 @@ import { XMLParser, XMLBuilder, } from 'fast-xml-parser'; -import JSONC from 'jsonc-parser'; +import * as JSONC from 'jsonc-parser'; /** * Create a symlink to a tempory directory. diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index dd0dfedc94..89cdfd13b3 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -21,6 +21,7 @@ import { jspi } from 'wasm-feature-detect'; import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; import { mountResources } from '../mounts'; import { logger } from '@php-wasm/logger'; +import type { Mount } from '@php-wasm/cli-util'; export type WorkerBootOptions = { phpVersion: SupportedPHPVersion; diff --git a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts index 82a1a12c76..0f84f02e9a 100644 --- a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts +++ b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts @@ -32,7 +32,6 @@ import { existsSync } from 'fs'; import path from 'path'; import { rootCertificates } from 'tls'; import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; -import type { Mount } from '../mounts'; import { jspi } from 'wasm-feature-detect'; import { type RunCLIArgs } from '../run-cli'; import type { @@ -40,6 +39,7 @@ import type { PHPInstanceCreatedHook, } from '@wp-playground/wordpress'; import { shouldRenderProgress } from '../utils/progress'; +import type { Mount } from '@php-wasm/cli-util'; async function mountResources(php: PHP, mounts: Mount[]) { for (const mount of mounts) { diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 24e411b6c1..5da5240322 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -51,11 +51,11 @@ import { } from './temp-dir'; import { type WordPressInstallMode } from '@wp-playground/wordpress'; import { - Mount, + type Mount, addXdebugIDEConfig, clearXdebugIDEConfig, - createPlaygroundCliTempDirSymlink, - removePlaygroundCliTempDirSymlink, + createTempDirSymlink, + removeTempDirSymlink, } from '@php-wasm/cli-util'; // Inlined worker URLs for static analysis by downstream bundlers @@ -693,13 +693,13 @@ export async function runCLI(args: RunCLIArgs): Promise { const symlinkName = '.playground-xdebug-root'; const symlinkPath = path.join(process.cwd(), symlinkName); - await removePlaygroundCliTempDirSymlink(symlinkPath); + await removeTempDirSymlink(symlinkPath); // Then, if xdebug, and experimental IDE are enabled, // recreate the symlink pointing to the temporary // directory and add the new IDE config. if (args.xdebug && args.experimentalUnsafeIdeIntegration) { - await createPlaygroundCliTempDirSymlink( + await createTempDirSymlink( nativeDir.path, symlinkPath, process.platform diff --git a/packages/playground/cli/src/xdebug-path-mappings.ts b/packages/playground/cli/src/xdebug-path-mappings.ts deleted file mode 100644 index 391f0d4371..0000000000 --- a/packages/playground/cli/src/xdebug-path-mappings.ts +++ /dev/null @@ -1,698 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { type Mount } from './mounts'; -import { - type X2jOptions, - type XmlBuilderOptions, - XMLParser, - XMLBuilder, -} from 'fast-xml-parser'; -import JSONC from 'jsonc-parser'; - -/** - * Create a symlink to temp dir for the Playground CLI. - * - * The symlink is created to access the system temp dir - * inside the current debugging directory. - * - * @param nativeDirPath The system temp dir path. - * @param symlinkPath The symlink name. - */ -export async function createPlaygroundCliTempDirSymlink( - nativeDirPath: string, - symlinkPath: string, - platform: string -) { - 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' - : 'dir'; - fs.symlinkSync(nativeDirPath, symlinkPath, type); -} - -/** - * Remove the temp dir symlink if it exists. - * - * @param symlinkPath The symlink path. - */ -export async function removePlaygroundCliTempDirSymlink(symlinkPath: string) { - try { - const stats = fs.lstatSync(symlinkPath); - if (stats.isSymbolicLink()) { - fs.unlinkSync(symlinkPath); - } - } catch { - // Symlink does not exist or cannot be accessed, nothing to remove - } -} - -/** - * Filters out mounts that are not in the current working directory - * - * @param mounts The Playground CLI mount options. - */ -function filterLocalMounts(cwd: string, mounts: Mount[]) { - return mounts.filter((mount) => { - const absoluteHostPath = path.resolve(mount.hostPath); - const cwdChildPrefix = path.join(cwd, path.sep); - return ( - // If auto-mounting from the current directory, - // the entire project directory can be mapped. - absoluteHostPath === cwd || - absoluteHostPath.startsWith(cwdChildPrefix) - ); - }); -} - -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 mounts to consider for debugger path mapping. - */ - mounts: Mount[]; - /** - * The IDE key to use for the debug configuration. Defaults to 'PLAYGROUNDCLI'. - */ - ideKey?: string; -}; - -type PhpStormConfigMetaData = { - name?: string; - version?: string; - host?: string; - use_path_mappings?: string; - 'local-root'?: string; - 'remote-root'?: 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[]; - path_mappings?: PhpStormConfigNode[]; - mapping?: PhpStormConfigNode[]; - configuration?: PhpStormConfigNode[]; - method?: PhpStormConfigNode[]; -}; - -type VSCodeConfigMetaData = { - [key: string]: string; -}; - -type VSCodeConfigNode = { - name: string; - type: string; - request: string; - port: number; - pathMappings: VSCodeConfigMetaData; -}; - -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; - mappings: Mount[]; - 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, mappings, 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: [ - { - path_mappings: mappings.map((mapping) => ({ - mapping: [], - ':@': { - 'local-root': `$PROJECT_DIR$/${toPosixPath( - path.relative(options.projectDir, mapping.hostPath) - )}`, - 'remote-root': mapping.vfsPath, - }, - })), - }, - ], - ':@': { - 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}`, - use_path_mappings: 'true', - }, - }; - - // 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; - mappings: Mount[]; -}; - -/** - * 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, mappings } = 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, - pathMappings: mappings.reduce((acc, mount) => { - acc[mount.vfsPath] = `\${workspaceFolder}/${toPosixPath( - path.relative(options.workspaceDir, mount.hostPath) - )}`; - return acc; - }, {} as VSCodeConfigMetaData), - }; - - // 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 and path mappings in IDE configuration files. - * - * @param name The configuration name. - * @param mounts The Playground CLI mount options. - */ -export async function addXdebugIDEConfig({ - name, - ides, - host, - port, - cwd, - mounts, - ideKey = 'PLAYGROUNDCLI', -}: IDEConfig) { - const mappings = filterLocalMounts(cwd, mounts); - 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, - mappings, - 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, - mappings, - }); - - // 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 and path mappings 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 Playground CLI modifications. This is likely ` + - `a Playground 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; -} - -function toPosixPath(pathStr: string) { - return pathStr.replaceAll(path.sep, path.posix.sep); -} diff --git a/packages/playground/cli/tests/xdebug-path-mappings.spec.ts b/packages/playground/cli/tests/xdebug-path-mappings.spec.ts deleted file mode 100644 index 0e03fed0b2..0000000000 --- a/packages/playground/cli/tests/xdebug-path-mappings.spec.ts +++ /dev/null @@ -1,1369 +0,0 @@ -import { - updatePhpStormConfig, - updateVSCodeConfig, - type PhpStormConfigOptions, - type VSCodeConfigOptions, -} from '../src/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(), - mappings: [ - { - hostPath: './src', - vfsPath: '/var/www/html/src', - }, - ], - ideKey: 'PLAYGROUNDCLI', - }; - - 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 multiple path mappings', () => { - const options: PhpStormConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './src', - vfsPath: '/var/www/html/src', - }, - { - hostPath: './tests', - vfsPath: '/var/www/html/tests', - }, - { - hostPath: './vendor', - vfsPath: '/var/www/html/vendor', - }, - ], - }; - - const xml = - '\n\n'; - const result = updatePhpStormConfig(xml, options); - - const expected = ` - - - - - - - - - - - - - - - - - -`; - - expectXMLEquals(result, expected); - }); - - it('should strip leading ./ from hostPath', () => { - const options: PhpStormConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './foo/bar', - vfsPath: '/var/www/html/foo/bar', - }, - { - hostPath: 'baz/qux', - vfsPath: '/var/www/html/baz/qux', - }, - ], - }; - - const xml = - '\n\n'; - const result = updatePhpStormConfig(xml, options); - - const expected = ` - - - - - - - - - - - - - - - - -`; - - expectXMLEquals(result, expected); - }); - - it('should make absolute hostPath relative to project directory', () => { - const options: PhpStormConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: `${defaultOptions.projectDir}/foo/bar`, - vfsPath: '/var/www/html/foo/bar', - }, - { - hostPath: `${defaultOptions.projectDir}/baz/qux`, - vfsPath: '/var/www/html/baz/qux', - }, - ], - }; - - const xml = - '\n\n'; - const result = updatePhpStormConfig(xml, options); - - const expected = ` - - - - - - - - - - - - - - - - -`; - - expectXMLEquals(result, expected); - }); - - 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".' - ); - }); - - it('should handle empty mappings array', () => { - const options: PhpStormConfigOptions = { - ...defaultOptions, - mappings: [], - }; - - const xml = - '\n\n'; - const result = updatePhpStormConfig(xml, options); - - const expected = ` - - - - - - - - - - - - - -`; - - expectXMLEquals(result, expected); - }); - }); - - 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 paths with special characters', () => { - const options: PhpStormConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './src/my-special-dir', - vfsPath: '/var/www/html/my-special-dir', - }, - ], - }; - - 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(), - mappings: [ - { - hostPath: './src', - vfsPath: '/var/www/html/src', - }, - ], - }; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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 multiple path mappings', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './src', - vfsPath: '/var/www/html/src', - }, - { - hostPath: './tests', - vfsPath: '/var/www/html/tests', - }, - { - hostPath: './vendor', - vfsPath: '/var/www/html/vendor', - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src", - "/var/www/html/tests": "\${workspaceFolder}/tests", - "/var/www/html/vendor": "\${workspaceFolder}/vendor" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - - it('should strip leading ./ from hostPath', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './foo/bar', - vfsPath: '/var/www/html/foo/bar', - }, - { - hostPath: 'baz/qux', - vfsPath: '/var/www/html/baz/qux', - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/foo/bar": "\${workspaceFolder}/foo/bar", - "/var/www/html/baz/qux": "\${workspaceFolder}/baz/qux" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - - it('should make absolute hostPath relative to workspace folder', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: `${defaultOptions.workspaceDir}/foo/bar`, - vfsPath: '/var/www/html/foo/bar', - }, - { - hostPath: `${defaultOptions.workspaceDir}/baz/qux`, - vfsPath: '/var/www/html/baz/qux', - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/foo/bar": "\${workspaceFolder}/foo/bar", - "/var/www/html/baz/qux": "\${workspaceFolder}/baz/qux" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ], - "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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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.'); - }); - - it('should handle empty mappings array', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": {} - } - ] -}`; - - expectJSONEquals(result, expected); - }); - }); - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - - it('should handle paths with special characters', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: './src/my-special-dir', - vfsPath: '/var/www/html/my-special-dir', - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/my-special-dir": "\${workspaceFolder}/src/my-special-dir" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/existing": "\${workspaceFolder}/existing" - } - } - ] -}`; - const result = updateVSCodeConfig(json, defaultOptions); - - const expected = `{ - "version": "0.2.0", - "configurations": [ - { - "name": "Existing Config", - "type": "php", - "request": "launch", - "port": 9000, - "pathMappings": { - "/var/www/html/existing": "\${workspaceFolder}/existing" - } - }, - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - 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, - "pathMappings": { - "/var/www/html/src": "\${workspaceFolder}/src" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - - it('should handle mixed single and double quotes in paths', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - mappings: [ - { - hostPath: "./src/path-with-'single'-quotes", - vfsPath: '/var/www/html/path', - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/path": "\${workspaceFolder}/src/path-with-'single'-quotes" - } - } - ] -}`; - - expectJSONEquals(result, expected); - expect(() => JSON.parse(result)).not.toThrow(); - }); - - 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, - pathMappings: { - '/var/www/html/src': '${workspaceFolder}/src', - }, - }, - ], - }; - - const parsed = JSON.parse(result); - expect(parsed).toEqual(expected); - }); - - it('should handle Unicode characters in configuration', () => { - const options: VSCodeConfigOptions = { - ...defaultOptions, - name: 'Test ๐Ÿš€ Configuration', - mappings: [ - { - hostPath: './src', - vfsPath: '/var/www/html/ั‚ะตัั‚', // Cyrillic characters - }, - ], - }; - - const json = '{\n "configurations": []\n}'; - const result = updateVSCodeConfig(json, options); - - const expected = `{ - "configurations": [ - { - "name": "Test ๐Ÿš€ Configuration", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/ั‚ะตัั‚": "\${workspaceFolder}/src" - } - } - ] -}`; - - expectJSONEquals(result, expected); - }); - }); -}); From 58f565687c57bd93da9c4073b8ef9c16c922ec5d Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 1 Dec 2025 16:45:55 +0100 Subject: [PATCH 3/3] Update lock file --- package-lock.json | 220 +++++++++++++++++++++++++--------------------- 1 file changed, 118 insertions(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad8dc739eb..df9810426b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,14 +155,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.16.tgz", - "integrity": "sha512-qiIaVs1w1XcNiFG6cjhOwolPuMFSvy6ZxDeLaPlEK/kSmNGfd+gUA2CTpBPWWT3qN6Zxfdrwq+ti4BfkdmLIJQ==", + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.17.tgz", + "integrity": "sha512-oVAG6q72KsjKlrYdLhWjRO7rcqAR8CjokAbYuyVZoCO4Uh2PH/VzZoxZav71w2ipwlXhHCNaInGYWNs889MMDA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.17", + "@ai-sdk/provider-utils": "3.0.18", "@vercel/oidc": "3.0.5" }, "engines": { @@ -186,9 +186,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.17.tgz", - "integrity": "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz", + "integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -204,14 +204,14 @@ } }, "node_modules/@ai-sdk/react": { - "version": "2.0.103", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.103.tgz", - "integrity": "sha512-r5uagRdTqLXPnUEv7xkUuaHG3sAZ5bvYbzJJlRWfRII7E9JfxyjTFGQirjeXjPJYsV8cUdwM1bEpp/46rsgQQg==", + "version": "2.0.105", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.105.tgz", + "integrity": "sha512-d/nr3fuAsgLli7g9CcShqME+QdTN3S6vbtyL9ZT8iAWfR0xBKYuNrzX3a89vY49lnbdgAqB65l67hsVNCsmVIg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "3.0.17", - "ai": "5.0.103", + "@ai-sdk/provider-utils": "3.0.18", + "ai": "5.0.105", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -2951,9 +2951,9 @@ } }, "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -3372,9 +3372,9 @@ } }, "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -3771,9 +3771,9 @@ } }, "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5803,9 +5803,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5815,7 +5815,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -8430,9 +8430,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", - "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", + "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", "license": "MIT" }, "node_modules/@lezer/css": { @@ -8489,9 +8489,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.3.tgz", - "integrity": "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.4.tgz", + "integrity": "sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -10902,9 +10902,9 @@ } }, "node_modules/@nx/eslint-plugin/node_modules/nx/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { @@ -10912,6 +10912,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/@nx/eslint-plugin/node_modules/ora": { @@ -12800,6 +12803,10 @@ "resolved": "packages/php-wasm/cli", "link": true }, + "node_modules/@php-wasm/cli-util": { + "resolved": "packages/php-wasm/cli-util", + "link": true + }, "node_modules/@php-wasm/compile": { "resolved": "packages/php-wasm/compile", "link": true @@ -19528,15 +19535,15 @@ } }, "node_modules/ai": { - "version": "5.0.103", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.103.tgz", - "integrity": "sha512-TpaeKAzSFHQkUZ5cwkvGZCzElVDY0W7nJNT9Oq31R30PTmCtU8A5ll4IRm+CmolPSYbpXRHHPkgADxGyqex9eg==", + "version": "5.0.105", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.105.tgz", + "integrity": "sha512-waQZAvv44KYzys6S3l25ti2jcSuJnkyWFTliSKy3swASL6w6ttPxJTm80d+v9sLWoIxrqE3OwhTJbweNp065fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "2.0.16", + "@ai-sdk/gateway": "2.0.17", "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.17", + "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -20589,9 +20596,9 @@ } }, "node_modules/bare-fs": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.1.tgz", - "integrity": "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -20692,9 +20699,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -23094,9 +23101,9 @@ } }, "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -23168,9 +23175,9 @@ } }, "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -23323,9 +23330,9 @@ } }, "node_modules/cssdb": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.4.2.tgz", - "integrity": "sha512-PzjkRkRUS+IHDJohtxkIczlxPPZqRo0nXplsYXOMBRPjcVRjj1W4DfvRgshUYTVuUigU7ptVYkFJQ7abUB0nyg==", + "version": "8.4.3", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.4.3.tgz", + "integrity": "sha512-8aaDS5nVqMXmYjlmmJpqlDJosiqbl2NJkYuSFOXR6RTY14qNosMrqT4t7O+EUm+OdduQg3GNI2ZwC03No1Y58Q==", "funding": [ { "type": "opencollective", @@ -34653,9 +34660,9 @@ } }, "node_modules/memfs": { - "version": "4.51.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.0.tgz", - "integrity": "sha512-4zngfkVM/GpIhC8YazOsM6E8hoB33NP0BCESPOA6z7qaL6umPJNqkO8CNYaLV2FB2MV6H1O3x2luHHOSqppv+A==", + "version": "4.51.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.1.tgz", + "integrity": "sha512-Eyt3XrufitN2ZL9c/uIRMyDwXanLI88h/L3MoWqNY747ha3dMR9dWqp8cRT5ntjZ0U1TNuq4U91ZXK0sMBjYOQ==", "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/json-pack": "^1.11.0", @@ -39976,9 +39983,9 @@ } }, "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40220,9 +40227,9 @@ } }, "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40258,9 +40265,9 @@ } }, "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40386,9 +40393,9 @@ } }, "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40424,9 +40431,9 @@ } }, "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40713,9 +40720,9 @@ } }, "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40741,9 +40748,9 @@ } }, "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -40840,9 +40847,9 @@ } }, "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -41209,9 +41216,9 @@ } }, "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -41302,9 +41309,9 @@ } }, "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -41462,9 +41469,9 @@ } }, "node_modules/prettier": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.0.tgz", - "integrity": "sha512-pBiBj/gjRY9Qpk1b7cDda6Rbwvkaggos779AHQ0Ek/odwDx6xG6DRBxtnp1QmxbuD7pAO8/SQ8vuhtGv9LoLWA==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", + "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", "bin": { @@ -42023,9 +42030,9 @@ } }, "node_modules/react-day-picker": { - "version": "9.11.2", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.2.tgz", - "integrity": "sha512-TD/xMUGg2oiKX8jUR21MST5pj+7Y36097YtnDHQFlIcZOu3mbLLw2B2JqEByEGrR3HHveWYnKlyls6WqJgohAg==", + "version": "9.11.3", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.3.tgz", + "integrity": "sha512-7lD12UvGbkyXqgzbYIGQTbl+x29B9bAf+k0pP5Dcs1evfpKk6zv4EdH/edNc8NxcmCiTNXr2HIYPrSZ3XvmVBg==", "dev": true, "license": "MIT", "dependencies": { @@ -42981,9 +42988,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -46710,9 +46717,9 @@ } }, "node_modules/swr": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", - "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.7.tgz", + "integrity": "sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==", "dev": true, "license": "MIT", "dependencies": { @@ -49899,9 +49906,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { "node": ">= 10" @@ -50979,6 +50986,15 @@ "php-wasm-cli": "php-wasm.js" } }, + "packages/php-wasm/cli-util": { + "name": "@php-wasm/cli-util", + "version": "3.0.22", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.18.3", + "npm": ">=10.1.0" + } + }, "packages/php-wasm/compile": { "name": "@php-wasm/compile", "version": "0.1.5",