diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 220ba636..41977c83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,6 +61,8 @@ jobs: run: ./node_modules/.bin/nyc report --reporter=json - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} release: runs-on: ubuntu-22.04 needs: test diff --git a/.vscode/launch.json b/.vscode/launch.json index 8b63fec0..ac1bdce9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,16 +12,18 @@ "NODE_ENV": "development" }, "sourceMaps": true, - "outFiles": ["${workspaceFolder}/out/**/*.js"] + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: watch" }, { "name": "Launch Extension", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}"], + "args": ["--extensionDevelopmentPath=${workspaceRoot}", "${workspaceFolder}/testproject"], "sourceMaps": true, - "outFiles": ["${workspaceFolder}/out/**/*.js"] + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: watch" }, { "name": "Mocha", diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac173b3..9b7cadde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.37.0] + +- Expection info and support for virtual exception property (Xdebug 3.5.0) + ## [1.36.2] - Revert watch handling to eval diff --git a/src/phpDebug.ts b/src/phpDebug.ts index b28669a6..c5dd9efb 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -12,7 +12,6 @@ import { Terminal } from './terminal' import { convertClientPathToDebugger, convertDebuggerPathToClient, isPositiveMatchInGlobs } from './paths' import { minimatch } from 'minimatch' import { BreakpointManager, BreakpointAdapter } from './breakpoints' -import * as semver from 'semver' import { LogPointManager } from './logpoint' import { ProxyConnect } from './proxyConnect' import { randomUUID } from 'crypto' @@ -20,6 +19,7 @@ import { getConfiguredEnvironment } from './envfile' import { XdebugCloudConnection } from './cloud' import { shouldIgnoreException } from './ignore' import { varExportProperty } from './varExport' +import { supportedEngine } from './xdebugUtils' if (process.env['VSCODE_NLS_CONFIG']) { try { @@ -249,6 +249,7 @@ class PhpDebugSession extends vscode.DebugSession { supportTerminateDebuggee: true, supportsDelayedStackTraceLoading: false, supportsClipboardContext: true, + supportsExceptionInfoRequest: true, } this.sendResponse(response) } @@ -522,29 +523,24 @@ class PhpDebugSession extends vscode.DebugSession { // support for breakpoints let feat: xdebug.FeatureGetResponse - const supportedEngine = - initPacket.engineName === 'Xdebug' && - semver.valid(initPacket.engineVersion, { loose: true }) && - semver.gte(initPacket.engineVersion, '3.0.0', { loose: true }) - const supportedEngine32 = - initPacket.engineName === 'Xdebug' && - semver.valid(initPacket.engineVersion, { loose: true }) && - semver.gte(initPacket.engineVersion, '3.2.0', { loose: true }) + const supportedEngine30 = supportedEngine(initPacket, '3.0.0') + const supportedEngine32 = supportedEngine(initPacket, '3.2.0') + const supportedEngine35 = supportedEngine(initPacket, '3.5.0') if ( - supportedEngine || + supportedEngine30 || ((feat = await connection.sendFeatureGetCommand('resolved_breakpoints')) && feat.supported === '1') ) { await connection.sendFeatureSetCommand('resolved_breakpoints', '1') } if ( - supportedEngine || + supportedEngine30 || ((feat = await connection.sendFeatureGetCommand('notify_ok')) && feat.supported === '1') ) { await connection.sendFeatureSetCommand('notify_ok', '1') connection.on('notify_user', (notify: xdebug.UserNotify) => this.handleUserNotify(notify, connection)) } if ( - supportedEngine || + supportedEngine30 || ((feat = await connection.sendFeatureGetCommand('extended_properties')) && feat.supported === '1') ) { await connection.sendFeatureSetCommand('extended_properties', '1') @@ -556,6 +552,12 @@ class PhpDebugSession extends vscode.DebugSession { ) { await connection.sendFeatureSetCommand('breakpoint_include_return_value', '1') } + if ( + supportedEngine35 || + ((feat = await connection.sendFeatureGetCommand('virtual_exception_value')) && feat.supported === '1') + ) { + await connection.sendFeatureSetCommand('virtual_exception_value', '1') + } // override features from launch.json try { @@ -1529,6 +1531,77 @@ class PhpDebugSession extends vscode.DebugSession { this.sendResponse(response) } } + + protected async exceptionInfoRequest( + response: VSCodeDebugProtocol.ExceptionInfoResponse, + args: VSCodeDebugProtocol.ExceptionInfoArguments + ): Promise { + const connection = this._connections.get(args.threadId) + if (!connection) { + throw new Error('Unknown thread ID') + } + + const status = this._statuses.get(connection)! + + let ex: VSCodeDebugProtocol.ExceptionDetails | undefined + + if (connection.featureSet('virtual_exception_value')) { + let old_max_depth = 3 + try { + const { stack } = await connection.sendStackGetCommand() // CACHE? + + if (stack.length > 0) { + const ctx = await stack[0].getContexts() // CACHE + old_max_depth = connection.featureSet('max_depth') ?? 1 + if (old_max_depth < 3) { + await connection.sendFeatureSetCommand('max_depth', 3) + } + + const res = await connection.sendPropertyGetNameCommand('$__EXCEPTION', ctx[0]) + + const s = res.property.children.find( + p => p.name === 'trace' || p.name === '*Exception*trace' || p.name === '*Error*trace' + ) + + const at = `Created at ${ + res.property.children.find(p => p.name == 'file')?.value ?? '**UNKNOWN**' + }:${res.property.children.find(p => p.name == 'line')?.value ?? '?'}\n` + const st = s?.children + .map( + (t, i) => + `at ${t.children.find(p => p.name == 'class')?.value ?? ''}${ + t.children.find(p => p.name == 'type')?.value ?? '' + }${t.children.find(p => p.name == 'function')?.value ?? '?'}() (${ + t.children.find(p => p.name == 'file')?.value ?? '**UNKNOWN**' + }:${t.children.find(p => p.name == 'line')?.value ?? '?'})` + ) + .join('\n') + ex = { + message: res.property.children.find(p => p.name === 'message')?.value ?? undefined, + typeName: res.property.class, + fullTypeName: res.property.class, + evaluateName: '$__EXCEPTION', + stackTrace: `${at}${st ?? ''}`, + // TODO process inner/previous exception + } + } + } catch { + // Probably $__EXCEPTION not present + } finally { + if (old_max_depth < 3) { + await connection.sendFeatureSetCommand('max_depth', old_max_depth) + } + } + } + + response.body = { + exceptionId: status.exception.name, + breakMode: 'always', + description: status.exception.message, + details: ex, + } + this.sendResponse(response) + } } vscode.DebugSession.run(PhpDebugSession) diff --git a/src/test/adapter.ts b/src/test/adapter.ts index 250db813..d09de4f5 100644 --- a/src/test/adapter.ts +++ b/src/test/adapter.ts @@ -946,4 +946,21 @@ describe('PHP Debug Adapter', () => { await client.assertOutput('console', 'skipping entry point') }) }) + + describe('exception info', () => { + it('should show exception info', async () => { + const program = path.join(TEST_PROJECT, 'error.php') + + await client.launch({ program }) + await client.setExceptionBreakpointsRequest({ filters: ['Exception'] }) + const [, { threadId }] = await Promise.all([ + client.configurationDoneRequest(), + assertStoppedLocation('exception', program, 12), + ]) + const response = await client.exceptionInfoRequest({ threadId }) + assert.equal(response.body.exceptionId, 'Exception') + assert.equal(response.body.description, 'this is an exception') + await Promise.all([client.continueRequest({ threadId }), client.waitForEvent('terminated')]) + }) + }) }) diff --git a/src/xdebugConnection.ts b/src/xdebugConnection.ts index e8253e80..9bb04864 100644 --- a/src/xdebugConnection.ts +++ b/src/xdebugConnection.ts @@ -845,6 +845,12 @@ export class Connection extends DbgpConnection { return this._pendingExecuteCommand } + private _featureSet = new Map() + + public featureSet(feature: string): number | string | undefined { + return this._featureSet.get(feature) + } + /** Constructs a new connection that uses the given socket to communicate with Xdebug. */ constructor(socket: Transport) { super(socket) @@ -999,7 +1005,9 @@ export class Connection extends DbgpConnection { * - notify_ok */ public async sendFeatureSetCommand(feature: string, value: string | number): Promise { - return new FeatureSetResponse(await this._enqueueCommand('feature_set', `-n ${feature} -v ${value}`), this) + const res = new FeatureSetResponse(await this._enqueueCommand('feature_set', `-n ${feature} -v ${value}`), this) + this._featureSet.set(feature, value) + return res } // ---------------------------- breakpoints ------------------------------------ diff --git a/src/xdebugUtils.ts b/src/xdebugUtils.ts new file mode 100644 index 00000000..c46e37c7 --- /dev/null +++ b/src/xdebugUtils.ts @@ -0,0 +1,10 @@ +import * as semver from 'semver' +import * as xdebug from './xdebugConnection' + +export function supportedEngine(initPacket: xdebug.InitPacket, version: string): boolean { + return ( + initPacket.engineName === 'Xdebug' && + semver.valid(initPacket.engineVersion.replace('-dev', ''), { loose: true }) !== null && + semver.gte(initPacket.engineVersion.replace('-dev', ''), version, { loose: true }) + ) +}