Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Variables and Variable Decorations #16

Merged
merged 16 commits into from
Apr 17, 2023
16 changes: 16 additions & 0 deletions .vscode/memory-inspector.code-snippets
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"Copyright": {
"prefix": [
"header",
"copyright"
],
"body": "/********************************************************************************\n * Copyright (C) $CURRENT_YEAR ${YourCompany} and others.\n *\n * This program and the accompanying materials are made available under the\n * terms of the Eclipse Public License v. 2.0 which is available at\n * http://www.eclipse.org/legal/epl-2.0.\n *\n * This Source Code may also be made available under the following Secondary\n * Licenses when the conditions for such availability set forth in the Eclipse\n * Public License v. 2.0 are satisfied: GNU General Public License, version 2\n * with the GNU Classpath Exception which is available at\n * https://www.gnu.org/software/classpath/license.html.\n *\n * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0\n ********************************************************************************/\n\n$0",
"description": "Adds the copyright...",
"scope": "css,javascript,javascriptreact,typescript,typescriptreact"
},
"Import VSCode": {
"prefix": "codeimport",
"body": "import * as vscode from 'vscode';",
"scope": "typescript,javascript"
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"dependencies": {
"@vscode/codicons": "^0.0.32",
"@vscode/webview-ui-toolkit": "^1.2.0",
"long": "^5.2.1",
"fast-deep-equal": "^3.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vscode-messenger": "^0.4.3",
Expand Down
89 changes: 89 additions & 0 deletions src/common/memory-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/********************************************************************************
* Copyright (C) 2023 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

/** Suitable for transmission as JSON */
export interface MemoryRange {
/** String representation of the address at which the range begins. May exceed maximum safe JS integer. */
startAddress: string;
/**
* String representation of the address at which the range ends, exclusive. I.e. this should be the first address not included in the range.
* May exceed maximum safe JS integer.
*
* If absent, the UI will indicate the first address at which the variable can be found but not its extent.
*/
endAddress?: string;
}

/** Suitable for arithemetic */
export interface BigIntMemoryRange {
startAddress: bigint;
endAddress: bigint;
}

export function isWithin(candidate: bigint, container: BigIntMemoryRange): boolean {
return container.startAddress <= candidate && container.endAddress > candidate;
}

export function doOverlap(one: BigIntMemoryRange, other: BigIntMemoryRange): boolean {
// If they overlap, they either start in the same place, or one starts in the other.
return isWithin(one.startAddress, other) || isWithin(other.startAddress, one);
}

export function areRangesEqual(one: BigIntMemoryRange, other: BigIntMemoryRange): boolean {
return one.startAddress === other.startAddress && one.endAddress === other.endAddress;
}

export function compareBigInt(left: bigint, right: bigint): number {
const difference = left - right;
return difference === BigInt(0) ? 0 : difference > 0 ? 1 : -1;
}

export enum RangeRelationship {
Before,
Within,
Past,
None,
}

export function determineRelationship(candidate: bigint, range?: BigIntMemoryRange): RangeRelationship {
if (range === undefined) { return RangeRelationship.None; }
if (candidate < range.startAddress) { return RangeRelationship.Before; }
if (candidate >= range.endAddress) { return RangeRelationship.Past; }
return RangeRelationship.Within;
}

export function toHexStringWithRadixMarker(target: bigint): string {
return `0x${target.toString(16)}`;
}

export interface VariableMetadata {
name: string;
type?: string;
/** If applicable, a string representation of the variable's value */
value?: string;
}

/** Suitable for transmission as JSON */
export interface VariableRange extends MemoryRange, VariableMetadata { }
/** Suitable for arithemetic */
export interface BigIntVariableRange extends BigIntMemoryRange, VariableMetadata { }

export function areVariablesEqual(one: BigIntVariableRange, other: BigIntVariableRange): boolean {
return areRangesEqual(one, other)
&& one.name === other.name
&& one.type === other.type
&& one.value === other.value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
********************************************************************************/

import type { DebugProtocol } from '@vscode/debugprotocol';
import { NotificationType, RequestType } from 'vscode-messenger-common';
import type { NotificationType, RequestType } from 'vscode-messenger-common';
import type { VariableRange } from './memory-range';

export type MemoryReadResult = DebugProtocol.ReadMemoryResponse['body'];
export type MemoryWriteResult = DebugProtocol.WriteMemoryResponse['body'];
Expand All @@ -25,3 +26,4 @@ export const logMessageType: RequestType<string, void> = { method: 'logMessage'
export const setOptionsType: RequestType<Partial<DebugProtocol.ReadMemoryArguments | undefined>, void> = { method: 'setOptions' };
export const readMemoryType: RequestType<DebugProtocol.ReadMemoryArguments, MemoryReadResult> = { method: 'readMemory' };
export const writeMemoryType: RequestType<DebugProtocol.WriteMemoryArguments, MemoryWriteResult> = { method: 'writeMemory' };
export const getVariables: RequestType<DebugProtocol.ReadMemoryArguments, VariableRange[]> = { method: 'getVariables' };
15 changes: 10 additions & 5 deletions src/browser/extension.ts → src/entry-points/browser/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
********************************************************************************/

import * as vscode from 'vscode';
import { MemoryProvider } from '../memory-provider';
import { MemoryWebview } from '../views/memory-webview-main';
import { AdapterRegistry } from '../../plugin/adapter-registry/adapter-registry';
import { GdbCapabilities } from '../../plugin/adapter-registry/gdb-capabilities';
import { MemoryProvider } from '../../plugin/memory-provider';
import { MemoryWebview } from '../../plugin/memory-webview-main';

export const activate = async (context: vscode.ExtensionContext): Promise<void> => {
export const activate = async (context: vscode.ExtensionContext): Promise<AdapterRegistry> => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are returning an API, it could be good to publish the API to npm (or even on the GitHub npm registry).

We do this internally by having an api folder with the interface exposed (which is published as a separate package) and then have the main class implement this interface.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's a good idea, since it will make it easier to inform people of changes. On the one hand, I think that allowing others to contribute handling for their own adapters is valuable, on the other hand managing the API when VSCode's dependency resolution is fairly crude could be tricky. I know that we would like to support at least memory watchpoints on the plugin side - is there anything that falls on the margins of the DAP that you'd like to support? Perhaps we can start releasing the API once we've got at least a first draft of the functionality we know we want?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using our browser debugger and the gdb debugger as test adapters should really help us uncover the correct abstraction points and any common code we don't need to duplicate in plugins. Let's let this evolve as we add features and see where it goes :)

const memoryProvider = new MemoryProvider();
const memoryView = new MemoryWebview(context.extensionUri, memoryProvider);

await memoryProvider.activate(context);
const registry = new AdapterRegistry();
context.subscriptions.push(registry);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer the architecture of having an activate function in the class isolating functionality together.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer not to have a two-step lifecycle of construction and then activation if it can be avoided, so if the class is going to handle its own lifecycle, I'd rather it do it in the constructor. What do you think of that approach?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My rationale for isolation is it keeps the activation needs along with the module being added. It makes it trivial to add/remove/change these modules and keeps the extension activation function from growing too large (a common problem I've seen in many extensions).

A couple of reasons why the two-step approach works better:

  • Constructors aren't asynchronous, but often activation is :(. This can be worked around using static class factories, but makes it a little messy
  • Instantiation is different to activation, we need to consider scenarios where classes may need to be deactivated, too (exposing a deactivate function, called in the global deactivate).

I'm not too precious about this, so happy to try adding the context to all constructors if you'd prefer?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds fair. Consistency has some value, too, so I'll switch the registry to use an activate method, as well.

context.subscriptions.push(registry.registerAdapter('gdb', new GdbCapabilities));
await memoryProvider.activate(context, registry);
await memoryView.activate(context);
return registry;
};

export const deactivate = async (): Promise<void> => {
Expand Down
15 changes: 10 additions & 5 deletions src/desktop/extension.ts → src/entry-points/desktop/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
********************************************************************************/

import * as vscode from 'vscode';
import { MemoryProvider } from '../memory-provider';
import { MemoryWebview } from '../views/memory-webview-main';
import { AdapterRegistry } from '../../plugin/adapter-registry/adapter-registry';
import { GdbCapabilities } from '../../plugin/adapter-registry/gdb-capabilities';
import { MemoryProvider } from '../../plugin/memory-provider';
import { MemoryWebview } from '../../plugin/memory-webview-main';

export const activate = async (context: vscode.ExtensionContext): Promise<void> => {
export const activate = async (context: vscode.ExtensionContext): Promise<AdapterRegistry> => {
const memoryProvider = new MemoryProvider();
const memoryView = new MemoryWebview(context.extensionUri, memoryProvider);

await memoryProvider.activate(context);
const registry = new AdapterRegistry();
context.subscriptions.push(registry);
context.subscriptions.push(registry.registerAdapter('gdb', new GdbCapabilities));
await memoryProvider.activate(context, registry);
await memoryView.activate(context);
return registry;
};

export const deactivate = async (): Promise<void> => {
Expand Down
28 changes: 28 additions & 0 deletions src/plugin/adapter-registry/adapter-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/********************************************************************************
* Copyright (C) 2023 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { DebugProtocol } from '@vscode/debugprotocol';
import * as vscode from 'vscode';
import { VariableRange } from '../../common/memory-range';

/** Represents capabilities that may be achieved with particular debug adapters but are not part of the DAP */
export interface AdapterCapabilities {
/** Resolve variables known to the adapter to their locations. Fallback if {@link getResidents} is not present */
getVariables?(session: vscode.DebugSession): Promise<VariableRange[]>;
/** Resolve symbols resident in the memory at the specified range. Will be preferred to {@link getVariables} if present. */
getResidents?(session: vscode.DebugSession, params: DebugProtocol.ReadMemoryArguments): Promise<VariableRange[]>;
initializeAdapterTracker?(session: vscode.DebugSession): vscode.DebugAdapterTracker | undefined;
}
42 changes: 42 additions & 0 deletions src/plugin/adapter-registry/adapter-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/********************************************************************************
* Copyright (C) 2023 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import * as vscode from 'vscode';
import { AdapterCapabilities } from './adapter-capabilities';

export class AdapterRegistry implements vscode.Disposable {
protected handlers = new Map<string, AdapterCapabilities>();
protected isDisposed = false;
registerAdapter(debugType: string, handlerToRegister: AdapterCapabilities): vscode.Disposable {
if (this.isDisposed) { return new vscode.Disposable(() => { }); }
this.handlers.set(debugType, handlerToRegister);
return new vscode.Disposable(() => {
const currentlyRegisteredHandler = this.handlers.get(debugType);
if (currentlyRegisteredHandler === handlerToRegister) {
this.handlers.delete(debugType);
}
});
};

getHandlerForSession(session: vscode.DebugSession): AdapterCapabilities | undefined {
return this.handlers.get(session.type);
}

dispose(): void {
this.isDisposed = true;
this.handlers.clear();
}
}
137 changes: 137 additions & 0 deletions src/plugin/adapter-registry/gdb-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/********************************************************************************
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our use case doesn't use gdb (and gdb doesn't work in the browser), so I'd be keen to see a basic adaptertracker implemented which does as much as possible, perhaps with an example gdb one extending the base one?

For example, scopes, variables and evaluation requests are all supported in DAP (we could check the capabilities request?) and even the local variables can be detected using the DAP presentation hints.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - I think the only GDB specific bit in the current implementation is the actual evaluate request, in the sense that even though the evaluate request is part of the DAP, what expressions get sent and their syntax may depend on the language and adapter being used. Another tricky thing, though, is that the GDB adapter (currently) sends its memory references formatted as pointers rather than addresses, so without the evaluate request, it isn't possible to match the variables to memory at all. Other adapters may work around the specification a little less and provide addresses in that field, but we're shooting in the dark a bit.

* Copyright (C) 2023 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import * as vscode from 'vscode';
import { DebugProtocol } from '@vscode/debugprotocol';
import { AdapterCapabilities } from './adapter-capabilities';
import { toHexStringWithRadixMarker, VariableRange } from '../../common/memory-range';

type WithChildren<Original> = Original & { children?: Array<WithChildren<DebugProtocol.Variable>> };
type VariablesTree = Record<number, WithChildren<DebugProtocol.Scope | DebugProtocol.Variable>>;

class GdbAdapterTracker implements vscode.DebugAdapterTracker {
protected currentFrame?: number;
protected variablesTree: VariablesTree = {};
protected readonly pendingMessages = new Map<number, number>();
protected static hexAddress = /0x[0-9a-f]+/i;
protected static notADigit = /[^0-9]/;

constructor(protected readonly onEnd: vscode.Disposable) { }

onWillReceiveMessage(message: unknown): void {
if (isScopesRequest(message)) {
this.currentFrame = message.arguments.frameId;
} else if (isVariableRequest(message)) {
if (message.arguments.variablesReference in this.variablesTree) {
this.pendingMessages.set(message.seq, message.arguments.variablesReference);
}
}
}
onDidSendMessage(message: unknown): void {
if (isScopesResponse(message)) {
for (const scope of message.body.scopes) {
if (scope.name === 'Local') {
if (!this.variablesTree[scope.variablesReference] || this.variablesTree[scope.variablesReference].name !== 'Local') {
this.variablesTree = { [scope.variablesReference]: { ...scope } };
}
return;
}
}
} else if (isVariableResponse(message)) {
if (this.pendingMessages.has(message.request_seq)) {
const parentReference = this.pendingMessages.get(message.request_seq)!;
this.pendingMessages.delete(message.request_seq);
if (parentReference in this.variablesTree) {
this.variablesTree[parentReference].children = message.body.variables;
}
}
}
}
onExit(): void {
this.onEnd.dispose();
this.pendingMessages.clear();
}

async getLocals(session: vscode.DebugSession): Promise<VariableRange[]> {
if (this.currentFrame === undefined) { return []; }
const maybeRanges = await Promise.all(Object.values(this.variablesTree).reduce<Array<Promise<VariableRange | undefined>>>((previous, parent) => {
if (parent.name === 'Local' && parent.children?.length) {
parent.children.forEach(child => {
previous.push(this.variableToVariableRange(child, session));
});
}
return previous;
}, []));
return maybeRanges.filter((candidate): candidate is VariableRange => !!candidate);
}

protected async variableToVariableRange(variable: DebugProtocol.Variable, session: vscode.DebugSession): Promise<VariableRange | undefined> {
if (variable.memoryReference === undefined || this.currentFrame === undefined) { return undefined; }
try {
const [addressResponse, sizeResponse] = await Promise.all([
session.customRequest('evaluate', <DebugProtocol.EvaluateArguments>{ expression: `&(${variable.name})`, context: 'watch', frameId: this.currentFrame }),
session.customRequest('evaluate', <DebugProtocol.EvaluateArguments>{ expression: `sizeof(${variable.name})`, context: 'watch', frameId: this.currentFrame }),
]) as DebugProtocol.EvaluateResponse['body'][];
const addressPart = GdbAdapterTracker.hexAddress.exec(addressResponse.result);
if (!addressPart) { return undefined; }
const startAddress = BigInt(addressPart[0]);
const endAddress = GdbAdapterTracker.notADigit.test(sizeResponse.result) ? undefined : startAddress + BigInt(sizeResponse.result);
return {
name: variable.name,
startAddress: toHexStringWithRadixMarker(startAddress),
endAddress: endAddress === undefined ? undefined : toHexStringWithRadixMarker(endAddress),
value: variable.value,
};
} catch (err) {
return undefined;
}
}
}

function isScopesRequest(message: unknown): message is DebugProtocol.ScopesRequest {
const candidate = message as DebugProtocol.ScopesRequest;
return !!candidate && candidate.command === 'scopes';
}

function isVariableRequest(message: unknown): message is DebugProtocol.VariablesRequest {
const candidate = message as DebugProtocol.VariablesRequest;
return !!candidate && candidate.command === 'variables';
}

function isScopesResponse(message: unknown): message is DebugProtocol.ScopesResponse {
const candidate = message as DebugProtocol.ScopesResponse;
return !!candidate && candidate.command === 'scopes' && Array.isArray(candidate.body.scopes);
}

function isVariableResponse(message: unknown): message is DebugProtocol.VariablesResponse {
const candidate = message as DebugProtocol.VariablesResponse;
return !!candidate && candidate.command === 'variables' && Array.isArray(candidate.body.variables);
}

export class GdbCapabilities implements AdapterCapabilities {
protected sessions = new Map<string, GdbAdapterTracker>();
initializeAdapterTracker(session: vscode.DebugSession): GdbAdapterTracker | undefined {
if (session.type === 'gdb') {
const sessionTracker = new GdbAdapterTracker(new vscode.Disposable(() => this.sessions.delete(session.id)));
this.sessions.set(session.id, sessionTracker);
return sessionTracker;
}
}

getVariables(session: vscode.DebugSession): Promise<VariableRange[]> {
return Promise.resolve(this.sessions.get(session.id)?.getLocals(session) ?? []);
}
}
File renamed without changes.
File renamed without changes.
Loading