From 7380f7b3769f58e757d572419e43d6aa7a97b15a Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Thu, 16 Jul 2026 07:37:35 -0400 Subject: [PATCH] Fold entry-point detection and RALE/RDB injection into the single staging walk Fixes the telnet-launch hang from #341, where fileUtils.findEntryPoint ran six sequential full-tree find-in-files searches over every .brs file (60-80s on large staging dirs). - Remove findEntryPoint and the find-in-files dependency entirely. Entry-point detection now happens inside the existing preprocessStagingFiles walk via a single combined regex, storing the result on Project.entryPoint. registerEntryBreakpoint reads that instead of re-scanning the tree. - Fold the two RALE/RDB replace-in-file tree walks into the same staging walk. The copyAndTransform* methods are split into a copy step (run before the walk) and a pure injectEntryComment transform (run during the walk). fixSourceMapComment is now a pure transform so source-map fixes and RALE/RDB injection combine into one write per file. - Add unit coverage: entry-point detection, registerEntryBreakpoint, walk-based RALE/RDB injection, and the combined injection + sourceMappingURL rewrite in a single pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 40 -- package.json | 2 - src/FileUtils.ts | 49 --- .../BrightScriptDebugSession.spec.ts | 77 ---- src/debugSession/BrightScriptDebugSession.ts | 2 +- src/managers/ProjectManager.spec.ts | 254 ++++++++++++ src/managers/ProjectManager.ts | 370 ++++++++++++++---- 7 files changed, 546 insertions(+), 248 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29735de0..a80c93ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "eol": "^0.9.1", "eventemitter3": "^4.0.7", "fast-glob": "^3.2.11", - "find-in-files": "^0.5.0", "fs-extra": "^10.0.0", "glob": "^7.2.0", "natural-orderby": "^2.0.3", @@ -45,7 +44,6 @@ "@types/dateformat": "~3", "@types/debounce": "^1.2.1", "@types/dedent": "^0.7.0", - "@types/find-in-files": "^0.5.1", "@types/fs-extra": "^9.0.13", "@types/glob": "^7.2.0", "@types/mocha": "^10.0.10", @@ -837,12 +835,6 @@ "integrity": "sha512-kRiitIeUg1mPV9yH4VUJ/1uk2XjyANfeL8/7rH1tsjvHeO9PJLBHJIYsFWmAvmGj5u8rj+1TZx7PZzW2qLw3Lw==", "dev": true }, - "node_modules/@types/find-in-files": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@types/find-in-files/-/find-in-files-0.5.3.tgz", - "integrity": "sha512-IGKtSn0Lonfx3HdK6KMcfd5GUc1xdeLtjW1n7ZSA5Tmn1n2gj878q6IC0s4MbF9KtBpXIRqjRQxBzi2kF4WvGw==", - "dev": true - }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -2543,14 +2535,6 @@ "node": ">=8" } }, - "node_modules/find": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/find/-/find-0.1.7.tgz", - "integrity": "sha512-jPrupTOe/pO//3a9Ty2o4NqQCp0L46UG+swUnfFtdmtQVN8pEltKpAqR7Nuf6vWn0GBXx5w+R1MyZzqwjEIqdA==", - "dependencies": { - "traverse-chain": "~0.1.0" - } - }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -2592,15 +2576,6 @@ "semver": "bin/semver.js" } }, - "node_modules/find-in-files": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/find-in-files/-/find-in-files-0.5.0.tgz", - "integrity": "sha512-VraTc6HdtdSHmAp0yJpAy20yPttGKzyBWc7b7FPnnsX9TOgmKx0g9xajizpF/iuu4IvNK4TP0SpyBT9zAlwG+g==", - "dependencies": { - "find": "^0.1.5", - "q": "^1.0.1" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4467,16 +4442,6 @@ "node": ">=6" } }, - "node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -5383,11 +5348,6 @@ "node": ">=8.0" } }, - "node_modules/traverse-chain": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/traverse-chain/-/traverse-chain-0.1.0.tgz", - "integrity": "sha512-up6Yvai4PYKhpNp5PkYtx50m3KbwQrqDwbuZP/ItyL64YEWHAvH6Md83LFLV/GRSk/BoUVwwgUzX6SOQSbsfAg==" - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", diff --git a/package.json b/package.json index 4acebd1d..a830bf29 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@types/dateformat": "~3", "@types/debounce": "^1.2.1", "@types/dedent": "^0.7.0", - "@types/find-in-files": "^0.5.1", "@types/fs-extra": "^9.0.13", "@types/glob": "^7.2.0", "@types/mocha": "^10.0.10", @@ -111,7 +110,6 @@ "eol": "^0.9.1", "eventemitter3": "^4.0.7", "fast-glob": "^3.2.11", - "find-in-files": "^0.5.0", "fs-extra": "^10.0.0", "glob": "^7.2.0", "natural-orderby": "^2.0.3", diff --git a/src/FileUtils.ts b/src/FileUtils.ts index 324df38b..609c1ef9 100644 --- a/src/FileUtils.ts +++ b/src/FileUtils.ts @@ -1,9 +1,7 @@ -import * as findInFiles from 'find-in-files'; import * as fsExtra from 'fs-extra'; import * as glob from 'glob'; import * as path from 'path'; import { promisify } from 'util'; -import { util as rokuDeployUtil } from 'roku-deploy'; const globp = promisify(glob); export class FileUtils { @@ -253,53 +251,6 @@ export class FileUtils { return result; } - /** - * Given a path to a folder, search all files until an entry point is found. - * (An entry point is a function that roku uses as the Main function to start the program). - * @param projectPath - a path to a Roku project - */ - public async findEntryPoint(projectPath: string) { - let results = { - ...await findInFiles.find({ term: 'sub\\s+RunScreenSaver\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/), - ...await findInFiles.find({ term: 'function\\s+RunScreenSaver\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/), - ...await findInFiles.find({ term: 'sub\\s+RunUserInterface\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/), - ...await findInFiles.find({ term: 'function\\s+RunUserInterface\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/), - ...await findInFiles.find({ term: 'sub\\s+main\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/), - ...await findInFiles.find({ term: 'function\\s+main\\s*\\(', flags: 'ig' }, projectPath, /.*\.brs/) - }; - let keys = Object.keys(results); - if (keys.length === 0) { - throw new Error('Unable to find an entry point. Please make sure that you have a RunUserInterface, RunScreenSaver, or Main sub/function declared in your BrightScript project'); - } - - let entryPath = keys[0]; - - let entryLineContents = results[entryPath].line[0]; - - let lineNumber: number; - //load the file contents - let contents = await fsExtra.readFile(entryPath); - let lines = contents.toString().split(/\r?\n/g); - //loop through the lines until we find the entry line - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - if (line.includes(entryLineContents)) { - lineNumber = i + 1; - break; - } - } - let relativePath = fileUtils.removeLeadingSlash( - rokuDeployUtil.stringReplaceInsensitive(entryPath, projectPath, '') - ); - - return { - relativePath: relativePath, - pathAbsolute: entryPath, - contents: entryLineContents, - lineNumber: lineNumber - }; - } - /** * If a string has a leading slash, remove it */ diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index d984e995..40f20056 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -7,7 +7,6 @@ import type { DebugProtocol } from '@vscode/debugprotocol/lib/debugProtocol'; import { DebugSession, InitializedEvent, Logger as DapLogger, logger as dapLogger, OutputEvent, ProgressEndEvent, ProgressStartEvent, ProgressUpdateEvent } from '@vscode/debugadapter'; import { BrightScriptDebugSession } from './BrightScriptDebugSession'; import type { AugmentedVariable } from './BrightScriptDebugSession'; -import { fileUtils } from '../FileUtils'; import type { StackFrame } from '../adapters/TelnetAdapter'; import { PrimativeType, TelnetAdapter } from '../adapters/TelnetAdapter'; import { defer, util } from '../util'; @@ -999,81 +998,6 @@ describe('BrightScriptDebugSession', () => { }); }); - describe('findMainFunction', () => { - let folder; - afterEach(() => { - fsExtra.emptyDirSync('./.tmp'); - fsExtra.rmdirSync('./.tmp'); - }); - - async function doTest(fileContents: string, lineContents: string, lineNumber: number) { - fsExtra.emptyDirSync('./.tmp'); - folder = path.resolve('./.tmp/findMainFunctionTests/'); - fsExtra.mkdirSync(folder); - - let filePath = path.resolve(`${folder}/main.brs`); - - //prevent actually talking to the file system...just hardcode the list to exactly our main file - (session.rokuDeploy as any).getFilePaths = () => { - return [{ - src: filePath, - dest: filePath - }]; - }; - - fsExtra.writeFileSync(filePath, fileContents); - (session as any).launchConfiguration = { - files: [ - folder + '/**/*' - ] - }; - let entryPoint = await fileUtils.findEntryPoint(folder); - expect(entryPoint.pathAbsolute).to.equal(filePath); - expect(entryPoint.lineNumber).to.equal(lineNumber); - expect(entryPoint.contents).to.equal(lineContents); - } - - it('works for RunUserInterface', async () => { - await doTest('\nsub RunUserInterface()\nend sub', 'sub RunUserInterface()', 2); - //works with args - await doTest('\n\nsub RunUserInterface(args as Dynamic)\nend sub', 'sub RunUserInterface(args as Dynamic)', 3); - //works with extra spacing - await doTest('\n\nsub RunUserInterface()\nend sub', 'sub RunUserInterface()', 3); - await doTest('\n\nsub RunUserInterface ()\nend sub', 'sub RunUserInterface ()', 3); - }); - - it('works for sub main', async () => { - await doTest('\nsub Main()\nend sub', 'sub Main()', 2); - //works with args - await doTest('sub Main(args as Dynamic)\nend sub', 'sub Main(args as Dynamic)', 1); - //works with extra spacing - await doTest('sub Main()\nend sub', 'sub Main()', 1); - await doTest('sub Main ()\nend sub', 'sub Main ()', 1); - }); - - it('works for function main', async () => { - await doTest('function Main()\nend function', 'function Main()', 1); - await doTest('function Main(args as Dynamic)\nend function', 'function Main(args as Dynamic)', 1); - //works with extra spacing - await doTest('function Main()\nend function', 'function Main()', 1); - await doTest('function Main ()\nend function', 'function Main ()', 1); - }); - - it('works for sub RunScreenSaver', async () => { - await doTest('sub RunScreenSaver()\nend sub', 'sub RunScreenSaver()', 1); - //works with extra spacing - await doTest('sub RunScreenSaver()\nend sub', 'sub RunScreenSaver()', 1); - await doTest('sub RunScreenSaver ()\nend sub', 'sub RunScreenSaver ()', 1); - }); - - it('works for function RunScreenSaver', async () => { - await doTest('function RunScreenSaver()\nend function', 'function RunScreenSaver()', 1); - //works with extra spacing - await doTest('function RunScreenSaver()\nend function', 'function RunScreenSaver()', 1); - await doTest('function RunScreenSaver ()\nend function', 'function RunScreenSaver ()', 1); - }); - }); - describe('initRendezvousTracking', () => { it('clears history when disabled', async () => { const stub = sinon.stub(session, 'sendEvent'); @@ -1185,7 +1109,6 @@ describe('BrightScriptDebugSession', () => { let stub = sinon.stub(session.projectManager, 'registerEntryBreakpoint').returns(Promise.resolve()); await session.handleEntryBreakpoint(); expect(stub.called).to.be.true; - expect(stub.args[0][0]).to.equal(stagingDir); }); it('does NOT register the entry breakpoint when stopOnEntry is enabled', async () => { (session as any).launchConfiguration = { stopOnEntry: false }; diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index f47972c6..452b77dc 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -3322,7 +3322,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { if (!this.enableDebugProtocol) { this.entryBreakpointWasHandled = true; if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) { - await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir); + await this.projectManager.registerEntryBreakpoint(); } } } diff --git a/src/managers/ProjectManager.spec.ts b/src/managers/ProjectManager.spec.ts index 44d2afe7..d833d425 100644 --- a/src/managers/ProjectManager.spec.ts +++ b/src/managers/ProjectManager.spec.ts @@ -234,6 +234,48 @@ describe('ProjectManager', () => { }); }); + describe('registerEntryBreakpoint', () => { + it('throws when the main project has no discovered entry point', async () => { + manager.mainProject = { stagingDir: stagingDir, entryPoint: undefined }; + let error: Error; + try { + await manager.registerEntryBreakpoint(); + } catch (e) { + error = e as Error; + } + expect(error).to.exist; + expect(error.message).to.include('Unable to find an entry point'); + }); + + it('registers a breakpoint on the line after the discovered entry point', async () => { + manager.mainProject = { + stagingDir: stagingDir, + entryPoint: { + relativePath: 'source/main.brs', + pathAbsolute: s`${stagingDir}/source/main.brs`, + contents: 'sub main()', + lineNumber: 10 + } + }; + //stub the source-location resolution so we don't touch the file system + const getSourceLocation = sinon.stub(manager, 'getSourceLocation').returns(Promise.resolve({ + filePath: s`${rootDir}/source/main.brs`, + lineNumber: 10, + columnIndex: 0 + })); + const setBreakpoint = sinon.stub(manager['breakpointManager'], 'setBreakpoint'); + + await manager.registerEntryBreakpoint(); + + //it resolves the source location for the entry point's relative path + line + expect(getSourceLocation.calledWith('source/main.brs', 10)).to.be.true; + //it registers a breakpoint one line past the entry-point declaration (to land on the first line of the function) + expect(setBreakpoint.calledOnce).to.be.true; + expect(setBreakpoint.firstCall.args[0]).to.equal(s`${rootDir}/source/main.brs`); + expect(setBreakpoint.firstCall.args[1]).to.eql({ line: 11 }); + }); + }); + describe('getStagingFileInfo', () => { it('finds standard files in main project', async () => { expect( @@ -1594,6 +1636,218 @@ describe('Project', () => { }); }); + describe('entry point detection', () => { + afterEach(async () => { + try { + await forceDeleteDir(tempPath); + } catch (e) { } + }); + + /** + * Stage a single `main.brs` file with the given contents, run the staging walk, and assert the + * discovered entry point matches the expected line contents and 1-based line number. + */ + async function doTest(fileContents: string, lineContents: string, lineNumber: number) { + project.stagingDir = stagingDir; + const filePath = s`${stagingDir}/source/main.brs`; + fsExtra.outputFileSync(filePath, fileContents); + project.fileMappings = []; + await project['preprocessStagingFiles'](); + expect(project.entryPoint).to.exist; + expect(project.entryPoint.pathAbsolute).to.equal(filePath); + expect(project.entryPoint.relativePath).to.equal(s`source/main.brs`); + expect(project.entryPoint.lineNumber).to.equal(lineNumber); + expect(project.entryPoint.contents).to.equal(lineContents); + } + + it('works for RunUserInterface', async () => { + await doTest('\nsub RunUserInterface()\nend sub', 'sub RunUserInterface()', 2); + //works with args + await doTest('\n\nsub RunUserInterface(args as Dynamic)\nend sub', 'sub RunUserInterface(args as Dynamic)', 3); + //works with extra spacing + await doTest('\n\nsub RunUserInterface()\nend sub', 'sub RunUserInterface()', 3); + await doTest('\n\nsub RunUserInterface ()\nend sub', 'sub RunUserInterface ()', 3); + }); + + it('works for sub main', async () => { + await doTest('\nsub Main()\nend sub', 'sub Main()', 2); + //works with args + await doTest('sub Main(args as Dynamic)\nend sub', 'sub Main(args as Dynamic)', 1); + //works with extra spacing + await doTest('sub Main()\nend sub', 'sub Main()', 1); + await doTest('sub Main ()\nend sub', 'sub Main ()', 1); + }); + + it('works for function main', async () => { + await doTest('function Main()\nend function', 'function Main()', 1); + await doTest('function Main(args as Dynamic)\nend function', 'function Main(args as Dynamic)', 1); + //works with extra spacing + await doTest('function Main()\nend function', 'function Main()', 1); + await doTest('function Main ()\nend function', 'function Main ()', 1); + }); + + it('works for sub RunScreenSaver', async () => { + await doTest('sub RunScreenSaver()\nend sub', 'sub RunScreenSaver()', 1); + //works with extra spacing + await doTest('sub RunScreenSaver()\nend sub', 'sub RunScreenSaver()', 1); + await doTest('sub RunScreenSaver ()\nend sub', 'sub RunScreenSaver ()', 1); + }); + + it('works for function RunScreenSaver', async () => { + await doTest('function RunScreenSaver()\nend function', 'function RunScreenSaver()', 1); + //works with extra spacing + await doTest('function RunScreenSaver()\nend function', 'function RunScreenSaver()', 1); + await doTest('function RunScreenSaver ()\nend function', 'function RunScreenSaver ()', 1); + }); + + it('leaves entryPoint undefined when no entry point is present', async () => { + project.stagingDir = stagingDir; + fsExtra.outputFileSync(s`${stagingDir}/source/util.brs`, `sub doStuff()\nend sub`); + project.fileMappings = []; + await project['preprocessStagingFiles'](); + expect(project.entryPoint).to.be.undefined; + }); + + it('prefers RunScreenSaver over RunUserInterface over Main when several exist', async () => { + project.stagingDir = stagingDir; + fsExtra.outputFileSync(s`${stagingDir}/source/main.brs`, `sub Main()\nend sub`); + fsExtra.outputFileSync(s`${stagingDir}/source/ui.brs`, `sub RunUserInterface()\nend sub`); + fsExtra.outputFileSync(s`${stagingDir}/source/screensaver.brs`, `sub RunScreenSaver()\nend sub`); + project.fileMappings = []; + await project['preprocessStagingFiles'](); + expect(project.entryPoint.pathAbsolute).to.equal(s`${stagingDir}/source/screensaver.brs`); + }); + + it('only scans .brs files (ignores entry-point-looking text in other extensions)', async () => { + project.stagingDir = stagingDir; + fsExtra.outputFileSync(s`${stagingDir}/source/notbrs.txt`, `sub Main()\nend sub`); + project.fileMappings = []; + await project['preprocessStagingFiles'](); + expect(project.entryPoint).to.be.undefined; + }); + }); + + describe('preprocessStagingFiles entry-comment injection', () => { + afterEach(async () => { + try { + await forceDeleteDir(tempPath); + } catch (e) { } + }); + + it('injects the RALE tracker task during the staging walk', async () => { + project.stagingDir = stagingDir; + project.injectRaleTrackerTask = true; + project.raleTrackerTaskFileLocation = 'z'; + project.injectRdbOnDeviceComponent = false; + const filePath = s`${stagingDir}/source/main.brs`; + fsExtra.outputFileSync(filePath, `sub main()\n ' ${Project.RALE_TRACKER_ENTRY}\nend sub`); + project.fileMappings = []; + + await project['preprocessStagingFiles'](); + + const contents = fsExtra.readFileSync(filePath, 'utf8'); + expect(contents).to.include(Project.RALE_TRACKER_TASK_CODE); + expect(contents).to.not.include(Project.RALE_TRACKER_ENTRY); + }); + + it('injects the RDB on-device-component during the staging walk', async () => { + project.stagingDir = stagingDir; + project.injectRaleTrackerTask = false; + project.injectRdbOnDeviceComponent = true; + project.rdbFilesBasePath = 'rdbSource'; + const filePath = s`${stagingDir}/source/main.brs`; + fsExtra.outputFileSync(filePath, `sub main()\n ' ${Project.RDB_ODC_ENTRY}\nend sub`); + project.fileMappings = []; + + await project['preprocessStagingFiles'](); + + const contents = fsExtra.readFileSync(filePath, 'utf8'); + expect(contents).to.include(Project.RDB_ODC_NODE_CODE); + expect(contents).to.not.include(Project.RDB_ODC_ENTRY); + }); + + it('does not inject when the inject flags are disabled', async () => { + project.stagingDir = stagingDir; + project.injectRaleTrackerTask = false; + project.injectRdbOnDeviceComponent = false; + const original = `sub main()\n ' ${Project.RALE_TRACKER_ENTRY}\n ' ${Project.RDB_ODC_ENTRY}\nend sub`; + const filePath = s`${stagingDir}/source/main.brs`; + fsExtra.outputFileSync(filePath, original); + project.fileMappings = []; + + await project['preprocessStagingFiles'](); + + //file is untouched (still contains both entry markers) + expect(fsExtra.readFileSync(filePath, 'utf8')).to.equal(original); + }); + + it('applies BOTH the RALE injection AND the sourceMappingURL rewrite to the same file in a single pass', async () => { + //a moved source file (in fileMappings) that has BOTH a RALE entry comment and a + //sourceMappingURL comment — the two rewrites must combine into one write. + const originalDir = s`${tempPath}/src/source`; + const originalPath = s`${originalDir}/main.brs`; + const originalMapPath = s`${originalDir}/main.brs.map`; + const stagingPath = s`${stagingDir}/source/main.brs`; + const stagingMapPath = s`${stagingDir}/source/main.brs.map`; + + fsExtra.ensureDirSync(originalDir); + fsExtra.ensureDirSync(path.dirname(stagingPath)); + + //original file has code with a RALE entry marker AND a sourcemap comment pointing at the original map + const originalContents = [ + `sub main()`, + ` screen.show ' ${Project.RALE_TRACKER_ENTRY}`, + `end sub`, + `'//# sourceMappingURL=main.brs.map` + ].join('\n'); + fsExtra.writeFileSync(originalPath, originalContents); + fsExtra.writeJsonSync(originalMapPath, { version: 3, sources: [], mappings: '' }); + //stage both the file and its map + fsExtra.copySync(originalPath, stagingPath); + fsExtra.copySync(originalMapPath, stagingMapPath); + + project.stagingDir = stagingDir; + project.injectRaleTrackerTask = true; + project.raleTrackerTaskFileLocation = 'z'; + project.injectRdbOnDeviceComponent = false; + project.fileMappings = [ + { src: originalPath, dest: stagingPath }, + { src: originalMapPath, dest: stagingMapPath } + ]; + + await project['preprocessStagingFiles'](); + + const contents = fsExtra.readFileSync(stagingPath, 'utf8'); + //the RALE injection landed (inline, so it uses the `: ` single-line syntax) + expect(contents).to.include(`: ${Project.RALE_TRACKER_TASK_CODE}`); + expect(contents).to.not.include(Project.RALE_TRACKER_ENTRY); + //AND the sourceMappingURL comment was rewritten to the canonical modern form (still present) + expect(contents).to.include('//# sourceMappingURL=main.brs.map'); + }); + + it('resets the injection state on each walk so a warning fires on a re-stage with no entry comment', async () => { + project.stagingDir = stagingDir; + project.injectRaleTrackerTask = true; + project.raleTrackerTaskFileLocation = 'z'; + project.injectRdbOnDeviceComponent = false; + //console.error is already stubbed in the describe's beforeEach; reuse that spy + const consoleError = console.error as sinonActual.SinonStub; + consoleError.resetHistory(); + + //first walk: entry comment present -> injected, no warning + const filePath = s`${stagingDir}/source/main.brs`; + fsExtra.outputFileSync(filePath, `sub main()\n ' ${Project.RALE_TRACKER_ENTRY}\nend sub`); + project.fileMappings = []; + await project['preprocessStagingFiles'](); + expect(consoleError.calledWithMatch(/Unable to find an entry point for Tracker Task/)).to.be.false; + + //second walk: entry comment gone -> the injected flag must have reset, so the warning fires + fsExtra.outputFileSync(filePath, `sub main()\nend sub`); + await project['preprocessStagingFiles'](); + expect(consoleError.calledWithMatch(/Unable to find an entry point for Tracker Task/)).to.be.true; + }); + }); + describe('updateManifestBsConsts', () => { let constsLine: string; let startingFileContents: string; diff --git a/src/managers/ProjectManager.ts b/src/managers/ProjectManager.ts index f33b6f5a..0952dc92 100644 --- a/src/managers/ProjectManager.ts +++ b/src/managers/ProjectManager.ts @@ -34,6 +34,42 @@ export interface ProjectStagingInfo { stagingDir: string; } +/** + * The location of a Roku entry-point function (Main, RunUserInterface, or RunScreenSaver) within a + * project's staging directory. Discovered during the staging-file walk. + */ +export interface EntryPoint { + /** + * The path to the entry-point file, relative to the staging dir (no leading slash). + */ + relativePath: string; + /** + * The absolute path to the entry-point file in the staging dir. + */ + pathAbsolute: string; + /** + * The full text of the line that declares the entry-point function. + */ + contents: string; + /** + * The 1-based line number of the entry-point declaration within the file. + */ + lineNumber: number; +} + +/** + * The set of entry-point function names Roku may use to start a program, listed in priority order. + * The matcher prefers an earlier name over a later one when more than one is present in a project. + */ +const entryPointFunctionNames = ['RunScreenSaver', 'RunUserInterface', 'main']; + +/** + * Matches a Roku entry-point declaration (e.g. `sub Main(`, `function RunUserInterface (`). + * Capture group 1 is the function name. This single regex replaces the six sequential + * `find-in-files` searches the old `findEntryPoint` performed. + */ +const entryPointRegex = new RegExp(`\\b(?:sub|function)\\s+(${entryPointFunctionNames.join('|')})\\s*\\(`, 'i'); + /** * Manages the collection of brightscript projects being used in a debug session. * Will contain the main project (in rootDir), as well as component libraries. @@ -234,12 +270,14 @@ export class ProjectManager { } /** - * - * @param stagingDir - the path to + * Register the entry breakpoint for the main project, using the entry point that was discovered + * while walking the staging files during `stage()`. */ - public async registerEntryBreakpoint(stagingDir: string) { - //find the main function from the staging flder - let entryPoint = await fileUtils.findEntryPoint(stagingDir); + public async registerEntryBreakpoint() { + let entryPoint = this.mainProject?.entryPoint; + if (!entryPoint) { + throw new Error('Unable to find an entry point. Please make sure that you have a RunUserInterface, RunScreenSaver, or Main sub/function declared in your BrightScript project'); + } //convert entry point staging location to source location let sourceLocation = await this.getSourceLocation(entryPoint.relativePath, entryPoint.lineNumber); @@ -382,6 +420,25 @@ export class Project { * staging-file walk in `preprocessStagingFiles`) so consumers don't have to re-scan the staging dir. */ public scriptReferencedFiles = new Set(); + + /** + * The entry point (Main/RunUserInterface/RunScreenSaver sub/function) discovered while walking the + * staging files in `preprocessStagingFiles`. This replaces the old standalone `findEntryPoint` + * file-tree search (which walked the entire `.brs` tree up to six times). Populated during `stage()` + * so `ProjectManager.registerEntryBreakpoint` can read it without re-scanning the staging dir. + * Remains `undefined` if no entry point was found. + */ + public entryPoint: EntryPoint; + + /** + * Whether the RALE tracker-task entry comment was found and replaced during the staging walk. + * Used to emit the "unable to find an entry point" warning (matching the old behavior). + */ + private raleTrackerTaskInjected = false; + /** + * Whether the RDB on-device-component entry comment was found and replaced during the staging walk. + */ + private rdbOnDeviceComponentInjected = false; public bsConst: Record; public injectRaleTrackerTask: boolean; public raleTrackerTaskFileLocation: string; @@ -416,6 +473,13 @@ export class Project { resolveFilesArray: false }); + //copy the RALE/RDB support files into staging BEFORE the staging walk, so the walk can inject the + //entry comments into them (and every other staged file) in a single pass. + await this.copyRaleTrackerTask(); + await this.copyRdbFiles(); + + //single walk over the staging dir: source-map fixes, script-uri collection, entry-point detection, + //and RALE/RDB entry-comment injection all happen here. await this.preprocessStagingFiles(); if (this.enhanceREPLCompletions) { @@ -440,10 +504,6 @@ export class Project { await this.resolveFileMappingsForSourceDirs(); await this.transformManifestWithBsConst(); - - await this.copyAndTransformRaleTrackerTask(); - - await this.copyAndTransformRDB(); } /** @@ -488,10 +548,17 @@ export class Project { } /** - * Walk every staged file once and apply all necessary rewrites for files that were moved - * from a different source location: + * Walk every staged file exactly once and apply all per-file staging work in a single pass (rather + * than re-traversing the file tree for each concern): * - .map files: rewrite `sources` paths to be relative to the new staging location - * - .brs/.xml files: rewrite the sourceMappingURL comment path to point to the staged map + * - .xml files: collect `