phadej/jsstana

View on GitHub
lib/jsstana.js

Summary

Maintainability
B
5 hrs
Test Coverage
/**
  # jsstana

  > s-expression match patterns for [Mozilla Parser AST](https://developer.mozilla.org/en-US/docs/SpiderMonkey/Parser_API)

  [jsstana](oleg.fi/jsstana) will help you to find exactly the code snippet you are searching for.
  It's much more precise than using grep.

  [![Build Status](https://secure.travis-ci.org/phadej/jsstana.svg?branch=master)](http://travis-ci.org/phadej/jsstana)
  [![NPM version](https://badge.fury.io/js/jsstana.svg)](http://badge.fury.io/js/jsstana)
  [![Dependency Status](https://gemnasium.com/phadej/jsstana.svg)](https://gemnasium.com/phadej/jsstana)
  [![Code Climate](https://img.shields.io/codeclimate/github/phadej/jsstana.svg)](https://codeclimate.com/github/phadej/jsstana)

  ## Synopsis

  ```javascript
  var jsstana = require("jsstana");
  var esprima = require("esprima");

  var contents = // ...
  var syntax = esprima.parse(contents);

  jsstana.traverse(syntax, function (node) {
    var m = jsstana.match("(call alert ?argument)", node);
    if (m) {
      console.log("alert called with argument", m.argument);
    }
  });
  ```

  ## jsgrep

  The jsgrep example utility is provided

  ```bash
  # find assertArguments calls with 4 arguments
  % jsgrep '(call ?.assertArguments ? ? ? ?)' lib
  matchers/literal.js:25:   this.assertArguments("true/false/null/infinity/nan/undefined", 0, arguments, 1);
  matchers/literal.js:111:   this.assertArguments("literal", 1, arguments, 1);
  matchers/member.js:18:   that.assertArguments("member/property/subscript", 2, arguments, 1);
  matchers/operator.js:63:   that.assertArguments(ratorName, 3, arguments, 3);
  matchers/operator.js:98:   that.assertArguments("unary", 2, arguments, 1);
  matchers/operator.js:128:   that.assertArguments("update/postfix/prefix", 2, arguments, 1);
  matchers/simple.js:7:   this.assertArguments(rator, 1, arguments, 3);
  ```
*/

"use strict";

var _ = require("underscore");
var assert = require("assert");
var sexprParse = require("./sexpr.js").parse;
var Levenshtein = require("levenshtein");
var misc = require("./misc.js");
var literal = require("./matchers/literal.js");

// Generic traversing function
function traverse(object, visitor) {
  if (visitor.call(null, object) === false) { // eslint-disable-line no-useless-call
    return;
  }

  _.each(object, function (child, key) {
    if (key === "loc" || key === "range") {
      // skip loc and range elements
      return;
    }

    // subelements are either "map" objects or arrays
    if (_.isArray(child)) {
      child.forEach(function (c) {
        traverse(c, visitor);
      });
    } else if (_.isObject(child) && !_.isRegExp(child)) {
      traverse(child, visitor);
    }
  });
}

var builtInMatchers = {};
_.extend(builtInMatchers, require("./matchers/logic.js"));
_.extend(builtInMatchers, require("./matchers/literal.js"));
_.extend(builtInMatchers, require("./matchers/operator.js"));
_.extend(builtInMatchers, require("./matchers/simple.js"));
_.extend(builtInMatchers, require("./matchers/call.js"));
_.extend(builtInMatchers, require("./matchers/ident.js"));
_.extend(builtInMatchers, require("./matchers/member.js"));
_.extend(builtInMatchers, require("./matchers/null.js"));
_.extend(builtInMatchers, require("./matchers/ternary.js"));
_.extend(builtInMatchers, require("./matchers/fn.js"));
_.extend(builtInMatchers, require("./matchers/object.js"));

