snapdragon-node
Version:
Class for creating AST nodes.
713 lines (637 loc) • 16.7 kB
JavaScript
'use strict';
/**
* Create a new AST `Node` with the given `type` and `value`, or an
* object to initialize with.
*
* ```js
* console.log(new Node({ type: 'star', value: '*' }));
* console.log(new Node('star', '*'));
* // both result in => Node { type: 'star', value: '*' }
* ```
* @name Node
* @param {object|string} `type` Either an object to initialize with, or a string to be used as the `node.type`.
* @param {string|boolean} `value` If the first argument is a string, the second argument may be a string value to set on `node.value`.
* @param {boolean} `clone` When an object is passed as the first argument, pass true as the last argument to deep clone values before assigning them to the new node.
* @return {Object} node instance
* @api public
*/
class Node {
constructor(type, value, clone) {
define(this, 'isNode', true);
define(this, 'parent', null);
define(this, 'size', 0);
if (isObject(type)) {
assign(this, type, clone);
} else {
this.type = type;
if (value != null) {
this.value = value;
}
}
}
/**
* Return a clone of the node. Values that are arrays or plain objects
* are deeply cloned.
*
* ```js
* const node = new Node({type: 'star', value: '*'});
* consle.log(node.clone() !== node);
* //=> true
* ```
* @name .clone
* @return {Object} returns a clone of the node
* @api public
*/
clone() {
return new this.constructor(this, null, true);
}
/**
* Return a string created from `node.value` and/or recursively
* visiting over `node.nodes`.
*
* ```js
* const node = new Node({type: 'star', value: '*'});
* consle.log(node.stringify());
* //=> '*'
* ```
* @name .stringify
* @return {String}
* @api public
*/
stringify(fn = n => n.value) {
let str = '';
visit(this, n => (str += fn(n)));
return str;
}
/**
* Push a child node onto the `node.nodes` array.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* foo.push(bar);
* ```
* @name .push
* @param {Object} `node`
* @return {Number} Returns the length of `node.nodes`, like `Array.push`
* @api public
*/
push(node) {
if (!node) return;
assert(isObject(node), 'expected node to be an object');
assert(node !== this, 'node should not be the same as node.parent');
if (!this.constructor.isNode(node)) {
node = new this.constructor(node);
}
this.nodes = this.nodes || [];
node.parent = this;
ensureNodes(node);
this.size++;
return this.nodes.push(node);
}
/**
* Unshift a child node onto `node.nodes`, and set `node` as
* the parent on `child.parent`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* foo.unshift(bar);
* ```
* @name .unshift
* @param {Object} `node`
* @return {Number} Returns the length of `node.nodes`
* @api public
*/
unshift(node) {
if (!node) return;
assert(isObject(node), 'expected node to be an object');
assert(node !== this, 'node should not be the same as node.parent');
if (!this.constructor.isNode(node)) {
node = new this.constructor(node);
}
this.nodes = this.nodes || [];
node.parent = this;
ensureNodes(node);
this.size++;
return this.nodes.unshift(node);
}
/**
* Pop a node from `node.nodes`.
*
* ```js
* const node = new Node({type: 'foo'});
* node.push(new Node({type: 'a'}));
* node.push(new Node({type: 'b'}));
* node.push(new Node({type: 'c'}));
* node.push(new Node({type: 'd'}));
* console.log(node.nodes.length);
* //=> 4
* node.pop();
* console.log(node.nodes.length);
* //=> 3
* ```
* @name .pop
* @return {Number} Returns the popped `node`
* @api public
*/
pop() {
if (this.nodes && this.nodes.length) {
this.size--;
return this.nodes.pop();
}
}
/**
* Shift a node from `node.nodes`.
*
* ```js
* const node = new Node({type: 'foo'});
* node.push(new Node({type: 'a'}));
* node.push(new Node({type: 'b'}));
* node.push(new Node({type: 'c'}));
* node.push(new Node({type: 'd'}));
* console.log(node.nodes.length);
* //=> 4
* node.shift();
* console.log(node.nodes.length);
* //=> 3
* ```
* @name .shift
* @return {Object} Returns the shifted `node`
* @api public
*/
shift() {
if (this.nodes && this.nodes.length) {
this.size--;
return this.nodes.shift();
}
}
/**
* Remove `node` from `node.nodes`.
*
* ```js
* node.remove(childNode);
* ```
* @name .remove
* @param {Object} `node`
* @return {Object} Returns the removed node.
* @api public
*/
remove(node) {
if (!this.nodes) return [];
assert(this.constructor.isNode(node), 'expected an instance of Node');
assert(node !== this, 'cannot remove a node from itself');
const idx = this.nodes.indexOf(node);
node.index = -1;
if (idx !== -1) {
this.size--;
return this.nodes.splice(idx, 1);
}
return [];
}
/**
* Get the first child node from `node.nodes` that matches the given `type`.
* If `type` is a number, the child node at that index is returned.
*
* ```js
* const child = node.find(1); //<= index of the node to get
* const child = node.find('foo'); //<= node.type of a child node
* const child = node.find(/^(foo|bar)$/); //<= regex to match node.type
* const child = node.find(['foo', 'bar']); //<= array of node.type(s)
* ```
* @name .find
* @param {String} `type`
* @return {Object} Returns a child node or undefined.
* @api public
*/
find(type, n = 0) {
if (!Array.isArray(this.nodes) || this.nodes.length === 0) return null;
if (typeof type === 'number') return this.nodes[type];
for (let i = n; i < this.nodes.length; i++) {
const node = this.nodes[i];
if (isType(node, type)) {
return node;
}
}
}
visit(fn) {
return visit(this, fn);
}
/**
* Returns true if `node.nodes` array contains the given `node`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* cosole.log(foo.has(bar)); // false
* foo.push(bar);
* cosole.log(foo.has(bar)); // true
* ```
* @name .has
* @param {String} `type`
* @return {Boolean}
* @api public
*/
has(node) {
if (this.constructor.isNode(node)) {
return Array.isArray(this.nodes) && this.nodes.includes(node);
}
return this.hasType(node);
}
/**
* Return true if the `node.nodes` has the given `type`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* foo.push(bar);
*
* cosole.log(foo.hasType('qux')); // false
* cosole.log(foo.hasType(/^(qux|bar)$/)); // true
* cosole.log(foo.hasType(['qux', 'bar'])); // true
* ```
* @name .hasType
* @param {String} `type`
* @return {Boolean}
* @api public
*/
hasType(type) {
return Array.isArray(this.nodes) && this.nodes.find(node => isType(node, type));
}
/**
* Return true if the node is the given `type`.
*
* ```js
* const node = new Node({type: 'bar'});
* cosole.log(node.isType('foo')); // false
* cosole.log(node.isType(/^(foo|bar)$/)); // true
* cosole.log(node.isType(['foo', 'bar'])); // true
* ```
* @name .isType
* @param {String} `type`
* @return {Boolean}
* @api public
*/
isType(type) {
return isType(this, type);
}
/**
* Returns true if `node.value` is an empty string, or `node.nodes` does
* not contain any non-empty text nodes.
*
* ```js
* const node = new Node({type: 'text'});
* node.isEmpty(); //=> true
* node.value = 'foo';
* node.isEmpty(); //=> false
* ```
* @name .isEmpty
* @param {Function} `fn` (optional) Filter function that is called on `node` and/or child nodes. `isEmpty` will return false immediately when the filter function returns false on any nodes.
* @return {Boolean}
* @api public
*/
isEmpty(fn) {
return isEmpty(this, fn);
}
/**
* Returns true if the node has an ancestor node of the given `type`
*
* ```js
* const box = new Node({type: 'box'});
* const marble = new Node({type: 'marble'});
* box.push(marble);
* marble.isInside('box'); //=> true
* ```
* @name .isInside
* @param {String} `type`
* @return {Boolean}
* @api public
*/
isInside(type) {
return this.parent && (this.parent.type === type || this.parent.isInside(type));
}
/**
* Get the siblings array, or `null` if it doesn't exist.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* foo.push(bar);
* foo.push(baz);
*
* console.log(bar.siblings.length) // 2
* console.log(baz.siblings.length) // 2
* ```
* @getter
* @name .siblings
* @return {Array}
* @api public
*/
get siblings() {
return this.parent ? this.parent.nodes : null;
}
/**
* Calculate the node's current index on `node.parent.nodes`, or `-1` if the
* node does not have a parent, or is not on `node.parent.nodes`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* const qux = new Node({type: 'qux'});
* foo.push(bar);
* foo.push(baz);
* foo.unshift(qux);
*
* console.log(bar.index) // 1
* console.log(baz.index) // 2
* console.log(qux.index) // 0
* ```
* @setter
* @getter
* @name .index
* @return {Number}
* @api public
*/
set index(index) {
define(this, '_index', index);
}
get index() {
if (!Array.isArray(this.siblings)) {
return -1;
}
if (this._index === -1 || this.siblings[this._index] !== this) {
define(this, '_index', this.siblings.indexOf(this));
}
return this._index;
}
/**
* Get the previous node from the [siblings](#siblings) array or `null`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* foo.push(bar);
* foo.push(baz);
*
* console.log(baz.prev.type) // 'bar'
* ```
* @getter
* @name .prev
* @return {Object}
* @api public
*/
get prev() {
if (Array.isArray(this.siblings)) {
return this.siblings[this.index - 1] || this.parent.prev;
}
return null;
}
/**
* Get the next element from the [siblings](#siblings) array, or `null` if
* a next node does not exist.
*
* ```js
* const parent = new Node({type: 'root'});
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* parent.push(foo);
* parent.push(bar);
* parent.push(baz);
*
* console.log(foo.next.type) // 'bar'
* console.log(bar.next.type) // 'baz'
* ```
* @getter
* @name .next
* @return {Object}
* @api public
*/
get next() {
if (Array.isArray(this.siblings)) {
return this.siblings[this.index + 1] || this.parent.next;
}
return null;
}
/**
* Get the first child node from `node.nodes`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* const qux = new Node({type: 'qux'});
* foo.push(bar);
* foo.push(baz);
* foo.push(qux);
*
* console.log(foo.first.type) // 'bar'
* ```
* @getter
* @name .first
* @return {Object} The first node, or undefiend
* @api public
*/
get first() {
return Array.isArray(this.nodes) ? this.nodes[0] : null;
}
/**
* Get the last child node from `node.nodes`.
*
* ```js
* const foo = new Node({type: 'foo'});
* const bar = new Node({type: 'bar'});
* const baz = new Node({type: 'baz'});
* const qux = new Node({type: 'qux'});
* foo.push(bar);
* foo.push(baz);
* foo.push(qux);
*
* console.log(foo.last.type) // 'qux'
* ```
* @getter
* @name .last
* @return {Object} The last node, or undefiend
* @api public
*/
get last() {
return Array.isArray(this.nodes) ? this.nodes[this.nodes.length - 1] : null;
}
/**
* Get the `node.depth`. The root node has a depth of 0. Add 1 to child nodes
* for each level of nesting.
*
* ```js
* const foo = new Node({type: 'foo'});
* foo.push(bar);
*
* console.log(foo.depth) // 1
* console.log(bar.depth) // 2
* ```
* @getter
* @name .depth
* @return {Object} The last node, or undefiend
* @api public
*/
get depth() {
return this.parent ? this.parent.depth + 1 : 0;
}
/**
* Static method that returns true if the given value is a node.
*
* ```js
* const Node = require('snapdragon-node');
* const node = new Node({type: 'foo'});
* console.log(Node.isNode(node)); //=> true
* console.log(Node.isNode({})); //=> false
* ```
* @name Node#isNode
* @param {Object} `node`
* @returns {Boolean}
* @api public
* @static
*/
static isNode(node) {
return isObject(node) && (node instanceof this || node.isNode === true);
}
}
/**
* Simplified assertion. Throws an error is `value` is not true.
*/
function assert(value, message) {
if (value !== true) throw new Error(message);
}
function expect(value, name) {
assert(value, 'expected ' + name + ' to be an instance of Node');
}
function hasOwn(obj, prop) {
return Object.hasOwnProperty.call(obj, prop);
}
function isEmpty(node, fn) {
expect(Node.isNode(node), 'node');
if (!Array.isArray(node.nodes)) {
if (typeof fn === 'function') {
return fn(node);
}
return !node.value;
}
if (node.nodes.length === 0) {
return true;
}
for (const child of node.nodes) {
if (!isEmpty(child, fn)) {
return false;
}
}
return true;
}
function isType(node, type) {
expect(Node.isNode(node), 'node');
switch (typeOf(type)) {
case 'string':
return node.type === type;
case 'regexp':
return type.test(node.type);
case 'array':
for (const key of type) {
if (node.isType(node, key)) {
return true;
}
}
return false;
default: {
throw new TypeError('expected "type" to be an array, string or regexp');
}
}
}
function isObject(val) {
return typeOf(val) === 'object';
}
function typeOf(val) {
if (typeof val === 'string') return 'string';
if (Array.isArray(val)) return 'array';
if (val instanceof RegExp) {
return 'regexp';
}
if (val === void 0) return 'undefiend';
if (val === null) return 'null';
return typeof val;
}
/**
* assign `token` properties to `node`
*/
function assign(node, token, clone) {
copy(node, token, clone);
ensureNodes(node, clone);
if (token.constructor && token.constructor.name === 'Token') {
copy(node, token.constructor.prototype, clone);
}
}
function copy(receiver, provider, clone) {
const descriptors = Object.getOwnPropertyDescriptors(provider);
for (const key in descriptors) {
if (key === 'constructor' || key in receiver) continue;
const desc = descriptors[key];
if (hasOwn(desc, 'value') && clone === true) {
desc.value = cloneDeep(desc.value);
}
Object.defineProperty(receiver, key, desc);
}
}
function ensureNodes(node, clone) {
if (!node.nodes) return;
if (Array.isArray(node.nodes)) {
const len = node.nodes.length;
for (let i = 0; i < len; i++) {
let child = node.nodes[i];
if (!Node.isNode(child)) {
child = node.nodes[i] = new Node(node.nodes[i], null, true);
child.parent = node;
child.index = i;
}
ensureNodes(child);
}
node.size = len;
}
}
/**
* Deeply clone plain objects and arrays.
*/
function cloneDeep(value) {
const obj = {};
switch (typeOf(value)) {
case 'array':
return value.map(ele => cloneDeep(ele));
case 'object':
for (const key of Object.keys(value)) {
obj[key] = cloneDeep(value[key]);
}
return obj;
default: {
return value;
}
}
}
function visit(node, fn) {
fn(node);
return node.nodes ? mapVisit(node, fn) : node;
}
function mapVisit(node, fn) {
node.nodes.forEach(n => visit(n, fn));
return node;
}
function define(obj, key, value) {
Object.defineProperty(obj, key, {
configurable: true,
enumerable: false,
writable: true,
value: value
});
}
/**
* Expose `Node`
*/
exports = module.exports = Node;