UNPKG

babel-plugin-transform-adana

Version:
578 lines (538 loc) 17.3 kB
import * as types from '@babel/types'; import micromatch from 'micromatch'; import {relative} from 'path'; import prelude from './prelude'; import meta from './meta'; import {applyRules, addRules} from './tags'; export function skip({ignore, only}, file) { if (only) { return micromatch( [file], Array.isArray(only) ? only : [only], {nocase: true} ).length <= 0; } if (ignore) { return micromatch( [file], Array.isArray(ignore) ? ignore : [ignore], {nocase: true} ).length > 0; } return false; } /** * Create an opaque, unique key for a given node. Useful for tagging the node * in separate places. * @param {Object} path Babel path to derive key from. * @returns {String} String key. */ export function key(path) { const node = path.node; if (node.loc) { const location = node.loc.start; return `${location.line}:${location.column}`; } throw new TypeError('Path must have valid location.'); } /** * Some nodes need to marked as non-instrumentable; since babel will apply * our plugin to nodes we create, we have to be careful to not put ourselves * into an infinite loop. * @param {Object} node Babel AST node. * @returns {Object} Babel AST node that won't be instrumented. */ function X(node) { node.__adana = true; return node; } function ignore(path) { return (!path.node || !path.node.loc || path.node.__adana); } function standardize(listener) { return (path, state) => ignore(path) ? undefined : listener(path, state); } /** * Create the transform-adana babel plugin. * @returns {Object} `babel` plugin object. */ export default function instrumenter() { /** * Create a chunk of code that marks the specified node as having * been executed. * @param {Object} state `babel` state for the path that's being walked. * @param {Object} options Configure how the marker behaves. * @returns {Object} AST node for marking coverage. */ function createMarker(state, options) { const {tags, loc, name, group} = options; const coverage = meta(state); const id = coverage.entries.length; coverage.entries.push({ id, loc, tags, name, group, count: 0, }); // Maker is simply a statement incrementing a coverage variable. return X(types.updateExpression('++', types.memberExpression( types.memberExpression( coverage.variable, types.numericLiteral(id), true ), types.stringLiteral('count'), true ), true)); } /** * [isInstrumentableStatement description] * @param {[type]} path [description] * @returns {Boolean} [description] */ function isInstrumentableStatement(path) { const parent = path.parentPath; return !parent.isReturnStatement() && !parent.isVariableDeclaration() && !parent.isExportDeclaration() && !parent.isFunctionDeclaration() && !parent.isIfStatement(); } /** * Inject a marker that measures whether the node for the given path has * been run or not. * @param {Object} path [description] * @param {Object} state [description] * @param {Object} options [description] * @returns {void} */ function instrument(path, state, options) { // This function is here because isInstrumentableStatement() is being // called; we can't create the marker without knowing the result of that, // otherwise dead markers will be created. function marker() { return createMarker(state, { loc: path.node.loc, ...options, }); } if (path.isBlockStatement()) { path.unshiftContainer('body', X(types.expressionStatement(marker()))); } else if (path.isExpression()) { path.replaceWith(X(types.sequenceExpression([marker(), path.node]))); } else if (path.isStatement()) { if (isInstrumentableStatement(path)) { path.insertBefore(X(types.expressionStatement(marker()))); } } } /** * [visitStatement description] * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitStatement(path, state) { instrument(path, state, { tags: ['statement', 'line'], loc: path.node.loc, }); } /** * The function visitor is mainly to track the definitions of functions; * being able ensure how many of your functions have actually been invoked. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitFunction(path, state) { instrument(path.get('body'), state, { tags: ['function'], name: path.node.id ? path.node.id.name : `@${key(path)}`, loc: path.node.loc, }); } /** * Multiple branches based on the result of `case _` and `default`. If you * do not provide a `default` one will be intelligently added for you, * forcing you to cover that case. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitSwitchStatement(path, state) { let hasDefault = false; path.get('cases').forEach((entry) => { if (entry.node.test) { addRules(state, entry.node.loc, entry.node.test.trailingComments); } if (entry.node.consequent.length > 1) { addRules( state, entry.node.loc, entry.node.consequent[0].leadingComments ); } if (entry.node.test === null) { hasDefault = true; } entry.unshiftContainer('consequent', createMarker(state, { tags: ['branch', 'switch'], loc: entry.node.loc, group: key(path), })); }); // Default is technically a branch, just like if statements without // else's are also technically a branch. if (!hasDefault) { // Add an extra break to the end of the last case in case some idiot // forgot to add it. const cases = path.get('cases'); if (cases.length > 0) { cases[cases.length - 1].pushContainer( 'consequent', types.breakStatement() ); } // Finally add the default case. path.pushContainer('cases', types.switchCase(null, [ types.expressionStatement(createMarker(state, { tags: ['branch', 'switch'], loc: { start: path.node.loc.end, end: path.node.loc.end, }, group: key(path), })), types.breakStatement(), ])); } } /** * [visitVariableDeclaration description] * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitVariableDeclaration(path, state) { path.get('declarations').forEach((decl) => { if (decl.has('init')) { instrument(decl.get('init'), state, { tags: ['statement', 'variable', 'line'], }); } }); } /** * Includes both while and do-while loops. They contain a single branch which * tests the loop condition. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitWhileLoop(path, state) { const test = path.get('test'); const group = key(path); // This is a particularly clever use of the fact JS operators are short- // circuiting. To instrument a loop one _cannot_ add a marker on the outside // of the loop body due to weird cases of things where loops are in non- // block if statements. So instead, create the following mechanism: // ((condition && A) || !B) where A and B are markers. Since markers are // postfix, they're always true. Ergo, A is only incremented when condition // is true, B only when it's false and the truth value of the whole // statement is preserved. Neato. test.replaceWith(types.logicalExpression( '||', types.logicalExpression( '&&', X(test.node), createMarker(state, { tags: ['branch', 'line', 'statement', 'loop', 'while'], loc: test.node.loc, group, }) ), types.unaryExpression( '!', createMarker(state, { tags: ['branch', 'line', 'loop', 'while'], loc: test.node.loc, group, }) ) )); } /** * The try block can either fully succeed (no error) or it can throw. Both * cases are accounted for. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitTryStatement(path, state) { const group = key(path); const body = path.get('block'); const trigger = path.scope.generateDeclaredUidIdentifier('_exception'); addRules(state, body.node.loc, body.node.leadingComments); path.get('block').unshiftContainer('body', types.expressionStatement( types.assignmentExpression('=', trigger, types.booleanLiteral(true)), )); const handlerExpression = types.expressionStatement( types.assignmentExpression('=', trigger, types.booleanLiteral(false)), ); let handlerLoc; if (path.has('handler')) { const handler = path.get('handler').node; handlerLoc = handler.loc; addRules(state, handler.loc, handler.body.leadingComments); path.get('handler.body').unshiftContainer( 'body', handlerExpression ); } else { const loc = path.get('block').node.loc.end; handlerLoc = {start: loc, end: loc}; path.get('handler').replaceWith(types.catchClause( types.identifier('err'), types.blockStatement([ handlerExpression, types.throwStatement( types.identifier('err') ), ]) )); } const guard = types.ifStatement( trigger, types.expressionStatement( createMarker(state, { tags: ['branch', 'line', 'exception'], loc: path.get('block').node.loc, group, }) ), types.expressionStatement( createMarker(state, { tags: ['branch', 'line', 'exception'], loc: handlerLoc, group, }) ) ); if (path.has('finalizer')) { path.get('finalizer').unshiftContainer('body', guard); } else { path.get('finalizer').replaceWith(types.blockStatement([guard])); } } /** * Return statements are instrumented by marking the next block they return. * This helps ensure multi-line expressions for return statements are * accurately captured. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {[type]} [description] */ function visitReturnStatement(path, state) { if (!path.has('argument')) { path.get('argument').replaceWith(types.sequenceExpression([ createMarker(state, { loc: path.node.loc, tags: ['line', 'statement'], }), types.identifier('undefined'), ])); } else { instrument(path.get('argument'), state, { tags: ['line', 'statement'], }); } } /** * For multi-line reporting (and objects do tend to span multiple lines) this * is required to know which parts of the object where actually executed. * Ignore shorthand property that look like `{ this }`. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {[type]} [description] */ function visitObjectProperty(path, state) { if (!path.node.shorthand && !path.parentPath.isPattern()) { const key = path.get('key'); const value = path.get('value'); if (path.node.computed) { instrument(key, state, { tags: ['line'], }); } instrument(value, state, { tags: ['line'], }); } } /** * For multi-line reporting (and arrays do tend to span multiple lines) this * is required to know which parts of the array where actually executed. * This does _not_ include destructed arrays. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {[type]} [description] */ function visitArrayExpression(path, state) { if (!path.parentPath.isPattern()) { path.get('elements').forEach((element) => { instrument(element, state, { tags: ['line'], }); }); } } /** * Logical expressions are those using logic operators like `&&` and `||`. * Since logic expressions short-circuit in JS they are effectively branches * and will be treated as such here. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitLogicalExpression(path, state) { const group = key(path); const left = path.get('left').node; const right = path.get('right').node; path.replaceWith(X(types.logicalExpression( path.node.operator, types.sequenceExpression([createMarker(state, { tags: ['branch', 'logic'], loc: left.loc, group, }), left]), types.sequenceExpression([createMarker(state, { tags: ['branch', 'logic'], loc: right.loc, group, }), right]) ))); } /** * Conditionals are either if/else statements or tenaiary expressions. They * have a test case and two choices (based on the test result). Both cases * are always accounted for, even if the code does not exist for the alternate * case. * @param {[type]} path [description] * @param {[type]} state [description] * @returns {void} */ function visitConditional(path, state) { // Branches can be grouped together so that each of the possible branch // destinations is accounted for under one group. For if statements, this // refers to all the blocks that fall under a single if.. else if.. else.. // grouping. const root = path.findParent((search) => { return search.node.type === path.node.type && !ignore(search) && (!search.parentPath || search.parentPath.node.type !== path.node.type); }) || path; // Create the group name based on the root `if` statement. const group = key(root); function tagBranch(path) { addRules(state, path.node.loc, path.node.leadingComments); if (path.isBlockStatement() && path.node.body.length > 0) { addRules(state, path.node.loc, path.node.body[0].leadingComments); } } tagBranch(path.get('consequent')); if (path.has('alternate')) { tagBranch(path.get('alternate')); } instrument(path.get('consequent'), state, { tags: ['branch', 'line', 'if'], loc: path.node.consequent.loc, group, }); if (path.has('alternate') && !path.get('alternate').isIfStatement()) { instrument(path.get('alternate'), state, { tags: ['branch', 'line', 'if'], loc: path.node.alternate.loc, group, }); } else if (!path.has('alternate')) { path.get('alternate').replaceWith(types.expressionStatement( createMarker(state, { tags: ['branch', 'if'], loc: { start: path.node.loc.end, end: path.node.loc.end, }, group, })) ); } } function noInstrument(path) { if ( path.node && path.node.leadingComments && path.node.leadingComments.some( (comment) => /^\s*adana-no-instrument\s*$/.exec(comment.value) ) ) { path.skip(); return; } } const visitor = { // Expressions ArrowFunctionExpression: visitFunction, FunctionExpression: visitFunction, ObjectMethod: visitFunction, ClassMethod: visitFunction, LogicalExpression: visitLogicalExpression, ConditionalExpression: visitConditional, ObjectProperty: visitObjectProperty, ArrayExpression: visitArrayExpression, // Declarations FunctionDeclaration: visitFunction, VariableDeclaration: visitVariableDeclaration, // Statements ContinueStatement: visitStatement, BreakStatement: visitStatement, ExpressionStatement: visitStatement, ThrowStatement: visitStatement, ReturnStatement: visitReturnStatement, TryStatement: visitTryStatement, WhileStatement: visitWhileLoop, DoWhileStatement: visitWhileLoop, IfStatement: visitConditional, SwitchStatement: visitSwitchStatement, // Generics enter: noInstrument, }; Object.keys(visitor).forEach((key) => { visitor[key] = standardize(visitor[key]); }); // Create the actual babel plugin object. return { visitor: { Program(path, state) { const {opts, filename, file: {opts: {cwd}}} = state; // Check if file should be instrumented or not. const name = filename ? relative(cwd, filename) : '<source>'; if (filename && skip(opts, name)) { return; } meta(state, { source: state.file.code, entries: [], rules: [], tags: {}, variable: path.scope.generateUidIdentifier('coverage'), name, }); path.traverse(visitor, state); applyRules(state); path.unshiftContainer('body', prelude(state)); }, }, }; }