function unknownNodeType(rator) {
  /* jshint validthis:true */
  var suggest = [];
  function findClose(key) {
    var d = new Levenshtein(rator, key).distance;

    if (d <= 2) {
      suggest.push(key);
    }
  }

  _.chain(this.matchers).keys().each(findClose);
  _.chain(builtInMatchers).keys().each(findClose);

  if (suggest.length === 0) {
    throw new Error("unknown node type: " + rator);
  } else {
    throw new Error("unknown node type: " + rator + ". Did you mean one of: " + suggest.join(" "));
  }
}

function matcherString(sexpr) {
  /* jshint validthis:true */
  var that = this;

  if (sexpr.indexOf(".") !== -1) {
    sexpr = sexpr.split(".").reduce(function (prev, next) {
      return ["property", prev, next];
    });
    return that.matcher(sexpr);
  } else if (sexpr === "?") {
    return function () {
      return {};
    };
  } else if (sexpr[0] === "?") {
    sexpr = sexpr.substr(1);
    return function (node) {
      var res = {};
      res[sexpr] = node;
      return res;
    };
  } else if (sexpr.match(/^\$\d+$/)) {
    sexpr = parseInt(sexpr.substr(1), 10);
    assert(sexpr < that.positionalMatchers.length,
      "there is only " + that.positionalMatchers.length + " positional matchers, required " + sexpr);
    return that.positionalMatchers[sexpr];
  } else if (sexpr === "this") {
    return literal.this.call(that);
  } else if (_.has(misc.CONSTANTS, sexpr)) {
    return literal[sexpr].call(that);
  } else {
    return function (node) {
      return node.type === "Identifier" && node.name === sexpr ? {} : undefined;
    };
  }
}

function matcherNumber(sexpr) {
  return function (node) {
    return node.type === "Literal" && node.value === sexpr ? {} : undefined;
  };
}

function matcherArray(sexpr) {
  /* jshint validthis:true */
  var that = this;
  var rator = _.first(sexpr);
  var rands = _.rest(sexpr);

  // Capture
  if (rator === "?") {
    return matcherArray.call(this, rands);
  } else if (rator[0] === "?") {
    rator = rator.substr(1);
    var nonCapturingMatcher = matcherArray.call(this, rands);
    return function (node) {
      var m = nonCapturingMatcher(node);
      if (m !== undefined) {
        var res = {};
        res[rator] = node;
        return _.extend(res, m);
      }
    };
  }

  if (_.has(that.matchers, rator)) {
    return that.matchers[rator].apply(that, rands);
  } else if (_.has(builtInMatchers, rator)) {
    return builtInMatchers[rator].apply(that, rands);
  } else {
    return unknownNodeType.call(that, rator);
  }
}

function matcher(sexpr) {
  /* jshint validthis:true */
  /* eslint no-use-before-define: 0 */
  // JsstancaContext is used here, even defined later

  assert(_.isString(sexpr) || _.isNumber(sexpr) || _.isArray(sexpr),
    "expression should be a number, a string or an array -- " + sexpr);

  var that = this instanceof JsstanaContext ? this : new JsstanaContext();
  var args = _.toArray(arguments).slice(1);
  if (args.length !== 0) {
    that = new JsstanaContext(that);
    that.positionalMatchers = args;
  }

  if (_.isString(sexpr)) {
    return matcherString.call(that, sexpr);
  } else if (_.isNumber(sexpr)) {
    return matcherNumber.call(that, sexpr);
  } else { /* if (_.isArray(sexpr)) */
    return matcherArray.call(that, sexpr);
  }
}

function assertArguments(rator, n, rands, m) {
  m = m || 0;
  assert(rands.length <= n + m, rator + " -- takes at most " + n + " argument(s)");
}

function combineMatches() {
  var args = _.toArray(arguments);
  if (args.some(_.isUndefined)) { return undefined; }
  return _.extend.apply(undefined, args);
}

