tree-mutate
Version:
n-ary tree mutation library.
408 lines (360 loc) • 10.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.treeMutate = factory());
}(this, (function () { 'use strict';
/**
* Enum of context flags.
*
* @private
* @enum
*/
const FLAGS = {
siblings: 0x0001,
children: 0x0010,
remove: 0x0100,
replace: 0x1000,
walk: 0x0011,
break: 0x0000
};
/**
* Hold data and state during a walk. A context reference is passed each time
* the `iteratee` function is invoked.
*
* The user can then interact with it in order to control how to walk the tree
* by setting/unsetting flags to it. Those flags will alter the walking process
* by bypassing some parts of the tree or adjusting the algorithm in reaction of
* some tree mutations (i.e removing a node).
*
* He also can use it to hold his own persistent data between each invocation.
*/
class Context {
/**
* Create a context.
*/
constructor() {
this._path = [];
this._index = -1;
this.walk();
}
/**
* Walk normally. It's the default flag.
*/
walk() {
this._flags = FLAGS.walk;
}
/**
* Break the walk, any further nodes won't be visited.
*/
break() {
this._flags = FLAGS.break;
}
/**
* Skip current node, any children won't be visited.
*/
skip() {
this._flags = FLAGS.siblings;
}
/**
* Remove current node, any children won't be visited and walk will visit
* the next siblings correctly.
*/
remove() {
this._flags = FLAGS.siblings | FLAGS.remove;
}
/**
* Replace current node with given one, walk will only visit the new node
* children.
*
* @param {Object} node Replacement node.
*/
replace(node) {
this._replace = node;
this._flags = FLAGS.walk | FLAGS.replace;
}
/**
* Set a flag, keeping other flags already set.
*
* @private
* @param {FLAGS} flag Flag to set.
*/
set(flag) {
this._flags |= FLAGS[flag];
}
/**
* Unset a flag, keeping other flags already set.
*
* @private
* @param {FLAGS} flag Flag to unset.
*/
unset(flag) {
this._flags &= ~FLAGS[flag];
}
/**
* Check if context has the given flag set.
*
* @private
* @param {FLAGS} flag Flag to check.
* @return {boolean} `true` if it has the flag, otherwize `false`.
*/
has(flag) {
return (0 !== (this._flags & FLAGS[flag]))
}
/**
* Check if context only has the given flag set.
*
* @private
* @param {FLAGS} flag Flag to check.
* @return {boolean} `true` if it is the only flag set, otherwize `false`.
*/
is(flag) {
return (this._flags === FLAGS[flag])
}
/**
* Get the **path** of the current node. The path is an array of nodes to
* traverse from the root included to the current node.
*
* @return {Array} Array of nodes.
*/
get path() {
return this._path
}
/**
* Get the parent of the current node.
*
* @return {Object} Parent of the current node.
*/
get parent() {
return this._path[this._path.length - 1]
}
/**
* Get the index of the current node.
*
* @return {number} Index of the current node.
*/
get index() {
return this._index
}
/**
* Get the **depth** of the current node. The depth is the number of
* ancestors the current node has.
*
* @return {number} Depth of current node.
*/
get depth() {
return this._path.length
}
/**
* Get the **level** of current node. The level is the number of ancestors+1
* the current node has.
*
* @return {number} Level of current node.
*/
get level() {
return (this._path.length + 1)
}
}
/**
* Iterate over children of a given node.
*
* @private
* @param {Object} node Node to be iterated.
* @param {Function} iteratee Function invoked per child.
* @param {Object} options Options customizing the iteration.
* @param {Context} context Context of the iteration.
* @param {Function} walker Function called to walk recursively.
*/
function eachChild(node, iteratee, options, context, walker) {
const children = node[options.childrenKey];
// early return if no children
if (null == children || 0 === children.length) return
// add parent node to the context path
context.path.push(node);
for (let i = 0; i < children.length; i++) {
const child = children[i];
context._index = i;
walker(child, iteratee, options, context);
// break if context flags do not allow to continue
if (!context.has('siblings')) break
// a node was removed, decrement the index so we don't miss the next node
if (context.has('remove')) {
i--;
context.unset('remove');
}
}
// set back `siblings` flag, if not breaking
if (!context.is('break')) {
context.set('siblings');
}
// remove parent node from the context path
context.path.pop();
}
/**
* Walk recursively in pre-order.
*
* @private
* @param {Object} node Node to be walked.
* @param {Function} iteratee Function invoked per node.
* @param {Object} options Options customizing the walk.
* @param {Context} context Context of the iteration.
*/
function walkPreorder(node, iteratee, options, context) {
iteratee(node, context);
// replace node if context flags say it
if (context.has('replace')) {
node = context._replace;
context.unset('replace');
}
// iterate over children if context flags allow it
if (context.has('children')) {
eachChild(node, iteratee, options, context, walkPreorder);
}
// set back `children`, if not breaking
else if (!context.is('break')) {
context.set('children');
}
}
/**
* Walk recursively in post-order.
*
* @private
* @param {Object} node Node to be walked.
* @param {Function} iteratee Function invoked per node.
* @param {Object} options Options customizing the walk.
* @param {Context} context Context of the iteration.
*/
function walkPostorder(node, iteratee, options, context) {
// special case here, we ignore `skip` as it does not make sense in a
// post-order walk to ignore children
if (context.is('siblings')) {
context.set('siblings');
}
// save current index
const index = context.index;
// iterate over children
eachChild(node, iteratee, options, context, walkPostorder);
// if not breaking, set back `children`
if (!context.is('break')) {
context.set('children');
context._index = index;
iteratee(node, context);
}
}
/**
* Walk options.
*
* @typedef {Object} Options
* @property {string} [childrenKey=children] Name of the node property holding
* an array of children.
* @property {'pre'|'post'} [order=pre] Order of the walk either in pre-order
* or post-order.
*/
/**
* Walk a tree recursively using either **pre-order** or **post-order**
* specified in `options`.
*
* The only requirement for the tree structure is that
* it must have a special property holding an array of its children.
* By default `children` is used, but it can be customized via the
* `childrenKey` option.
*
* @param {Object} root Root node of the tree to be walked.
* @param {Function} iteratee Function invoked per node.
* @param {Options} [options] Options customizing the walk.
*/
function crawl(root, iteratee, options) {
if (null == root) return
// merge options with defaults
options = Object.assign({
childrenKey: 'children',
order: 'pre'
}, options);
// create a context for this walk
const context = new Context();
// walk in `pre`/`post` order
if ('pre' === options.order) {
walkPreorder(root, iteratee, options, context);
}
else {
walkPostorder(root, iteratee, options, context);
}
}
/**
* Mutate node data.
*
* It treats the node atomically and only modifies its own properties.
* Structural properties should be left untouched and modified in the layout
* mutator instead.
* If `null` is returned, the node is marked as removed and processed by the
* layout mutator.
*
* @callback DataMutator
* @param {Object} node Node to be mutated.
* @param {Context} context Walk context.
* @return {Object|undefined|null} The node itself, nothing or `null`.
*/
/**
* Mutate node layout.
*
* It treats the node as a black box that has a position in the tree. It
* modifies its structural properties and may alter ancestors, siblings or
* descendants nodes.
*
* @callback LayoutMutator
* @param {'identity'|'replace'|'remove'} mutation Type of layout mutation.
* @param {Object|null} node Node to be mutated.
* @param {Object} parentNode Parent of the node to be mutated.
*/
/**
* Walk over a **mutable** tree and invoke **mutators** on each node.
*
* Mutators implements mutations at 2 different levels:
* - data level: mutate node data
* - layout level: mutate node layout
*
* @param {Object} root Root node of the tree.
* @param {DataMutator} dataMutator Mutate node data.
* @param {LayoutMutator} layoutMutator Mutate node layout.
* @param {'pre'|'post'} [order] Walk order.
* @return {Object} The mutated tree.
*/
function mutate(root, dataMutator, layoutMutator, order) {
// both mutators are mandatory
if ('function' !== typeof dataMutator) {
throw new TypeError('dataMutator is not a function');
}
if ('function' !== typeof layoutMutator) {
throw new TypeError('layoutMutator is not a function');
}
crawl(root, function (node, context) {
// mutate node data
var ret = dataMutator(node, context);
// if `null` was returned then layout mutator will have to remove the node,
// if a different node was returned the it will have to replace the node.
var layoutMutation = null !== ret ? undefined === ret || ret === node ? 'identity' : 'replace' : 'remove';
// if a **remove** layout mutation is scheduled, the library adapts the
// walk behavior in consequence: root will simply break the walk, any other
// node is marked as removed.
if ('remove' === layoutMutation) {
if (0 === context.depth) {
root = ret;
layoutMutation = 'identity';
context.break();
} else {
context.remove();
}
} else if ('replace' === layoutMutation) {
if (0 === context.depth) {
root = ret;
layoutMutation = 'identity';
}
node = ret;
context.replace(node);
}
// mutate node layout
layoutMutator(layoutMutation, node, context.parent, context.index);
}, { order: order || 'pre' });
return root;
}
return mutate;
})));