diff --git a/src/logic/sourcemap.ts b/src/logic/sourcemap.ts index b00564911..932681c06 100644 --- a/src/logic/sourcemap.ts +++ b/src/logic/sourcemap.ts @@ -1,13 +1,46 @@ import * as vlq from 'vlq'; -export class SourceMap { - version: number; - sources: string[]; - names: string[]; - mappings: string; +/** + * Represents a location in a source file. + */ +export interface SourceLocation { + line: number; + column: number; + sourceIndex: number; + nameIndex?: number; +} + +/** + * Represents the location of a specific PC in a source line. + */ +export interface PcLineLocation { + pc: number; + column: number; + nameIndex?: number; +} + +/** + * Contains a mapping from TEAL program PC to source file location. + */ +export class ProgramSourceMap { + public readonly version: number; + /** + * A list of original sources used by the "mappings" entry. + */ + public readonly sources: string[]; + /** + * A list of symbol names used by the "mappings" entry. + */ + public readonly names: string[]; + /** + * A string with the encoded mapping data. + */ + public readonly mappings: string; - pcToLine: { [key: number]: number }; - lineToPc: { [key: number]: number[] }; + private pcToLocation: Map; + + // Key is `${sourceIndex}:${line}` + private sourceAndLineToPc: Map; constructor({ version, @@ -33,35 +66,67 @@ export class SourceMap { 'mapping undefined, cannot build source map without `mapping`' ); - const pcList = this.mappings.split(';').map((m) => { - const decoded = vlq.decode(m); - if (decoded.length > 2) return decoded[2]; - return undefined; - }); - - this.pcToLine = {}; - this.lineToPc = {}; - - let lastLine = 0; - for (const [pc, lineDelta] of pcList.entries()) { - // If the delta is not undefined, the lastLine should be updated with - // lastLine + the delta - if (lineDelta !== undefined) { - lastLine += lineDelta; + const pcList = this.mappings.split(';').map(vlq.decode); + + this.pcToLocation = new Map(); + this.sourceAndLineToPc = new Map(); + + const lastLocation: SourceLocation = { + line: 0, + column: 0, + sourceIndex: 0, + nameIndex: 0, + }; + for (const [pc, data] of pcList.entries()) { + if (data.length < 4) continue; + + const nameDelta = data.length > 4 ? data[4] : undefined; + const [, sourceDelta, lineDelta, columnDelta] = data; + + lastLocation.sourceIndex += sourceDelta; + lastLocation.line += lineDelta; + lastLocation.column += columnDelta; + if (typeof nameDelta !== 'undefined') { + lastLocation.nameIndex += nameDelta; } - if (!(lastLine in this.lineToPc)) this.lineToPc[lastLine] = []; + const sourceAndLineKey = `${lastLocation.sourceIndex}:${lastLocation.line}`; + let pcsForSourceAndLine = this.sourceAndLineToPc.get(sourceAndLineKey); + if (pcsForSourceAndLine === undefined) { + pcsForSourceAndLine = []; + this.sourceAndLineToPc.set(sourceAndLineKey, pcsForSourceAndLine); + } - this.lineToPc[lastLine].push(pc); - this.pcToLine[pc] = lastLine; + const pcInLine: PcLineLocation = { + pc, + column: lastLocation.column, + }; + const pcLocation: SourceLocation = { + line: lastLocation.line, + column: lastLocation.column, + sourceIndex: lastLocation.sourceIndex, + }; + if (typeof nameDelta !== 'undefined') { + pcInLine.nameIndex = lastLocation.nameIndex; + pcLocation.nameIndex = lastLocation.nameIndex; + } + + pcsForSourceAndLine.push(pcInLine); + this.pcToLocation.set(pc, pcLocation); } } - getLineForPc(pc: number): number | undefined { - return this.pcToLine[pc]; + getPcs(): number[] { + return Array.from(this.pcToLocation.keys()); + } + + getLocationForPc(pc: number): SourceLocation | undefined { + return this.pcToLocation.get(pc); } - getPcsForLine(line: number): number[] | undefined { - return this.lineToPc[line]; + getPcsOnSourceLine(sourceIndex: number, line: number): PcLineLocation[] { + const pcs = this.sourceAndLineToPc.get(`${sourceIndex}:${line}`); + if (pcs === undefined) return []; + return pcs; } } diff --git a/src/main.ts b/src/main.ts index be5b92d12..26ed02cfe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -183,7 +183,11 @@ export { verifyMultisig, multisigAddress, } from './multisig'; -export { SourceMap } from './logic/sourcemap'; +export { + ProgramSourceMap, + SourceLocation, + PcLineLocation, +} from './logic/sourcemap'; export * from './dryrun'; export * from './makeTxn'; diff --git a/tests/8.LogicSig.ts b/tests/8.LogicSig.ts index c5db8fbf4..7cff3ed59 100644 --- a/tests/8.LogicSig.ts +++ b/tests/8.LogicSig.ts @@ -690,3 +690,116 @@ describe('signLogicSigTransaction', () => { assert.deepStrictEqual(actual, expected); }); }); + +describe('ProgramSourceMap', () => { + const input = { + version: 3, + sources: ['test/scripts/e2e_subs/tealprogs/sourcemap-test.teal'], + names: [], + mappings: + ';;;;AAGA;;AAAmB;;AAAO;AAAI;;;AAE9B;;AAAO;;AAAO;AACd;;AAAO;AAAO;AALO;AAAI;AASrB;AACA', + }; + + const expectedLocations = new Map([ + [4, { sourceIndex: 0, line: 3, column: 0 }], + [6, { sourceIndex: 0, line: 3, column: 19 }], + [8, { sourceIndex: 0, line: 3, column: 26 }], + [9, { sourceIndex: 0, line: 3, column: 30 }], + [12, { sourceIndex: 0, line: 5, column: 0 }], + [14, { sourceIndex: 0, line: 5, column: 7 }], + [16, { sourceIndex: 0, line: 5, column: 14 }], + [17, { sourceIndex: 0, line: 6, column: 0 }], + [19, { sourceIndex: 0, line: 6, column: 7 }], + [20, { sourceIndex: 0, line: 6, column: 14 }], + [21, { sourceIndex: 0, line: 1, column: 21 }], + [22, { sourceIndex: 0, line: 1, column: 25 }], + [23, { sourceIndex: 0, line: 10, column: 4 }], + [24, { sourceIndex: 0, line: 11, column: 4 }], + ]); + + const expectedPcsForLine = new Map([ + [ + 3, + [ + { pc: 4, column: 0 }, + { pc: 6, column: 19 }, + { pc: 8, column: 26 }, + { pc: 9, column: 30 }, + ], + ], + [ + 5, + [ + { pc: 12, column: 0 }, + { pc: 14, column: 7 }, + { pc: 16, column: 14 }, + ], + ], + [ + 6, + [ + { pc: 17, column: 0 }, + { pc: 19, column: 7 }, + { pc: 20, column: 14 }, + ], + ], + [ + 1, + [ + { pc: 21, column: 21 }, + { pc: 22, column: 25 }, + ], + ], + [10, [{ pc: 23, column: 4 }]], + [11, [{ pc: 24, column: 4 }]], + ]); + + it('should be able to read a ProgramSourceMap', () => { + const sourceMap = new algosdk.ProgramSourceMap(input); + + assert.strictEqual(sourceMap.version, input.version); + assert.deepStrictEqual(sourceMap.sources, input.sources); + assert.deepStrictEqual(sourceMap.names, input.names); + assert.strictEqual(sourceMap.mappings, input.mappings); + }); + + describe('getLocationForPc', () => { + it('should return the correct location for all pcs', () => { + const sourceMap = new algosdk.ProgramSourceMap(input); + const maxPcToCheck = 30; + + for (let pc = 0; pc < maxPcToCheck; pc++) { + const expected = expectedLocations.get(pc); + assert.deepStrictEqual( + sourceMap.getLocationForPc(pc), + expected, + `pc=${pc}` + ); + } + }); + }); + + describe('getPcs', () => { + it('should return the correct pcs', () => { + const sourceMap = new algosdk.ProgramSourceMap(input); + const expectedPcs = Array.from(expectedLocations.keys()); + assert.deepStrictEqual(sourceMap.getPcs(), expectedPcs); + }); + }); + + describe('getPcsForLine', () => { + it('should return the correct pcs for all lines', () => { + const sourceMap = new algosdk.ProgramSourceMap(input); + const maxLineToCheck = 15; + + for (let line = 1; line <= maxLineToCheck; line++) { + const expected = expectedPcsForLine.get(line) || []; + assert.deepStrictEqual( + sourceMap.getPcsOnSourceLine(0, line), + expected, + `line=${line}` + ); + } + }); + }); +}); diff --git a/tests/cucumber/steps/steps.js b/tests/cucumber/steps/steps.js index 02b52ff5d..95dedc706 100644 --- a/tests/cucumber/steps/steps.js +++ b/tests/cucumber/steps/steps.js @@ -4694,32 +4694,41 @@ module.exports = function getSteps(options) { Given('a source map json file {string}', async function (srcmap) { const js = await loadResourceAsJson(srcmap); - this.sourcemap = new algosdk.SourceMap(js); + this.sourcemap = new algosdk.ProgramSourceMap(js); }); - Then( - 'the string composed of pc:line number equals {string}', - function (mapping) { - const buff = Object.entries(this.sourcemap.pcToLine).map( - ([pc, line]) => `${pc}:${line}` - ); - assert.deepStrictEqual(buff.join(';'), mapping); - } - ); + Then('the source map contains pcs {string}', function (pcsString) { + const expectedPcs = makeArray( + ...pcsString.split(',').map((pc) => parseInt(pc, 10)) + ); + const actualPcs = makeArray(...this.sourcemap.getPcs()); + assert.deepStrictEqual(actualPcs, expectedPcs); + }); Then( - 'getting the line associated with a pc {string} equals {string}', - function (pc, expectedLine) { - const actualLine = this.sourcemap.getLineForPc(parseInt(pc)); - assert.deepStrictEqual(actualLine, parseInt(expectedLine)); + 'the source map maps pc {int} to line {int} and column {int} of source {string}', + function (pc, expectedLine, expectedColumn, source) { + const actual = this.sourcemap.getLocationForPc(pc); + assert.ok(actual); + assert.strictEqual(actual.line, expectedLine); + assert.strictEqual(actual.column, expectedColumn); + assert.strictEqual(this.sourcemap.sources[actual.sourceIndex], source); } ); Then( - 'getting the last pc associated with a line {string} equals {string}', - function (line, expectedPc) { - const actualPcs = this.sourcemap.getPcsForLine(parseInt(line)); - assert.deepStrictEqual(actualPcs.pop(), parseInt(expectedPc)); + 'the source map maps source {string} and line {int} to pc {int} at column {int}', + function (source, line, pc, expectedColumn) { + const sourceIndex = this.sourcemap.sources.indexOf(source); + assert.ok(sourceIndex >= 0); + const actualPcs = this.sourcemap.getPcsOnSourceLine(sourceIndex, line); + for (const actualPcInfo of actualPcs) { + if (actualPcInfo.pc === pc) { + assert.strictEqual(actualPcInfo.column, expectedColumn); + return; + } + } + throw new Error(`Could not find pc ${pc}`); } ); diff --git a/tests/cucumber/unit.tags b/tests/cucumber/unit.tags index 5c66cad6c..1dd481160 100644 --- a/tests/cucumber/unit.tags +++ b/tests/cucumber/unit.tags @@ -27,7 +27,7 @@ @unit.responses.timestamp @unit.responses.txid.json @unit.responses.txngroupdeltas.json -@unit.sourcemap +@unit.sourcemapv2 @unit.stateproof.paths @unit.stateproof.responses @unit.stateproof.responses.msgp