pcjs/modules/grunts/prepjs/tasks/prepjs.js
2014-12-29 23:06:28 -08:00

401 lines
17 KiB
JavaScript

/**
* @fileoverview Pre-process JavaScript file(s) with well-defined constants inlined
* @author <a href="mailto:Jeff@pcjs.org">Jeff Parsons</a> (@jeffpar)
* @version 1.1
* Created 2014-Mar-22
*
* Copyright © 2012-2015 Jeff Parsons <Jeff@pcjs.org>
*
* This file is part of C1Pjs, PCjs, and other related components written
* by Jeff Parsons and originally published at cpusim.org and jsmachines.net.
*
* C1Pjs and PCjs are free software: you can redistribute them and/or modify
* them under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* C1Pjs and PCjs are distributed in the hope that they will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with C1Pjs and PCjs. If not, see <http://www.gnu.org/licenses/gpl.html>.
*
* You are required to include the above copyright notice in every source
* code file of every copy or modified version of this work, and to display
* that copyright notice on every screen that loads or runs any version
* of this software (see Computer.sCopyright).
*
* Some C1Pjs and PCjs files also attempt to load external resource files, such
* as character-image files and ROM files. Those external resource files are
* not considered part of the PCjs program for purposes of the GNU General Public
* License, and the author does not claim any copyright as to their contents.
*/
/*
* Options
* ---
* The 'includeObjectConstants' option allows replacement of constants defined
* within objects, instead of only global constants. Use with caution, because
* constants defined within an object scope may not be unique. This does attempt
* to catch any constant collisions and completely disable their replacement, but
* it's not foolproof.
*
* History
* ---
* Although we run all our code through Google's Closure Compiler, which does a
* great job of inlining not only variables but also code to improve performance,
* JavaScript doesn't have the notion of "constants", so we have to define all our
* constants as properties and simply trust that the Closure Compiler will inline
* them all. I'm not completely trusting, so I've created this script that allows
* us to verify the compiler produces substantially the same code whether or not
* we inline all our constants first.
*
* See "inline.php" for the original PHP version of this module.
*
* Conventions
* ---
* This script looks for global constant definitions of the form "Component.XXX = YYY;".
*
* The current approach requires all inlined constants to be defined as properties on
* the associated class constructor (ie, as "class constants"). Life might be simpler
* if the Closure Compiler honored "@const" references for properties, and who knows,
* perhaps the latest version does now; but on the other hand, having my own convention
* relieves me from having to annotate every single constant with a "@const" JSDoc tag.
*
* TODO: Update the C1Pjs sources to use class constants instead of "object constants",
* because in order for C1Pjs to benefit from constant inlining, we must reply on the
* 'includeObjectConstants' option (hack) to expand the contexts that constants may live
* in, which is inherently less safe. Moreover, that option prevents us from removing
* the original constant definitions, because constants like "this.PORT_CRA" could be used
* in other contexts that this script will NOT catch (eg, "controller.PORT_CRA").
*
* TODO: Think about adding another quick hack to this tool, to convert all:
*
* at-param {Debugger} dbg
* to:
* at-param {Component} dbg
*
* prior to compilation. The only reason I declared my "dbg" variables generically,
* as Component objects rather than Debugger objects, was to work around compilation
* errors in the non-Debugger builds.
*
* Implementation
* ---
* This script uses a very simplistic replacement approach that doesn't perform any
* parsing, tokenizing or other pre-processing of the source code, which would otherwise
* be required if we wanted to guarantee that all our replacements precisely mirrored what
* JavaScript actually replaces at run-time. For example, JavaScript allows any so-called
* constant to be redefined at any point, and we don't attempt to catch modifications.
*
* This is why it's important that we limit inlining to only those constants described
* above, and why such constants must never be altered by the code using them.
*
* Debugging
* ===
* Use the following command to debug this task (after making Chrome your default browser):
*
* node-debug $(which grunt) prepjs
*
* which required installing "node-inspector" first:
*
* sudo npm install -g node-inspector
*
* You may also want to enable the heapdump code below, which required installing "heapdump" first:
*
* npm install heapdump --save-dev
*
* TODO: Resolve once and for all the "process out of memory" error that occurs if we don't divide
* the src input into smaller chunks. See the WARNING below.
*/
'use strict';
// var heapdump = require("heapdump");
/**
* compareConstants(a, b)
*
* @param {Array} a
* @param {Array} b
* @returns {number}
*/
var compareConstants = function(a, b)
{
return b[0].length - a[0].length;
};
/**
* indexOfConstant(sConstant, aConstants)
*
* @param {string} sConstant
* @param {Array} aConstants
* @returns {number} index of aConstants entry, or -1 if not found
*/
var indexOfConstant = function(sConstant, aConstants)
{
for (var i = 0; i < aConstants.length; i++) {
if (sConstant == aConstants[i][0]) {
return i;
}
}
return -1;
};
/**
* findConstants(sInput)
*
* @param {string} sInput
* @param {Array} aConstants
* @param {boolean} fObjectConstants
* @param {boolean} fReplaceConstants
* @param {function} [fnLog]
* @returns {string} modified input
*/
var findConstants = function(sInput, aConstants, fObjectConstants, fReplaceConstants, fnLog) {
var sWarning = "";
var sConstDef = "[A-Z][A-Za-z0-9_]*";
if (fObjectConstants) {
sConstDef = "(?:" + sConstDef + "|this)";
}
var aConstDef;
var reConstDef = new RegExp("[ \t]*(" + sConstDef + "\\.[A-Z_][A-Z0-9_\\.]*)\\s*=\\s*(.*?)\\s*;[\t ]*(?://[^\n]*|)\n", "g");
while (aConstDef = reConstDef.exec(sInput)) {
var i;
var sFind = aConstDef[1];
var sReplace = aConstDef[2];
if (fReplaceConstants && !fObjectConstants) {
sInput = sInput.substr(0, aConstDef.index) + sInput.substr(aConstDef.index + aConstDef[0].length);
reConstDef.lastIndex -= aConstDef[0].length;
}
if ((i = indexOfConstant(sFind, aConstants)) >= 0) {
sWarning += "/*\n * warning: multiple definitions for '" + sFind + "' (" + sReplace + ")\n */\n";
aConstants[i][2] = -1; // set a negative replacement count to disable this constant definition
continue;
}
/*
* If the replacement string is entirely quoted, or parenthesized, or a single constant, then we can leave
* the replacement string as-is; otherwise, let's wrap it with parentheses (we could probably wrap everything
* with parentheses, but I like to avoid doing that whenever it's completely unnecessary).
*/
if (!sReplace.match(/^["'\(].*["'\)]$/) && sReplace.match(/[^A-Za-z0-9\.]/)) {
sReplace = "(" + sReplace + ")";
}
if (fnLog) {
fnLog("found '" + sFind + "' => '" + sReplace + "'");
/*
if (sFind == "Video.CRT.CURSOR_END") {
fnLog("here's where we usually run of memory (" + process.cwd() + ")");
heapdump.writeSnapshot();
}
*/
}
aConstants.push([sFind, sReplace, 0]);
}
if (sWarning) sInput = sWarning + sInput;
return sInput;
};
/**
* replaceConstants(sInput, aConstants, fObjectConstants, fInConstant, fnLog)
*
* @param {string} sInput
* @param {Array} aConstants
* @param {boolean} fObjectConstants
* @param {boolean} [fInConstant]
* @param {function} [fnLog]
* @returns {string} modified input
*/
var replaceConstants = function(sInput, aConstants, fObjectConstants, fInConstant, fnLog)
{
do {
var cReplacements = 0;
for (var i = 0; i < aConstants.length; i++) {
if (aConstants[i][2] < 0) continue; // skip any replacement for which we recorded multiple definitions (ie, negative replacement count)
var sFind = aConstants[i][0];
var sReplace = aConstants[i][1];
var cchFind = sFind.length;
var cchReplace = sReplace.length;
var iNext = 0;
while ((iNext = sInput.indexOf(sFind, iNext)) >= 0) {
if (fObjectConstants) {
/*
* As discussed earlier, the 'includeObjectConstants' option precludes removing any constant definitions,
* so we must additionally ensure that we don't inadvertently perform replacements on those definitions.
*/
if (sInput.substr(iNext, 1024).match(/([A-Za-z][A-Za-z0-9_]*\.[A-Z_][A-Z0-9_\.]*)\s*=\s*(.*?)\s*;[\t ]*(?:\/\/[^\n]*|)\n/)) {
iNext += cchFind;
continue;
}
}
if (fnLog) {
fnLog("replaced '" + sFind + "' with '" + sReplace + "'");
}
sInput = sInput.substr(0, iNext) + sReplace + sInput.substr(iNext + cchFind);
aConstants[i][2]++;
cReplacements++;
iNext += cchReplace;
}
/*
* If we've just done any replacements within another constant, then start the process over again
* with the longest constant, to ensure we don't perform any partial replacements.
*/
if (fInConstant && cReplacements) break;
}
} while (cReplacements);
return sInput;
};
module.exports = function(grunt) {
// Please see the Grunt documentation for more information regarding task
// creation: http://gruntjs.com/creating-tasks
grunt.registerMultiTask('prepjs', 'JS Preprocessor', function() {
/*
* Merge task-specific and/or target-specific options with these defaults.
*/
var options = this.options({
includeObjectConstants: false,
listConstants: true,
replaceConstants: true
});
/*
* Iterate over all specified file groups.
*
* See http://gruntjs.com/inside-tasks#this.files, which says in part:
*
* Your task should iterate over the this.files array, utilizing the src and dest
* properties of each object in that array. The this.files property will always be an array.
* The src property will also always be an array, in case your task cares about multiple
* source files per destination file.
*
* And http://gruntjs.com/configuring-tasks#files-array-format, which explains that all files
* objects support src and dest but the 'Files Array' format supports a few additional properties:
*
* filter: Either a valid fs.Stats method name or a function that is passed the matched
* src filepath and returns true or false;
*
* nonull: If set to true then the operation will include non-matching patterns. Combined
* with grunt's --verbose flag, this option can help debug file path issues;
*
* dot: Allow patterns to match filenames starting with a period, even if the pattern does
* not explicitly have a period in that spot;
*
* matchBase: If set, patterns without slashes will be matched against the basename of the path
* if it contains slashes. For example, a?b would match the path /xyz/123/acb, but not /xyz/acb/123;
*
* expand: Process a dynamic src-dest file mapping, see "Building the files object dynamically"
* for more information.
*/
this.files.forEach(function(file) {
/*
* Allow any of our options to be set at the target level as well
*/
var fObjectConstants = file.includeObjectConstants;
if (fObjectConstants === undefined) fObjectConstants = options.includeObjectConstants;
var fListConstants = file.listConstants;
if (fListConstants === undefined) fListConstants = options.listConstants;
var fReplaceConstants = file.replaceConstants;
if (fReplaceConstants === undefined) fReplaceConstants = options.replaceConstants;
/*
* Read all file contents into src
*/
var src = file.src.filter(function(sFilePath) {
/*
* Warn on and remove invalid source files (if nonull was set)
*/
if (!grunt.file.exists(sFilePath)) {
grunt.log.warn('Source file "' + sFilePath + '" not found.');
return false;
}
return true;
}).map(function(sFilePath) {
/*
* Read a file
*/
return grunt.file.read(sFilePath);
}).join("\n");
/*
* Find all the constants in src, and remove their definitions from src if possible
* (ie, if fReplaceConstants is true and fObjectConstants is false).
*
* aConstants is the array of constant definitions, where each definition is another
* 3-element array:
*
* [0]: the original string (ie, the name of the constant)
* [1]: the replacement string (ie, the value of the constant)
* [2]: a replacement count, initialized to zero; set to -1 if a duplicate is found
*
* WARNING: Grunt (ie, Node) fails with the following error:
*
* FATAL ERROR: CALL_AND_RETRY_2 Allocation failed - process out of memory
*
* when processing a large src stream (eg, on the order 1.5Mb), and it always seems to
* die in findConstants()'s call to RegExp's exec() method. I couldn't glean any clues
* from the heapdump (other than observing that, yes, running Grunt generates a shitload of
* objects and is probably not very well-tuned), so I've implemented a simple work-around:
* divide the src stream into two parts. If necessary, this work-around can easily be
* generalized to N parts. If the problem is a direct side-effect of passing very large
* strings to the RegExp library, then this is clearly one way to avoid that problem.
*/
var aConstants = [];
var i = src.length/2;
i = src.indexOf("\n", i) + 1; // divide the src after the first linefeed beyond the midpoint
var src1 = src.substr(0, i);
var src2 = src.substr(i);
// grunt.log.writeln("findConstants(src1): " + src1.length + " chars");
src1 = findConstants(src1, aConstants, fObjectConstants, fReplaceConstants);
// grunt.log.writeln("findConstants(src2): " + src2.length + " chars");
src2 = findConstants(src2, aConstants, fObjectConstants, fReplaceConstants);
/*
* Sort the constants in order of longest definition to shortest, because we perform all the replacements
* in array order, and we can't allow definitions that are subsets of longer definitions to be replaced first.
*/
// grunt.log.writeln("aConstants.sort()");
aConstants.sort(compareConstants);
// grunt.log.writeln("replacing constants in other constants");
aConstants.forEach(function(constant) {
constant[1] = replaceConstants(constant[1], aConstants, fObjectConstants, true);
});
if (fReplaceConstants) {
// grunt.log.writeln("replaceConstants(src1)");
src1 = replaceConstants(src1, aConstants, fObjectConstants, false);
// grunt.log.writeln("replaceConstants(src2)");
src2 = replaceConstants(src2, aConstants, fObjectConstants, false);
// grunt.log.writeln("replaceConstants() complete");
}
var sListing = "";
if (fListConstants) {
// grunt.log.writeln("listing constants");
sListing += "/*\n * List of grunt-prepjs replacements:\n *\n";
for (i = 0; i < aConstants.length; i++) {
sListing += " * " + aConstants[i][0] + " => " + aConstants[i][1] + " (" + aConstants[i][2] + " occurrences)\n";
}
sListing += " */\n";
}
/*
* Write the destination file
*/
// grunt.log.writeln("writing " + f.dest);
grunt.file.write(file.dest, sListing + src1 + src2);
/*
* Print a success message
*/
grunt.log.writeln('File "' + file.dest + '" created.');
});
});
};