diff --git a/package.json b/package.json index 717617de..8dfd5d2e 100644 --- a/package.json +++ b/package.json @@ -568,6 +568,57 @@ "command": "extension.php-debug.runEditorContents", "when": "resourceLangId == php && !inDiffEditor && resourceScheme == file" } + ], + "debug/variables/context": [ + { + "command": "extension.php-debug.copyVarExport", + "group": "5_cutcopypaste@11", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyJson", + "group": "5_cutcopypaste@12", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyRaw", + "group": "5_cutcopypaste@13", + "when": "debugType == php" + } + ], + "debug/watch/context": [ + { + "command": "extension.php-debug.copyVarExport", + "group": "3_modification@51", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyJson", + "group": "3_modification@52", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyRaw", + "group": "3_modification@53", + "when": "debugType == php" + } + ], + "debug/hover/context": [ + { + "command": "extension.php-debug.copyVarExport", + "group": "5_cutcopypaste@11", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyJson", + "group": "5_cutcopypaste@12", + "when": "debugType == php" + }, + { + "command": "extension.php-debug.copyRaw", + "group": "5_cutcopypaste@13", + "when": "debugType == php" + } ] }, "commands": [ @@ -589,6 +640,18 @@ "category": "PHP Debug", "enablement": "!inDebugMode", "icon": "$(play)" + }, + { + "command": "extension.php-debug.copyVarExport", + "title": "Copy Value as var_export" + }, + { + "command": "extension.php-debug.copyJson", + "title": "Copy Value as json_encode" + }, + { + "command": "extension.php-debug.copyRaw", + "title": "Copy Value as raw" } ], "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index a9514e25..0838b3b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,9 @@ import * as vscode from 'vscode' import { WorkspaceFolder, DebugConfiguration, CancellationToken } from 'vscode' -import { LaunchRequestArguments } from './phpDebug' +import { EvaluateExtendedArguments, LaunchRequestArguments } from './phpDebug' import * as which from 'which' import * as path from 'path' +import { DebugProtocol } from '@vscode/debugprotocol' export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( @@ -143,4 +144,57 @@ export function activate(context: vscode.ExtensionContext) { }) }) ) + + /* This is coppied from vscode/src/vs/workbench/contrib/debug/browser/variablesView.ts */ + interface IVariablesContext { + sessionId: string | undefined + container: DebugProtocol.Variable | DebugProtocol.Scope | DebugProtocol.EvaluateArguments + variable: DebugProtocol.Variable + } + + /* This is coppied from @vscode/debugprotocol/lib/debugProtocol.d.ts because customRequest returns the body of the response and not the response itself */ + interface EvaluateResponse { + /** The result of the evaluate request. */ + result: string + } + + const copyVar = async (arg: IVariablesContext, context: string) => { + const aci = vscode.debug.activeStackItem + if (aci && aci instanceof vscode.DebugStackFrame) { + const ret = (await vscode.debug.activeDebugSession?.customRequest('evaluate', { + context, + expression: arg.variable.evaluateName, + frameId: aci.frameId, + variablesReference: arg.variable.variablesReference, + })) as EvaluateResponse + await vscode.env.clipboard.writeText(ret.result) + } else { + await vscode.window.showErrorMessage('Cannot derermine active debug session') + } + } + + context.subscriptions.push( + vscode.commands.registerCommand( + 'extension.php-debug.copyVarExport', + async (arg: IVariablesContext, p2: any, p3: any) => { + await copyVar(arg, 'clipboard-var_export') + } + ) + ) + context.subscriptions.push( + vscode.commands.registerCommand( + 'extension.php-debug.copyJson', + async (arg: IVariablesContext, p2: any, p3: any) => { + await copyVar(arg, 'clipboard-json') + } + ) + ) + context.subscriptions.push( + vscode.commands.registerCommand( + 'extension.php-debug.copyRaw', + async (arg: IVariablesContext, p2: any, p3: any) => { + await copyVar(arg, 'clipboard-raw') + } + ) + ) } diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 02d78478..64d8c445 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -20,6 +20,7 @@ import { XdebugCloudConnection } from './cloud' import { shouldIgnoreException } from './ignore' import { varExportProperty } from './varExport' import { supportedEngine } from './xdebugUtils' +import { varJsonProperty } from './varJson' if (process.env['VSCODE_NLS_CONFIG']) { try { @@ -31,7 +32,7 @@ if (process.env['VSCODE_NLS_CONFIG']) { } /** formats a xdebug property value for VS Code */ -function formatPropertyValue(property: xdebug.BaseProperty): string { +function formatPropertyValue(property: xdebug.BaseProperty, quoteString: boolean = true): string { let displayValue: string if (property.hasChildren || property.type === 'array' || property.type === 'object') { if (property.type === 'array') { @@ -47,7 +48,7 @@ function formatPropertyValue(property: xdebug.BaseProperty): string { } else { // for null, uninitialized, resource, etc. show the type displayValue = property.value || property.type === 'string' ? property.value : property.type - if (property.type === 'string') { + if (property.type === 'string' && quoteString) { displayValue = `"${displayValue}"` } else if (property.type === 'bool') { displayValue = Boolean(parseInt(displayValue, 10)).toString() @@ -56,6 +57,11 @@ function formatPropertyValue(property: xdebug.BaseProperty): string { return displayValue } +export interface EvaluateExtendedArguments extends VSCodeDebugProtocol.EvaluateArguments { + /** The variable for which to retrieve its children. The `variablesReference` must have been obtained in the current suspended state. See 'Lifetime of Object References' in the Overview section for details. */ + variablesReference?: number +} + /** * This interface should always match the schema found in the mock-debug extension manifest. */ @@ -1496,9 +1502,18 @@ class PhpDebugSession extends vscode.DebugSession { this.shutdown() } + private getPropertyFromReference(variablesReference?: number): xdebug.Property | undefined { + if (variablesReference && this._properties.has(variablesReference)) { + return this._properties.get(variablesReference)! + } /*else if (variablesReference && this._evalResultProperties.has(variablesReference)) { + return this._evalResultProperties.get(variablesReference)! + }*/ + return + } + protected async evaluateRequest( response: VSCodeDebugProtocol.EvaluateResponse, - args: VSCodeDebugProtocol.EvaluateArguments + args: EvaluateExtendedArguments ): Promise { try { if (!args.frameId) { @@ -1510,6 +1525,7 @@ class PhpDebugSession extends vscode.DebugSession { const stackFrame = this._stackFrames.get(args.frameId)! const connection = stackFrame.connection let result: xdebug.BaseProperty | null = null + if (args.context === 'hover') { // try to get variable from property_get const ctx = await stackFrame.getContexts() // TODO CACHE THIS @@ -1517,33 +1533,46 @@ class PhpDebugSession extends vscode.DebugSession { if (res.property) { result = res.property } - } else if (args.context === 'repl') { - const uuid = randomUUID() - await connection.sendEvalCommand(`$GLOBALS['eval_cache']['${uuid}']=${args.expression}`) - const ctx = await stackFrame.getContexts() // TODO CACHE THIS - const res = await connection.sendPropertyGetNameCommand(`$eval_cache['${uuid}']`, ctx[1]) - if (res.property) { - result = res.property + } else { + let property = this.getPropertyFromReference(args.variablesReference) + let ctx: xdebug.Context[] + if (!property) { + // try to get variable + ctx = await stackFrame.getContexts() // TODO CACHE THIS + try { + // we might need to try other contexts too? + const res = await connection.sendPropertyGetNameCommand(args.expression, ctx[0]) + property = res.property + } catch { + // ignore we failed, lets try evaling + } } - } else if (args.context === 'clipboard') { - const ctx = await stackFrame.getContexts() // TODO CACHE THIS - const res = await connection.sendPropertyGetNameCommand(args.expression, ctx[0]) - response.body = { result: await varExportProperty(res.property), variablesReference: 0 } + if (!property) { + const uuid = randomUUID() + await connection.sendEvalCommand(`$GLOBALS['eval_cache']['${uuid}']=${args.expression}`) + const res = await connection.sendPropertyGetNameCommand(`$eval_cache['${uuid}']`, ctx![1]) + property = res.property + } + result = property + } + + if (result && args.context === 'clipboard-var_export') { + response.body = { result: await varExportProperty(result as xdebug.Property), variablesReference: 0 } + this.sendResponse(response) + return + } else if (result && args.context === 'clipboard-json') { + response.body = { result: await varJsonProperty(result as xdebug.Property), variablesReference: 0 } + this.sendResponse(response) + return + } else if (result && args.context === 'clipboard-raw') { + response.body = { result: formatPropertyValue(result, false), variablesReference: 0 } + this.sendResponse(response) + return + } else if (result && this._initializeArgs.clientID !== 'vscode' && args.context === 'clipboard') { + // special case for NON-vscode clients where we cant add extra clipboard related contexts and var_export should be the default + response.body = { result: await varExportProperty(result as xdebug.Property), variablesReference: 0 } this.sendResponse(response) return - } else if (args.context === 'watch') { - const uuid = randomUUID() - await connection.sendEvalCommand(`$GLOBALS['eval_cache']['watch']['${uuid}']=${args.expression}`) - const ctx = await stackFrame.getContexts() // TODO CACHE THIS - const res = await connection.sendPropertyGetNameCommand(`$eval_cache['watch']['${uuid}']`, ctx[1]) - if (res.property) { - result = res.property - } - } else { - const res = await connection.sendEvalCommand(args.expression) - if (res.result) { - result = res.result - } } if (result) { diff --git a/src/test/adapter.ts b/src/test/adapter.ts index d09de4f5..7d0decfe 100644 --- a/src/test/adapter.ts +++ b/src/test/adapter.ts @@ -831,41 +831,74 @@ describe('PHP Debug Adapter', () => { await client.configurationDoneRequest() const { frame } = await assertStoppedLocation('breakpoint', program, 19) - const response = ( - await client.evaluateRequest({ - context: 'clipboard', - frameId: frame.id, - expression: '$anInt', - }) - ).body - - assert.equal(response.result, '123') - assert.equal(response.variablesReference, 0) + interface TestCase { + context: string + expression: string + result: string + hasVariablesReference: boolean + } - const response2 = ( - await client.evaluateRequest({ + const testCases: TestCase[] = [ + { context: 'hover', expression: '$anInt', result: '123', hasVariablesReference: false }, + { context: 'hover', expression: '$aString', result: '"123"', hasVariablesReference: false }, + { context: 'hover', expression: '$anArray', result: 'array(3)', hasVariablesReference: true }, + { context: 'hover', expression: '$aBoolean', result: 'true', hasVariablesReference: false }, + { context: 'hover', expression: '$aFloat', result: '1.23', hasVariablesReference: false }, + { context: 'clipboard', expression: '$anInt', result: '123', hasVariablesReference: false }, + { context: 'clipboard', expression: '$aString', result: "'123'", hasVariablesReference: false }, + { context: 'clipboard', - frameId: frame.id, - expression: '$aString', - }) - ).body - - assert.equal(response2.result, "'123'") - assert.equal(response2.variablesReference, 0) - - const response3 = ( - await client.evaluateRequest({ - context: 'clipboard', - frameId: frame.id, expression: '$anArray', - }) - ).body - - assert.equal( - response3.result, - 'array (\n 0 => 1,\n test => 2,\n test2 => \n array (\n t => 123,\n ),\n)' - ) - assert.equal(response3.variablesReference, 0) + result: 'array (\n 0 => 1,\n test => 2,\n test2 => \n array (\n t => 123,\n ),\n)', + hasVariablesReference: false, + }, + { context: 'clipboard', expression: '$aBoolean', result: 'true', hasVariablesReference: false }, + { context: 'clipboard', expression: '$aFloat', result: '1.23', hasVariablesReference: false }, + { context: 'clipboard-json', expression: '$anInt', result: '123', hasVariablesReference: false }, + { context: 'clipboard-json', expression: '$aString', result: '"123"', hasVariablesReference: false }, + { + context: 'clipboard-json', + expression: '$anArray', + result: '{\n "0": 1,\n "test": 2,\n "test2": {\n "t": 123\n }\n}', + hasVariablesReference: false, + }, + { context: 'clipboard-json', expression: '$aBoolean', result: 'true', hasVariablesReference: false }, + { context: 'clipboard-json', expression: '$aFloat', result: '1.23', hasVariablesReference: false }, + { context: 'clipboard-raw', expression: '$anInt', result: '123', hasVariablesReference: false }, + { context: 'clipboard-raw', expression: '$aString', result: '123', hasVariablesReference: false }, + { context: 'clipboard-raw', expression: '$anArray', result: 'array(3)', hasVariablesReference: false }, + { context: 'clipboard-raw', expression: '$aBoolean', result: 'true', hasVariablesReference: false }, + { context: 'clipboard-raw', expression: '$aFloat', result: '1.23', hasVariablesReference: false }, + ] + + for (const testCase of testCases) { + const response = ( + await client.evaluateRequest({ + context: testCase.context as any, + frameId: frame.id, + expression: testCase.expression, + }) + ).body + + assert.equal( + response.result, + testCase.result, + `Failed for ${testCase.context} - ${testCase.expression}` + ) + if (testCase.hasVariablesReference) { + assert.notEqual( + response.variablesReference, + 0, + `Expected variablesReference for ${testCase.context} - ${testCase.expression}` + ) + } else { + assert.equal( + response.variablesReference, + 0, + `Unexpected variablesReference for ${testCase.context} - ${testCase.expression}` + ) + } + } }) }) diff --git a/src/varExport.ts b/src/varExport.ts index 42b73ba3..e835f402 100644 --- a/src/varExport.ts +++ b/src/varExport.ts @@ -42,16 +42,15 @@ export async function varExportProperty(property: xdebug.Property, indent: strin // for null, uninitialized, resource, etc. show the type displayValue = property.value || property.type === 'string' ? property.value : property.type if (property.type === 'string') { - // escaping ? if (property.size > property.value.length) { - // get value const p2 = await property.context.stackFrame.connection.sendPropertyValueNameCommand( property.fullName, property.context ) displayValue = p2.value } - displayValue = `'${displayValue}'` + const escaped = displayValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + displayValue = `'${escaped}'` } else if (property.type === 'bool') { displayValue = Boolean(parseInt(displayValue, 10)).toString() } diff --git a/src/varJson.ts b/src/varJson.ts new file mode 100644 index 00000000..8ee7e2e0 --- /dev/null +++ b/src/varJson.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'crypto' +import * as xdebug from './xdebugConnection' + +const recursionMagic = `---MAGIC---${randomUUID()}---MAGIC---` + +/** + * Generate a JSON object and pretty print it + */ +export async function varJsonProperty(property: xdebug.Property): Promise { + const obj = await _varJsonProperty(property) + const json = JSON.stringify(obj, null, ' ') + return json.replace(new RegExp(`"${recursionMagic}"`, 'g'), '...') +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function _varJsonProperty(property: xdebug.Property, depth: number = 0): Promise { + if (depth >= 20) { + // prevent infinite recursion + return recursionMagic + } + + let displayValue: string + if (property.hasChildren || property.type === 'array' || property.type === 'object') { + if (!property.children || property.children.length === 0) { + // TODO: also take into account the number of children for pagination + property.children = await property.getChildren() + } + + const obj = await Promise.all( + property.children.map(async property => { + return [property.name, await _varJsonProperty(property, depth + 1)] + }) + ) + + // TODO: handle only numeric, sequential arrays? + + return Object.fromEntries(obj) + } else { + // for null, uninitialized, resource, etc. show the type + displayValue = property.value || property.type === 'string' ? property.value : property.type + if (property.type === 'string') { + // escaping ? + if (property.size > property.value.length) { + // get value + const p2 = await property.context.stackFrame.connection.sendPropertyValueNameCommand( + property.fullName, + property.context + ) + displayValue = p2.value + } + return displayValue + } else if (property.type === 'bool') { + return Boolean(parseInt(displayValue, 10)) + } else if (property.type === 'int') { + return parseInt(displayValue, 10) + } else if (property.type === 'float' || property.type === 'double') { + return parseFloat(displayValue) + } else { + return property.value + } + } +}