From a94ee75e0a1c9bff5b0c59e5f9eaf76a3611d53f Mon Sep 17 00:00:00 2001 From: Chad Meyers Date: Tue, 21 Jun 2022 09:26:35 -0700 Subject: [PATCH] Better TypeScript debugging, support for alternate workflows other than MC's gulp template. (#13) --- CHANGELOG.md | 4 + package.json | 13 +- src/MCConfigProvider.ts | 9 +- src/MCDebugSession.ts | 42 +++++-- src/MCSourceMaps.ts | 261 ++++++++++++++++++++++------------------ 5 files changed, 187 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3002ce..a077b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,7 @@ ## Version 0.2.0 (April 2022) - Support for TypeScript debugging. + +## Version 0.3.0 (April 2022) + +- Better TypeScript support. diff --git a/package.json b/package.json index 18b3665..3b7b936 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "minecraft-debugger", "displayName": "Minecraft Bedrock Edition Debugger", "description": "Debug your JavaScript code running as part of the GameTest Framework experimental feature in Minecraft Bedrock Edition.", - "version": "0.2.0", + "version": "0.3.0", "publisher": "mojang-studios", "author": { "name": "Mojang Studios" @@ -12,7 +12,7 @@ "type": "git", "url": "https://github.com/Mojang/minecraft-debugger.git" }, - "icon": "bedrock-icon.png", + "icon": "bedrock-icon.png", "engines": { "vscode": "^1.55.0" }, @@ -64,14 +64,13 @@ "description": "The local root of the Minecraft Add-On.", "default": "${workspaceFolder}/" }, - "remoteRoot": { + "sourceMapRoot": { "type": "string", - "description": "The remote root of the Minecraft Add-On." + "description": "The location of the source maps." }, - "sourceMapRoot": { + "generatedSourceRoot": { "type": "string", - "description": "The local output folder for source maps.", - "default": "{workspaceFolder}/" + "description": "The location of the generated source files (js). Not required if same as source maps." }, "host": { "type": "string", diff --git a/src/MCConfigProvider.ts b/src/MCConfigProvider.ts index 9d6447e..dab0d6e 100644 --- a/src/MCConfigProvider.ts +++ b/src/MCConfigProvider.ts @@ -23,15 +23,8 @@ export class MCConfigProvider implements vscode.DebugConfigurationProvider { if (!config.localRoot) { config.localRoot = "${workspaceFolder}/"; } - - // default to local root - if (!config.remoteRoot) { - config.remoteRoot = config.localRoot; - } - - // if no port, then set a command string that will trigger a user input dialog if (!config.port) { - config.inputPort = "${command:PromptForPort}"; + config.inputPort = "${command:PromptForPort}"; // prompt user for port } return config; diff --git a/src/MCDebugSession.ts b/src/MCDebugSession.ts index a06fe39..02dee63 100644 --- a/src/MCDebugSession.ts +++ b/src/MCDebugSession.ts @@ -7,7 +7,7 @@ import { DebugProtocol } from 'vscode-debugprotocol'; import { LogOutputEvent, LogLevel } from 'vscode-debugadapter/lib/logger'; import { MCMessageStreamParser } from './MCMessageStreamParser'; import { MCSourceMaps } from './MCSourceMaps'; -import { window } from 'vscode'; +import { FileSystemWatcher, window, workspace } from 'vscode'; import * as path from 'path'; interface PendingResponse { @@ -20,7 +20,7 @@ interface PendingResponse { interface IAttachRequestArguments extends DebugProtocol.AttachRequestArguments { mode: string; localRoot: string; - remoteRoot: string; + generatedSourceRoot: string; sourceMapRoot: string; host: string; port: number; @@ -39,8 +39,8 @@ export class MCDebugSession extends DebugSession { private _terminated: boolean = false; private _threads = new Set(); private _requests = new Map(); - private _sourceMaps: MCSourceMaps = new MCSourceMaps("", ""); - private _localRoot: string = ""; + private _sourceMaps: MCSourceMaps = new MCSourceMaps(""); + private _fileWatcher?: FileSystemWatcher; private _activeThreadId: number = 0; // the one being debugged public constructor() { @@ -72,11 +72,6 @@ export class MCDebugSession extends DebugSession { // VSCode wants to attach to a debugee (MC), create socket connection on specified port protected async attachRequest(response: DebugProtocol.AttachResponse, args: IAttachRequestArguments, request?: DebugProtocol.Request) { - // capture arguments from launch.json - this._localRoot = path.normalize(args.localRoot); - // init source maps - this._sourceMaps = new MCSourceMaps(args.localRoot, args.remoteRoot, args.sourceMapRoot); - this.closeSession(); const host = args.host || 'localhost'; @@ -101,6 +96,12 @@ export class MCDebugSession extends DebugSession { return; } + // init source maps + this._sourceMaps = new MCSourceMaps(args.localRoot, args.sourceMapRoot, args.generatedSourceRoot); + + // watch for source map changes + this.createSourceMapFileWatcher(args.sourceMapRoot); + // tell VSCode that attach is complete this.sendResponse(response); } @@ -194,8 +195,7 @@ export class MCDebugSession extends DebugSession { line: line || 0, column: column || 0 }); - let localOriginalAbsolutePath = path.join(this._localRoot, originalLocation.source); - const source = new Source(path.basename(originalLocation.source), localOriginalAbsolutePath); + const source = new Source(path.basename(originalLocation.source), originalLocation.source); stackFrames.push(new StackFrame(id, name, source, originalLocation.line, originalLocation.column)); } catch (e) { @@ -380,6 +380,11 @@ export class MCDebugSession extends DebugSession { this._connectionSocket.destroy(); } this._connectionSocket = undefined; + + if (this._fileWatcher) { + this._fileWatcher.dispose(); + this._fileWatcher = undefined; + } } // close and terminate session (could be from debugee request) @@ -439,7 +444,7 @@ export class MCDebugSession extends DebugSession { // json = 1 line json + new line let messageLength = jsonBuffer.byteLength + 1; let length = '00000000' + messageLength.toString(16) + '\n'; - length = length.substr(length.length - 9); + length = length.substring(length.length - 9); let lengthBuffer = Buffer.from(length); let newline = Buffer.from('\n'); let buffer = Buffer.concat([lengthBuffer, jsonBuffer, newline]); @@ -517,6 +522,19 @@ export class MCDebugSession extends DebugSession { } } + private createSourceMapFileWatcher(sourceMapRoot?: string) { + if (this._fileWatcher) { + this._fileWatcher.dispose(); + this._fileWatcher = undefined + } + if (sourceMapRoot) { + this._fileWatcher = workspace.createFileSystemWatcher('**/*.{map}', false, false, false); + this._fileWatcher.onDidChange(uri => { this._sourceMaps.reset() }); + this._fileWatcher.onDidCreate(uri => { this._sourceMaps.reset() }); + this._fileWatcher.onDidDelete(uri => { this._sourceMaps.reset() }); + } + } + // ------------------------------------------------------------------------ private log(message: string, logLevel: LogLevel) { diff --git a/src/MCSourceMaps.ts b/src/MCSourceMaps.ts index 2c1371b..999af56 100644 --- a/src/MCSourceMaps.ts +++ b/src/MCSourceMaps.ts @@ -1,164 +1,199 @@ // Copyright (C) Microsoft Corporation. All rights reserved. -import { BasicSourceMapConsumer, MappedPosition, NullableMappedPosition, NullablePosition, Position, SourceMapConsumer } from 'source-map'; +import { BasicSourceMapConsumer, MappedPosition, NullablePosition, SourceMapConsumer } from 'source-map'; import * as fs from 'fs'; import * as path from 'path'; -// Loaded/cached source map -class MapInfo { - private _mapFilePath: string; - private _generatedRemoteRelativePath: string; - private _sourceMap: BasicSourceMapConsumer; +interface MapInfo { + mapAbsoluteDirectory: string // full path to parent folder of map file, needed when combining with relative paths within map + originalSourceRelativePath: string; // original source ts that generated the js, must match path found in map + generatedSourceRelativePath: string; // relative path to the local generated js file + sourceMap: BasicSourceMapConsumer; // the source map +} + +function sanitizeDelimitersForRemote(filePath: string) { + // remote debugger expects forward slashes on all platforms + return filePath.replace(/\\/g,"/"); +} - public get mapFilePath() { return this._mapFilePath; } - public get generatedPath() { return this._generatedRemoteRelativePath; } +// Load and cache source map files +class SourceMapCache { + private static readonly _mapFileExt: string = ".map"; + private _sourceMapRoot?: string; + public _generatedSourceRoot?: string; + private _mapsLoaded: boolean = false; + public _originalSourcePathToMapLookup = new Map(); + public _generatedSourcePathToMapLookup = new Map(); - public constructor(mapFilePath: string, generatedRemoteRelativePath: string, sourceMap: BasicSourceMapConsumer) { - this._mapFilePath = mapFilePath; - this._generatedRemoteRelativePath = generatedRemoteRelativePath; - this._sourceMap = sourceMap; + public constructor(sourceMapRoot?: string, generatedSourceRoot?: string) { + this._sourceMapRoot = (sourceMapRoot) ? path.normalize(sourceMapRoot) : undefined; + this._generatedSourceRoot = (generatedSourceRoot) ? path.normalize(generatedSourceRoot) : undefined; } - public originalPositionFor(generatedPosition: Position & { bias?: number }): NullableMappedPosition { - return this._sourceMap.originalPositionFor({ - column: generatedPosition.column, - line: generatedPosition.line, - bias: SourceMapConsumer.LEAST_UPPER_BOUND - }); + public reset() { + this._mapsLoaded = false; + this._originalSourcePathToMapLookup.clear(); + this._generatedSourcePathToMapLookup.clear(); } - public generatedPositionFor(originalPosition: MappedPosition & { bias?: number }): NullablePosition { - return this._sourceMap.generatedPositionFor({ - source: this.sanitizePathForMapLookup(originalPosition.source), - line: originalPosition.line, - column: originalPosition.column, - bias: SourceMapConsumer.LEAST_UPPER_BOUND - });; + public async getMapFromOriginalSource(originalSource: string) { + await this._loadSourceMaps(); + return this._originalSourcePathToMapLookup.get(path.normalize(originalSource)); } - private sanitizePathForMapLookup(filePath: string): string { - return filePath.replace(/\\/g,"/"); // source map data uses forward slashes internally + public async getMapFromGeneratedSource(generatedSource: string) { + await this._loadSourceMaps(); + return this._generatedSourcePathToMapLookup.get(path.normalize(generatedSource)); } -} -// Load and cache source map files -class SourceMapCache { - private _remoteRoot: string; - public _mapInfoList = new Array(); + private async _loadSourceMaps() { + if (this._mapsLoaded || !this._sourceMapRoot) { + return; + } - public constructor(remoteRoot: string) { - this._remoteRoot = path.normalize(remoteRoot); - } + // assume generated js files live with map files unless explicitly set otherwise + if (this._generatedSourceRoot == undefined) { + this._generatedSourceRoot = this._sourceMapRoot; + } - public async tryGetSourceMap(mapFilePath: string) { try { - let mapInfo = this.findSourceMap(mapFilePath); - if (!mapInfo) { - let mapBuffer = fs.readFileSync(mapFilePath); + const mapFileNames = this._findAllMapFilesInFolder(this._sourceMapRoot, undefined); + for (let mapFileName of mapFileNames) { + + const mapFullPath = path.resolve(this._sourceMapRoot, mapFileName); + let mapBuffer = fs.readFileSync(mapFullPath); let mapJson = JSON.parse(mapBuffer.toString()); let sourceMapConsumer = await new SourceMapConsumer(mapJson); - let mapDir = path.dirname(mapFilePath); - let generatedFileAbsolutePath = path.resolve(mapDir, sourceMapConsumer.file); - let generatedRemoteRelativePath = path.relative(this._remoteRoot, generatedFileAbsolutePath); - mapInfo = new MapInfo(mapFilePath, generatedRemoteRelativePath, sourceMapConsumer); - this._mapInfoList.push(mapInfo); + + // map has relative path to generated source, resolve for absolute path + let generatedSourceAbsolutePath = path.resolve(path.dirname(mapFullPath), sourceMapConsumer.file); + let generatedSourceRelativePath = path.relative(this._generatedSourceRoot, generatedSourceAbsolutePath); + + // generate lookup tables for source maps, original to remote and remote to original + for (let originalSource of sourceMapConsumer.sources) { + + // generate relative path from map to ts file + let originalSourceRelative = sanitizeDelimitersForRemote(originalSource); + // map has relative path back to original source, resolve for absolute path + let originalSourceAbsolutePath = path.resolve(this._sourceMapRoot, originalSourceRelative); + + // collect all relevant path info, used for resolving original->generated and generated->original + let mapInfo: MapInfo = { + mapAbsoluteDirectory: path.dirname(mapFullPath), + originalSourceRelativePath: originalSourceRelative, + generatedSourceRelativePath: generatedSourceRelativePath, + sourceMap: sourceMapConsumer + }; + + // create lookups using absolute paths of original and generated sources to map + this._originalSourcePathToMapLookup.set(originalSourceAbsolutePath.toLowerCase(), mapInfo); + + // multiple original sources can end up in a single generated file, but only 1 generated file will exist for a given map + if (!this._generatedSourcePathToMapLookup.has(generatedSourceRelativePath.toLowerCase())) { + this._generatedSourcePathToMapLookup.set(generatedSourceRelativePath.toLowerCase(), mapInfo); + } + } } - return mapInfo; } catch (e) { - throw new Error(`Failed to load source map at ${mapFilePath}, check that 'sourceMapRoot' is set correctly.`); + throw new Error(`Failed to load source maps at [${this._sourceMapRoot}], check that 'sourceMapRoot' is set correctly.`); } + + this._mapsLoaded = true; } - private findSourceMap(mapFilePath: string) { - for (let sm of this._mapInfoList) { - if (sm.mapFilePath === mapFilePath) { - return sm; - } - } - return null; + private _findAllMapFilesInFolder(dirPath: string, existingFiles?: Array): Array { + let fileNames = fs.readdirSync(dirPath); + let allFiles = existingFiles || []; + fileNames.forEach((file) => { + const fullPath = path.join(dirPath, file); + if (fs.statSync(fullPath).isDirectory()) { + allFiles = this._findAllMapFilesInFolder(fullPath, allFiles); + } + else if (path.extname(file) === SourceMapCache._mapFileExt) { + allFiles.push(fullPath); + } + }); + return allFiles; } } // Source map manager, responsible for loading source maps and translating // from original to generated positions and back again. export class MCSourceMaps { + private REMOTE_SOURCE_PATH_PREFIX = "scripts"; // TODO: remove me private _localRoot: string; - private _remoteRoot: string; private _sourceMapRoot?: string; private _sourceMapCache: SourceMapCache; - public constructor(localRoot: string, remoteRoot: string, sourceMapRoot?: string) { + public constructor(localRoot: string, sourceMapRoot?: string, generatedSourceRoot?: string) { this._localRoot = path.normalize(localRoot); - this._remoteRoot = path.normalize(remoteRoot); this._sourceMapRoot = (sourceMapRoot) ? path.normalize(sourceMapRoot) : undefined; - this._sourceMapCache = new SourceMapCache(this._remoteRoot); + this._sourceMapCache = new SourceMapCache(this._sourceMapRoot, generatedSourceRoot); } - public async getGeneratedRemoteRelativePath(originalSource: string): Promise { - // only interested in the name of the generated source, pass in dummy position values - let generatedSource = await this.getGeneratedPositionFor({ - source: originalSource, - line: 1, - column: 0 - }); - return generatedSource.source; + public reset() { + this._sourceMapCache.reset(); } - public async getGeneratedPositionFor(originalPosition: MappedPosition): Promise { - let originalLocalRelativePath = path.relative(this._localRoot, originalPosition.source); - - let originalLocalRelativePosition: MappedPosition = Object.assign({}, originalPosition); - originalLocalRelativePosition.source = originalLocalRelativePath; - - // no source maps is ok unless this is a .ts file - if (!this._sourceMapRoot) { - if (path.extname(originalLocalRelativePath) != '.ts') { - return originalLocalRelativePosition; // no source maps, return original position - } - throw new Error(`Could not map position, 'sourceMapRoot' not defined.`); + public async getGeneratedRemoteRelativePath(originalSource: string): Promise { + let mapInfo = await this._sourceMapCache.getMapFromOriginalSource(originalSource); + if (!mapInfo || !this._sourceMapRoot) { + // no source map, convert to remote relative path suitable for debugger. + return sanitizeDelimitersForRemote(path.relative(this._localRoot, originalSource)); } - let mapFilePath = this.mapFilePathFromOriginalSource(this._sourceMapRoot, originalLocalRelativePath); - let mapInfo = await this._sourceMapCache.tryGetSourceMap(mapFilePath); - if (mapInfo) { - // get generated position from original - let generatedPosition = mapInfo.generatedPositionFor({ - source: originalLocalRelativePosition.source, - line: originalLocalRelativePosition.line, - column: originalLocalRelativePosition.column - }); + // given absolute path to generated source, convert to a remote relative path the debugger understands + let generatedRemoteRelativePath = this._addRemotePathPrefix_HACK(mapInfo.generatedSourceRelativePath); + return sanitizeDelimitersForRemote(generatedRemoteRelativePath); + } + + public async getGeneratedPositionFor(originalPosition: MappedPosition): Promise { + let mapInfo = await this._sourceMapCache.getMapFromOriginalSource(originalPosition.source); + if (!mapInfo) { + // no source maps, return original position as is return { - source: mapInfo.generatedPath, - line: generatedPosition.line || 0, - column: generatedPosition.column || 0 - }; + line: originalPosition.line, + column: originalPosition.column, + lastColumn: null + } } - throw new Error(`Could not map generated position for ${originalPosition.source} at line ${originalPosition.line}.`); + // use the map to get the generated source (js) position using original source path (a relative path to the map) + let generatedPosition = mapInfo.sourceMap.generatedPositionFor({ + source: mapInfo.originalSourceRelativePath, + line: originalPosition.line, + column: originalPosition.column, + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }); + + return generatedPosition; } public async getOriginalPositionFor(generatedPosition: MappedPosition): Promise { - // no source maps, original position is same as generated if (!this._sourceMapRoot) { - return generatedPosition; + // no source maps, convert remote relative path to local absolute + let originalLocalRelativePosition: MappedPosition = Object.assign({}, generatedPosition); + originalLocalRelativePosition.source = path.resolve(this._localRoot, generatedPosition.source); + return originalLocalRelativePosition; } - let mapFilePath = this.mapFilePathFromGeneratedSource(this._sourceMapRoot, generatedPosition.source); - let mapInfo = await this._sourceMapCache.tryGetSourceMap(mapFilePath); + let mapInfo = await this._sourceMapCache.getMapFromGeneratedSource(this._removeRemotePathPrefix_HACK(generatedPosition.source)); if (mapInfo) { - // get original position from generated - const originalPosition = mapInfo.originalPositionFor({ + let originalPos = mapInfo.sourceMap.originalPositionFor({ column: generatedPosition.column, - line: generatedPosition.line + line: generatedPosition.line, + bias: SourceMapConsumer.LEAST_UPPER_BOUND }); - // return if original position was found, else throw error - if (originalPosition.line !== null && originalPosition.column !== null && originalPosition.source !== null) { + + if (originalPos.line !== null && originalPos.column !== null && originalPos.source !== null) { + // combine directory of map and relative path from map to .ts to arrive at absolute path of .ts + let originalSourceAbsolutePath = path.resolve(mapInfo.mapAbsoluteDirectory, originalPos.source); return { - source: originalPosition.source, - line: originalPosition.line, - column: originalPosition.column + source: originalSourceAbsolutePath, + line: originalPos.line, + column: originalPos.column }; } } @@ -166,19 +201,15 @@ export class MCSourceMaps { throw new Error(`Could not map original position for ${generatedPosition.source} at line ${generatedPosition.line}.`); } - private mapFilePathFromGeneratedSource(sourceMapRoot: string, generatedSource: string): string { - let generatedSourceWithoutPrefix = generatedSource.split('/').slice(1).join('/'); // remove the /scripts/ prefix required by MC remote paths - let mapFileAbsolutePath = path.join(sourceMapRoot, generatedSourceWithoutPrefix + ".map"); - return mapFileAbsolutePath; - } - - private mapFilePathFromOriginalSource(sourceMapRoot: string, originalSource: string): string { - let originalLocalRelativePathNoExt = this.pathRemoveExtension(originalSource); // the .ts file - let mapFilePath = path.join(sourceMapRoot, originalLocalRelativePathNoExt + ".js.map"); // rooted to sourcmaps folder - return mapFilePath; + // TODO: remove this after fixing internal root path of MC scripts + private _removeRemotePathPrefix_HACK(filePath: string) { + // remove the required "/scripts/" prefix from the generated sources when coming back from debugger + return filePath.split('/').slice(1).join('/'); } - private pathRemoveExtension(fullPath: string): string { - return fullPath.split('.').slice(0, -1).join('.'); + // TODO: remove this + private _addRemotePathPrefix_HACK(filePath: string) { + // required to prepend "/scripts/" to generated sources for remote debugger + return path.join(this.REMOTE_SOURCE_PATH_PREFIX, filePath); } }