coffee-coverage
Version:
Istanbul and JSCoverage-style instrumentation for CoffeeScript files.
498 lines (457 loc) • 19.9 kB
JavaScript
// Generated by CoffeeScript 2.3.2
(function() {
// This is an instrumentor which provides [Istanbul](https://github.com/gotwarlost/istanbul) style
// instrumentation. This will add a JSON report object to the source code. The report object is a
// hash where keys are file names (absolute paths), and values are coverage data for that file
// (the result of `json.stringify(collector.fileCoverageFor(filename))`) Each coverage data
// consists of:
// * `path` - The path to the file. This is an absolute path.
// * `s` - Hash of statement counts, where keys as statement IDs.
// * `b` - Hash of branch counts, where keys are branch IDs and values are arrays of counts.
// For an if statement, the value would have two counts; one for the if, and one for the
// else. Switch statements would have an array of values for each case.
// * `f` - Hash of function counts, where keys are function IDs.
// * `fnMap` - Hash of functions where keys are function IDs, and values are `{name, line, loc}`,
// where `name` is the name of the function, `line` is the line the function is declared on,
// and `loc` is the `Location` of the function declaration (just the declaration, not the entire
// function body.)
// * `statementMap` - Hash where keys are statement IDs, and values are `Location` objects for each
// statement. The `Location` for a function definition is really an assignment, and should
// include the entire function.
// * `branchMap` - Hash where keys are branch IDs, and values are `{line, type, locations}` objects.
// `line` is the line the branch starts on. `type` is the type of the branch (e.g. "if", "switch").
// `locations` is an array of `Location` objects, one for each possible outcome of the branch.
// Note for an `if` statement where there is no `else` clause, there will still be two `locations`
// generated. Istanbul does *not* generate coverage for the `default` case of a switch statement
// if `default` is not explicitly present in the source code.
// `locations` for an if statement are always 0-length and located at the start of the `if` (even
// the location for the "else"). For a `switch` statement, `locations` start at the start of the
// `case` statement and go to the end of the line before the next case statement (note Istanbul
// does nothing clever here if a `case` is missing a `break`.)
// ## Location Objects
// Location objects are a `{start: {line, column}, end: {line, column}, skip}` object that describes
// the start and end of a piece of code. Note that `line` is 1-based, but `column` is 0-based.
// `skip` is optional - if true it instructs Istanbul to ignore if this location has no executions.
// An `### istanbul ignore next ###` before a statement would cause that statement's location
// in the `staementMap` to be marked `skip: true`. For an `if` or a `switch`, this should also
// cause all desendant statments to be marked `skip`, as well as all locations in the `branchMap`.
// An `### istanbul ignore if ###` should cause the loction for the `if` in the `branchMap` to be
// marked `skip`, along with all statements inside the `if`. Similar for
// `### istanbul ignore else ###`.
// An `### istanbul ignore next ###` before a `when` in a `switch` should cause the appropriate
// entry in the `branchMap` to be marked skip, and all statements inside the `when`.
// (coffeescript doesn't allow block comments at top scope inside a switch. Might not be
// able to do this.)
// An `### istanbul ignore next ###` before a function declaration should cause the function (not
// the location) in the `fnMap` to be marked `skip`, the statement for the function delcaration and
// all statements in the function to be marked `skip` in the `statementMap`.
var Istanbul, NodeWrapper, _, assert, compareLocations, fileToLines, findInCode, minLocation, nodeToLocation, toQuotedString;
assert = require('assert');
_ = require('lodash');
NodeWrapper = require('../NodeWrapper');
({toQuotedString} = require('../utils/helpers'));
({compareLocations, fileToLines, minLocation} = require('../utils/codeUtils'));
nodeToLocation = function(node) {
var answer, ref;
// Istanbul uses 1-based lines, but 0-based columns
answer = {
start: {
line: node.locationData.first_line + 1,
column: node.locationData.first_column
},
end: {
line: node.locationData.last_line + 1,
column: node.locationData.last_column
}
};
if (((ref = node.coffeeCoverage) != null ? ref.skip : void 0) || (typeof node.isMarked === "function" ? node.isMarked('skip') : void 0)) {
answer.skip = true;
}
return answer;
};
// Find a string in the source code, and return a `{line, column}`.
// Line is 1-based and column is 0-based.
findInCode = function(code, str, options = {}) {
var column, currentCol, currentLine, end, ref, ref1, start;
start = (ref = options.start) != null ? ref : {
line: 1,
column: 0
};
end = (ref1 = options.end) != null ? ref1 : {
line: code.length + 1,
column: 0
};
currentLine = start.line;
currentCol = start.column;
while (currentLine < end.line) {
column = code[currentLine - 1].indexOf(str, currentCol);
if (column > -1 && compareLocations({
line: currentLine,
column
}, end) < 1) {
return {
line: currentLine,
column
};
}
currentLine++;
currentCol = 0;
}
return null;
};
module.exports = Istanbul = class Istanbul {
// Return default options for this instrumentor.
static getDefaultOptions() {
var ref;
return {
coverageVar: (ref = module.exports.findIstanbulVariable()) != null ? ref : '__coverage__'
};
}
// Find the runtime Istanbul variable, if it exists. Otherwise, fall back to a sensible default.
static findIstanbulVariable() {
var coverageVar, coverageVars;
coverageVar = `$$cov_${Date.now()}$$`;
if (global[coverageVar] == null) {
coverageVars = Object.keys(global).filter(function(key) {
return _.startsWith(key, '$$cov_');
});
if (coverageVars.length === 1) {
coverageVar = coverageVars[0];
} else {
// Needs to be undefined and not `null`, because `_.defaults()` treats them differently.
coverageVar = void 0;
}
}
return coverageVar;
}
// `options` is a `{log, coverageVar}` object.
constructor(fileName, source, options = {}) {
this.fileName = fileName;
this.source = source;
({log: this.log, coverageVar: this.coverageVar} = options);
options = _.defaults({}, options, Istanbul.getDefaultOptions());
this.sourceLines = fileToLines(this.source);
this.quotedFileName = toQuotedString(this.fileName);
this.statementMap = [];
this.branchMap = [];
this.fnMap = [];
this.instrumentedLineCount = 0;
this.anonId = 1;
this._prefix = `${this.coverageVar}[${this.quotedFileName}]`;
}
/* !pragma coverage-skip-next */
_warn(message, options = {}) {
var lineNumber, ref, str;
str = message;
str += `\n file: ${this.fileName}`;
if (options.node) {
str += `\n node: ${options.node.toString()}`;
}
if ((options.line != null) || options.node) {
lineNumber = options.line != null ? options.line : options.node.locationData.first_line + 1;
str += `\n source: ${this.sourceLines[lineNumber - 1]}`;
}
return (ref = this.log) != null ? ref.warn(str) : void 0;
}
// Called on each non-comment statement within a Block. If a `visitXXX` exists for the
// specific node type, it will also be called after `visitStatement`.
visitStatement(node) {
var grandParentType, location, ref, ref1, ref2, ref3, statementId;
grandParentType = (ref = node.parent) != null ? (ref1 = ref.parent) != null ? (ref2 = ref1.node) != null ? (ref3 = ref2.constructor) != null ? ref3.name : void 0 : void 0 : void 0 : void 0;
if (grandParentType === "StringWithInterpolations" && !node.parent.parent.skipped) {
node.parent.parent.skipped = true;
return;
}
// Ignore nodes marked 'noCoverage'
if (node.isMarked('noCoverage')) {
return;
}
statementId = this.statementMap.length + 1;
location = nodeToLocation(node);
if (node.type === 'If') {
location.end = this._findEndOfIf(node);
}
this.statementMap.push(location);
node.insertBefore(`${this._prefix}.s[${statementId}]++`);
return this.instrumentedLineCount++;
}
// coffeescript will put the end of an 'If' statement as being right before the start of
// the 'else' (which is probably a bug.) Istanbul expects the end to be the end of the last
// line in the else (and for chained ifs, Istanbul expects the end of the very last else.)
_findEndOfIf(ifNode) {
var elseBody, elseChild;
assert(ifNode.type === 'If');
elseBody = ifNode.child('elseBody');
if (ifNode.node.isChain || ifNode.isMarked('wasChain')) {
assert(elseBody != null);
elseChild = elseBody.child('expressions', 0);
assert(elseChild.type === 'If');
return this._findEndOfIf(elseChild);
} else if (elseBody != null) {
return nodeToLocation(elseBody).end;
} else {
return nodeToLocation(ifNode).end;
}
}
visitIf(node) {
var body, bodyPresent, branchId, elseBody, elseBodyPresent, elseLocation, ifLocation, ref;
// Ignore nodes marked 'noCoverage'
if (node.isMarked('noCoverage')) {
return;
}
branchId = this.branchMap.length + 1;
// Make a 0-length `Location` object.
ifLocation = nodeToLocation(node);
ifLocation.end.line = ifLocation.start.line;
ifLocation.end.column = ifLocation.start.column;
elseLocation = ifLocation;
if (!ifLocation.skip) {
elseLocation = _.clone(ifLocation);
if (node.isMarked('skipIf')) {
ifLocation.skip = true;
}
if (node.isMarked('skipElse')) {
elseLocation.skip = true;
}
}
if (node.node.isChain) {
// Chaining is where coffee compiles something into `... else if ...`
// instead of '... else {if ...}`. Chaining produces nicer looking coder
// with fewer indents, but it also produces code that's harder to instrument
// (because we can't add code between the `else` and the `if`), so we turn it off.
if ((ref = this.log) != null) {
if (typeof ref.debug === "function") {
ref.debug(" Disabling chaining for if statement");
}
}
node.node.isChain = false;
node.mark('wasChain', true);
}
body = node.child('body');
elseBody = node.child('elseBody');
bodyPresent = body && body.node.expressions.length > 0;
elseBodyPresent = elseBody && elseBody.node.expressions.length > 0;
if (node.parent.type === 'Return' && (!bodyPresent || !elseBodyPresent)) {
if (bodyPresent && !elseBodyPresent) {
// This is kind of a weird case:
// fn = (x) ->
// return if x then 10
// return 20
// You might think that second `return` statement was unreachable, but it will actually
// be hit if `x` is fasley. We can't add anything to the `elseBody` here, though
// without making the the second return statement unreachable. The solution is
// to add the instrumentation for the "else" case after the `return`.
node.insertAtStart('body', `${this._prefix}.b[${branchId}][0]++`);
node.parent.insertAfter(`${this._prefix}.b[${branchId}][1]++`);
this.instrumentedLineCount += 2;
} else if (elseBodyPresent && !bodyPresent) {
// This is the even weirder case:
// fn = (x) ->
// return if x then else 10
// return 20
node.insertAtStart('elseBody', `${this._prefix}.b[${branchId}][1]++`);
node.parent.insertAfter(`${this._prefix}.b[${branchId}][0]++`);
this.instrumentedLineCount += 2;
} else if (!elseBodyPresent && !bodyPresent) {
// Yes, you can do this:
// fn = (x) ->
// return if x then else
// return 20
// but it's a bit stupid, so if you do it, we're just going to ignore this
// statement.
this._warn("If statement could not be instrumented", {node});
ifLocation.skip = true;
elseLocation.skip = true;
}
} else {
if (!bodyPresent) {
node.insertAtStart('body', "undefined");
}
if (!elseBodyPresent) {
node.insertAtStart('elseBody', "undefined");
}
node.insertAtStart('body', `${this._prefix}.b[${branchId}][0]++`);
node.insertAtStart('elseBody', `${this._prefix}.b[${branchId}][1]++`);
this.instrumentedLineCount += 2;
}
return this.branchMap.push({
line: ifLocation.start.line,
loc: ifLocation,
type: 'if',
locations: [ifLocation, elseLocation]
});
}
visitSwitch(node) {
var branchId, loc, locations;
// Ignore nodes marked 'noCoverage'
if (node.isMarked('noCoverage')) {
return;
}
branchId = this.branchMap.length + 1;
locations = [];
locations = node.node.cases.map(([conditions, block]) => {
var answer, blockLocation, ref, start, startColumn;
start = minLocation(_.flatten([conditions], true).map(function(condition) {
return nodeToLocation(condition).start;
}));
blockLocation = nodeToLocation(block);
// start.column is the start of the condition, but we want the start of the
// `when`.
if ((startColumn = (ref = this.sourceLines[start.line - 1]) != null ? ref.indexOf('when') : void 0) > -1) {
start.column = startColumn;
} else {
/* !pragma coverage-skip-block */
this._warn("Couldn't find 'when'", {
node,
line: start.line
});
// Intelligent guess
start.column -= 5;
if (start.column < 0) {
start.column = 0;
}
}
answer = {
start,
end: blockLocation.end
};
if (node.isMarked('skip') || blockLocation.skip) {
answer.skip = true;
}
return answer;
});
if (node.node.otherwise != null) {
locations.push(nodeToLocation(node.node.otherwise));
}
loc = nodeToLocation(node);
this.branchMap.push({
line: loc.start.line,
loc,
type: 'switch',
locations
});
node.node.cases.forEach(([conditions, block], index) => {
var caseNode;
caseNode = new NodeWrapper(block, node, 'cases', index, node.depth + 1);
assert.equal(caseNode.type, 'Block');
return caseNode.insertAtStart('expressions', `${this._prefix}.b[${branchId}][${index}]++`);
});
return node.forEachChildOfType('otherwise', (otherwise) => {
var index;
index = node.node.cases.length;
assert.equal(otherwise.type, 'Block');
return otherwise.insertAtStart('expressions', `${this._prefix}.b[${branchId}][${index}]++`);
});
}
visitCode(node) {
var arrow, end, endOfFn, fnMapEntry, functionId, isAssign, lastParam, name, paramCount, ref, ref1, ref2, ref3, ref4, start;
// Ignore nodes marked 'noCoverage'
if (node.isMarked('noCoverage')) {
return;
}
functionId = this.fnMap.length + 1;
paramCount = (ref = (ref1 = node.node.params) != null ? ref1.length : void 0) != null ? ref : 0;
isAssign = node.parent.type === 'Assign' && (((ref2 = node.parent.node.variable) != null ? (ref3 = ref2.base) != null ? ref3.value : void 0 : void 0) != null);
// Figure out the name of this funciton
name = isAssign ? node.parent.node.variable.base.value : `(anonymous_${this.anonId++})`;
// Find the start and end of the function declaration.
start = isAssign ? nodeToLocation(node.parent).start : nodeToLocation(node).start;
if (paramCount > 0) {
lastParam = node.child('params', paramCount - 1);
end = nodeToLocation(lastParam).end;
// Coffee-script doesn't tell us where the `->` is, so we have to find it
arrow = node.node.bound ? '=>' : '->';
endOfFn = findInCode(this.sourceLines, arrow, {
start: {
line: end.line,
column: end.column
},
end: nodeToLocation(node).end
});
if (endOfFn) {
end = endOfFn;
end.column += 1;
} else {
/* !pragma coverage-skip-block */
this._warn("Couldn't find '->' or '=>'", {
node,
line: start.line
});
// Educated guess
end.column += 4;
}
} else {
end = nodeToLocation(node).start;
// Fix off-by-one error
end.column++;
}
fnMapEntry = {
name,
line: start.line,
loc: nodeToLocation(node),
decl: {start, end}
};
if ((ref4 = node.node.coffeeCoverage) != null ? ref4.skip : void 0) {
fnMapEntry.skip = true;
}
this.fnMap.push(fnMapEntry);
return node.insertAtStart('body', `${this._prefix}.f[${functionId}]++`);
}
visitClass(node) {
var functionId, loc, ref;
// Ignore nodes marked 'noCoverage'
if (node.isMarked('noCoverage')) {
return;
}
functionId = this.fnMap.length + 1;
if (node.node.variable != null) {
loc = nodeToLocation(node.node.variable);
} else {
loc = nodeToLocation(node);
loc.end = loc.start;
}
this.fnMap.push({
name: (ref = node.node.determineName()) != null ? ref : '_Class',
line: loc.start.line,
loc: nodeToLocation(node),
decl: loc
});
return node.insertAtStart('body', `${this._prefix}.f[${functionId}]++`);
}
getInitString() {
var init, initData;
initData = {
path: this.fileName,
s: {},
b: {},
f: {},
fnMap: {},
statementMap: {},
branchMap: {}
};
this.statementMap.forEach(function(statement, id) {
initData.s[id + 1] = 0;
return initData.statementMap[id + 1] = statement;
});
this.branchMap.forEach(function(branch, id) {
initData.b[id + 1] = (function() {
var i, ref, results;
results = [];
for (i = 0, ref = branch.locations.length; (0 <= ref ? i < ref : i > ref); 0 <= ref ? i++ : i--) {
results.push(0);
}
return results;
})();
return initData.branchMap[id + 1] = branch;
});
this.fnMap.forEach(function(fn, id) {
initData.f[id + 1] = 0;
return initData.fnMap[id + 1] = fn;
});
return init = `if (typeof ${this.coverageVar} === 'undefined') ${this.coverageVar} = {};\n(function(_export) {\n if (typeof _export.${this.coverageVar} === 'undefined') {\n _export.${this.coverageVar} = ${this.coverageVar};\n }\n})(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);\nif (! ${this._prefix}) { ${this._prefix} = ${JSON.stringify(initData)} }`;
}
getInstrumentedLineCount() {
return this.instrumentedLineCount;
}
};
}).call(this);