diff --git a/lib/modules/coverage/contract_source.js b/lib/modules/coverage/contract_source.js new file mode 100644 index 000000000..29bb10337 --- /dev/null +++ b/lib/modules/coverage/contract_source.js @@ -0,0 +1,297 @@ +const SourceMap = require('./source_map'); + +class ContractSource { + constructor(file, body) { + this.file = file; + this.body = body; + + this.lineLengths = body.split("\n").map((line) => { return line.length; }); + this.lineCount = this.lineLengths.length; + + this.lineOffsets = []; + this.lineLengths.forEach((length, line) => { + if(line == 0) { + this.lineOffsets[0] = 0; + return; + } + + // +1 here factors in newline characters. + this.lineOffsets[line] = this.lineOffsets[line-1] + this.lineLengths[line-1] + 1; + }); + + this.contracts = {}; + } + + sourceMapToLocations(sourceMap) { + var [offset, length, ..._rest] = sourceMap.split(":").map((val) => { + return parseInt(val, 10); + }); + + var locations = {}; + + for(var i = 0; i < this.lineCount; i++) { + if(this.lineOffsets[i+1] <= offset) + continue; + + locations.start = {line: i, column: offset - this.lineOffsets[i]}; + break; + } + + for(var i = locations.start.line; i < this.lineCount; i++) { + if(this.lineOffsets[i+1] <= offset + length) + continue; + + locations.end = {line: i, column: ((offset + length) - this.lineOffsets[i])}; + break; + } + + // Ensure we return an "end" as a safeguard if the marker ends up to be + // or surpass the offset for last character. + if(!locations.end) { + var lastLine = this.lineCount - 1; + locations.end = {line: lastLine, column: this.lineLengths[lastLine]}; + } + + // Istanbul likes lines to be 1-indexed, so we'll increment here before returning. + locations.start.line++; + locations.end.line++; + return locations; + } + + parseSolcOutput(source, contracts) { + this.id = source.id; + this.ast = source.ast; + this.contractBytecode = {}; + + for(var contractName in contracts) { + this.contractBytecode[contractName] = {}; + + var contract = contracts[contractName]; + var bytecodeMapping = this.contractBytecode[contractName]; + var opcodes = contract.evm.deployedBytecode.opcodes.trim().split(' '); + var sourceMaps = contract.evm.deployedBytecode.sourceMap.split(';'); + + var bytecodeIdx = 0; + var pc = 0; + var instructions = 0; + do { + var instruction = opcodes[bytecodeIdx]; + var length = this._instructionLength(instruction); + bytecodeMapping[pc] = { + instruction: instruction, + sourceMap: sourceMaps[instructions], + seen: false + } + + pc += length; + instructions++; + bytecodeIdx += (length > 1) ? 2 : 1; + } while(bytecodeIdx < opcodes.length); + } + } + + generateCodeCoverage(trace) { + if(!this.ast || !this.contractBytecode) + throw new Error('Error generating coverage: solc output was not assigned'); + + var coverage = { + l: {}, + path: this.file, + s: {}, + b: {}, + f: {}, + fnMap: {}, + statementMap: {}, + branchMap: {}, + }; + + var nodesRequiringVisiting = [this.ast]; + var nodeStack = []; + var sourceMapToNodeType = {}; + + do { + var node = nodesRequiringVisiting.pop(); + + if(!node) + continue; + + var children = []; + var markLocations = []; + + switch(node.nodeType) { + case 'Identifier': + case 'Literal': + case 'VariableDeclaration': + // We don't need to do anything with these. Just carry on. + break; + + case 'IfStatement': + coverage.b[node.id] = [0,0]; + + var location = this.sourceMapToLocations(node.src); + var trueBranchLocation = this.sourceMapToLocations(node.trueBody.src); + var falseBranchLocation = this.sourceMapToLocations(node.falseBody.src); + + coverage.branchMap[node.id] = { + type: 'if', + locations: [trueBranchLocation, falseBranchLocation], + line: location.start.line, + } + + children = [node.condition] + .concat(node.trueBody.statements) + .concat(node.falseBody.statements); + + markLocations = [location, trueBranchLocation, falseBranchLocation]; + + if(node.trueBody.statements[0]) + node.trueBody.statements[0]._parent = {type: 'b', id: node.id, idx: 0} + + if(node.falseBody.statements[0]) + node.falseBody.statements[0]._parent = {type: 'b', id: node.id, idx: 1} + + sourceMapToNodeType[node.src] = [{ + type: 'b', + id: node.id, + body: {loc: location}, + }] + + break; + + case 'BinaryOperation': + children = [node.leftExpression, node.rightExpression]; + break; + + case 'Return': + // Return is a bit of a special case, because the statement src is the + // return "body". We don't `break` here on purpose so it can share the + // same logic below. + node.src = node.expression.src; + + case 'Assignment': + case 'ExpressionStatement': + coverage.s[node.id] = 0; + + var location = this.sourceMapToLocations(node.src); + coverage.statementMap[node.id] = location; + + if(!sourceMapToNodeType[node.src]) sourceMapToNodeType[node.src] = []; + sourceMapToNodeType[node.src].push({ + type: 's', + id: node.id, + body: {loc: coverage.statementMap[node.id]}, + parent: node._parent, + }); + + + children = node.expression ? [node.expression] : []; + markLocations = [location]; + break; + + case 'ContractDefinition': + case 'SourceUnit': + children = node.nodes; + break; + + case 'PragmaDirective': + // These nodes do nothing, so we move on. + break; + + case 'FunctionDefinition': + // Istanbul only wants the function definition, not the body, so we're + // going to do some fun math here. + var functionSourceMap = new SourceMap(node.src); + var functionParametersSourceMap = new SourceMap(node.parameters.src); + + var functionDefinitionSourceMap = new SourceMap( + functionSourceMap.offset, + (functionParametersSourceMap.offset + functionParametersSourceMap.length) - functionSourceMap.offset + ).toString(); + + var fnName = node.isConstructor ? "(constructor)" : node.name; + var location = this.sourceMapToLocations(functionDefinitionSourceMap); + + coverage.f[node.id] = 0; + coverage.fnMap[node.id] = { + name: fnName, + line: location.start.line, + loc: location, + }; + + // Record function positions. + sourceMapToNodeType[node.src] = [{ + type: 'f', + id: node.id, + body: coverage.fnMap[node.id], + }]; + + children = node.body.statements; + markLocations = [location]; + break; + + default: + console.log(`Don't know how to handle node type ${node.nodeType}`); + break; + } + + nodesRequiringVisiting = nodesRequiringVisiting.concat(children); + + markLocations.forEach((location) => { + for(var i = location.start.line; i <= location.end.line; i++) { + coverage.l[i] = 0; + } + }); + + } while(nodesRequiringVisiting.length > 0); + + for(var contractName in this.contractBytecode) { + var bytecode = this.contractBytecode[contractName]; + trace.structLogs.forEach((step) => { + var step = bytecode[step.pc]; + step.seen = true; + + if(!step.sourceMap || step.sourceMap == '') + return; + + var nodes = sourceMapToNodeType[step.sourceMap] ; + + if(!nodes) return; + + var recordedLineHit = false; + + nodes.forEach((node) => { + if(node.body && node.body.loc && !recordedLineHit) { + for(var line = node.body.loc.start.line; line <= node.body.loc.end.line; line++) { + coverage.l[line]++; + } + + recordedLineHit = true; + } + + if(node.type != 'b') + coverage[node.type][node.id]++; + + if(!node.parent) + return; + + switch(node.parent.type) { + case 'b': + coverage.b[node.parent.id][node.parent.idx]++; + break; + } + }); + }); + } + + return coverage; + } + + _instructionLength(instruction) { + if(instruction.indexOf('PUSH') == -1) + return 1; + + return parseInt(instruction.match(/PUSH(\d+)/m)[1], 10) + 1; + } +} + +module.exports = ContractSource; diff --git a/lib/modules/coverage/contract_sources.js b/lib/modules/coverage/contract_sources.js new file mode 100644 index 000000000..dade388c8 --- /dev/null +++ b/lib/modules/coverage/contract_sources.js @@ -0,0 +1,51 @@ +const fs = require('fs'); + +const ContractSource = require('./contract_source'); + +class ContractSources { + constructor(files) { + if(!Array.isArray(files)) + files = [files]; + + this.files = {}; + + files.forEach((file) => { + try { + var content = fs.readFileSync(file).toString() + this.files[file] = new ContractSource(file, content); + } catch(e) { + throw new Error(`Error loading ${file}: ${e.code}`) + } + }); + } + + toSolcInputs() { + var inputs = {}; + + for(var file in this.files) { + inputs[file] = {content: this.files[file].body}; + } + + return inputs; + } + + parseSolcOutput(output) { + for(var file in output.contracts) { + var contractSource = this.files[file]; + contractSource.parseSolcOutput(output.sources[file], output.contracts[file]) + } + } + + generateCodeCoverage(trace) { + var coverageReport = {}; + + for(var file in this.files) { + var contractSource = this.files[file]; + coverageReport[file] = contractSource.generateCodeCoverage(trace); + } + + return coverageReport; + } +} + +module.exports = ContractSources; diff --git a/lib/modules/coverage/index.js b/lib/modules/coverage/index.js new file mode 100644 index 000000000..e03548dcb --- /dev/null +++ b/lib/modules/coverage/index.js @@ -0,0 +1,9 @@ +class CodeCoverage { + constructor(embark, options) { + const self = this; + + this.events = embark.events; + this.logger = embark.logger; + } +} +