diff --git a/src/FileUtils.ts b/src/FileUtils.ts index 3a04d1f9..324df38b 100644 --- a/src/FileUtils.ts +++ b/src/FileUtils.ts @@ -205,7 +205,7 @@ export class FileUtils { * force all drive letters to lower case (because that's what VSCode does sometimes so this makes it consistent) * @param thePath */ - public standardizePath(thePath: string) { + public standardizePath(thePath: string): string { if (!thePath) { return thePath; } @@ -330,7 +330,7 @@ export let fileUtils = new FileUtils(); /** * A tagged template literal function for standardizing the path. */ -export function standardizePath(stringParts, ...expressions: any[]) { +export function standardizePath(stringParts, ...expressions: any[]): string { let result = []; for (let i = 0; i < stringParts.length; i++) { result.push(stringParts[i], expressions[i]); diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts index a4081231..b64b2559 100644 --- a/src/debugSession/BrightScriptDebugSession.spec.ts +++ b/src/debugSession/BrightScriptDebugSession.spec.ts @@ -65,6 +65,9 @@ describe('BrightScriptDebugSession', () => { } catch (e) { console.log(e); } + //always resolve the stagingDefered promise right away since most tests don't care about staging and this prevents a lot of unnecessary waiting + session['stagingDefered'].resolve(); + errorSpy = sinon.spy(session.logger, 'error'); //override the error response function and throw an exception so we can fail any tests (session as any).sendErrorResponse = (...args: string[]) => { diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts index 4bc7ef4a..a6976b59 100644 --- a/src/debugSession/BrightScriptDebugSession.ts +++ b/src/debugSession/BrightScriptDebugSession.ts @@ -84,6 +84,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession { //give util a reference to this session to assist in logging across the entire module util._debugSession = this; + this.fileManager = new FileManager(); this.sourceMapManager = new SourceMapManager(); this.locationManager = new LocationManager(this.sourceMapManager); @@ -291,6 +292,11 @@ export class BrightScriptDebugSession extends LoggingDebugSession { */ private firstRunDeferred = defer(); + /** + * Resolved whenever we're finished copying all the files to staging for all projects + */ + private stagingDefered = defer(); + private evaluateRefIdLookup: Record = {}; private evaluateRefIdCounter = 1; @@ -608,6 +614,10 @@ export class BrightScriptDebugSession extends LoggingDebugSession { this.prepareMainProject(), this.prepareAndHostComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort) ]); + + //all of the projects have been successfully staged. + this.stagingDefered.tryResolve(); + packageEnd(); if (this.enableDebugProtocol) { @@ -1423,6 +1433,9 @@ export class BrightScriptDebugSession extends LoggingDebugSession { }; this.sendResponse(response); + //ensure we've staged all the files + await this.stagingDefered.promise; + await this.rokuAdapter?.syncBreakpoints(); } @@ -2483,6 +2496,8 @@ export class BrightScriptDebugSession extends LoggingDebugSession { await this.rokuAdapter.destroy(); await this.ensureAppIsInactive(); this.rokuAdapterDeferred = defer(); + this.stagingDefered.tryResolve(); + this.stagingDefered = defer(); } await this.launchRequest(response, args.arguments as LaunchConfiguration); } diff --git a/src/managers/ProjectManager.spec.ts b/src/managers/ProjectManager.spec.ts index 876548e0..90046a2a 100644 --- a/src/managers/ProjectManager.spec.ts +++ b/src/managers/ProjectManager.spec.ts @@ -228,7 +228,7 @@ describe('ProjectManager', () => { // Simulate the full flow: // 1. compiler produces MainScene.brs + MainScene.brs.map in srcDir, with sources relative to srcDir // 2. prepublishToStaging copies them to stagingDir (recorded in fileMappings) - // 3. fixSourceMapSources rewrites the map's sources to be relative to stagingDir + // 3. preprocessStagingFiles rewrites the map's sources to be relative to stagingDir // 4. getSourceLocation('pkg:/source/MainScene.brs', 1) resolves back to rootDir/source/MainScene.bs const srcDir = s`${tempPath}/srcDir/source`; @@ -260,10 +260,10 @@ describe('ProjectManager', () => { { src: originalMapPath, dest: stagingMapPath } ]; - // fixSourceMapSources rewrites the on-disk map to use paths relative to stagingDir + // preprocessStagingFiles rewrites the on-disk map to use paths relative to stagingDir const project = new Project({ rootDir: rootDir, outDir: outDir, files: [], stagingDir: stagingDir, enhanceREPLCompletions: false }); project.fileMappings = fileMappings; - await project['fixSourceMapSources'](); + await project['preprocessStagingFiles'](); // Point the manager's mainProject at this project manager.mainProject = project as any; @@ -403,7 +403,93 @@ describe('Project', () => { }); }); - describe('fixSourceMapSources', () => { + describe('getSourceMapComment', () => { + const call = (contents: string) => Project.getSourceMapComment(contents); + + it('returns undefined when no sourceMappingURL comment is present', () => { + expect(call(`sub main()\nend sub`)).to.be.undefined; + expect(call(``)).to.be.undefined; + }); + + it('returns the correct named fields for a standard brs comment', () => { + const result = call(`sub main()\nend sub\n'//# sourceMappingURL=main.brs.map`); + expect(result).to.exist; + expect(result.fullMatch).to.equal(`'//# sourceMappingURL=main.brs.map`); + expect(result.leadingInfo).to.equal(`'`); + expect(result.wholeComment).to.equal(`//# sourceMappingURL=main.brs.map`); + expect(result.mapPath).to.equal(`main.brs.map`); + }); + + it('returns the correct named fields for a standard xml comment', () => { + const result = call(`\n\n`); + expect(result).to.exist; + // fullMatch does not include the trailing ' -->' (it's consumed by the non-capturing (?:|-->) group) + expect(result.fullMatch).to.equal(``); + expect(result?.leadingInfo).to.equal(``); + expect(result?.leadingInfo).to.equal(``); + expect(result).to.equal(`content\n`); + }); + + it('rewrites the comment in an arbitrary text-based file format', async () => { + const result = await stageFileWithComment('.md', `//# sourceMappingURL=../maps/main.md.map`); + expect(result).to.equal(`content\n//# sourceMappingURL=main.md.map`); + }); + + it('keeps the correct path when brs and map are siblings in both source and staging', async () => { + const result = await stageFileWithComment('.brs', `'//# sourceMappingURL=main.brs.map`, { + originalDir: s`${tempPath}/src/source`, + originalMapDir: s`${tempPath}/src/source` + }); + expect(result).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + + it('rewrites an absolute comment path to point at the colocated map in staging', async () => { + const absoluteMapPath = s`${tempPath}/src/source/main.brs.map`; + const stagingBrsPath = s`${stagingDir}/source/main.brs`; + const stagingMapPath = s`${stagingDir}/source/main.brs.map`; + const result = await stageFileWithComment('.brs', `'//# sourceMappingURL=${absoluteMapPath}`, { + originalDir: s`${tempPath}/src/source`, + originalMapDir: s`${tempPath}/src/source`, + stageMap: false + }); + // Map should be colocated next to the staging file + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been colocated').to.be.true; + // Comment should point at the colocated copy (relative path = 'main.brs.map') + expect(result).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + + it('uses the last comment when multiple sourceMappingURL comments exist in one file', async () => { + // Only the last comment should be rewritten; the first should be left as-is. + const originalDir = s`${tempPath}/src/source`; + const originalMapDir = s`${tempPath}/src/source`; + const originalPath = s`${originalDir}/main.brs`; + const originalMapPath = s`${originalMapDir}/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)); + fsExtra.writeFileSync(originalPath, `line1\n'//# sourceMappingURL=first.brs.map\nline2\n'//# sourceMappingURL=main.brs.map`); + fsExtra.writeJsonSync(originalMapPath, { version: 3, sources: [], mappings: '' }); + fsExtra.copySync(originalPath, stagingPath); + fsExtra.copySync(originalMapPath, stagingMapPath); + project.fileMappings = [ + { src: originalPath, dest: stagingPath }, + { src: originalMapPath, dest: stagingMapPath } + ]; + + await project['preprocessStagingFiles'](); + const result = fsExtra.readFileSync(stagingPath, 'utf8'); + expect(result).to.equal(`line1\n'//# sourceMappingURL=first.brs.map\nline2\n'//# sourceMappingURL=main.brs.map`); + }); + + // ── colocated map (no comment) ──────────────────────────────────────────── + it('does not modify the file when there is no comment but a colocated .map exists — copies the map next to the staging file instead', async () => { + const originalContent = `sub main()\nend sub`; + const stagingBrsPath = s`${stagingDir}/source/main.brs`; + const stagingMapPath = s`${stagingDir}/source/main.brs.map`; + + const result = await stageFileWithColocatedMap('.brs'); + + expect(result).to.equal(originalContent); + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied next to the staging file').to.be.true; + }); + + it('copies the colocated map next to the staging file for xml files', async () => { + const originalContent = `sub main()\nend sub`; + const stagingXmlPath = s`${stagingDir}/source/main.xml`; + const stagingMapPath = s`${stagingDir}/source/main.xml.map`; + + const result = await stageFileWithColocatedMap('.xml'); + + expect(result).to.equal(originalContent); + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied next to the staging file').to.be.true; + }); + + it('copies the colocated map next to the staging file for other file types', async () => { + const originalContent = `sub main()\nend sub`; + const stagingMapPath = s`${stagingDir}/source/main.md.map`; + + const result = await stageFileWithColocatedMap('.md'); + + expect(result).to.equal(originalContent); + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied next to the staging file').to.be.true; + }); + + it('does not modify the file when the colocated .map was already staged right next to the source file', async () => { + const original = `sub main()\nend sub`; + const result = await stageFileWithColocatedMap('.brs', { stageMap: true }); + expect(result).to.equal(original); + }); + + it('does not modify the file when the colocated .map was staged at a different location — colocates the map next to the staging file', async () => { + const originalContent = `sub main()\nend sub`; + const stagingBrsPath = s`${stagingDir}/source/main.brs`; + const stagingMapPath = s`${stagingDir}/source/main.brs.map`; + const mapStagedElsewhere = s`${stagingDir}/maps/main.brs.map`; + + const result = await stageFileWithColocatedMap('.brs', { stageMap: true, stagingMapDest: mapStagedElsewhere }); + + expect(result).to.equal(originalContent); + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied next to the staging file').to.be.true; + }); + + // ── no comment, no colocated map ────────────────────────────────────────── + it('leaves the file untouched when there is no comment and no colocated map', async () => { + const originalBrsPath = s`${tempPath}/src/source/main.brs`; + const stagingBrsPath = s`${stagingDir}/source/main.brs`; + + fsExtra.ensureDirSync(path.dirname(originalBrsPath)); + fsExtra.ensureDirSync(path.dirname(stagingBrsPath)); + + const originalContents = `sub main()\nend sub\n`; + fsExtra.writeFileSync(originalBrsPath, originalContents); + fsExtra.copySync(originalBrsPath, stagingBrsPath); + project.fileMappings = [{ src: originalBrsPath, dest: stagingBrsPath }]; + + await project['preprocessStagingFiles'](); + + expect(fsExtra.readFileSync(stagingBrsPath, 'utf8')).to.equal(originalContents); + }); + + // ── binary files ────────────────────────────────────────────────────────── + it('skips binary files without modifying them', async () => { + for (const ext of Project.binaryExtensions) { + const originalPath = s`${tempPath}/src/source/file${ext}`; + const stagingPath = s`${stagingDir}/source/file${ext}`; + + fsExtra.ensureDirSync(path.dirname(originalPath)); + fsExtra.ensureDirSync(path.dirname(stagingPath)); + + const binaryContents = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + fsExtra.writeFileSync(originalPath, binaryContents); + fsExtra.copySync(originalPath, stagingPath); + + project.fileMappings = [{ src: originalPath, dest: stagingPath }]; + await project['preprocessStagingFiles'](); + + expect(Buffer.compare(fsExtra.readFileSync(stagingPath), binaryContents)).to.equal(0, `${ext} file should be untouched`); + } + }); + + // ── legacy and variant comment forms ────────────────────────────────────── + describe('legacy and variant comment forms', () => { + // brs variants + it('brs: rewrites legacy @ form', async () => { + expect(await stageFileWithComment('.brs', `'//@ sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it(`brs: rewrites when // is omitted ('# sourceMappingURL=...)`, async () => { + expect(await stageFileWithComment('.brs', `'# sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it(`brs: rewrites when // is omitted with legacy @ ('@ sourceMappingURL=...)`, async () => { + expect(await stageFileWithComment('.brs', `'@ sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it(`brs: rewrites with whitespace between ' and //# (' //# sourceMappingURL=...)`, async () => { + expect(await stageFileWithComment('.brs', `' //# sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it(`brs: rewrites with whitespace and no // (' # sourceMappingURL=...)`, async () => { + expect(await stageFileWithComment('.brs', `' # sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it('brs: no space between # and sourceMappingURL', async () => { + expect(await stageFileWithComment('.brs', `'//#sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + it('brs: no space between @ and sourceMappingURL (legacy)', async () => { + expect(await stageFileWithComment('.brs', `'//@sourceMappingURL=../maps/main.brs.map`)).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + }); + + // xml variants + it('xml: rewrites legacy @ form ()', async () => { + expect(await stageFileWithComment('.xml', ``)).to.equal(`content\n`); + }); + it('xml: rewrites when // is omitted ()', async () => { + expect(await stageFileWithComment('.xml', ``)).to.equal(`content\n`); + }); + it('xml: rewrites with whitespace between )', async () => { + expect(await stageFileWithComment('.xml', ``)).to.equal(`content\n`); + }); + it('xml: rewrites with whitespace and no // ()', async () => { + expect(await stageFileWithComment('.xml', ``)).to.equal(`content\n`); + }); + it('xml: no space between # and sourceMappingURL', async () => { + expect(await stageFileWithComment('.xml', ``)).to.equal(`content\n`); + }); + + // other (markdown) variants + it('other: rewrites legacy @ form (//@ sourceMappingURL=...)', async () => { + expect(await stageFileWithComment('.md', `//@ sourceMappingURL=../maps/main.md.map`)).to.equal(`content\n//# sourceMappingURL=main.md.map`); + }); + it('other: rewrites with whitespace between // and # (// # sourceMappingURL=...)', async () => { + expect(await stageFileWithComment('.md', `// # sourceMappingURL=../maps/main.md.map`)).to.equal(`content\n//# sourceMappingURL=main.md.map`); + }); + it('other: rewrites with whitespace between // and @ (// @ sourceMappingURL=...)', async () => { + expect(await stageFileWithComment('.md', `// @ sourceMappingURL=../maps/main.md.map`)).to.equal(`content\n//# sourceMappingURL=main.md.map`); + }); + it('other: no space between # and sourceMappingURL', async () => { + expect(await stageFileWithComment('.md', `//#sourceMappingURL=../maps/main.md.map`)).to.equal(`content\n//# sourceMappingURL=main.md.map`); + }); + }); + + // ── map file lifecycle ──────────────────────────────────────────────────── + it('deletes the original map from its source location when a comment references it', async () => { + const originalMapDir = s`${tempPath}/src/components/maps`; + const originalMapPath = s`${originalMapDir}/main.brs.map`; + + await stageFileWithComment('.brs', `'//# sourceMappingURL=../maps/main.brs.map`, { + originalMapDir: originalMapDir + }); + + expect(fsExtra.pathExistsSync(originalMapPath), 'original map should have been deleted by colocateSourceMap').to.be.false; + }); + + it('deletes the original map from its source location when the map is colocated next to the original source', async () => { + const srcDir = s`${tempPath}/rootDir/source`; + const originalMapPath = s`${srcDir}/main.brs.map`; + + await stageFileWithColocatedMap('.brs'); + + expect(fsExtra.pathExistsSync(originalMapPath), 'original colocated map should have been deleted by colocateSourceMap').to.be.false; + }); + + it('copies the map file to staging and it is valid JSON', async () => { + const mapContent = { version: 3, sources: ['main.brs'], mappings: 'AAAA' }; + const srcDir = s`${tempPath}/rootDir/source`; + const originalPath = s`${srcDir}/main.brs`; + const originalMapPath = s`${srcDir}/main.brs.map`; + const stagingPath = s`${stagingDir}/source/main.brs`; + const stagingMapPath = s`${stagingDir}/source/main.brs.map`; + + fsExtra.ensureDirSync(srcDir); + fsExtra.ensureDirSync(path.dirname(stagingPath)); + fsExtra.writeFileSync(originalPath, `sub main()\nend sub`); + fsExtra.writeJsonSync(originalMapPath, mapContent); + fsExtra.copySync(originalPath, stagingPath); + project.fileMappings = [{ src: originalPath, dest: stagingPath }]; + + await project['preprocessStagingFiles'](); + + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied to staging').to.be.true; + const copiedMap = fsExtra.readJsonSync(stagingMapPath); + // version is preserved; sources are rewritten by fixSourceMapSources (which is expected) + expect(copiedMap.version).to.equal(mapContent.version); + expect(copiedMap.mappings).to.equal(mapContent.mappings); + }); + + it('rewrites the comment and copies the map even when the map was not listed in fileMappings', async () => { + // The map exists on disk but was not staged through fileMappings — colocateSourceMap + // should still copy it next to the staging file and the comment should point at it. + const originalDir = s`${tempPath}/src/source`; + const originalMapDir = s`${tempPath}/src/source`; + const originalPath = s`${originalDir}/main.brs`; + const originalMapPath = s`${originalMapDir}/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)); + fsExtra.writeFileSync(originalPath, `content\n'//# sourceMappingURL=main.brs.map`); + fsExtra.writeJsonSync(originalMapPath, { version: 3, sources: [], mappings: '' }); + fsExtra.copySync(originalPath, stagingPath); + // Only stage the source file, not the map + project.fileMappings = [{ src: originalPath, dest: stagingPath }]; + + await project['preprocessStagingFiles'](); + + const result = fsExtra.readFileSync(stagingPath, 'utf8'); + expect(result).to.equal(`content\n'//# sourceMappingURL=main.brs.map`); + expect(fsExtra.pathExistsSync(stagingMapPath), 'map should have been copied next to the staging file').to.be.true; + }); }); }); diff --git a/src/managers/ProjectManager.ts b/src/managers/ProjectManager.ts index fd74db0a..7b6ec934 100644 --- a/src/managers/ProjectManager.ts +++ b/src/managers/ProjectManager.ts @@ -1,11 +1,8 @@ -import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; import * as path from 'path'; import { rokuDeploy, RokuDeploy, util as rokuDeployUtil } from 'roku-deploy'; import type { FileEntry } from 'roku-deploy'; -import * as glob from 'glob'; -import { promisify } from 'util'; -const globAsync = promisify(glob); +import * as fastGlob from 'fast-glob'; import type { BreakpointManager } from './BreakpointManager'; import { fileUtils, standardizePath as s } from '../FileUtils'; import type { LocationManager, SourceLocation } from './LocationManager'; @@ -16,7 +13,6 @@ import { BscProjectThreaded } from '../bsc/BscProjectThreaded'; import type { ScopeFunction } from '../bsc/BscProject'; import type { Position } from 'brighterscript'; import type { SourceMapPayload } from 'module'; -import { SourceMap } from 'module'; // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports const replaceInFile = require('replace-in-file'); @@ -298,10 +294,14 @@ export interface AddProjectParams { export class Project { constructor(params: AddProjectParams) { - assert(params?.rootDir, 'rootDir is required'); + if (!params?.rootDir) { + throw new Error('rootDir is required'); + } this.rootDir = fileUtils.standardizePath(params.rootDir); - assert(params?.outDir, 'outDir is required'); + if (!params?.outDir) { + throw new Error('outDir is required'); + } this.outDir = fileUtils.standardizePath(params.outDir); this.stagingDir = params.stagingDir ?? rokuDeploy.getOptions(this).stagingDir; this.bsConst = params.bsConst; @@ -357,7 +357,7 @@ export class Project { resolveFilesArray: false }); - await this.fixSourceMapSources(); + await this.preprocessStagingFiles(); if (this.enhanceREPLCompletions) { //activate our background brighterscript ProgramBuilder now that the staging directory contains the final production project @@ -429,61 +429,220 @@ export class Project { } /** - * Find all .map files in the staging directory and update their `sources` paths to be - * relative to the staging map file location instead of the original source location. - * This ensures source maps work correctly when the stagingDir differs from the original - * source directory (e.g. when using sourceDirs or a customized stagingDir). + * Walk every staged file once and apply all necessary rewrites for files that were moved + * from a different source location: + * - .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 */ - private async fixSourceMapSources() { - // Build a lookup from staging dest path -> original src path - const stagingToSrcMap = new Map(); + private async preprocessStagingFiles() { + const srcToDestMap = new Map(); + const destToSrcMap = new Map(); for (const mapping of this.fileMappings) { - stagingToSrcMap.set(mapping.dest, mapping.src); + srcToDestMap.set(mapping.src, mapping.dest); + destToSrcMap.set(mapping.dest, mapping.src); } - // Find all .map files currently in the staging directory - const mapFiles = (await globAsync('**/*.map', { cwd: this.stagingDir, absolute: true })) - .map(f => fileUtils.standardizePath(f)); + //walk over every file + const stagedFiles: string[] = (await fastGlob('**/*', { cwd: this.stagingDir, absolute: true, onlyFiles: true })) + .map((f: string) => fileUtils.standardizePath(f)); - await Promise.all(mapFiles.map(async (stagingMapPath) => { - const originalMapPath = stagingToSrcMap.get(stagingMapPath); + await Promise.all(stagedFiles.map(async (stagingFilePath: string) => { + const originalSrcPath = destToSrcMap.get(stagingFilePath); - // If not in fileMappings or location is unchanged, no rewriting needed - if (!originalMapPath || originalMapPath === stagingMapPath) { + // Skip files not in fileMappings (e.g. generated after staging) + if (!originalSrcPath) { return; } - try { - const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload; + const ext = path.extname(stagingFilePath).toLowerCase(); - if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) { - return; - } + if (ext === '.map') { + await this.fixSourceMapSources({ + stagingMapPath: stagingFilePath, + originalMapPath: originalSrcPath + }); + } else { + await this.fixSourceMapComment(stagingFilePath, originalSrcPath, srcToDestMap); + } + })); + } - // Resolve sources relative to original map's base dir (honoring sourceRoot if present) - const originalBaseDir = path.resolve( - //sourceRoot should resolve relative to originalMapDir, or keep it as-is if it's an absolute path - path.dirname(originalMapPath), - sourceMap.sourceRoot ?? '' - ); + /** + * Rewrite the `sources` paths in a staged .map file so they are relative to the map's + * new staging location rather than the original source directory. + */ + private async fixSourceMapSources(params: { stagingMapPath: string; originalMapPath: string }) { + const { stagingMapPath, originalMapPath } = params; + + try { + const sourceMap = await fsExtra.readJsonSync(stagingMapPath) as SourceMapPayload; + if (!Array.isArray(sourceMap.sources) || sourceMap.sources.length === 0) { + return; + } + // Resolve sources relative to original map's base dir (honoring sourceRoot if present) + const originalBaseDir = path.resolve( + //sourceRoot should resolve relative to originalMapDir, or keep as-is when absolute path + path.dirname(originalMapPath), + sourceMap.sourceRoot ?? '' + ); + + const stagingMapDir = path.dirname(stagingMapPath); + + sourceMap.sources = sourceMap.sources.map((source) => { + const absoluteSourcePath = path.resolve(originalBaseDir, source); + return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath)); + }); + + // Clear sourceRoot since sources are now relative to the map file's new location + delete sourceMap.sourceRoot; + + await fsExtra.writeFile(stagingMapPath, JSON.stringify(sourceMap)); + } catch (e) { + this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e); + } + } + + + public static readonly binaryExtensions = new Set([ + // images + '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg', + '.heic', '.heif', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng', + // video + '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg', + '.m2v', '.ts', '.mts', '.m2ts', '.vob', '.ogv', '.3gp', '.3g2', + // audio + '.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma', '.opus', '.aiff', '.aif', + // fonts + '.ttf', '.otf', '.woff', '.woff2', '.eot', + // archives / binary containers + '.zip', '.gz', '.tar', '.bz2', '.xz', '.7z', '.rar', '.pkg', '.exe', '.dll', '.so', + // documents / other binary formats + '.pdf', '.psd', '.ai', '.eps', '.indd', + // roku-specific + '.roku', '.rdb', '.squashfs' + ]); + + /** + * Extracts the sourceMappingURL comment from the given file contents. + * + * `match[3]` is the path (which may be relative or absolute) + * @param contents + * @returns + */ + public static getSourceMapComment(contents: string) { + + //https://regex101.com/r/FMRJNy/2 + const commentMatch = [ + ...contents.matchAll(/^([ \t]*(?:'|)?/gm) + ].pop(); + if (commentMatch) { + return { + /** + * The entire matched comment, including any leading whitespace and comment characters (e.g. `'` or ` + * other: // \s* [#|@] sourceMappingURL= + * + * When rewriting, the canonical modern form is always written: + * BRS: '//# sourceMappingURL= + * XML: + * other: //# sourceMappingURL= + */ + private async fixSourceMapComment(stagingFilePath: string, originalSrcPath: string, srcToDestMap: Map) { + try { + //if this is a media file, skip it because it won't have a source map + if (Project.binaryExtensions.has(path.extname(stagingFilePath).toLowerCase())) { + return; + } + let contents = await fsExtra.readFile(stagingFilePath, 'utf8'); + + const commentMatch = Project.getSourceMapComment(contents); + + let absoluteMapPath: string; - const stagingMapDir = path.dirname(stagingMapPath); + if (commentMatch) { + absoluteMapPath = fileUtils.standardizePath( + path.isAbsolute(commentMatch.mapPath) + ? commentMatch.mapPath + : path.resolve(path.dirname(originalSrcPath), commentMatch.mapPath) + ); - sourceMap.sources = sourceMap.sources.map((source) => { - const absoluteSourcePath = path.resolve(originalBaseDir, source); - return fileUtils.standardizePath(path.relative(stagingMapDir, absoluteSourcePath)); + //copy the sourcemap right next to our file in staging + absoluteMapPath = await this.colocateSourceMap({ + absoluteMapPath: absoluteMapPath, + stagingFilePath: stagingFilePath }); - // Clear sourceRoot since sources are now relative to the map file's new location - delete sourceMap.sourceRoot; + } else { + // No comment — check if a colocated map exists next to the original source file + absoluteMapPath = fileUtils.standardizePath(originalSrcPath + '.map'); + + //there is no colocated map next to the original source file + if (!await fsExtra.pathExists(absoluteMapPath)) { + return; + } - await fsExtra.writeFile(stagingMapPath, JSON.stringify(sourceMap)); - } catch (e) { - this.logger.error(`Error updating source map sources for '${stagingMapPath}'`, e); + //copy the sourcemap right next to our file in staging — the debugger will find it automatically + await this.colocateSourceMap({ + absoluteMapPath: absoluteMapPath, + stagingFilePath: stagingFilePath + }); + return; } - })); + + // If the map was also staged, point at its new location; otherwise point back at the original + const mapTarget = srcToDestMap.get(absoluteMapPath) ?? absoluteMapPath; + const newRelativePath = fileUtils.standardizePath( + path.relative(path.dirname(stagingFilePath), mapTarget) + ); + + const newComment = `${commentMatch.leadingInfo.trimEnd()}//# sourceMappingURL=${newRelativePath}`; + contents = contents.replace(commentMatch.fullMatch, newComment); + await fsExtra.writeFile(stagingFilePath, contents, 'utf8'); + } catch (e) { + this.logger.error(`Error updating sourceMappingURL comment in '${stagingFilePath}'`, e); + } } + private async colocateSourceMap(options: { stagingFilePath: string; absoluteMapPath: string }) { + //copy the sourcemap right next to our file + const stagingMapPath = `${options.stagingFilePath}.map`; + await fsExtra.copyFile(options.absoluteMapPath, stagingMapPath); + //delete the original sourcemap so node-debug doesn't use it + await fsExtra.unlink(options.absoluteMapPath); + await this.fixSourceMapSources({ + stagingMapPath: stagingMapPath, + originalMapPath: options.absoluteMapPath + }); + return stagingMapPath; + } + + /** * Apply the bsConst transformations to the manifest file for this project */ @@ -590,10 +749,10 @@ export class Project { return; } try { - let files = await globAsync(`${this.rdbFilesBasePath}/**/*`, { + let files: string[] = await fastGlob(`${this.rdbFilesBasePath}/**/*`, { cwd: './', absolute: false, - follow: true + followSymbolicLinks: true }); for (let filePathAbsolute of files) { const promises = []; diff --git a/src/managers/SourceMapManager.ts b/src/managers/SourceMapManager.ts index 5a80377a..ea51b0e6 100644 --- a/src/managers/SourceMapManager.ts +++ b/src/managers/SourceMapManager.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import type { SourceLocation } from './LocationManager'; import { logger } from '../logging'; import type { MaybePromise } from '../interfaces'; +import { Project } from './ProjectManager'; /** * Unifies access to source files across the whole project @@ -83,6 +84,40 @@ export class SourceMapManager { } } + /** + * Get the path to the sourcemap for a given file, either from a sourceMappingURL comment or by assuming a co-located .map file. + * + * Returns undefined if no source map is found. + * @param stagingFilePath + * @returns + */ + private async getSourceMapPath(stagingFilePath: string) { + stagingFilePath = s`${stagingFilePath}`; + let sourceMapPath = this.sourceMapPathCache.get(stagingFilePath); + if (!sourceMapPath) { + //read the file on disk and find the sourceMapURL comment (if available) + let contents: string | undefined; + try { + contents = await fsExtra.readFile(stagingFilePath, 'utf8'); + } catch { + // file doesn't exist — fall through to the colocated map assumption + } + const match = contents ? Project.getSourceMapComment(contents) : undefined; + //if we have a comment, use it + if (match) { + sourceMapPath = path.resolve(path.dirname(stagingFilePath), match.mapPath ?? ''); + + //we don't have a comment. Assume a co-located source map with the same name as the file + .map + } else { + sourceMapPath = `${stagingFilePath}.map`; + } + + this.sourceMapPathCache.set(stagingFilePath, sourceMapPath); + } + return sourceMapPath; + } + private sourceMapPathCache = new Map(); + /** * Get the source location of a position using a source map. If no source map is found, undefined is returned * @param filePath - the absolute path to the file @@ -90,8 +125,7 @@ export class SourceMapManager { * @param currentColumnIndex - the 0-based column number of the current location. */ public async getOriginalLocation(filePath: string, currentLineNumber: number, currentColumnIndex = 0): Promise { - //look for a source map for this file - let sourceMapPath = `${filePath}.map`; + const sourceMapPath = await this.getSourceMapPath(filePath); //if we have a source map, use it let parsedSourceMap = await this.getSourceMap(sourceMapPath); @@ -169,6 +203,7 @@ export class SourceMapManager { locations.push({ lineNumber: position.line, columnIndex: position.column, + //TODO this may be wrong if the sourcemap is not colocated with the generated file filePath: sourceMapPath.replace(/\.map$/g, '') }); }