diff --git a/CHANGELOG.md b/CHANGELOG.md index a9090e2ba..249d16a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he ## Nightly +- feat: support renamed sourcemap identifiers ([ref](https://github.com/microsoft/vscode/issues/12066)) +- feat: support DAP `hitBreakpointIds` ([#994](https://github.com/microsoft/vscode-js-debug/issues/994)) - refactor: include a mandator path in the CDP proxy ([#987](https://github.com/microsoft/vscode-js-debug/issues/987)) - fix: make sure servers are listening before returning - fix: don't send infinite telemetry requests for React Native ([#981](https://github.com/microsoft/vscode-js-debug/issues/981)) @@ -12,7 +14,6 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he - chore: log errors activating auto attach - fix: intermittent debug failures with browsers, especially Electron ([ref](https://github.com/microsoft/vscode/issues/123420))) - fix: add additional languages for browser debugging ([ref](https://github.com/microsoft/vscode/issues/123484)) -- feat: support DAP `hitBreakpointIds` ([#994](https://github.com/microsoft/vscode-js-debug/issues/994)) - feat: allow limited adjustment of launch config options during restart ([ref](https://github.com/microsoft/vscode/issues/118196)) ## v1.56 (April 2021) diff --git a/demos/node/.vscode/launch.json b/demos/node/.vscode/launch.json index f330fabe2..c8293adad 100644 --- a/demos/node/.vscode/launch.json +++ b/demos/node/.vscode/launch.json @@ -11,6 +11,13 @@ "name": "[Node] Launch program", "program": "${workspaceFolder}/main.js", }, + { + "type": "node", + "request": "launch", + "trace": true, + "name": "[Node] Launch Minified program", + "program": "${workspaceFolder}/main.min.js", + }, { "name": "[Node] Launch File with Deno", "type": "pwa-node", diff --git a/demos/node/main.min.js b/demos/node/main.min.js new file mode 100644 index 000000000..fa6fef0c0 --- /dev/null +++ b/demos/node/main.min.js @@ -0,0 +1,2 @@ +const o=require("crypto");const t=require("micromatch");function e(){let e=0;for(let n=0;n<10;n++){const n=o.randomBytes(8).toString("hex");for(let o=0;o<200;o++){e+=t([n],[`${o}*`]).length}}return e}setInterval((()=>{const o=Date.now();const t=e();console.log(`hello, took ${Date.now()-o}ms`)}),100);setTimeout((()=>{console.log("stop")}),1e4); +//# sourceMappingURL=main.min.js.map \ No newline at end of file diff --git a/demos/node/main.min.js.map b/demos/node/main.min.js.map new file mode 100644 index 000000000..607202619 --- /dev/null +++ b/demos/node/main.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["main.js"],"names":["crypto","require","micromatch","doBusyWork","n","i","input","randomBytes","toString","length","setInterval","start","Date","now","busyStuff","console","log","setTimeout"],"mappings":"AAAA,MAAMA,EAASC,QAAQ,UACvB,MAAMC,EAAaD,QAAQ,cAE3B,SAASE,IACP,IAAIC,EAAI,EACR,IAAK,IAAIC,EAAI,EAAGA,EAAI,GAAIA,IAAK,CAC3B,MAAMC,EAAQN,EAAOO,YAAY,GAAGC,SAAS,OAC7C,IAAK,IAAIH,EAAI,EAAGA,EAAI,IAAKA,IAAK,CAC5BD,GAAKF,EAAW,CAACI,GAAQ,CAAC,GAAGD,OAAOI,QAIxC,OAAOL,EAGTM,aAAY,KACV,MAAMC,EAAQC,KAAKC,MACnB,MAAMC,EAAYX,IAClBY,QAAQC,IAAI,eAAeJ,KAAKC,MAAQF,SACvC,KAEHM,YAAW,KACTF,QAAQC,IAAI,UACX"} \ No newline at end of file diff --git a/demos/node/package-lock.json b/demos/node/package-lock.json new file mode 100644 index 000000000..d45bde81f --- /dev/null +++ b/demos/node/package-lock.json @@ -0,0 +1,53 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "terser": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.7.0.tgz", + "integrity": "sha512-HP5/9hp2UaZt5fYkuhNBR8YyRcT8juw8+uFbAme53iN9hblvKnLUTKkmwJG6ocWpIKf8UK4DoeWG4ty0J6S6/g==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.19" + } + } + } +} diff --git a/demos/node/package.json b/demos/node/package.json index f6c6d51c8..c99591aaa 100644 --- a/demos/node/package.json +++ b/demos/node/package.json @@ -1,5 +1,9 @@ { "scripts": { - "start": "node main" + "start": "node main", + "minify": "terser main.js -m -o main.min.js --source-map \"url=main.min.js.map\" --toplevel" + }, + "devDependencies": { + "terser": "^5.7.0" } } diff --git a/src/adapter/completions.ts b/src/adapter/completions.ts index f93aadac7..b07edb547 100644 --- a/src/adapter/completions.ts +++ b/src/adapter/completions.ts @@ -9,8 +9,9 @@ import { Identifier, MemberExpression, Node, Program } from 'estree'; import { inject, injectable } from 'inversify'; import Cdp from '../cdp/api'; import { ICdpApi } from '../cdp/connection'; +import { IPosition } from '../common/positions'; import { getEnd, getStart, getText, parseProgram } from '../common/sourceCodeManipulations'; -import { positionToOffset } from '../common/sourceUtils'; +import { PositionToOffset } from '../common/stringUtils'; import Dap from '../dap/api'; import { IEvaluator, returnValueStr } from './evaluator'; import { StackFrame } from './stackTrace'; @@ -30,8 +31,7 @@ export interface ICompletionContext { */ export interface ICompletionExpression { expression: string; - line: number; - column: number; + position: IPosition; } export interface ICompletionWithSort extends Dap.CompletionItem { @@ -141,8 +141,7 @@ export class Completions { options: ICompletionContext & ICompletionExpression, ): Promise { const source = parseProgram(options.expression); - - const offset = positionToOffset(options.expression, options.line, options.column); + const offset = new PositionToOffset(options.expression).convert(options.position); let candidate: () => Promise = () => Promise.resolve([]); traverse(source, { @@ -310,6 +309,7 @@ export class Completions { const callFrameId = stackFrame && stackFrame.callFrameId(); const objRefResult = await this.evaluator.evaluate( callFrameId ? { ...params, callFrameId } : { ...params, contextId: executionContextId }, + { stackFrame }, ); if (!objRefResult || objRefResult.exceptionDetails) { diff --git a/src/adapter/debugAdapter.ts b/src/adapter/debugAdapter.ts index e1359bfe0..29fb6b6a3 100644 --- a/src/adapter/debugAdapter.ts +++ b/src/adapter/debugAdapter.ts @@ -8,6 +8,7 @@ import { Cdp } from '../cdp/api'; import { DisposableList, IDisposable } from '../common/disposable'; import { ILogger, LogTag } from '../common/logging'; import { getDeferred, IDeferred } from '../common/promiseUtil'; +import { IRenameProvider } from '../common/sourceMaps/renameProvider'; import * as sourceUtils from '../common/sourceUtils'; import * as urlUtils from '../common/urlUtils'; import { AnyLaunchConfiguration } from '../configuration'; @@ -308,6 +309,7 @@ export class DebugAdapter implements IDisposable { cdp, this.dap, delegate, + this._services.get(IRenameProvider), this._services.get(ILogger), this._services.get(IEvaluator), this._services.get(ICompletions), diff --git a/src/adapter/evaluator.ts b/src/adapter/evaluator.ts index e893b17e7..f5b55c2b1 100644 --- a/src/adapter/evaluator.ts +++ b/src/adapter/evaluator.ts @@ -6,11 +6,14 @@ import { Node as AcornNode } from 'acorn'; import { generate } from 'astring'; import { randomBytes } from 'crypto'; import { replace } from 'estraverse'; -import { ConditionalExpression } from 'estree'; +import { ConditionalExpression, Expression } from 'estree'; import { inject, injectable } from 'inversify'; import Cdp from '../cdp/api'; import { ICdpApi } from '../cdp/connection'; +import { IPosition } from '../common/positions'; import { parseProgram } from '../common/sourceCodeManipulations'; +import { IRenameProvider, RenameMapping } from '../common/sourceMaps/renameProvider'; +import { StackFrame } from './stackTrace'; import { getSourceSuffix } from './templates'; export const returnValueStr = '$returnValue'; @@ -97,14 +100,27 @@ export interface IPrepareOptions extends IEvaluatorBaseOptions { * given remote objects. */ hoist?: ReadonlyArray; + + /** + * Optional information used to rename identifiers. + */ + renames?: RenamePrepareOptions; } +export type RenamePrepareOptions = { position: IPosition; mapping: RenameMapping }; + export interface IEvaluateOptions extends IEvaluatorBaseOptions { /** * Replaces the identifiers in the associated script with references to the * given remote objects. */ - hoist?: { [key: string]: Cdp.Runtime.RemoteObject }; + hoist?: ReadonlyArray; + + /** + * Stack frame object on which the evaluation is being run. This is + * necessary to allow for renamed properties. + */ + stackFrame?: StackFrame; } /** @@ -121,7 +137,10 @@ export class Evaluator implements IEvaluator { return !!this.returnValue; } - constructor(@inject(ICdpApi) private readonly cdp: Cdp.Api) {} + constructor( + @inject(ICdpApi) private readonly cdp: Cdp.Api, + @inject(IRenameProvider) private readonly renameProvider: IRenameProvider, + ) {} /** * @inheritdoc @@ -135,9 +154,9 @@ export class Evaluator implements IEvaluator { */ public prepare( expression: string, - options: IPrepareOptions = {}, + { isInternalScript, hoist, renames }: IPrepareOptions = {}, ): { canEvaluateDirectly: boolean; invoke: PreparedCallFrameExpr } { - if (options.isInternalScript !== false) { + if (isInternalScript !== false) { expression += getSourceSuffix(); } @@ -147,15 +166,16 @@ export class Evaluator implements IEvaluator { // evalute the expression and unhoist it from the globals. const toHoist = new Map(); toHoist.set(returnValueStr, makeHoistedName()); - for (const key of options.hoist ?? []) { + for (const key of hoist ?? []) { toHoist.set(key, makeHoistedName()); } - const { transformed, hoisted } = this.replaceVariableInExpression(expression, toHoist); + const { transformed, hoisted } = this.replaceVariableInExpression(expression, toHoist, renames); if (!hoisted.size) { return { canEvaluateDirectly: true, - invoke: params => this.cdp.Debugger.evaluateOnCallFrame({ ...params, expression }), + invoke: params => + this.cdp.Debugger.evaluateOnCallFrame({ ...params, expression: transformed }), }; } @@ -178,18 +198,27 @@ export class Evaluator implements IEvaluator { ): Promise; public evaluate( params: Cdp.Runtime.EvaluateParams, - options?: IPrepareOptions, + options?: IEvaluateOptions, ): Promise; public async evaluate( params: Cdp.Debugger.EvaluateOnCallFrameParams | Cdp.Runtime.EvaluateParams, - options?: IPrepareOptions, + options?: IEvaluateOptions, ) { // no call frame means there will not be any relevant $returnValue to reference if (!('callFrameId' in params)) { return this.cdp.Runtime.evaluate(params); } - return this.prepare(params.expression, options).invoke(params); + let prepareOptions: IPrepareOptions | undefined = options; + if (options?.stackFrame) { + const mapping = await this.renameProvider.provideOnStackframe(options.stackFrame); + prepareOptions = { + ...prepareOptions, + renames: { mapping, position: options.stackFrame.rawPosition }, + }; + } + + return this.prepare(params.expression, prepareOptions).invoke(params); } /** @@ -221,9 +250,12 @@ export class Evaluator implements IEvaluator { private replaceVariableInExpression( expr: string, hoistMap: Map, + renames: RenamePrepareOptions | undefined, ): { hoisted: Set; transformed: string } { const hoisted = new Set(); - const replacement = (name: string): ConditionalExpression => ({ + let mutated = false; + + const replacement = (name: string, fallback: Expression): ConditionalExpression => ({ type: 'ConditionalExpression', test: { type: 'BinaryExpression', @@ -237,23 +269,30 @@ export class Evaluator implements IEvaluator { right: { type: 'Literal', value: 'undefined' }, }, consequent: { type: 'Identifier', name }, - alternate: { - type: 'Identifier', - name: 'undefined', - }, + alternate: fallback, }); const parents: Node[] = []; const transformed = replace(parseProgram(expr), { - enter: node => { + enter(node) { const asAcorn = node as AcornNode; - if ( - node.type === 'Identifier' && - hoistMap.has(node.name) && - expr[asAcorn.start - 1] !== '.' - ) { + if (node.type !== 'Identifier' || expr[asAcorn.start - 1] === '.') { + return; + } + + const hoistName = hoistMap.get(node.name); + if (hoistName) { hoisted.add(node.name); - return replacement(hoistMap.get(node.name) as string); + mutated = true; + this.skip(); + return replacement(hoistName, undefinedExpression); + } + + const cname = renames?.mapping.getCompiledName(node.name, renames.position); + if (cname) { + mutated = true; + this.skip(); + return replacement(cname, node); } }, leave: () => { @@ -261,6 +300,11 @@ export class Evaluator implements IEvaluator { }, }); - return { hoisted, transformed: hoisted.size ? generate(transformed) : expr }; + return { hoisted, transformed: mutated ? generate(transformed) : expr }; } } + +const undefinedExpression: Expression = { + type: 'Identifier', + name: 'undefined', +}; diff --git a/src/adapter/stackTrace.ts b/src/adapter/stackTrace.ts index 18daae007..49816e604 100644 --- a/src/adapter/stackTrace.ts +++ b/src/adapter/stackTrace.ts @@ -5,6 +5,7 @@ import * as nls from 'vscode-nls'; import Cdp from '../cdp/api'; import { once } from '../common/objUtils'; +import { Base0Position } from '../common/positions'; import Dap from '../dap/api'; import { asyncScopesNotAvailable } from '../dap/errors'; import { ProtocolError } from '../dap/protocolError'; @@ -181,6 +182,11 @@ export class StackFrame { private _scope: IScope | undefined; private _thread: Thread; + public get rawPosition() { + // todo: move RawLocation to use Positions, then just return that. + return new Base0Position(this._rawLocation.lineNumber, this._rawLocation.columnNumber); + } + static fromRuntime( thread: Thread, callFrame: Cdp.Runtime.CallFrame, @@ -376,7 +382,12 @@ export class StackFrame { return existing; } - const scopeRef: IScopeRef = { callFrameId: scope.callFrameId, scopeNumber }; + const scopeRef: IScopeRef = { + stackFrame: this, + callFrameId: scope.callFrameId, + scopeNumber, + }; + const extraProperties: IExtraProperty[] = []; if (scopeNumber === 0) { extraProperties.push({ name: 'this', value: scope.thisObject }); diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 100ecde53..795ecd97b 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -8,7 +8,9 @@ import { DebugType } from '../common/contributionUtils'; import { EventEmitter } from '../common/events'; import { HrTime } from '../common/hrnow'; import { ILogger, LogTag } from '../common/logging'; +import { Base1Position } from '../common/positions'; import { delay, getDeferred, IDeferred } from '../common/promiseUtil'; +import { IRenameProvider } from '../common/sourceMaps/renameProvider'; import * as sourceUtils from '../common/sourceUtils'; import * as urlUtils from '../common/urlUtils'; import { fileUrlToAbsolutePath } from '../common/urlUtils'; @@ -179,6 +181,7 @@ export class Thread implements IVariableStoreDelegate { cdp: Cdp.Api, dap: Dap.Api, delegate: IThreadDelegate, + renameProvider: IRenameProvider, private readonly logger: ILogger, private readonly evaluator: IEvaluator, private readonly completer: ICompletions, @@ -195,6 +198,7 @@ export class Thread implements IVariableStoreDelegate { this.replVariables = new VariableStore( this._cdp, this, + renameProvider, launchConfig.__autoExpandGetters, launchConfig.customDescriptionGenerator, launchConfig.customPropertiesGenerator, @@ -372,8 +376,7 @@ export class Thread implements IVariableStoreDelegate { executionContextId: this._selectedContext ? this._selectedContext.description.id : undefined, stackFrame, expression: params.text, - line: params.line || 1, - column: params.column, + position: new Base1Position(params.line || 1, params.column), }); // Merge the actual completion items with the synthetic target changing items. @@ -394,8 +397,9 @@ export class Thread implements IVariableStoreDelegate { async evaluate(args: Dap.EvaluateParams): Promise { let callFrameId: Cdp.Debugger.CallFrameId | undefined; + let stackFrame: StackFrame | undefined; if (args.frameId !== undefined) { - const stackFrame = this._pausedDetails + stackFrame = this._pausedDetails ? this._pausedDetails.stackTrace.frame(args.frameId) : undefined; if (!stackFrame) return this._stackFrameNotFoundError(); @@ -451,7 +455,7 @@ export class Thread implements IVariableStoreDelegate { ...params, contextId: this._selectedContext ? this._selectedContext.description.id : undefined, }, - { isInternalScript: false }, + { isInternalScript: false, stackFrame }, ); // Report result for repl immediately so that the user could see the expression they entered. @@ -716,13 +720,7 @@ export class Thread implements IVariableStoreDelegate { this._waitingForStepIn = undefined; this._pausedDetailsEvent.set(pausedDetails, event); - this._pausedVariables = new VariableStore( - this._cdp, - this, - this.launchConfig.__autoExpandGetters, - this.launchConfig.customDescriptionGenerator, - this.launchConfig.customPropertiesGenerator, - ); + this._pausedVariables = this.replVariables.createDetached(); await this._onThreadPaused(pausedDetails); } diff --git a/src/adapter/variables.ts b/src/adapter/variables.ts index 671a685ba..9d23d8d5a 100644 --- a/src/adapter/variables.ts +++ b/src/adapter/variables.ts @@ -7,10 +7,11 @@ import * as nls from 'vscode-nls'; import Cdp from '../cdp/api'; import { flatten } from '../common/objUtils'; import { parseSource, statementsToFunction } from '../common/sourceCodeManipulations'; +import { IRenameProvider } from '../common/sourceMaps/renameProvider'; import Dap from '../dap/api'; import * as errors from '../dap/errors'; import * as objectPreview from './objectPreview'; -import { StackTrace } from './stackTrace'; +import { StackFrame, StackTrace } from './stackTrace'; import { getSourceSuffix, RemoteException } from './templates'; import { getArrayProperties } from './templates/getArrayProperties'; import { getArraySlots } from './templates/getArraySlots'; @@ -41,6 +42,7 @@ class RemoteObject { cdp: Cdp.Api, object: Cdp.Runtime.RemoteObject, public readonly parent?: RemoteObject, + public renamedFromSource?: string, ) { this.o = object; // eslint-disable-next-line @@ -83,6 +85,7 @@ class RemoteObject { } export interface IScopeRef { + stackFrame: StackFrame; callFrameId: Cdp.Debugger.CallFrameId; scopeNumber: number; } @@ -109,6 +112,7 @@ export class VariableStore { constructor( cdp: Cdp.Api, delegate: IVariableStoreDelegate, + private readonly renameProvider: IRenameProvider, private readonly autoExpandGetters: boolean, private readonly customDescriptionGenerator: string | undefined, private readonly customPropertiesGenerator: string | undefined, @@ -117,6 +121,17 @@ export class VariableStore { this._delegate = delegate; } + createDetached() { + return new VariableStore( + this._cdp, + this._delegate, + this.renameProvider, + this.autoExpandGetters, + this.customDescriptionGenerator, + this.customPropertiesGenerator, + ); + } + hasVariables(variablesReference: number): boolean { return ( this._referenceToVariables.has(variablesReference) || @@ -583,6 +598,15 @@ export class VariableStore { value?: RemoteObject, context?: string, ): Promise { + const scopeRef = value?.parent?.scopeRef; + if (scopeRef) { + const renames = await this.renameProvider.provideOnStackframe(scopeRef.stackFrame); + const original = renames.getOriginalName(name, scopeRef.stackFrame.rawPosition); + if (original) { + name = original; + } + } + if (!value) { return { name, diff --git a/src/common/disposable.ts b/src/common/disposable.ts index 09d90c86a..c328de13e 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -2,10 +2,47 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ +import { once } from './objUtils'; + export interface IDisposable { dispose(): void; } +export interface IReference extends IDisposable { + value: T; +} + +export class RefCounter { + private disposed = false; + private count = 0; + + constructor(public readonly value: T) {} + + public checkout(): IReference { + if (this.disposed) { + throw new Error('Cannot checkout a disposed instance'); + } + + this.count++; + + return { + value: this.value, + dispose: once(() => { + if (--this.count === 0) { + this.dispose(); + } + }), + }; + } + + public dispose() { + if (!this.disposed) { + this.disposed = true; + this.value.dispose; + } + } +} + /** * A dispoable that does nothing. */ diff --git a/src/common/objUtils.ts b/src/common/objUtils.ts index 4e181d25c..2dce48345 100644 --- a/src/common/objUtils.ts +++ b/src/common/objUtils.ts @@ -355,3 +355,15 @@ export function pick(obj: T, keys: ReadonlyArray): Partial { } export const upcastPartial = (v: Partial): T => v as T; + +/** + * Inverts the keys and values in a map. + */ +export function invertMap(map: ReadonlyMap): Map { + const result = new Map(); + for (const [key, value] of map) { + result.set(value, key); + } + + return result; +} diff --git a/src/common/positions.ts b/src/common/positions.ts new file mode 100644 index 000000000..5c619c70c --- /dev/null +++ b/src/common/positions.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +/** + * Defines a position which gives accessors to various projections. We use + * many different kinds of bases for different consumers, this is intended + * to elimate off-by-1 errors. + */ +export interface IPosition { + base0: Base0Position; + base1: Base1Position; + base01: Base01Position; + /** + * Compares the position and returns the sort order, <0 if `this` is + * before `other`, >0 if it's after, 0 if it's equal. + */ + compare(other: IPosition): number; +} + +export const comparePositions = (a: IPosition, b: IPosition) => { + if (a instanceof Base0Position) { + return a.compare(b.base0); + } else if (a instanceof Base01Position) { + return b.compare(b.base01); + } else if (a instanceof Base1Position) { + return b.compare(b.base1); + } else { + throw new Error(`Invalid position ${a}`); + } +}; + +/** + * A position that starts a line 0 and column 0 (used by CDP). + */ +export class Base0Position implements IPosition { + declare readonly __isBase0: undefined; + + constructor(public readonly lineNumber: number, public readonly columnNumber: number) {} + + public get base0() { + return this; + } + + public get base1() { + return new Base1Position(this.lineNumber + 1, this.columnNumber + 1); + } + + public get base01() { + return new Base01Position(this.lineNumber, this.columnNumber + 1); + } + + public compare(other: Base0Position) { + return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + } +} + +/** + * A position that starts a line 1 and column 1 (used by DAP). + */ +export class Base1Position implements IPosition { + declare readonly __isBase1: undefined; + + constructor(public readonly lineNumber: number, public readonly columnNumber: number) {} + + public get base0() { + return new Base0Position(this.lineNumber - 1, this.columnNumber - 1); + } + + public get base1() { + return this; + } + + public get base01() { + return new Base01Position(this.lineNumber - 1, this.columnNumber); + } + + public compare(other: Base1Position) { + return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + } +} + +/** + * A position that starts a line 0 and column 1 (used by sourcemaps). + */ +export class Base01Position implements IPosition { + declare readonly __isBase01: undefined; + + constructor(public readonly lineNumber: number, public readonly columnNumber: number) {} + + public get base0() { + return new Base0Position(this.lineNumber - 1, this.columnNumber); + } + + public get base1() { + return new Base1Position(this.lineNumber, this.columnNumber + 1); + } + + public get base01() { + return this; + } + + public compare(other: Base01Position) { + return this.lineNumber - other.lineNumber || this.columnNumber - other.columnNumber || 0; + } +} diff --git a/src/common/sourceMaps/renameProvider.ts b/src/common/sourceMaps/renameProvider.ts new file mode 100644 index 000000000..27eb5e7a4 --- /dev/null +++ b/src/common/sourceMaps/renameProvider.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { inject, injectable } from 'inversify'; +import { ISourceWithMap, Source, SourceFromMap } from '../../adapter/sources'; +import { StackFrame } from '../../adapter/stackTrace'; +import { Base01Position, IPosition } from '../positions'; +import { PositionToOffset } from '../stringUtils'; +import { SourceMap } from './sourceMap'; +import { ISourceMapFactory } from './sourceMapFactory'; + +interface IRename { + original: string; + compiled: string; + position: Base01Position; +} + +/** Very approximate regex for JS identifiers */ +const identifierRe = /[$a-z_][$0-9A-Z_$]*/iy; + +export interface IRenameProvider { + /** + * Provides renames at the given stackframe. + */ + provideOnStackframe(frame: StackFrame): RenameMapping | Promise; + + /** + * Provides renames for the given Source. + */ + provideForSource(source: Source | undefined): RenameMapping | Promise; +} + +export const IRenameProvider = Symbol('IRenameProvider'); + +@injectable() +export class RenameProvider implements IRenameProvider { + private renames = new Map>(); + + constructor(@inject(ISourceMapFactory) private readonly sourceMapFactory: ISourceMapFactory) {} + + /** + * @inheritdoc + */ + public provideOnStackframe(frame: StackFrame) { + const location = frame.uiLocation(); + if (location === undefined) { + return RenameMapping.None; + } else if ('then' in location) { + return location.then(s => this.provideForSource(s?.source)); + } else { + return this.provideForSource(location?.source); + } + } + + /** + * @inheritdoc + */ + public provideForSource(source: Source | undefined) { + if (!(source instanceof SourceFromMap)) { + return RenameMapping.None; + } + + const original: ISourceWithMap | undefined = source.compiledToSourceUrl.keys().next().value; + if (!original) { + throw new Error('unreachable'); + } + + const cached = this.renames.get(original.url); + if (cached) { + return cached; + } + + const promise = Promise.all([ + this.sourceMapFactory.load(original.sourceMap.metadata), + original.content(), + ]) + .then(([sm, content]) => + sm && content ? this.createFromSourceMap(sm, content) : RenameMapping.None, + ) + .catch(() => RenameMapping.None); + + this.renames.set(original.url, promise); + return promise; + } + + private createFromSourceMap(sourceMap: SourceMap, content: string) { + const toOffset = new PositionToOffset(content); + const renames: IRename[] = []; + + // todo: may eventually want to be away + sourceMap.eachMapping(mapping => { + if (!mapping.name) { + return; + } + + // convert to base 0 columns + const position = new Base01Position(mapping.generatedLine, mapping.generatedColumn); + const start = toOffset.convert(position); + identifierRe.lastIndex = start; + const match = identifierRe.exec(content); + if (!match) { + return; + } + + renames.push({ compiled: match[0], original: mapping.name, position }); + }); + + renames.sort((a, b) => a.position.compare(b.position)); + + return new RenameMapping(renames); + } +} + +/** + * Accessor for mapping of compiled and original source names. This works by + * getting the rename closest to a compiled position. It would be more + * correct to parse the AST and use scopes, but doing so is relatively slow. + * This is probably good enough. + */ +export class RenameMapping { + public static None = new RenameMapping([]); + + constructor(private readonly renames: readonly IRename[]) {} + + /** + * Gets the original identifier name from a compiled name, with the + * interpreter paused at the given position. + */ + public getOriginalName(compiledName: string, compiledPosition: IPosition) { + return this.getClosestRename(compiledPosition, r => r.compiled === compiledName)?.original; + } + + /** + * Gets the compiled identifier name from an original name. + */ + public getCompiledName(originalName: string, compiledPosition: IPosition) { + return this.getClosestRename(compiledPosition, r => r.original === originalName)?.compiled; + } + + private getClosestRename(compiledPosition: IPosition, filter: (rename: IRename) => boolean) { + const compiled01 = compiledPosition.base01; + let best: IRename | undefined; + + for (const rename of this.renames) { + if (!filter(rename)) { + continue; + } + + const isBefore = rename.position.compare(compiled01) < 0; + if (!isBefore && best) { + return best; + } + + best = rename; + } + + return best; + } +} diff --git a/src/common/sourceUtils.ts b/src/common/sourceUtils.ts index 045ce55dd..b3b6b4e04 100644 --- a/src/common/sourceUtils.ts +++ b/src/common/sourceUtils.ts @@ -262,14 +262,6 @@ export async function checkContentHash( return result ? absolutePath : undefined; } -export function positionToOffset(text: string, line: number, column: number): number { - let offset = 0; - const lines = text.split('\n'); - for (let l = 1; l < line; ++l) offset += lines[l - 1].length + 1; - offset += column - 1; - return offset; -} - interface INotNullRange { line: number; column: number; diff --git a/src/common/stringUtils.test.ts b/src/common/stringUtils.test.ts new file mode 100644 index 000000000..d16bb8a98 --- /dev/null +++ b/src/common/stringUtils.test.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import { Base0Position } from './positions'; +import { PositionToOffset } from './stringUtils'; + +describe('stringUtils', () => { + it('positionToOffset', () => { + const simple = new PositionToOffset('hello\nworld'); + expect(simple.convert(new Base0Position(0, 2))).to.equal(2); + expect(simple.convert(new Base0Position(1, 2))).to.equal(8); + + const toOffset = new PositionToOffset('\nhello\nworld\n'); + + expect(toOffset.convert(new Base0Position(0, 0))).to.equal(0); + expect(toOffset.convert(new Base0Position(0, 10))).to.equal(0); + + expect(toOffset.convert(new Base0Position(1, 0))).to.equal(1); + expect(toOffset.convert(new Base0Position(1, 5))).to.equal(6); + expect(toOffset.convert(new Base0Position(2, 1))).to.equal(8); + + expect(toOffset.convert(new Base0Position(3, 0))).to.equal(13); + expect(toOffset.convert(new Base0Position(10, 0))).to.equal(13); + }); +}); diff --git a/src/common/stringUtils.ts b/src/common/stringUtils.ts index 919ae846c..624e87bf7 100644 --- a/src/common/stringUtils.ts +++ b/src/common/stringUtils.ts @@ -2,6 +2,8 @@ * Copyright (C) Microsoft Corporation. All rights reserved. *--------------------------------------------------------*/ +import { IPosition } from './positions'; + export function trimEnd(text: string, maxLength: number) { if (text.length <= maxLength) return text; return text.substr(0, maxLength - 1) + '…'; @@ -49,3 +51,34 @@ export function escapeRegexSpecialChars(str: string, except?: string): string { const r = new RegExp(`[${useRegexChars}]`, 'g'); return str.replace(r, '\\$&'); } + +export class PositionToOffset { + private readonly lines: number[] = []; + + constructor(public readonly source: string) { + let last = 0; + for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', last)) { + this.lines.push(i - last); + last = i + 1; + } + + this.lines.push(source.length - last); + } + + /** + * Converts from a base 0 line and column to a file offset. + */ + public convert(position: IPosition) { + const base0 = position.base0; + if (base0.lineNumber > this.lines.length) { + return this.source.length; + } + + let offset = 0; + for (let i = 0; i < base0.lineNumber; i++) { + offset += this.lines[i] + 1; + } + + return offset + Math.min(this.lines[base0.lineNumber], base0.columnNumber); + } +} diff --git a/src/ioc.ts b/src/ioc.ts index 5dd5cb774..d741585a9 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -54,6 +54,7 @@ import { ILogger } from './common/logging'; import { Logger } from './common/logging/logger'; import { createMutableLaunchConfig, MutableLaunchConfig } from './common/mutableLaunchConfig'; import { CodeSearchStrategy } from './common/sourceMaps/codeSearchStrategy'; +import { IRenameProvider, RenameProvider } from './common/sourceMaps/renameProvider'; import { CachingSourceMapFactory, ISourceMapFactory } from './common/sourceMaps/sourceMapFactory'; import { ISearchStrategy } from './common/sourceMaps/sourceMapRepository'; import { ISourcePathResolver } from './common/sourcePathResolver'; @@ -357,7 +358,7 @@ export const provideLaunchParams = ( .to(CachingSourceMapFactory) .inSingletonScope() .onActivation(trackDispose); - + container.bind(IRenameProvider).to(RenameProvider).inSingletonScope(); container.bind(DiagnosticToolSuggester).toSelf().inSingletonScope().onActivation(trackDispose); container.bind(BreakpointsPredictor).toSelf(); diff --git a/src/test/breakpoints/breakpoints-reevaluates-breakpoints-when-new-sources-come-in-600.txt b/src/test/breakpoints/breakpoints-reevaluates-breakpoints-when-new-sources-come-in-600.txt index 3f6588956..8f1debb40 100644 --- a/src/test/breakpoints/breakpoints-reevaluates-breakpoints-when-new-sources-come-in-600.txt +++ b/src/test/breakpoints/breakpoints-reevaluates-breakpoints-when-new-sources-come-in-600.txt @@ -1,6 +1,9 @@ { allThreadsStopped : false description : Paused on breakpoint + hitBreakpointIds : [ + [0] : 1 + ] reason : breakpoint threadId : } @@ -10,6 +13,9 @@ { allThreadsStopped : false description : Paused on breakpoint + hitBreakpointIds : [ + [0] : 1 + ] reason : breakpoint threadId : } diff --git a/src/test/variables/variables-setvariable-name-mapping.txt b/src/test/variables/variables-setvariable-name-mapping.txt new file mode 100644 index 000000000..09cafafce --- /dev/null +++ b/src/test/variables/variables-setvariable-name-mapping.txt @@ -0,0 +1,21 @@ + +hitDebugger @ ${workspaceFolder}/web/minified/index.js:13:5 + > scope #0: Local: hitDebugger + arg1: 2 + arg2: 3 + > this: Window + scope #1: Global [expensive] + +test @ ${workspaceFolder}/web/minified/index.js:6:5 + > scope #0: Block: test + inner1: 2 + inner2: 3 + > this: Window + > scope #1: Local: test + > hitDebugger: ƒ hitDebugger(arg1, arg2) {\n debugger;\n } + later: undefined + outer: 1 + scope #2: Global [expensive] + + @ /VM:1:1 + scope #0: Global [expensive] diff --git a/src/test/variables/variablesTest.ts b/src/test/variables/variablesTest.ts index 517b4731a..f169be47f 100644 --- a/src/test/variables/variablesTest.ts +++ b/src/test/variables/variablesTest.ts @@ -308,6 +308,15 @@ describe('variables', () => { p.assertLog(); }); + itIntegrates('name mapping', async ({ r }) => { + const p = await r.launchUrlAndLoad('minified/index.html'); + p.cdp.Runtime.evaluate({ expression: `test()` }); + const event = await p.dap.once('stopped'); + await p.logger.logStackTrace(event.threadId!, true); + await p.dap.continue({ threadId: event.threadId! }); + p.assertLog(); + }); + itIntegrates('evaluateName', async ({ r }) => { const p = await r.launchAndLoad('blank'); p.cdp.Runtime.evaluate({ diff --git a/testWorkspace/web/minified/.gitignore b/testWorkspace/web/minified/.gitignore new file mode 100644 index 000000000..d50251241 --- /dev/null +++ b/testWorkspace/web/minified/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/package-lock.json diff --git a/testWorkspace/web/minified/index.html b/testWorkspace/web/minified/index.html new file mode 100644 index 000000000..8654b3141 --- /dev/null +++ b/testWorkspace/web/minified/index.html @@ -0,0 +1,5 @@ + + + + + diff --git a/testWorkspace/web/minified/index.js b/testWorkspace/web/minified/index.js new file mode 100644 index 000000000..14be90dac --- /dev/null +++ b/testWorkspace/web/minified/index.js @@ -0,0 +1,15 @@ +function test() { + const outer = 1; + if (outer) { + const inner1 = 2; + const inner2 = 3; + hitDebugger(inner1, inner2); + } + + const later = 4; + hitDebugger(later); + + function hitDebugger(arg1, arg2) { + debugger; + } +} diff --git a/testWorkspace/web/minified/index.min.js b/testWorkspace/web/minified/index.min.js new file mode 100644 index 000000000..cb8bbf91f --- /dev/null +++ b/testWorkspace/web/minified/index.min.js @@ -0,0 +1,2 @@ +function test(){const n=1;if(n){const n=2;const t=3;c(n,t)}const t=4;c(t);function c(n,t){debugger}} +//# sourceMappingURL=index.min.js.map \ No newline at end of file diff --git a/testWorkspace/web/minified/index.min.js.map b/testWorkspace/web/minified/index.min.js.map new file mode 100644 index 000000000..ecd8bff67 --- /dev/null +++ b/testWorkspace/web/minified/index.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.js"],"names":["test","outer","inner1","inner2","hitDebugger","later","arg1","arg2"],"mappings":"AAAA,SAASA,OACP,MAAMC,EAAQ,EACd,GAAIA,EAAO,CACT,MAAMC,EAAS,EACf,MAAMC,EAAS,EACfC,EAAYF,EAAQC,GAGtB,MAAME,EAAQ,EACdD,EAAYC,GAEZ,SAASD,EAAYE,EAAMC,GACzB"} \ No newline at end of file diff --git a/testWorkspace/web/minified/package.json b/testWorkspace/web/minified/package.json new file mode 100644 index 000000000..da21b8125 --- /dev/null +++ b/testWorkspace/web/minified/package.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "minify": "terser index.js -m -o index.min.js --source-map \"url=index.min.js.map\"" + }, + "dependencies": { + "terser": "^5.7.0" + } +}