Skip to content

Commit b4a8208

Browse files
authored
feat: Implement advanced copy functions (#1067)
* feat: Implement additional evaluate contexts and extend variables and watch context menus * Add Copy Value as raw. Add future debug/hover/context menu. Fix var_export escaping. Extend adapter tests for evaluateRequest. Refactor evaluateRequest. * lint * Increase coverage * Improve coverage
1 parent bcb2ba1 commit b4a8208

File tree

6 files changed

+302
-63
lines changed

6 files changed

+302
-63
lines changed

package.json

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,57 @@
568568
"command": "extension.php-debug.runEditorContents",
569569
"when": "resourceLangId == php && !inDiffEditor && resourceScheme == file"
570570
}
571+
],
572+
"debug/variables/context": [
573+
{
574+
"command": "extension.php-debug.copyVarExport",
575+
"group": "5_cutcopypaste@11",
576+
"when": "debugType == php"
577+
},
578+
{
579+
"command": "extension.php-debug.copyJson",
580+
"group": "5_cutcopypaste@12",
581+
"when": "debugType == php"
582+
},
583+
{
584+
"command": "extension.php-debug.copyRaw",
585+
"group": "5_cutcopypaste@13",
586+
"when": "debugType == php"
587+
}
588+
],
589+
"debug/watch/context": [
590+
{
591+
"command": "extension.php-debug.copyVarExport",
592+
"group": "3_modification@51",
593+
"when": "debugType == php"
594+
},
595+
{
596+
"command": "extension.php-debug.copyJson",
597+
"group": "3_modification@52",
598+
"when": "debugType == php"
599+
},
600+
{
601+
"command": "extension.php-debug.copyRaw",
602+
"group": "3_modification@53",
603+
"when": "debugType == php"
604+
}
605+
],
606+
"debug/hover/context": [
607+
{
608+
"command": "extension.php-debug.copyVarExport",
609+
"group": "5_cutcopypaste@11",
610+
"when": "debugType == php"
611+
},
612+
{
613+
"command": "extension.php-debug.copyJson",
614+
"group": "5_cutcopypaste@12",
615+
"when": "debugType == php"
616+
},
617+
{
618+
"command": "extension.php-debug.copyRaw",
619+
"group": "5_cutcopypaste@13",
620+
"when": "debugType == php"
621+
}
571622
]
572623
},
573624
"commands": [
@@ -589,6 +640,18 @@
589640
"category": "PHP Debug",
590641
"enablement": "!inDebugMode",
591642
"icon": "$(play)"
643+
},
644+
{
645+
"command": "extension.php-debug.copyVarExport",
646+
"title": "Copy Value as var_export"
647+
},
648+
{
649+
"command": "extension.php-debug.copyJson",
650+
"title": "Copy Value as json_encode"
651+
},
652+
{
653+
"command": "extension.php-debug.copyRaw",
654+
"title": "Copy Value as raw"
592655
}
593656
],
594657
"keybindings": [

src/extension.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as vscode from 'vscode'
22
import { WorkspaceFolder, DebugConfiguration, CancellationToken } from 'vscode'
3-
import { LaunchRequestArguments } from './phpDebug'
3+
import { EvaluateExtendedArguments, LaunchRequestArguments } from './phpDebug'
44
import * as which from 'which'
55
import * as path from 'path'
6+
import { DebugProtocol } from '@vscode/debugprotocol'
67

78
export function activate(context: vscode.ExtensionContext) {
89
context.subscriptions.push(
@@ -143,4 +144,57 @@ export function activate(context: vscode.ExtensionContext) {
143144
})
144145
})
145146
)
147+
148+
/* This is coppied from vscode/src/vs/workbench/contrib/debug/browser/variablesView.ts */
149+
interface IVariablesContext {
150+
sessionId: string | undefined
151+
container: DebugProtocol.Variable | DebugProtocol.Scope | DebugProtocol.EvaluateArguments
152+
variable: DebugProtocol.Variable
153+
}
154+
155+
/* This is coppied from @vscode/debugprotocol/lib/debugProtocol.d.ts because customRequest returns the body of the response and not the response itself */
156+
interface EvaluateResponse {
157+
/** The result of the evaluate request. */
158+
result: string
159+
}
160+
161+
const copyVar = async (arg: IVariablesContext, context: string) => {
162+
const aci = vscode.debug.activeStackItem
163+
if (aci && aci instanceof vscode.DebugStackFrame) {
164+
const ret = (await vscode.debug.activeDebugSession?.customRequest('evaluate', <EvaluateExtendedArguments>{
165+
context,
166+
expression: arg.variable.evaluateName,
167+
frameId: aci.frameId,
168+
variablesReference: arg.variable.variablesReference,
169+
})) as EvaluateResponse
170+
await vscode.env.clipboard.writeText(ret.result)
171+
} else {
172+
await vscode.window.showErrorMessage('Cannot derermine active debug session')
173+
}
174+
}
175+
176+
context.subscriptions.push(
177+
vscode.commands.registerCommand(
178+
'extension.php-debug.copyVarExport',
179+
async (arg: IVariablesContext, p2: any, p3: any) => {
180+
await copyVar(arg, 'clipboard-var_export')
181+
}
182+
)
183+
)
184+
context.subscriptions.push(
185+
vscode.commands.registerCommand(
186+
'extension.php-debug.copyJson',
187+
async (arg: IVariablesContext, p2: any, p3: any) => {
188+
await copyVar(arg, 'clipboard-json')
189+
}
190+
)
191+
)
192+
context.subscriptions.push(
193+
vscode.commands.registerCommand(
194+
'extension.php-debug.copyRaw',
195+
async (arg: IVariablesContext, p2: any, p3: any) => {
196+
await copyVar(arg, 'clipboard-raw')
197+
}
198+
)
199+
)
146200
}