/**
  ## Pattern syntax

  #### (?name pattern)

  Gives pattern a name, so matching node is also captured.

  ```js
  jsstana.match("(binary ?op ?lhs (?rhs (or (literal) (ident))))");
  ```
*/
/// include matchers/logic.js
/// include matchers/call.js
/// include matchers/ident.js
/// include matchers/null.js
/// include matchers/literal.js
/// include matchers/simple.js
/// include matchers/member.js
/// include matchers/operator.js
/// include matchers/ternary.js
/// include matchers/fn.js
/// include matchers/object.js

/**
  ## API
*/

// memoized patterns
var matchPatterns = {};

/**
    ### match(pattern, node)

    Match `node` against `pattern`.
    If pattern matches returns an object with match captures.
    Otherwise returns `undefined`.

    This function is autocurried ie. when one argument is passed, returns function `node -> matchresult`.

    This function is also memoized on the pattern, ie each pattern is compiled only once.
*/
function match(pattern, node) {
  /* jshint validthis:true */
  assert(arguments.length === 1 || arguments.length === 2, "match takes one or two arguments");
  var that = this instanceof JsstanaContext ? this : new JsstanaContext();

  if (!_.has(matchPatterns, pattern)) {
    matchPatterns[pattern] = that.matcher(sexprParse(pattern));
  }

  var m = matchPatterns[pattern];

  if (arguments.length === 1) {
    return m;
  } else {
    return m(node);
  }
}

/**
    ### createMatcher(pattern, [posMatcher])

    Create matcher. With one argument, `matcher(pattern) === match(pattern)`.
    With additional arguments, you can add `$0`, `$1`... additional anonymous matchers.

    ```js
    var matcher = jsstana.createMatcher("(expr (= a $0))", function (node) {
      return node.type === "ObjectExpression" && node.properties.length === 0 ? {} : undefined;
    });
    ```
*/
function createMatcher() {
  /* jshint validthis:true */
  var args = _.toArray(arguments);
  args[0] = sexprParse(args[0]);
  return matcher.apply(this, args);
}

/**
  ### eslintRule(pattern, f)
*/
function eslintRule(pattern, f) {
  /* jshint validthis:true */
  var compiled = this.match(pattern);
  return function (context) {
    var ruleCheck = function (node) {
      var m = compiled(node);
      if (m) {
        f(context, node, m);
      }
    };

    var res = {};
    _.each(compiled.nodeTypes, function (nodeType) {
      res[nodeType] = ruleCheck;
    });
    return res;
  };
}

/**
  ### new jsstana()

  Create new jsstana context. You can add new operations to this one.

  ```js
  var ctx = new jsstana();
  ctx.addMatchers("empty-object", function () {
    this.assertArguments("empty-object", 0, arguments);
    return function (node) {
      return node.type === "ObjectExpression" && node.properties.length === 0 ? {} : undefined;
    };
  });
  ctx.match("(empty-object", node);
  ```

  You may compile submatchers with `this.matcher(sexpr)` and combine their results with `this.combineMatches`.
  `this.assertArguments` checks argument (rator) count, to help validate pattern grammar.
*/
function JsstanaContext(context) {
  this.matchers = context instanceof JsstanaContext ? context.matchers : {};
  this.positionalMatchers = [];
}

// matcher utilities
JsstanaContext.prototype.combineMatches = combineMatches;
JsstanaContext.prototype.assertArguments = assertArguments;
JsstanaContext.prototype.matcher = matcher;

// public api
JsstanaContext.prototype.match = match;
JsstanaContext.prototype.eslintRule = eslintRule;
JsstanaContext.prototype.createMatcher = createMatcher;
JsstanaContext.prototype.addMatcher = function (name, f) {
  assert(!_.has(this.matchers, name), "matcher names should be unique: " + name);
  this.matchers[name] = f;
};

// Exports
JsstanaContext.traverse = traverse;
JsstanaContext.match = match;
JsstanaContext.eslintRule = eslintRule;
JsstanaContext.createMatcher = createMatcher;

module.exports = JsstanaContext;

/// plain ../CONTRIBUTING.md
/// plain ../CHANGELOG.md
/// plain ../LICENSE