From 13299158d1e22d1af1cd36434fc403a74743ecb1 Mon Sep 17 00:00:00 2001 From: Leonid Logvinov Date: Sun, 4 Mar 2018 19:05:26 -0800 Subject: Add sol-cover implementation --- packages/sol-cov/.npmignore | 5 + packages/sol-cov/CHANGELOG.md | 5 + packages/sol-cov/README.md | 1 + packages/sol-cov/package.json | 42 +++++++ packages/sol-cov/scripts/postpublish.js | 5 + packages/sol-cov/src/ast_visitor.ts | 115 ++++++++++++++++++ packages/sol-cov/src/collect_contract_data.ts | 40 +++++++ packages/sol-cov/src/constants.ts | 3 + packages/sol-cov/src/coverage_manager.ts | 166 ++++++++++++++++++++++++++ packages/sol-cov/src/coverage_subprovider.ts | 124 +++++++++++++++++++ packages/sol-cov/src/globals.d.ts | 6 + packages/sol-cov/src/index.ts | 1 + packages/sol-cov/src/instructions.ts | 24 ++++ packages/sol-cov/src/instrument_solidity.ts | 16 +++ packages/sol-cov/src/source_maps.ts | 77 ++++++++++++ packages/sol-cov/src/types.ts | 89 ++++++++++++++ packages/sol-cov/src/utils.ts | 13 ++ packages/sol-cov/tsconfig.json | 7 ++ packages/sol-cov/tslint.json | 3 + 19 files changed, 742 insertions(+) create mode 100644 packages/sol-cov/.npmignore create mode 100644 packages/sol-cov/CHANGELOG.md create mode 100644 packages/sol-cov/README.md create mode 100644 packages/sol-cov/package.json create mode 100644 packages/sol-cov/scripts/postpublish.js create mode 100644 packages/sol-cov/src/ast_visitor.ts create mode 100644 packages/sol-cov/src/collect_contract_data.ts create mode 100644 packages/sol-cov/src/constants.ts create mode 100644 packages/sol-cov/src/coverage_manager.ts create mode 100644 packages/sol-cov/src/coverage_subprovider.ts create mode 100644 packages/sol-cov/src/globals.d.ts create mode 100644 packages/sol-cov/src/index.ts create mode 100644 packages/sol-cov/src/instructions.ts create mode 100644 packages/sol-cov/src/instrument_solidity.ts create mode 100644 packages/sol-cov/src/source_maps.ts create mode 100644 packages/sol-cov/src/types.ts create mode 100644 packages/sol-cov/src/utils.ts create mode 100644 packages/sol-cov/tsconfig.json create mode 100644 packages/sol-cov/tslint.json (limited to 'packages/sol-cov') diff --git a/packages/sol-cov/.npmignore b/packages/sol-cov/.npmignore new file mode 100644 index 000000000..87bc30436 --- /dev/null +++ b/packages/sol-cov/.npmignore @@ -0,0 +1,5 @@ +.* +yarn-error.log +/src/ +/scripts/ +tsconfig.json diff --git a/packages/sol-cov/CHANGELOG.md b/packages/sol-cov/CHANGELOG.md new file mode 100644 index 000000000..c1b299ed2 --- /dev/null +++ b/packages/sol-cov/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## v0.0.1 - _TBD, 2018_ + + * Initial implementation (#TBD) diff --git a/packages/sol-cov/README.md b/packages/sol-cov/README.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/sol-cov/README.md @@ -0,0 +1 @@ + diff --git a/packages/sol-cov/package.json b/packages/sol-cov/package.json new file mode 100644 index 000000000..5aaec4a38 --- /dev/null +++ b/packages/sol-cov/package.json @@ -0,0 +1,42 @@ +{ + "name": "@0xproject/sol-cov", + "version": "0.0.1", + "description": "Generate coverage reports for solidity code", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build:watch": "tsc -w", + "lint": "tslint --project . 'src/**/*.ts'", + "clean": "shx rm -rf lib", + "build": "tsc" + }, + "repository": { + "type": "git", + "url": "https://github.com/0xProject/0x.js.git" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/0xProject/0x.js/issues" + }, + "homepage": "https://github.com/0xProject/0x.js/packages/sol-cov/README.md", + "dependencies": { + "@0xproject/subproviders": "^0.7.0", + "@0xproject/utils": "^0.3.4", + "glob": "^7.1.2", + "istanbul": "^0.4.5", + "lodash": "^4.17.4", + "solidity-coverage": "^0.4.10", + "solidity-parser-sc": "^0.4.4", + "web3": "^0.20.0" + }, + "devDependencies": { + "@0xproject/tslint-config": "^0.4.9", + "@types/istanbul": "^0.4.29", + "@types/node": "^8.0.53", + "npm-run-all": "^4.1.2", + "shx": "^0.2.2", + "tslint": "5.8.0", + "typescript": "2.7.1", + "web3-typescript-typings": "^0.9.11" + } +} diff --git a/packages/sol-cov/scripts/postpublish.js b/packages/sol-cov/scripts/postpublish.js new file mode 100644 index 000000000..b3e5407c8 --- /dev/null +++ b/packages/sol-cov/scripts/postpublish.js @@ -0,0 +1,5 @@ +const postpublish_utils = require('../../../scripts/postpublish_utils'); +const packageJSON = require('../package.json'); + +const subPackageName = packageJSON.name; +postpublish_utils.standardPostPublishAsync(subPackageName); \ No newline at end of file diff --git a/packages/sol-cov/src/ast_visitor.ts b/packages/sol-cov/src/ast_visitor.ts new file mode 100644 index 000000000..f450dadbb --- /dev/null +++ b/packages/sol-cov/src/ast_visitor.ts @@ -0,0 +1,115 @@ +import * as _ from 'lodash'; +import * as SolidityParser from 'solidity-parser-sc'; + +import { BranchMap, FnMap, LocationByOffset, SingleFileSourceRange, StatementMap } from './types'; + +export interface CoverageEntriesDescription { + fnMap: FnMap; + branchMap: BranchMap; + statementMap: StatementMap; +} + +export class ASTVisitor { + private _entryId = 0; + private _fnMap: FnMap = {}; + private _branchMap: BranchMap = {}; + private _statementMap: StatementMap = {}; + private _locationByOffset: LocationByOffset; + private static _doesLookLikeAnASTNode(ast: any): boolean { + const isAST = _.isObject(ast) && _.isString(ast.type) && _.isNumber(ast.start) && _.isNumber(ast.end); + return isAST; + } + constructor(locationByOffset: LocationByOffset) { + this._locationByOffset = locationByOffset; + } + public walkAST(astNode: SolidityParser.AST): void { + if (_.isArray(astNode) || _.isObject(astNode)) { + if (ASTVisitor._doesLookLikeAnASTNode(astNode)) { + const nodeType = astNode.type; + const visitorFunctionName = `_visit${nodeType}`; + // tslint:disable-next-line:no-this-assignment + const self: { [visitorFunctionName: string]: (ast: SolidityParser.AST) => void } = this as any; + if (_.isFunction(self[visitorFunctionName])) { + self[visitorFunctionName](astNode); + } + } + _.forEach(astNode, subtree => { + this.walkAST(subtree); + }); + } + } + public getCollectedCoverageEntries(): CoverageEntriesDescription { + const coverageEntriesDescription = { + fnMap: this._fnMap, + branchMap: this._branchMap, + statementMap: this._statementMap, + }; + return coverageEntriesDescription; + } + private _visitConditionalExpression(ast: SolidityParser.AST): void { + this._visitBinaryBranch(ast, ast.consequent, ast.alternate, 'cond-expr'); + } + private _visitFunctionDeclaration(ast: SolidityParser.AST): void { + const loc = this._getExpressionRange(ast); + this._fnMap[this._entryId++] = { + name: ast.name, + line: loc.start.line, + loc, + }; + } + private _visitBinaryExpression(ast: SolidityParser.AST): void { + this._visitBinaryBranch(ast, ast.left, ast.right, 'binary-expr'); + } + private _visitIfStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + this._visitBinaryBranch(ast, ast.consequent, ast.alternate || ast, 'if'); + } + private _visitBreakStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitContractStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitExpressionStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitForStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitPlaceholderStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitReturnStatement(ast: SolidityParser.AST): void { + this._visitStatement(ast); + } + private _visitModifierArgument(ast: SolidityParser.AST): void { + const BUILTIN_MODIFIERS = ['public', 'view', 'payable', 'external', 'internal', 'pure']; + if (!_.includes(BUILTIN_MODIFIERS, ast.name)) { + this._visitStatement(ast); + } + } + private _visitBinaryBranch( + ast: SolidityParser.AST, + left: SolidityParser.AST, + right: SolidityParser.AST, + type: 'if' | 'cond-expr' | 'binary-expr', + ): void { + this._branchMap[this._entryId++] = { + line: this._getExpressionRange(ast).start.line, + type, + locations: [this._getExpressionRange(left), this._getExpressionRange(right)], + }; + } + private _visitStatement(ast: SolidityParser.AST): void { + this._statementMap[this._entryId++] = this._getExpressionRange(ast); + } + private _getExpressionRange(ast: SolidityParser.AST): SingleFileSourceRange { + const start = this._locationByOffset[ast.start - 1]; + const end = this._locationByOffset[ast.end - 1]; + const range = { + start, + end, + }; + return range; + } +} diff --git a/packages/sol-cov/src/collect_contract_data.ts b/packages/sol-cov/src/collect_contract_data.ts new file mode 100644 index 000000000..dffccce2f --- /dev/null +++ b/packages/sol-cov/src/collect_contract_data.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import * as glob from 'glob'; +import * as _ from 'lodash'; +import * as path from 'path'; + +import { ContractData } from './types'; + +export const collectContractsData = (artifactsPath: string, sourcesPath: string, networkId: number) => { + const sourcesGlob = `${sourcesPath}/**/*.sol`; + const sourceFileNames = glob.sync(sourcesGlob, { absolute: true }); + const contractsDataIfExists: Array = _.map(sourceFileNames, sourceFileName => { + const baseName = path.basename(sourceFileName, '.sol'); + const artifactFileName = path.join(artifactsPath, `${baseName}.json`); + if (!fs.existsSync(artifactFileName)) { + // If the contract isn't directly compiled, but is imported as the part of the other contract - we don't have an artifact for it and therefore can't do anything usefull with it + return {}; + } + const artifact = JSON.parse(fs.readFileSync(artifactFileName).toString()); + const sources = _.map(artifact.networks[networkId].sources, source => { + const includedFileName = glob.sync(`${sourcesPath}/**/${source}`, { absolute: true })[0]; + return includedFileName; + }); + const sourceCodes = _.map(sources, source => { + const includedSourceCode = fs.readFileSync(source).toString(); + return includedSourceCode; + }); + const contractData = { + baseName, + sourceCodes, + sources, + sourceMap: artifact.networks[networkId].source_map, + sourceMapRuntime: artifact.networks[networkId].source_map_runtime, + runtimeBytecode: artifact.networks[networkId].runtime_bytecode, + bytecode: artifact.networks[networkId].bytecode, + }; + return contractData; + }); + const contractsData = _.filter(contractsDataIfExists, contractData => !_.isEmpty(contractData)) as ContractData[]; + return contractsData; +}; diff --git a/packages/sol-cov/src/constants.ts b/packages/sol-cov/src/constants.ts new file mode 100644 index 000000000..970734f2d --- /dev/null +++ b/packages/sol-cov/src/constants.ts @@ -0,0 +1,3 @@ +export const constants = { + NEW_CONTRACT: 'NEW_CONTRACT', +}; diff --git a/packages/sol-cov/src/coverage_manager.ts b/packages/sol-cov/src/coverage_manager.ts new file mode 100644 index 000000000..aced9208f --- /dev/null +++ b/packages/sol-cov/src/coverage_manager.ts @@ -0,0 +1,166 @@ +import * as fs from 'fs'; +import { Collector } from 'istanbul'; +import * as _ from 'lodash'; +import * as path from 'path'; + +import { collectContractsData } from './collect_contract_data'; +import { constants } from './constants'; +import { collectCoverageEntries } from './instrument_solidity'; +import { parseSourceMap } from './source_maps'; +import { + BranchCoverage, + BranchDescription, + BranchMap, + ContractData, + Coverage, + FnMap, + FunctionCoverage, + FunctionDescription, + LineColumn, + SingleFileSourceRange, + SourceRange, + StatementCoverage, + StatementDescription, + StatementMap, + TraceInfo, +} from './types'; +import { utils } from './utils'; + +function getSingleFileCoverageForTrace( + contractData: ContractData, + coveredPcs: number[], + pcToSourceRange: { [programCounter: number]: SourceRange }, + fileIndex: number, +): Coverage { + const fileName = contractData.sources[fileIndex]; + const coverageEntriesDescription = collectCoverageEntries(contractData.sourceCodes[fileIndex], fileName); + let sourceRanges = _.map(coveredPcs, coveredPc => pcToSourceRange[coveredPc]); + sourceRanges = _.compact(sourceRanges); // Some PC's don't map to a source range and we just ignore them. + sourceRanges = _.uniqBy(sourceRanges, s => JSON.stringify(s)); // We don't care if one PC was covered multiple times within a single transaction + sourceRanges = _.filter(sourceRanges, sourceRange => sourceRange.fileName === fileName); + const branchCoverage: BranchCoverage = {}; + for (const branchId of _.keys(coverageEntriesDescription.branchMap)) { + const branchDescription = coverageEntriesDescription.branchMap[branchId]; + const isCovered = _.map(branchDescription.locations, location => + _.some(sourceRanges, range => utils.isRangeInside(range.location, location)), + ); + branchCoverage[branchId] = isCovered; + } + const statementCoverage: StatementCoverage = {}; + for (const statementId of _.keys(coverageEntriesDescription.statementMap)) { + const statementDescription = coverageEntriesDescription.statementMap[statementId]; + const isCovered = _.some(sourceRanges, range => utils.isRangeInside(range.location, statementDescription)); + statementCoverage[statementId] = isCovered; + } + const functionCoverage: FunctionCoverage = {}; + for (const fnId of _.keys(coverageEntriesDescription.fnMap)) { + const functionDescription = coverageEntriesDescription.fnMap[fnId]; + const isCovered = _.some(sourceRanges, range => utils.isRangeInside(range.location, functionDescription.loc)); + functionCoverage[fnId] = isCovered; + } + const partialCoverage = { + [contractData.sources[fileIndex]]: { + ...coverageEntriesDescription, + l: {}, // It's able to derive it from statement coverage + path: fileName, + f: functionCoverage, + s: statementCoverage, + b: branchCoverage, + }, + }; + return partialCoverage; +} + +export class CoverageManager { + private _traceInfoByAddress: { [address: string]: TraceInfo[] } = {}; + private _contractsData: ContractData[] = []; + private _txDataByHash: { [txHash: string]: string } = {}; + private _getContractCodeAsync: (address: string) => Promise; + constructor( + artifactsPath: string, + sourcesPath: string, + networkId: number, + getContractCodeAsync: (address: string) => Promise, + ) { + this._getContractCodeAsync = getContractCodeAsync; + this._contractsData = collectContractsData(artifactsPath, sourcesPath, networkId); + } + public setTxDataByHash(txHash: string, data: string): void { + this._txDataByHash[txHash] = data; + } + public appendTraceInfo(address: string, traceInfo: TraceInfo): void { + if (_.isUndefined(this._traceInfoByAddress[address])) { + this._traceInfoByAddress[address] = []; + } + this._traceInfoByAddress[address].push(traceInfo); + } + public async writeCoverageAsync(): Promise { + const finalCoverage = await this._computeCoverageAsync(); + const jsonReplacer: null = null; + const numberOfJsonSpaces = 4; + const stringifiedCoverage = JSON.stringify(finalCoverage, jsonReplacer, numberOfJsonSpaces); + fs.writeFileSync('coverage/coverage.json', stringifiedCoverage); + } + private async _computeCoverageAsync(): Promise { + const collector = new Collector(); + for (const address of _.keys(this._traceInfoByAddress)) { + if (address !== constants.NEW_CONTRACT) { + // Runtime transaction + const runtimeBytecode = await this._getContractCodeAsync(address); + const contractData = _.find(this._contractsData, { runtimeBytecode }) as ContractData; + if (_.isUndefined(contractData)) { + throw new Error(`Transaction to an unknown address: ${address}`); + } + const bytecodeHex = contractData.runtimeBytecode.slice(2); + const sourceMap = contractData.sourceMapRuntime; + const pcToSourceRange = parseSourceMap( + contractData.sourceCodes, + sourceMap, + bytecodeHex, + contractData.sources, + ); + for (let fileIndex = 0; fileIndex < contractData.sources.length; fileIndex++) { + _.forEach(this._traceInfoByAddress[address], (traceInfo: TraceInfo) => { + const singleFileCoverageForTrace = getSingleFileCoverageForTrace( + contractData, + traceInfo.coveredPcs, + pcToSourceRange, + fileIndex, + ); + collector.add(singleFileCoverageForTrace); + }); + } + } else { + // Contract creation transaction + _.forEach(this._traceInfoByAddress[address], (traceInfo: TraceInfo) => { + const bytecode = this._txDataByHash[traceInfo.txHash]; + const contractData = _.find(this._contractsData, contractDataCandidate => + bytecode.startsWith(contractDataCandidate.bytecode), + ) as ContractData; + if (_.isUndefined(contractData)) { + throw new Error(`Unknown contract creation transaction`); + } + const bytecodeHex = contractData.bytecode.slice(2); + const sourceMap = contractData.sourceMap; + const pcToSourceRange = parseSourceMap( + contractData.sourceCodes, + sourceMap, + bytecodeHex, + contractData.sources, + ); + for (let fileIndex = 0; fileIndex < contractData.sources.length; fileIndex++) { + const singleFileCoverageForTrace = getSingleFileCoverageForTrace( + contractData, + traceInfo.coveredPcs, + pcToSourceRange, + fileIndex, + ); + collector.add(singleFileCoverageForTrace); + } + }); + } + } + // TODO: Submit a PR to DT + return (collector as any).getFinalCoverage(); + } +} diff --git a/packages/sol-cov/src/coverage_subprovider.ts b/packages/sol-cov/src/coverage_subprovider.ts new file mode 100644 index 000000000..ef425ee81 --- /dev/null +++ b/packages/sol-cov/src/coverage_subprovider.ts @@ -0,0 +1,124 @@ +import { Callback, NextCallback, Subprovider } from '@0xproject/subproviders'; +import { promisify } from '@0xproject/utils'; +import * as _ from 'lodash'; +import * as Web3 from 'web3'; + +import { constants } from './constants'; +import { CoverageManager } from './coverage_manager'; + +/* + * This class implements the web3-provider-engine subprovider interface and collects traces of all transactions that were sent and all calls that were executed. + * Source: https://github.com/MetaMask/provider-engine/blob/master/subproviders/subprovider.js + */ +export class CoverageSubprovider extends Subprovider { + private _coverageManager: CoverageManager; + constructor(artifactsPath: string, sourcesPath: string, networkId: number) { + super(); + this._coverageManager = new CoverageManager( + artifactsPath, + sourcesPath, + networkId, + this._getContractCodeAsync.bind(this), + ); + } + public handleRequest( + payload: Web3.JSONRPCRequestPayload, + next: NextCallback, + end: (err: Error | null, result: any) => void, + ) { + switch (payload.method) { + case 'eth_sendTransaction': + const txData = payload.params[0]; + next(this._onTransactionSentAsync.bind(this, txData)); + return; + + case 'eth_call': + const callData = payload.params[0]; + const blockNumber = payload.params[1]; + next(this._onCallExecutedAsync.bind(this, callData, blockNumber)); + return; + + default: + next(); + return; + } + } + public async writeCoverageAsync(): Promise { + await this._coverageManager.writeCoverageAsync(); + } + private async _onTransactionSentAsync( + txData: Web3.TxData, + err: Error | null, + txHash?: string, + cb?: Callback, + ): Promise { + if (_.isNull(err)) { + await this._recordTxTraceAsync(txData.to || constants.NEW_CONTRACT, txData.data, txHash as string); + } else { + const payload = { + method: 'eth_getBlockByNumber', + params: ['latest', true], + }; + const jsonRPCResponsePayload = await this.emitPayloadAsync(payload); + const transactions = jsonRPCResponsePayload.result.transactions; + for (const transaction of transactions) { + await this._recordTxTraceAsync( + transaction.to || constants.NEW_CONTRACT, + transaction.data, + transaction.hash, + ); + } + } + if (!_.isUndefined(cb)) { + cb(); + } + } + private async _onCallExecutedAsync( + callData: Partial, + blockNumber: Web3.BlockParam, + err: Error | null, + callResult: string, + cb: Callback, + ): Promise { + await this._recordCallTraceAsync(callData, blockNumber); + cb(); + } + private async _recordTxTraceAsync(address: string, data: string | undefined, txHash: string): Promise { + this._coverageManager.setTxDataByHash(txHash, data || ''); + const payload = { + method: 'debug_traceTransaction', + params: [txHash, { disableMemory: true, disableStack: true, disableStorage: true }], // TODO For now testrpc just ignores those parameters https://github.com/trufflesuite/ganache-cli/issues/489 + }; + const jsonRPCResponsePayload = await this.emitPayloadAsync(payload); + const trace: Web3.TransactionTrace = jsonRPCResponsePayload.result; + const coveredPcs = _.map(trace.structLogs, log => log.pc); + this._coverageManager.appendTraceInfo(address, { coveredPcs, txHash }); + } + private async _recordCallTraceAsync(callData: Partial, blockNumber: Web3.BlockParam): Promise { + const snapshotId = Number((await this.emitPayloadAsync({ method: 'evm_snapshot' })).result); + const txData = callData; + if (_.isUndefined(txData.from)) { + txData.from = '0x5409ed021d9299bf6814279a6a1411a7e866a631'; // TODO + } + const txDataWithFromAddress = txData as Web3.TxData & { from: string }; + try { + const txHash = (await this.emitPayloadAsync({ + method: 'eth_sendTransaction', + params: [txDataWithFromAddress], + })).result; + await this._onTransactionSentAsync(txDataWithFromAddress, null, txHash); + } catch (err) { + await this._onTransactionSentAsync(txDataWithFromAddress, err, undefined); + } + const didRevert = (await this.emitPayloadAsync({ method: 'evm_revert', params: [snapshotId] })).result; + } + private async _getContractCodeAsync(address: string): Promise { + const payload = { + method: 'eth_getCode', + params: [address, 'latest'], + }; + const jsonRPCResponsePayload = await this.emitPayloadAsync(payload); + const contractCode: string = jsonRPCResponsePayload.result; + return contractCode; + } +} diff --git a/packages/sol-cov/src/globals.d.ts b/packages/sol-cov/src/globals.d.ts new file mode 100644 index 000000000..f807730cc --- /dev/null +++ b/packages/sol-cov/src/globals.d.ts @@ -0,0 +1,6 @@ +// tslint:disable:completed-docs +declare module 'solidity-parser-sc' { + // This is too time-consuming to define and we don't rely on it anyway + export type AST = any; + export function parse(sourceCode: string): AST; +} diff --git a/packages/sol-cov/src/index.ts b/packages/sol-cov/src/index.ts new file mode 100644 index 000000000..e5c5e5be3 --- /dev/null +++ b/packages/sol-cov/src/index.ts @@ -0,0 +1 @@ +export { CoverageSubprovider } from './coverage_subprovider'; diff --git a/packages/sol-cov/src/instructions.ts b/packages/sol-cov/src/instructions.ts new file mode 100644 index 000000000..c6506e58d --- /dev/null +++ b/packages/sol-cov/src/instructions.ts @@ -0,0 +1,24 @@ +// tslint:disable:number-literal-format +const PUSH1 = 0x60; +const PUSH32 = 0x7f; +const isPush = (inst: number) => inst >= PUSH1 && inst <= PUSH32; + +const pushDataLength = (inst: number) => inst - PUSH1 + 1; + +const instructionLength = (inst: number) => (isPush(inst) ? pushDataLength(inst) + 1 : 1); + +export const getPcToInstructionIndexMapping = (bytecode: Uint8Array) => { + const result: { + [programCounter: number]: number; + } = {}; + let byteIndex = 0; + let instructionIndex = 0; + while (byteIndex < bytecode.length) { + const instruction = bytecode[byteIndex]; + const length = instructionLength(instruction); + result[byteIndex] = instructionIndex; + byteIndex += length; + instructionIndex += 1; + } + return result; +}; diff --git a/packages/sol-cov/src/instrument_solidity.ts b/packages/sol-cov/src/instrument_solidity.ts new file mode 100644 index 000000000..ac58270cb --- /dev/null +++ b/packages/sol-cov/src/instrument_solidity.ts @@ -0,0 +1,16 @@ +import * as fs from 'fs'; +import * as _ from 'lodash'; +import * as path from 'path'; +import * as SolidityParser from 'solidity-parser-sc'; + +import { ASTVisitor, CoverageEntriesDescription } from './ast_visitor'; +import { getLocationByOffset } from './source_maps'; + +export const collectCoverageEntries = (contractSource: string, fileName: string) => { + const ast = SolidityParser.parse(contractSource); + const locationByOffset = getLocationByOffset(contractSource); + const astVisitor = new ASTVisitor(locationByOffset); + astVisitor.walkAST(ast); + const coverageEntries = astVisitor.getCollectedCoverageEntries(); + return coverageEntries; +}; diff --git a/packages/sol-cov/src/source_maps.ts b/packages/sol-cov/src/source_maps.ts new file mode 100644 index 000000000..795b15a9b --- /dev/null +++ b/packages/sol-cov/src/source_maps.ts @@ -0,0 +1,77 @@ +import * as _ from 'lodash'; + +import { getPcToInstructionIndexMapping } from './instructions'; +import { LineColumn, LocationByOffset, SourceRange } from './types'; + +const RADIX = 10; + +export interface SourceLocation { + offset: number; + length: number; + fileIndex: number; +} + +export const getLocationByOffset = (str: string) => { + const locationByOffset: LocationByOffset = {}; + let currentOffset = 0; + for (const char of str.split('')) { + const location = locationByOffset[currentOffset - 1] || { line: 1, column: 0 }; + const isNewline = char === '\n'; + locationByOffset[currentOffset] = { + line: location.line + (isNewline ? 1 : 0), + column: isNewline ? 0 : location.column + 1, + }; + currentOffset++; + } + return locationByOffset; +}; + +// Parses a sourcemap string +// The solidity sourcemap format is documented here: https://github.com/ethereum/solidity/blob/develop/docs/miscellaneous.rst#source-mappings +export const parseSourceMap = (sourceCodes: string[], srcMap: string, bytecodeHex: string, sources: string[]) => { + const bytecode = Uint8Array.from(Buffer.from(bytecodeHex, 'hex')); + const pcToInstructionIndex: { [programCounter: number]: number } = getPcToInstructionIndexMapping(bytecode); + const locationByOffsetByFileIndex = _.map(sourceCodes, getLocationByOffset); + const entries = srcMap.split(';'); + const parsedEntries: SourceLocation[] = []; + let lastParsedEntry: SourceLocation = {} as any; + const instructionIndexToSourceRange: { [instructionIndex: number]: SourceRange } = {}; + _.each(entries, (entry: string, i: number) => { + const [instructionIndexStrIfExists, lengthStrIfExists, fileIndexStrIfExists, jumpTypeStrIfExists] = entry.split( + ':', + ); + const instructionIndexIfExists = parseInt(instructionIndexStrIfExists, RADIX); + const lengthIfExists = parseInt(lengthStrIfExists, RADIX); + const fileIndexIfExists = parseInt(fileIndexStrIfExists, RADIX); + const offset = _.isNaN(instructionIndexIfExists) ? lastParsedEntry.offset : instructionIndexIfExists; + const length = _.isNaN(lengthIfExists) ? lastParsedEntry.length : lengthIfExists; + const fileIndex = _.isNaN(fileIndexIfExists) ? lastParsedEntry.fileIndex : fileIndexIfExists; + const parsedEntry = { + offset, + length, + fileIndex, + }; + if (parsedEntry.fileIndex !== -1) { + const sourceRange = { + location: { + start: locationByOffsetByFileIndex[parsedEntry.fileIndex][parsedEntry.offset - 1], + end: + locationByOffsetByFileIndex[parsedEntry.fileIndex][parsedEntry.offset + parsedEntry.length - 1], + }, + fileName: sources[parsedEntry.fileIndex], + }; + instructionIndexToSourceRange[i] = sourceRange; + } else { + // Some assembly code generated by Solidity can't be mapped back to a line of source code. + // Source: https://github.com/ethereum/solidity/issues/3629 + } + lastParsedEntry = parsedEntry; + }); + const pcsToSourceRange: { [programCounter: number]: SourceRange } = {}; + for (const programCounterKey of _.keys(pcToInstructionIndex)) { + const pc = parseInt(programCounterKey, RADIX); + const instructionIndex: number = pcToInstructionIndex[pc]; + pcsToSourceRange[pc] = instructionIndexToSourceRange[instructionIndex]; + } + return pcsToSourceRange; +}; diff --git a/packages/sol-cov/src/types.ts b/packages/sol-cov/src/types.ts new file mode 100644 index 000000000..5d07cd01b --- /dev/null +++ b/packages/sol-cov/src/types.ts @@ -0,0 +1,89 @@ +export interface LineColumn { + line: number; + column: number; +} + +export interface SourceRange { + location: SingleFileSourceRange; + fileName: string; +} + +export interface SingleFileSourceRange { + start: LineColumn; + end: LineColumn; +} + +export interface LocationByOffset { + [offset: number]: LineColumn; +} + +export interface FunctionDescription { + name: string; + line: number; + loc: SingleFileSourceRange; + skip?: boolean; +} + +export type StatementDescription = SingleFileSourceRange; + +export interface BranchDescription { + line: number; + type: 'if' | 'switch' | 'cond-expr' | 'binary-expr'; + locations: SingleFileSourceRange[]; +} + +export interface FnMap { + [functionId: string]: FunctionDescription; +} + +export interface BranchMap { + [branchId: string]: BranchDescription; +} + +export interface StatementMap { + [statementId: string]: StatementDescription; +} + +export interface LineCoverage { + [lineNo: number]: boolean; +} + +export interface FunctionCoverage { + [functionId: string]: boolean; +} + +export interface StatementCoverage { + [statementId: string]: boolean; +} + +export interface BranchCoverage { + [branchId: string]: boolean[]; +} + +export interface Coverage { + [fineName: string]: { + l: LineCoverage; + f: FunctionCoverage; + s: StatementCoverage; + b: BranchCoverage; + fnMap: FnMap; + branchMap: BranchMap; + statementMap: StatementMap; + path: string; + }; +} + +export interface ContractData { + bytecode: string; + sourceMap: string; + runtimeBytecode: string; + sourceMapRuntime: string; + sourceCodes: string[]; + baseName: string; + sources: string[]; +} + +export interface TraceInfo { + coveredPcs: number[]; + txHash: string; +} diff --git a/packages/sol-cov/src/utils.ts b/packages/sol-cov/src/utils.ts new file mode 100644 index 000000000..f155043a1 --- /dev/null +++ b/packages/sol-cov/src/utils.ts @@ -0,0 +1,13 @@ +import { LineColumn, SingleFileSourceRange } from './types'; + +export const utils = { + compareLineColumn(lhs: LineColumn, rhs: LineColumn): number { + return lhs.line !== rhs.line ? lhs.line - rhs.line : lhs.column - rhs.column; + }, + isRangeInside(childRange: SingleFileSourceRange, parentRange: SingleFileSourceRange): boolean { + return ( + utils.compareLineColumn(parentRange.start, childRange.start) <= 0 && + utils.compareLineColumn(childRange.end, parentRange.end) <= 0 + ); + }, +}; diff --git a/packages/sol-cov/tsconfig.json b/packages/sol-cov/tsconfig.json new file mode 100644 index 000000000..3d967d05f --- /dev/null +++ b/packages/sol-cov/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["./src/**/*", "../../node_modules/web3-typescript-typings/index.d.ts"] +} diff --git a/packages/sol-cov/tslint.json b/packages/sol-cov/tslint.json new file mode 100644 index 000000000..ffaefe83a --- /dev/null +++ b/packages/sol-cov/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": ["@0xproject/tslint-config"] +} -- cgit v1.2.3