src/phpDebug.ts

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { XdebugCloudConnection } from './cloud'
2020
import { shouldIgnoreException } from './ignore'
2121
import { varExportProperty } from './varExport'
2222
import { supportedEngine } from './xdebugUtils'
23+
import { varJsonProperty } from './varJson'
2324

2425
if (process.env['VSCODE_NLS_CONFIG']) {
2526
try {
@@ -31,7 +32,7 @@ if (process.env['VSCODE_NLS_CONFIG']) {
3132
}
3233

3334
/** formats a xdebug property value for VS Code */
34-
function formatPropertyValue(property: xdebug.BaseProperty): string {
35+
function formatPropertyValue(property: xdebug.BaseProperty, quoteString: boolean = true): string {
3536
let displayValue: string
3637
if (property.hasChildren || property.type === 'array' || property.type === 'object') {
3738
if (property.type === 'array') {
@@ -47,7 +48,7 @@ function formatPropertyValue(property: xdebug.BaseProperty): string {
4748
} else {
4849
// for null, uninitialized, resource, etc. show the type
4950
displayValue = property.value || property.type === 'string' ? property.value : property.type
50-
if (property.type === 'string') {
51+
if (property.type === 'string' && quoteString) {
5152
displayValue = `"${displayValue}"`
5253
} else if (property.type === 'bool') {
5354
displayValue = Boolean(parseInt(displayValue, 10)).toString()
@@ -56,6 +57,11 @@ function formatPropertyValue(property: xdebug.BaseProperty): string {
5657
return displayValue
5758
}
5859

60+
export interface EvaluateExtendedArguments extends VSCodeDebugProtocol.EvaluateArguments {
61+
/** 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. */
62+
variablesReference?: number
63+
}
64+
5965
/**
6066
* This interface should always match the schema found in the mock-debug extension manifest.
6167
*/
@@ -1496,9 +1502,18 @@ class PhpDebugSession extends vscode.DebugSession {
14961502
this.shutdown()
14971503
}
14981504

1505+
private getPropertyFromReference(variablesReference?: number): xdebug.Property | undefined {
1506+
if (variablesReference && this._properties.has(variablesReference)) {
1507+
return this._properties.get(variablesReference)!
1508+
} /*else if (variablesReference && this._evalResultProperties.has(variablesReference)) {
1509+
return this._evalResultProperties.get(variablesReference)!
1510+
}*/
1511+
return
1512+
}
1513+
14991514
protected async evaluateRequest(
15001515
response: VSCodeDebugProtocol.EvaluateResponse,
1501-
args: VSCodeDebugProtocol.EvaluateArguments
1516+
args: EvaluateExtendedArguments
15021517
): Promise<void> {
15031518
try {
15041519
if (!args.frameId) {
@@ -1510,40 +1525,54 @@ class PhpDebugSession extends vscode.DebugSession {
15101525
const stackFrame = this._stackFrames.get(args.frameId)!
15111526
const connection = stackFrame.connection
15121527
let result: xdebug.BaseProperty | null = null
1528+
15131529
if (args.context === 'hover') {
15141530
// try to get variable from property_get
15151531
const ctx = await stackFrame.getContexts() // TODO CACHE THIS
15161532
const res = await connection.sendPropertyGetNameCommand(args.expression, ctx[0])
15171533
if (res.property) {
15181534
result = res.property
15191535
}
1520-
} else if (args.context === 'repl') {
1521-
const uuid = randomUUID()
1522-
await connection.sendEvalCommand(`$GLOBALS['eval_cache']['${uuid}']=${args.expression}`)
1523-
const ctx = await stackFrame.getContexts() // TODO CACHE THIS
1524-
const res = await connection.sendPropertyGetNameCommand(`$eval_cache['${uuid}']`, ctx[1])
1525-
if (res.property) {
1526-
result = res.property
1536+
} else {
1537+
let property = this.getPropertyFromReference(args.variablesReference)
1538+
let ctx: xdebug.Context[]
1539+
if (!property) {
1540+
// try to get variable
1541+
ctx = await stackFrame.getContexts() // TODO CACHE THIS
1542+
try {
1543+
// we might need to try other contexts too?
1544+
const res = await connection.sendPropertyGetNameCommand(args.expression, ctx[0])
1545+
property = res.property
1546+
} catch {
1547+
// ignore we failed, lets try evaling
1548+
}
15271549
}
1528-
} else if (args.context === 'clipboard') {
1529-
const ctx = await stackFrame.getContexts() // TODO CACHE THIS
1530-
const res = await connection.sendPropertyGetNameCommand(args.expression, ctx[0])
1531-
response.body = { result: await varExportProperty(res.property), variablesReference: 0 }
1550+
if (!property) {
1551+
const uuid = randomUUID()
1552+
await connection.sendEvalCommand(`$GLOBALS['eval_cache']['${uuid}']=${args.expression}`)
1553+
const res = await connection.sendPropertyGetNameCommand(`$eval_cache['${uuid}']`, ctx![1])
1554+
property = res.property
1555+
}
1556+
result = property
1557+
}
1558+
1559+
if (result && args.context === 'clipboard-var_export') {
1560+
response.body = { result: await varExportProperty(result as xdebug.Property), variablesReference: 0 }
1561+
this.sendResponse(response)
1562+
return
1563+
} else if (result && args.context === 'clipboard-json') {
1564+
response.body = { result: await varJsonProperty(result as xdebug.Property), variablesReference: 0 }
1565+
this.sendResponse(response)
1566+
return
1567+
} else if (result && args.context === 'clipboard-raw') {
1568+
response.body = { result: formatPropertyValue(result, false), variablesReference: 0 }
1569+
this.sendResponse(response)
1570+
return
1571+
} else if (result && this._initializeArgs.clientID !== 'vscode' && args.context === 'clipboard') {
1572+
// special case for NON-vscode clients where we cant add extra clipboard related contexts and var_export should be the default
1573+
response.body = { result: await varExportProperty(result as xdebug.Property), variablesReference: 0 }
15321574
this.sendResponse(response)
15331575
return
1534-
} else if (args.context === 'watch') {
1535-
const uuid = randomUUID()
1536-
await connection.sendEvalCommand(`$GLOBALS['eval_cache']['watch']['${uuid}']=${args.expression}`)
1537-
const ctx = await stackFrame.getContexts() // TODO CACHE THIS
1538-
const res = await connection.sendPropertyGetNameCommand(`$eval_cache['watch']['${uuid}']`, ctx[1])
1539-
if (res.property) {
1540-
result = res.property
1541-
}
1542-
} else {
1543-
const res = await connection.sendEvalCommand(args.expression)
1544-
if (res.result) {
1545-
result = res.result
1546-
}
15471576
}
15481577

15491578
if (result) {

src/test/adapter.ts

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -831,41 +831,74 @@ describe('PHP Debug Adapter', () => {
831831
await client.configurationDoneRequest()
832832
const { frame } = await assertStoppedLocation('breakpoint', program, 19)
833833

834-
const response = (
835-
await client.evaluateRequest({
836-
context: 'clipboard',
837-
frameId: frame.id,
838-
expression: '$anInt',
839-
})
840-
).body
841-
842-
assert.equal(response.result, '123')
843-
assert.equal(response.variablesReference, 0)
834+
interface TestCase {
835+
context: string
836+
expression: string
837+
result: string
838+
hasVariablesReference: boolean
839+
}
844840

845-
const response2 = (
846-
await client.evaluateRequest({
841+
const testCases: TestCase[] = [
842+
{ context: 'hover', expression: '$anInt', result: '123', hasVariablesReference: false },
843+
{ context: 'hover', expression: '$aString', result: '"123"', hasVariablesReference: false },
844+
{ context: 'hover', expression: '$anArray', result: 'array(3)', hasVariablesReference: true },
845+
{ context: 'hover', expression: '$aBoolean', result: 'true', hasVariablesReference: false },
846+
{ context: 'hover', expression: '$aFloat', result: '1.23', hasVariablesReference: false },
847+
{ context: 'clipboard', expression: '$anInt', result: '123', hasVariablesReference: false },
848+
{ context: 'clipboard', expression: '$aString', result: "'123'", hasVariablesReference: false },
849+
{
847850
context: 'clipboard',
848-
frameId: frame.id,
849-
expression: '$aString',
850-
})
851-
).body
852-
853-
assert.equal(response2.result, "'123'")
854-
assert.equal(response2.variablesReference, 0)
855-
856-
const response3 = (
857-
await client.evaluateRequest({
858-
context: 'clipboard',
859-
frameId: frame.id,
860851
expression: '$anArray',
861-
})
862-
).body
863-
864-
assert.equal(
865-
response3.result,
866-
'array (\n 0 => 1,\n test => 2,\n test2 => \n array (\n t => 123,\n ),\n)'
867-
)
868-
assert.equal(response3.variablesReference, 0)
852+
result: 'array (\n 0 => 1,\n test => 2,\n test2 => \n array (\n t => 123,\n ),\n)',
853+
hasVariablesReference: false,
854+
},
855+
{ context: 'clipboard', expression: '$aBoolean', result: 'true', hasVariablesReference: false },
856+
{ context: 'clipboard', expression: '$aFloat', result: '1.23', hasVariablesReference: false },
857+
{ context: 'clipboard-json', expression: '$anInt', result: '123', hasVariablesReference: false },
858+
{ context: 'clipboard-json', expression: '$aString', result: '"123"', hasVariablesReference: false },
859+
{
860+
context: 'clipboard-json',
861+
expression: '$anArray',
862+
result: '{\n "0": 1,\n "test": 2,\n "test2": {\n "t": 123\n }\n}',
863+
hasVariablesReference: false,
864+
},
865+
{ context: 'clipboard-json', expression: '$aBoolean', result: 'true', hasVariablesReference: false },
866+
{ context: 'clipboard-json', expression: '$aFloat', result: '1.23', hasVariablesReference: false },
867+
{ context: 'clipboard-raw', expression: '$anInt', result: '123', hasVariablesReference: false },
868+
{ context: 'clipboard-raw', expression: '$aString', result: '123', hasVariablesReference: false },
869+
{ context: 'clipboard-raw', expression: '$anArray', result: 'array(3)', hasVariablesReference: false },
870+
{ context: 'clipboard-raw', expression: '$aBoolean', result: 'true', hasVariablesReference: false },
871+
{ context: 'clipboard-raw', expression: '$aFloat', result: '1.23', hasVariablesReference: false },
872+
]
873+
874+
for (const testCase of testCases) {
875+
const response = (
876+
await client.evaluateRequest({
877+
context: testCase.context as any,
878+
frameId: frame.id,
879+
expression: testCase.expression,
880+
})
881+
).body
882+
883+
assert.equal(
884+
response.result,
885+
testCase.result,
886+
`Failed for ${testCase.context} - ${testCase.expression}`
887+
)
888+
if (testCase.hasVariablesReference) {
889+
assert.notEqual(
890+
response.variablesReference,
891+
0,
892+
`Expected variablesReference for ${testCase.context} - ${testCase.expression}`
893+
)
894+
} else {
895+
assert.equal(
896+
response.variablesReference,
897+
0,
898+
`Unexpected variablesReference for ${testCase.context} - ${testCase.expression}`
899+
)
900+
}
901+
}
869902
})
870903
})
871904

0 commit comments

Comments
 (0)