diff --git a/src/LaunchConfiguration.ts b/src/LaunchConfiguration.ts index 75b6740d..2b712cad 100644 --- a/src/LaunchConfiguration.ts +++ b/src/LaunchConfiguration.ts @@ -276,6 +276,12 @@ export interface LaunchConfiguration extends DebugProtocol.LaunchRequestArgument */ enableSourceMaps?: boolean; + /** + * If true, automatically continue stepping through generated/uninteresting code until a mapped source line is reached. + * @default false + */ + smartStep?: boolean; + /** * If true, then any pkg path found in the device logs will be converted to a source location leveraging source maps if available * diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index 45483be6..273c060c 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -19,7 +19,7 @@ import { CompileError, DefaultFiles, rokuDeploy } from 'roku-deploy'; import type { AddProjectParams, ComponentLibraryConstructorParams } from '../managers/ProjectManager'; import { ComponentLibraryProject, Project } from '../managers/ProjectManager'; import { RendezvousTracker } from '../RendezvousTracker'; -import { ClientToServerCustomEventName, isCustomRequestEvent, isProcessCrashEvent, LogOutputEvent } from './Events'; +import { ClientToServerCustomEventName, isCustomRequestEvent, isProcessCrashEvent, LogOutputEvent, StoppedEventReason } from './Events'; import { EventEmitter } from 'eventemitter3'; import type { EvaluateContainer } from '../adapters/DebugProtocolAdapter'; import { VariableType } from '../debugProtocol/events/responses/VariablesResponse'; @@ -121,6 +121,9 @@ describe('BrightScriptDebugSession', () => { registerSourceLocator: (a, b) => { }, setConsoleOutput: (a) => { }, evaluate: () => { }, + stepOver: () => { }, + stepInto: () => { }, + stepOut: () => { }, syncBreakpoints: () => { }, getVariable: () => { }, getScopeVariables: (a) => { }, @@ -2866,6 +2869,89 @@ describe('BrightScriptDebugSession', () => { }); }); + describe('smartStep', () => { + beforeEach(() => { + session['entryBreakpointWasHandled'] = true; + session['launchConfiguration'].smartStep = true; + }); + + it('continues stepping when suspended on a generated line not covered by source map', async () => { + const activeThread = { + isSelected: true, + filePath: 'pkg:/source/main.brs', + lineNumber: 50, + threadId: 0 + }; + sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]); + sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({ + absolutePath: s`${stagingDir}/source/main.brs` + } as any); + sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(false); + sinon.stub(session.sourceMapManager, 'getSourceMapPath').resolves(s`${stagingDir}/source/main.brs.map`); + sinon.stub(session.sourceMapManager, 'getSourceMap').resolves({} as any); + const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves(); + const sendEventSpy = sinon.spy(session, 'sendEvent'); + + session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 }; + await session['onSuspend'](); + + expect(stepOverStub.calledOnce).to.be.true; + expect(sendEventSpy.called).to.be.false; + }); + + it('emits a step stopped event when smart step lands on a mapped line', async () => { + const activeThread = { + isSelected: true, + filePath: 'pkg:/source/main.brs', + lineNumber: 4, + threadId: 0 + }; + sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]); + sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({ + absolutePath: s`${stagingDir}/source/main.brs` + } as any); + sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(true); + const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves(); + const sendEventSpy = sinon.spy(session, 'sendEvent'); + + session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 }; + await session['onSuspend'](); + + expect(stepOverStub.called).to.be.false; + expect(sendEventSpy.calledOnce).to.be.true; + expect((sendEventSpy.getCall(0).args[0] as any).body.reason).to.equal(StoppedEventReason.step); + }); + + it('continues stepping when no source map exists but location is remapped by roku-debug', async () => { + const activeThread = { + isSelected: true, + filePath: 'pkg:/source/main.brs', + lineNumber: 7, + threadId: 0 + }; + sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]); + sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({ + absolutePath: s`${stagingDir}/source/main.brs` + } as any); + sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(false); + sinon.stub(session.sourceMapManager, 'getSourceMapPath').resolves(s`${stagingDir}/source/main.brs.map`); + sinon.stub(session.sourceMapManager, 'getSourceMap').resolves(undefined); + sinon.stub(session.projectManager, 'getSourceLocation').resolves({ + filePath: s`${rootDir}/source/main.bs`, + lineNumber: 7, + columnIndex: 0 + }); + const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves(); + const sendEventSpy = sinon.spy(session, 'sendEvent'); + + session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 }; + await session['onSuspend'](); + + expect(stepOverStub.calledOnce).to.be.true; + expect(sendEventSpy.called).to.be.false; + }); + }); + describe('stackTraceRequest', () => { beforeEach(() => { session['rokuAdapterDeferred'].resolve(session['rokuAdapter']); diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index d2f49dc4..9edf9bc0 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -326,6 +326,8 @@ export class BrightScriptDebugSession extends LoggingDebugSession { * A magic number to represent a fake thread that will be used for showing compile errors in the UI as if they were runtime crashes */ private COMPILE_ERROR_THREAD_ID = 7_777; + private pendingSmartStep: { kind: 'next' | 'stepIn' | 'stepOut'; threadId: number; attempts: number } | undefined; + private readonly maxSmartStepAttempts = 100; private get enableDebugProtocol() { return this.launchConfiguration?.enableDebugProtocol; @@ -529,6 +531,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { config.stagingDir ??= config.stagingFolderPath; config.emitChannelPublishedEvent ??= true; config.rewriteDevicePathsInLogs ??= true; + config.smartStep ??= false; config.autoResolveVirtualVariables ??= false; config.enhanceREPLCompletions ??= true; config.username ??= 'rokudev'; @@ -900,6 +903,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { // launchRequest gets invoked by our restart session flow. // We need to clear/reset some state to avoid issues. this.entryBreakpointWasHandled = false; + this.pendingSmartStep = undefined; this.breakpointManager.clearBreakpointLastState(); } @@ -1725,6 +1729,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { } this.logger.log('continueRequest'); + this.pendingSmartStep = undefined; await this.setTransientsToInvalid(); // call before clearState this.clearState(); @@ -1735,6 +1740,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) { this.logger.log('pauseRequest'); + this.pendingSmartStep = undefined; //if we have a compile error, we should shut down if (this.compileError) { @@ -1769,12 +1775,14 @@ export class BrightScriptDebugSession extends LoggingDebugSession { await this.setTransientsToInvalid(); // call before clearState this.clearState(); + this.beginSmartStep('next', args.threadId); // The debug session ends after the next line. Do not put new work after this line. try { await this.rokuAdapter.stepOver(args.threadId); this.logger.info('[nextRequest] end'); } catch (error) { + this.pendingSmartStep = undefined; this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error); } this.sendResponse(response); @@ -1792,8 +1800,14 @@ export class BrightScriptDebugSession extends LoggingDebugSession { await this.setTransientsToInvalid(); // call before clearState this.clearState(); + this.beginSmartStep('stepIn', args.threadId); // The debug session ends after the next line. Do not put new work after this line. - await this.rokuAdapter.stepInto(args.threadId); + try { + await this.rokuAdapter.stepInto(args.threadId); + } catch (error) { + this.pendingSmartStep = undefined; + throw error; + } this.sendResponse(response); this.logger.info('[stepInRequest] end'); } @@ -1810,9 +1824,15 @@ export class BrightScriptDebugSession extends LoggingDebugSession { await this.setTransientsToInvalid(); // call before clearState this.clearState(); + this.beginSmartStep('stepOut', args.threadId); // The debug session ends after the next line. Do not put new work after this line. - await this.rokuAdapter.stepOut(args.threadId); + try { + await this.rokuAdapter.stepOut(args.threadId); + } catch (error) { + this.pendingSmartStep = undefined; + throw error; + } this.sendResponse(response); this.logger.info('[stepOutRequest] end'); } @@ -2664,8 +2684,15 @@ export class BrightScriptDebugSession extends LoggingDebugSession { } } + if (await this.trySmartStep(activeThread)) { + return; + } + + const stoppedEventReason = this.pendingSmartStep ? StoppedEventReason.step : StoppedEventReason.breakpoint; + this.pendingSmartStep = undefined; + const event: StoppedEvent = new StoppedEvent( - StoppedEventReason.breakpoint, + stoppedEventReason, //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing activeThread?.threadId ?? 0, '' //exception text @@ -2675,6 +2702,79 @@ export class BrightScriptDebugSession extends LoggingDebugSession { this.sendEvent(event); } + private beginSmartStep(kind: 'next' | 'stepIn' | 'stepOut', threadId: number) { + if (this.launchConfiguration?.smartStep) { + this.pendingSmartStep = { + kind: kind, + threadId: threadId, + attempts: 0 + }; + } else { + this.pendingSmartStep = undefined; + } + } + + private async trySmartStep(activeThread: { filePath: string; lineNumber: number; threadId: number }) { + if (!this.pendingSmartStep || !this.launchConfiguration?.smartStep || !activeThread) { + return false; + } + + if (!await this.shouldSmartStepThroughLine(activeThread)) { + return false; + } + + if (this.pendingSmartStep.attempts >= this.maxSmartStepAttempts) { + this.logger.warn(`Smart step reached max attempts (${this.maxSmartStepAttempts}). Stopping on current line.`); + this.pendingSmartStep = undefined; + return false; + } + + this.pendingSmartStep.attempts++; + + await this.setTransientsToInvalid(); // call before clearState + this.clearState(); + + try { + const threadId = this.pendingSmartStep.threadId ?? activeThread.threadId; + if (this.pendingSmartStep.kind === 'next') { + await this.rokuAdapter.stepOver(threadId); + } else if (this.pendingSmartStep.kind === 'stepIn') { + await this.rokuAdapter.stepInto(threadId); + } else { + await this.rokuAdapter.stepOut(threadId); + } + return true; + } catch (error) { + this.logger.error('Error running smart step', error); + this.pendingSmartStep = undefined; + return false; + } + } + + private async shouldSmartStepThroughLine(activeThread: { filePath: string; lineNumber: number; threadId: number }) { + const stagingFileInfo = await this.projectManager.getStagingFileInfo(activeThread.filePath); + if (!stagingFileInfo) { + return false; + } + + const hasMappedGeneratedLine = await this.sourceMapManager.generatedLineIsMapped(stagingFileInfo.absolutePath, activeThread.lineNumber); + if (hasMappedGeneratedLine) { + return false; + } + + const sourceMapPath = await this.sourceMapManager.getSourceMapPath(stagingFileInfo.absolutePath); + const hasSourceMap = !!(await this.sourceMapManager.getSourceMap(sourceMapPath)); + if (hasSourceMap) { + return true; + } + + const sourceLocation = await this.projectManager.getSourceLocation(activeThread.filePath, activeThread.lineNumber); + if (!sourceLocation?.filePath) { + return false; + } + return fileUtils.standardizePath(sourceLocation.filePath).toLowerCase() !== fileUtils.standardizePath(stagingFileInfo.absolutePath).toLowerCase(); + } + private async setupSuspendedState() { //clear the index for storing evalutated expressions this.evaluateVarIndexByFrameId.clear(); diff --git a/src/managers/SourceMapManager.spec.ts b/src/managers/SourceMapManager.spec.ts index ec8e21b5..837b9d31 100644 --- a/src/managers/SourceMapManager.spec.ts +++ b/src/managers/SourceMapManager.spec.ts @@ -209,4 +209,29 @@ describe('SourceMapManager', () => { expect(location).to.be.undefined; }); }); + + describe('generatedLineIsMapped', () => { + it('returns true only for generated lines with mappings', async () => { + const brsPath = s`${tmpPath}/staging/components/foo.brs`; + const mapPath = `${brsPath}.map`; + fsExtra.ensureDirSync(path.dirname(mapPath)); + + const { SourceMapGenerator } = await import('source-map'); + const gen = new SourceMapGenerator({ file: 'foo.brs' }); + gen.addMapping({ + generated: { line: 1, column: 0 }, + original: { line: 5, column: 0 }, + source: '../../src/components/foo.brs' + }); + fsExtra.writeFileSync(mapPath, gen.toString()); + + expect(await manager.generatedLineIsMapped(brsPath, 1)).to.be.true; + expect(await manager.generatedLineIsMapped(brsPath, 2)).to.be.false; + }); + + it('returns false when no source map exists', async () => { + const brsPath = s`${tmpPath}/staging/source/main.brs`; + expect(await manager.generatedLineIsMapped(brsPath, 1)).to.be.false; + }); + }); }); diff --git a/src/managers/SourceMapManager.ts b/src/managers/SourceMapManager.ts index 7000ce53..d3c2ba58 100644 --- a/src/managers/SourceMapManager.ts +++ b/src/managers/SourceMapManager.ts @@ -20,6 +20,7 @@ export class SourceMapManager { * So take that into consideration when deciding to use falsey checking */ private cache = {} as Record; + private generatedMappedLinesCache: Record> = {}; /** * Does a source map exist at the specified path? @@ -71,6 +72,8 @@ export class SourceMapManager { //casings to be safe across platforms. delete this.sourceMapConsumerCache[sourceMapPath]; delete this.sourceMapConsumerCache[key]; + delete this.generatedMappedLinesCache[sourceMapPath]; + delete this.generatedMappedLinesCache[key]; //standardize the source map paths const mapDir = path.dirname(sourceMapPath); // Resolve sourceRoot relative to the map file's directory (handles relative sourceRoot correctly) @@ -179,6 +182,32 @@ export class SourceMapManager { return consumer; } + /** + * Returns true when the generated line has at least one explicit mapping in the source map. + */ + public async generatedLineIsMapped(filePath: string, generatedLineNumber: number) { + const sourceMapPath = await this.getSourceMapPath(filePath); + const parsedSourceMap = await this.getSourceMap(sourceMapPath); + if (!parsedSourceMap) { + return false; + } + + const cacheKey = s`${sourceMapPath.toLowerCase()}`; + let mappedLines = this.generatedMappedLinesCache[cacheKey]; + if (!mappedLines) { + const consumer = await this.getSourceMapConsumer(sourceMapPath, parsedSourceMap); + mappedLines = new Set(); + consumer.eachMapping((mapping) => { + if (mapping?.source && mapping.generatedLine) { + mappedLines.add(mapping.generatedLine); + } + }, undefined, SourceMapConsumer.GENERATED_ORDER); + this.generatedMappedLinesCache[cacheKey] = mappedLines; + } + + return mappedLines.has(generatedLineNumber); + } + /** * Given a source location, find the generated location using source maps */