/*
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
'use strict';
var assert = require('assert');
var recast = require('recast');
var _ = require('lodash');
var astTypes = recast.types;
var types = astTypes.namedTypes;
var NodePath = astTypes.NodePath;
var Node = types.Node;
/**
* This represents a generic collection of node paths. It only has a generic
* API to access and process the elements of the list. It doesn't know anything
* about AST types.
*
* @mixes traversalMethods
* @mixes mutationMethods
* @mixes transformMethods
* @mixes globalMethods
*/
class Collection {
/**
* @param {Array} paths An array of AST paths
* @param {Collection} parent A parent collection
* @param {Array} types An array of types all the paths in the collection
* have in common. If not passed, it will be inferred from the paths.
* @return {Collection}
*/
constructor(paths, parent, types) {
assert.ok(Array.isArray(paths), 'Collection is passed an array');
assert.ok(
paths.every(p => p instanceof NodePath),
'Array contains only paths'
);
this._parent = parent;
this.__paths = paths;
if (types && !Array.isArray(types)) {
types = _toTypeArray(types);
} else if (!types || Array.isArray(types) && types.length === 0) {
types = _inferTypes(paths);
}
this._types = types.length === 0 ? _defaultType : types;
}
/**
* Returns a new collection containing the nodes for which the callback
* returns true.
*
* @param {function} callback
* @return {Collection}
*/
filter(callback) {
return new this.constructor(this.__paths.filter(callback), this);
}
/**
* Executes callback for each node/path in the collection.
*
* @param {function} callback
* @return {Collection} The collection itself
*/
forEach(callback) {
this.__paths.forEach(
(path, i, paths) => callback.call(path, path, i, paths)
);
return this;
}
/**
* Executes the callback for every path in the collection and returns a new
* collection from the return values (which must be paths).
*
* The callback can return null to indicate to exclude the element from the
* new collection.
*
* If an array is returned, the array will be flattened into the result
* collection.
*
* @param {function} callback
* @param {Type} type Force the new collection to be of a specific type
*/
map(callback, type) {
var paths = [];
this.forEach(function(path) {
/*jshint eqnull:true*/
var result = callback.apply(path, arguments);
if (result == null) return;
if (!Array.isArray(result)) {
result = [result];
}
for (var i = 0; i < result.length; i++) {
if (paths.indexOf(result[i]) === -1) {
paths.push(result[i]);
}
}
});
return fromPaths(paths, this, type);
}
/**
* Returns the number of elements in this collection.
*
* @return {number}
*/
size() {
return this.__paths.length;
}
/**
* Returns the number of elements in this collection.
*
* @return {number}
*/
get length() {
return this.__paths.length;
}
/**
* Returns an array of AST nodes in this collection.
*
* @return {Array}
*/
nodes() {
return this.__paths.map(p => p.value);
}
paths() {
return this.__paths;
}
getAST() {
if (this._parent) {
return this._parent.getAST();
}
return this.__paths;
}
toSource(options) {
if (this._parent) {
return this._parent.toSource(options);
}
if (this.__paths.length === 1) {
return recast.print(this.__paths[0], options).code;
} else {
return this.__paths.map(p => recast.print(p, options).code);
}
}
/**
* Returns a new collection containing only the element at position index.
*
* In case of a negative index, the element is taken from the end:
*
* .at(0) - first element
* .at(-1) - last element
*
* @param {number} index
* @return {Collection}
*/
at(index) {
return fromPaths(
this.__paths.slice(
index,
index === -1 ? undefined : index + 1
),
this
);
}
/**
* Proxies to NodePath#get of the first path.
*
* @param {string|number} ...fields
*/
get() {
var path = this.__paths[0];
if (!path) {
throw Error(
'You cannot call "get" on a collection with no paths. ' +
'Instead, check the "length" property first to verify at least 1 path exists.'
);
}
return path.get.apply(path, arguments);
}
/**
* Returns the type(s) of the collection. This is only used for unit tests,
* I don't think other consumers would need it.
*
* @return {Array<string>}
*/
getTypes() {
return this._types;
}
/**
* Returns true if this collection has the type 'type'.
*
* @param {Type} type
* @return {boolean}
*/
isOfType(type) {
return !!type && this._types.indexOf(type.toString()) > -1;
}
}
/**
* Given a set of paths, this infers the common types of all paths.
* @private
* @param {Array} paths An array of paths.
* @return {Type} type An AST type
*/
function _inferTypes(paths) {
var _types = [];
if (paths.length > 0 && Node.check(paths[0].node)) {
var nodeType = types[paths[0].node.type];
var sameType = paths.length === 1 ||
paths.every(path => nodeType.check(path.node));
if (sameType) {
_types = [nodeType.toString()].concat(
astTypes.getSupertypeNames(nodeType.toString())
);
} else {
// try to find a common type
_types = _.intersection.apply(
null,
paths.map(path => astTypes.getSupertypeNames(path.node.type))
);
}
}
return _types;
}
function _toTypeArray(value) {
value = !Array.isArray(value) ? [value] : value;
value = value.map(v => v.toString());
if (value.length > 1) {
return _.union(value, _.intersection.apply(
null,
value.map(type => astTypes.getSupertypeNames(type))
));
} else {
return value.concat(astTypes.getSupertypeNames(value[0]));
}
}
/**
* Creates a new collection from an array of node paths.
*
* If type is passed, it will create a typed collection if such a collection
* exists. The nodes or path values must be of the same type.
*
* Otherwise it will try to infer the type from the path list. If every
* element has the same type, a typed collection is created (if it exists),
* otherwise, a generic collection will be created.
*
* @ignore
* @param {Array} paths An array of paths
* @param {Collection} parent A parent collection
* @param {Type} type An AST type
* @return {Collection}
*/
function fromPaths(paths, parent, type) {
assert.ok(
paths.every(n => n instanceof NodePath),
'Every element in the array should be a NodePath'
);
return new Collection(paths, parent, type);
}
/**
* Creates a new collection from an array of nodes. This is a convenience
* method which converts the nodes to node paths first and calls
*
* Collections.fromPaths(paths, parent, type)
*
* @ignore
* @param {Array} nodes An array of AST nodes
* @param {Collection} parent A parent collection
* @param {Type} type An AST type
* @return {Collection}
*/
function fromNodes(nodes, parent, type) {
assert.ok(
nodes.every(n => Node.check(n)),
'Every element in the array should be a Node'
);
return fromPaths(
nodes.map(n => new NodePath(n)),
parent,
type
);
}
var CPt = Collection.prototype;
/**
* This function adds the provided methods to the prototype of the corresponding
* typed collection. If no type is passed, the methods are added to
* Collection.prototype and are available for all collections.
*
* @param {Object} methods Methods to add to the prototype
* @param {Type=} type Optional type to add the methods to
*/
function registerMethods(methods, type) {
for (var methodName in methods) {
if (!methods.hasOwnProperty(methodName)) {
return;
}
if (hasConflictingRegistration(methodName, type)) {
var msg = `There is a conflicting registration for method with name "${methodName}".\nYou tried to register an additional method with `;
if (type) {
msg += `type "${type.toString()}".`
} else {
msg += 'universal type.'
}
msg += '\nThere are existing registrations for that method with ';
var conflictingRegistrations = CPt[methodName].typedRegistrations;
if (conflictingRegistrations) {
msg += `type ${Object.keys(conflictingRegistrations).join(', ')}.`;
} else {
msg += 'universal type.';
}
throw Error(msg);
}
if (!type) {
CPt[methodName] = methods[methodName];
} else {
type = type.toString();
if (!CPt.hasOwnProperty(methodName)) {
installTypedMethod(methodName);
}
var registrations = CPt[methodName].typedRegistrations;
registrations[type] = methods[methodName];
astTypes.getSupertypeNames(type).forEach(function (name) {
registrations[name] = false;
});
}
}
}
function installTypedMethod(methodName) {
if (CPt.hasOwnProperty(methodName)) {
throw new Error(`Internal Error: "${methodName}" method is already installed`);
}
var registrations = {};
function typedMethod() {
var types = Object.keys(registrations);
for (var i = 0; i < types.length; i++) {
var currentType = types[i];
if (registrations[currentType] && this.isOfType(currentType)) {
return registrations[currentType].apply(this, arguments);
}
}
throw Error(
`You have a collection of type [${this.getTypes()}]. ` +
`"${methodName}" is only defined for one of [${types.join('|')}].`
);
}
typedMethod.typedRegistrations = registrations;
CPt[methodName] = typedMethod;
}
function hasConflictingRegistration(methodName, type) {
if (!type) {
return CPt.hasOwnProperty(methodName);
}
if (!CPt.hasOwnProperty(methodName)) {
return false;
}
var registrations = CPt[methodName] && CPt[methodName].typedRegistrations;
if (!registrations) {
return true;
}
type = type.toString();
if (registrations.hasOwnProperty(type)) {
return true;
}
return astTypes.getSupertypeNames(type.toString()).some(function (name) {
return !!registrations[name];
});
}
var _defaultType = [];
/**
* Sets the default collection type. In case a collection is created form an
* empty set of paths and no type is specified, we return a collection of this
* type.
*
* @ignore
* @param {Type} type
*/
function setDefaultCollectionType(type) {
_defaultType = _toTypeArray(type);
}
exports.fromPaths = fromPaths;
exports.fromNodes = fromNodes;
exports.registerMethods = registerMethods;
exports.hasConflictingRegistration = hasConflictingRegistration;
exports.setDefaultCollectionType = setDefaultCollectionType;