closure-util
Version:
Utilities for Closure Library based projects.
354 lines (311 loc) • 8.88 kB
JavaScript
var EventEmitter = require('events').EventEmitter;
var path = require('path');
var util = require('util');
var async = require('async');
var Gaze = require('gaze').Gaze;
var log = require('npmlog');
var config = require('./config');
var globs = require('./util').globs;
var getLibraryPath = require('./util').getLibraryPath;
var minimatches = require('./util').minimatches;
var scripts = require('./scripts');
/**
* Configurable log level.
* @type {string}
*/
log.level = config.get('log_level');
/**
* Script manager.
* @param {Object} options Manager options.
* @constructor
* @extends {EventEmitter}
*/
var Manager = exports.Manager = function Manager(options) {
EventEmitter.call(this);
options = options || {};
/**
* Cache of managed scripts.
* @type {Object.<string, Script}
*/
this._scripts = {};
/**
* Cache of parsing errors per script.
* @type {Object.<string, Error}
*/
this._errors = {};
/**
* Cache of the sorted dependencies.
* @type {Object.<string, Array.<Script>>}
*/
this._dependencies = {};
/**
* Root for lib and main file patterns.
* @type {string}
*/
this._cwd = options.cwd || process.cwd();
var lib = options.lib || [];
if (!Array.isArray(lib)) {
lib = [lib];
}
/**
* Lib glob patterns.
* @type {Array.<string>}
*/
this._lib = lib;
var main = options.main || [];
if (!Array.isArray(main)) {
main = [main];
}
/**
* Main glob patterns.
* @type {Array.<string>}
*/
this._main = main;
/**
* Ignore requires pattern.
* @type {RegExp}
*/
this._ignoreRequires = options.ignoreRequires ?
new RegExp(options.ignoreRequires) : null;
/**
* Gaze instance.
* @type {gaze.Gaze}
*/
this._gaze = null;
/**
* The manager is closed.
* @type {boolean}
*/
this._closed = false;
var paths = lib.concat(main);
if (options.closure !== false) {
var closure = getLibraryPath();
paths = paths.concat([
path.join(closure, 'closure', 'goog', '**', '*.js'),
path.join(closure, 'third_party', 'closure', 'goog', '**', '*.js')
]);
}
process.nextTick(function() {
this._processPaths(paths);
}.bind(this));
};
util.inherits(Manager, EventEmitter);
/**
* Add a script to the manager.
* @param {Script} script Script to add.
*/
Manager.prototype._addScript = function(script) {
var filepath = script.path;
if (this._scripts.hasOwnProperty(filepath)) {
throw new Error('Script with same path already added: ' + filepath);
}
log.verbose('manager', 'Adding ' + filepath);
this._scripts[filepath] = script;
this._dependencies = {};
};
/**
* Get a list of scripts sorted in dependency order.
* @param {string=} opt_main Optional main script (must already be included in
* currently managed paths).
* @return {Array.<Script>} List of scripts.
*/
Manager.prototype.getDependencies = function(opt_main) {
var main = opt_main && path.resolve(opt_main);
var scriptsLookup = this._scripts;
var ignoreRequires = this._ignoreRequires;
if (main && !scriptsLookup.hasOwnProperty(main)) {
throw new Error('Main script not currently managed: ' + main);
}
var mainKey = main || '*';
if (!this._dependencies.hasOwnProperty(mainKey)) {
var providesLookup = {};
var base = [];
var depsOnly = [];
Object.keys(scriptsLookup).forEach(function(filepath) {
var script = scriptsLookup[filepath];
script.provides.forEach(function(provide) {
if (provide === 'goog') {
base.push(filepath);
}
if (providesLookup.hasOwnProperty(provide)) {
throw new Error('Redundant provide "' + provide + '" ' +
'in script: ' + filepath + ' - already provided by ' +
providesLookup[provide].path);
}
providesLookup[provide] = script;
});
if (script.provides.length === 0 && script.requires.length === 0 &&
script.addsDependencies) {
depsOnly.push(script);
}
});
var visited = {};
var dependencies = [];
// check for base
if (base.length === 0) {
throw new Error('Could not find base.js');
} else if (base.length > 1) {
throw new Error('Found more than one base: ' + base.join(' '));
}
// seed dependencies with base.js
visited[base[0]] = true;
dependencies[0] = scriptsLookup[base[0]];
var visit = function(script) {
if (!visited.hasOwnProperty(script.path)) {
visited[script.path] = true;
script.requires.forEach(function(require) {
if (!providesLookup.hasOwnProperty(require)) {
if (!ignoreRequires || !require.match(ignoreRequires)) {
throw new Error('Unsatisfied dependency "' + require + '" ' +
'in script: ' + script.path);
}
} else {
visit(providesLookup[require]);
}
});
// do not include scripts that neither provide nor require
if (script.provides.length > 0 || script.requires.length > 0) {
dependencies.push(script);
}
}
};
if (main) {
visit(scriptsLookup[main]);
} else {
var lib = this._lib;
var cwd = this._cwd;
Object.keys(scriptsLookup).forEach(function(filepath) {
/**
* Bundled closure library includes test and debug scripts that should
* not be included unless explicitly required. Any main scripts should
* not be included unless explicitly required.
*/
if (minimatches(path.relative(cwd, filepath), lib)) {
visit(scriptsLookup[filepath]);
}
});
}
if (depsOnly.length > 0) {
dependencies = dependencies.concat(depsOnly);
}
this._dependencies[mainKey] = dependencies;
}
return this._dependencies[mainKey];
};
/**
* Get any cached parsing errors.
* @return {Array.<Error>} Cached parsing errors.
*/
Manager.prototype.getErrors = function() {
var errors = this._errors;
return Object.keys(errors).map(function(key) {
return errors[key];
});
};
/**
* Get a script given the absolute path to a script.
* @param {string} filepath Absolute path to script.
* @return {Script} The script (or null if not found).
*/
Manager.prototype.getScript = function(filepath) {
return this._scripts[filepath] || null;
};
/**
* Add all scripts from provided path patterns.
* @param {Array.<string>} paths Paths patterns to process.
*/
Manager.prototype._processPaths = function(paths) {
var self = this;
async.waterfall([
function(callback) {
globs(paths, {cwd: self._cwd}, callback);
},
function(paths, callback) {
paths = paths.map(function(relative) {
return path.resolve(self._cwd, relative);
});
async.map(paths, scripts.read, callback);
},
function(results, callback) {
var err;
try {
results.forEach(self._addScript.bind(self));
} catch (e) {
err = e;
}
callback(err);
}
], function(err) {
if (err) {
log.verbose('manager', err);
self.emit('error', err);
} else {
self.emit('beforewatch');
self._startWatch();
}
});
};
/**
* Start watching managed file paths.
*/
Manager.prototype._startWatch = function() {
if (this._closed) {
return;
}
var paths = this._lib.concat(this._main);
var gaze = new Gaze(paths);
gaze.on('changed', this._handleChanged.bind(this));
gaze.on('added', function(filepath) {
// TODO: remove this when gaze@0.5 is published
if (minimatches(path.relative(this._cwd, filepath), paths)) {
this._handleChanged(filepath);
}
}.bind(this));
gaze.on('deleted', this._handleDeleted.bind(this));
this._gaze = gaze;
this.emit('ready');
};
/**
* Handle script addition and modification.
* @param {string} filepath Script path.
*/
Manager.prototype._handleChanged = function(filepath) {
var self = this;
scripts.read(filepath, function(err, script) {
if (err) {
log.verbose('manager', err);
self._errors[filepath] = err;
return self.emit('error', err);
} else {
delete self._errors[filepath];
}
log.verbose('manager', 'updated ' + filepath);
delete self._scripts[filepath];
self._addScript(script);
self.emit('update', script);
});
};
/**
* Handle script removal.
* @param {string} filepath Script path.
*/
Manager.prototype._handleDeleted = function(filepath) {
delete this._scripts[filepath];
delete this._errors[filepath];
this._dependencies = {};
log.verbose('manager', 'deleted ' + filepath);
this.emit('update');
};
/**
* Stop watching all files.
*/
Manager.prototype.close = function() {
if (this._gaze) {
this._gaze.close();
delete this._gaze;
}
if (!this._closed) {
this._closed = true;
this.emit('close');
}
};