mako-tree
Version:
The build tree structure used internally by mako
440 lines (396 loc) • 12.7 kB
JavaScript
'use strict'
require('babel-polyfill-safe')
let bytes = require('bytes')
let debug = require('debug')('mako-tree')
let defaults = require('defaults')
let File = require('./file')
let iso = require('regex-iso-date')()
let Graph = require('graph.js/dist/graph.js')
let pretty = require('pretty-time')
let relative = require('relative')
let toposort = require('graph-toposort')
/**
* Represents a dependency graph for the builder.
*
* @class
*/
class Tree {
/**
* Creates a new instance, particularly creating the Graph instance.
*
* @param {String} [root] The project root. (default: pwd)
*/
constructor (root) {
debug('initialize')
this.root = root || null
this.graph = new Graph()
}
/**
* Checks to see if the given file ID exists in the tree.
*
* @param {File|String} file The file or string ID.
* @return {Boolean}
*/
hasFile (file) {
return this.graph.hasVertex(id(file))
}
/**
* Adds the file with the given `params` to the tree. If a file with that
* path already exists in the tree, that is returned instead.
*
* @param {Object} params The vinyl params for this file.
* @return {File}
*/
addFile (params) {
if (typeof params === 'string') {
params = { base: this.root, path: params }
} else {
params.base = this.root
}
let file = new File(params, this)
debug('adding file: %s', relative(file.path))
this.graph.addNewVertex(file.id, file)
return file
}
/**
* Returns the `File` with the given `id`.
*
* @param {String} file The file ID.
* @return {File}
*/
getFile (file) {
return this.graph.vertexValue(file)
}
/**
* Iterates through the files looking for one that matches the input path.
*
* @param {File|String} file The path to search for.
* @return {File}
*/
findFile (file) {
let timer = time()
let path = typeof file === 'string' ? file : file.path
debug('searching for file with path %s', relative(path))
for (let vertex of this.graph.vertices()) {
let file = vertex[1]
if (file.hasPath(path)) {
debug('match found: %s (took %s)', relative(file.path), timer())
return file
}
}
debug('file not found (took %s)', timer())
}
/**
* Retrieve a list of file paths based on the given criteria.
*
* Available `options`:
* - `topological` sort the files topologically
*
* @param {Object} [options] The filter criteria.
* @return {Array}
*/
getFiles (options) {
let config = defaults(options, { topological: false })
let timer = time()
debug('getting %d files: %j', this.size(), config)
let files = config.topological
? toposort(this.graph).map(id => this.getFile(id))
: Array.from(this.graph.vertices()).map(v => v[1])
debug('finished getting %d files (took %s)', files.length, timer())
return files
}
/**
* Remove the file with the given `id` from the graph.
*
* Available `options`:
* - `force` removes the file even if dependencies/dependants exist
*
* @param {File|String} node The file or string ID.
* @param {Object} [options] Additional options.
*/
removeFile (node, options) {
let config = defaults(options, { force: false })
let file = this.getFile(id(node))
debug('removing file %s: %j', relative(file.path), config)
if (config.force) {
this.graph.destroyVertex(file.id)
} else {
this.graph.removeVertex(file.id)
}
}
/**
* Checks to see if the given `parent` has a link to dependency `child`.
*
* @param {String|File} parent The parent file (or it's string ID).
* @param {String|File} child The child file (or it's string ID).
* @return {Boolean}
*/
hasDependency (parent, child) {
return this.graph.hasEdge(id(child), id(parent))
}
/**
* Sets up the file `child` as a dependency of `parent`.
*
* @param {String|File} parent The parent file (or it's string ID).
* @param {String|File} child The child file (or it's string ID).
*/
addDependency (parent, child) {
let childId = id(child)
let parentId = id(parent)
this.graph.addEdge(childId, parentId)
let childPath = relative(this.getFile(childId).path)
let parentPath = relative(this.getFile(parentId).path)
debug('added dependency %s -> %s', childPath, parentPath)
}
/**
* Removes the dependency `child` from the `parent` file.
*
* @param {String|File} parent The parent file (or it's string ID).
* @param {String|File} child The child file (or it's string ID).
*/
removeDependency (parent, child) {
let childId = id(child)
let parentId = id(parent)
this.graph.removeEdge(childId, parentId)
let childPath = relative(this.getFile(childId).path)
let parentPath = relative(this.getFile(parentId).path)
debug('removed dependency %s -> %s', childPath, parentPath)
}
/**
* Return a list of all files that the given `node` file depends on.
*
* Available `options`:
* - `recursive` when set, go recursively down the entire graph
*
* @param {String|File} node The parent file (or it's string ID).
* @param {Object} [options] The search criteria.
* @return {Array}
*/
dependenciesOf (node, options) {
let timer = time()
let config = defaults(options, { recursive: false })
let file = this.getFile(id(node))
debug('getting dependencies of %s: %j', relative(file.path), config)
let deps = config.recursive
? Array.from(this.graph.verticesWithPathTo(file.id))
: Array.from(this.graph.verticesTo(file.id))
debug('%d dependencies found (took %s)', deps.length, timer())
return deps.map(v => v[1])
}
/**
* Checks to see if the given `child` has a link to dependant `parent`.
*
* @param {String|File} child The child file (or it's string ID).
* @param {String|File} parent The parent file (or it's string ID).
* @return {Boolean}
*/
hasDependant (child, parent) {
return this.graph.hasEdge(id(child), id(parent))
}
/**
* Sets up the given `parent` as a dependant of `child`. In other words,
* the reverse of addDependency()
*
* @param {String|File} child The child file (or it's string ID).
* @param {String|File} parent The parent file (or it's string ID).
*/
addDependant (child, parent) {
let childId = id(child)
let parentId = id(parent)
this.graph.addEdge(childId, parentId)
let childPath = relative(this.getFile(childId).path)
let parentPath = relative(this.getFile(parentId).path)
debug('added dependant %s <- %s', childPath, parentPath)
}
/**
* Removes the dependant `parent` from the `child` file.
*
* @param {String|File} child The child file (or it's string ID).
* @param {String|File} parent The parent file (or it's string ID).
*/
removeDependant (child, parent) {
let childId = id(child)
let parentId = id(parent)
this.graph.removeEdge(childId, parentId)
let childPath = relative(this.getFile(childId).path)
let parentPath = relative(this.getFile(parentId).path)
debug('removed dependant %s <- %s', childPath, parentPath)
}
/**
* Return a list of all files that depend on the given `node` file.
*
* Available `options`:
* - `recursive` when set, go recursively down the entire graph
*
* @param {String|File} node The child file (or it's string ID).
* @param {Object} [options] The search criteria.
* @return {Array}
*/
dependantsOf (node, options) {
let timer = time()
let config = defaults(options, { recursive: false })
let file = this.getFile(id(node))
debug('getting dependants of %s: %j', relative(file.path), config)
let deps = config.recursive
? Array.from(this.graph.verticesWithPathFrom(id(file)))
: Array.from(this.graph.verticesFrom(id(file)))
debug('%d dependants found (took %s)', deps.length, timer())
return deps.map(v => v[1])
}
/**
* Tells us how large the underlying graph is.
*
* @return {Number}
*/
size () {
return this.graph.vertexCount()
}
/**
* Returns a clone of the current `Tree` instance.
*
* @return {Tree}
*/
clone () {
debug('cloning tree')
let timer = time()
let tree = new Tree()
tree.graph = this.graph.clone(file => file.clone(tree), value => value)
debug('cloned tree (took %s)', timer())
return tree
}
/**
* Remove any files that cannot be reached from the given `anchors`.
*
* @param {Array} anchors A list of files to anchor others to.
*/
prune (anchors) {
let timer = time()
let initialSize = this.size()
let files = anchors.map(file => this.getFile(id(file)))
debug('pruning files from tree that are not accessible from:')
files.forEach(file => debug('> %s', relative(file.path)))
let deps = new Set()
files.forEach(file => {
deps.add(file)
this.dependenciesOf(file, { recursive: true })
.forEach(file => deps.add(file))
})
this.getFiles({ topological: true })
.filter(file => !deps.has(file))
.forEach(file => this.removeFile(file, { force: true }))
debug('%d files pruned from tree (took %s)', initialSize, timer())
}
/**
* Forcibly make this graph acyclic.
*/
removeCycles () {
debug('removing cycles from tree')
let timer = time()
let graph = this.graph
let cycles = Array.from(graph.cycles())
cycles.forEach(cycle => {
let files = cycle.map(file => this.getFile(file))
debug('cycle detected:')
files.forEach(file => debug('> %s (degree: %d)', relative(file.path), graph.outDegree(file.id)))
// prefer to remove edges where the degree is higher, in an attempt to
// avoid altering the graph more than necessary.
let degrees = files.map(file => graph.outDegree(file.id))
let highest = degrees.indexOf(Math.max.apply(Math, degrees))
let child = files[highest]
let parent = files[highest + 1] ? files[highest + 1] : files[0]
this.removeDependency(parent, child)
})
debug('removed %d cycles (took %s)', cycles.length, timer())
}
/**
* Returns a trimmed object that can be serialized as JSON. It includes a list
* of vertices and edges for reconstructing the underlying graph.
*
* @return {Object}
*/
toJSON () {
debug('convert to json')
let timer = time()
let o = {
root: this.root,
files: this.getFiles(),
dependencies: Array.from(this.graph.edges()).map(e => e.slice(0, 2))
}
debug('converted to json (took %s)', timer())
return o
}
/**
* Serializes the tree into a plain JSON string for writing to storage.
* (probably disk)
*
* @param {Number} space The JSON.stringify space parameter.
* @return {String}
*/
toString (space) {
debug('convert to string')
let timer = time()
let str = JSON.stringify(this, null, space)
debug('converted to %s string (took %s)', bytes(str.length), timer())
return str
}
/**
* Used to parse a string value into a usable tree.
*
* @static
* @param {String} input The raw JSON string to parse.
* @return {Tree}
*/
static fromString (input) {
debug('convert from string')
let timer = time()
let parsed = JSON.parse(input, reviver)
let tree = new Tree(parsed.root)
parsed.files.forEach(o => {
let file = File.fromObject(o, tree)
debug('file from cache: %s', file.id)
tree.graph.addNewVertex(file.id, file)
})
parsed.dependencies.forEach(e => {
debug('dependency from cache: %s', e.join(' '))
tree.graph.addNewEdge(e[0], e[1])
})
debug('converted from %s string (took %s)', bytes(input.length), timer())
return tree
}
}
// single export
module.exports = Tree
/**
* Helper for retrieving a file id.
*
* @param {File|String} file The file object or a string id.
* @return {String}
*/
function id (file) {
return file instanceof File ? file.id : file
}
/**
* JSON.parse reviver param for restoring buffers and dates to file objects.
*
* @param {String} key See JSON.parse reviver documentation
* @param {String} value See JSON.parse reviver documentation
* @return {Mixed}
*/
function reviver (key, value) {
if (value && value.type === 'Buffer') {
return new Buffer(value.data)
}
if (typeof value === 'string' && iso.test(value)) {
return new Date(value)
}
return value
}
/**
* Start a timer that we can use for logging timing information.
*
* @return {Function}
*/
function time () {
let start = process.hrtime()
return () => pretty(process.hrtime(start))
}