Files
meteor/packages/coffeescript/plugin/compile-coffeescript.js
2015-07-06 15:25:11 -07:00

268 lines
9.7 KiB
JavaScript

var fs = Npm.require('fs');
var path = Npm.require('path');
var _ = Npm.require('underscore');
var sourcemap = Npm.require('source-map');
var LRU = Npm.require('lru-cache');
// The coffee-script compiler overrides Error.prepareStackTrace, mostly for the
// use of coffee.run which we don't use. This conflicts with the tool's use of
// Error.prepareStackTrace to properly show error messages in linked code. Save
// the tool's one and restore it after coffee-script clobbers it.
var prepareStackTrace = Error.prepareStackTrace;
var coffee = Npm.require('coffee-script');
Error.prepareStackTrace = prepareStackTrace;
var CACHE_SIZE = process.env.METEOR_COFFEESCRIPT_CACHE_SIZE || 1024*1024*10;
var CACHE_DEBUG = !! process.env.METEOR_TEST_PRINT_CACHE_DEBUG;
var stripExportedVars = function (source, exports) {
if (!exports || _.isEmpty(exports))
return source;
var lines = source.split("\n");
// We make the following assumptions, based on the output of CoffeeScript
// 1.7.1.
// - The var declaration in question is not indented and is the first such
// var declaration. (CoffeeScript only produces one var line at each
// scope and there's only one top-level scope.) All relevant variables
// are actually on this line.
// - The user hasn't used a ###-comment containing a line that looks like
// a var line, to produce something like
// /* bla
// var foo;
// */
// before an actual var line. (ie, we do NOT attempt to figure out if
// we're inside a /**/ comment, which is produced by ### comments.)
// - The var in question is not assigned to in the declaration, nor are any
// other vars on this line. (CoffeeScript does produce some assignments
// but only for internal helpers generated by CoffeeScript, and they end
// up on subsequent lines.)
// XXX relax these assumptions by doing actual JS parsing (eg with jsparse).
// I'd do this now, but there's no easy way to "unparse" a jsparse AST.
// Or alternatively, hack the compiler to allow us to specify unbound
// symbols directly.
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var match = /^var (.+)([,;])$/.exec(line);
if (!match)
continue;
// If there's an assignment on this line, we assume that there are ONLY
// assignments and that the var we are looking for is not declared. (Part
// of our strong assumption about the layout of this code.)
if (match[1].indexOf('=') !== -1)
continue;
// We want to replace the line with something no shorter, so that all
// records in the source map continue to point at valid
// characters.
var replaceLine = function (x) {
if (x.length >= lines[i].length) {
lines[i] = x;
} else {
lines[i] = x + new Array(1 + (lines[i].length - x.length)).join(' ');
}
};
var vars = match[1].split(', ');
vars = _.difference(vars, exports);
if (!_.isEmpty(vars)) {
replaceLine("var " + vars.join(', ') + match[2]);
} else {
// We got rid of all the vars on this line. Drop the whole line if this
// didn't continue to the next line, otherwise keep just the 'var '.
if (match[2] === ';')
replaceLine('');
else
replaceLine('var');
}
break;
}
return lines.join('\n');
};
var addSharedHeader = function (source, sourceMap) {
// We want the symbol "share" to be visible to all CoffeeScript files in the
// package (and shared between them), but not visible to JavaScript
// files. (That's because we don't want to introduce two competing ways to
// make package-local variables into JS ("share" vs assigning to non-var
// variables).) The following hack accomplishes that: "__coffeescriptShare"
// will be visible at the package level and "share" at the file level. This
// should work both in "package" mode where __coffeescriptShare will be added
// as a var in the package closure, and in "app" mode where it will end up as
// a global.
//
// This ends in a newline to make the source map easier to adjust.
var header = ("__coffeescriptShare = typeof __coffeescriptShare === 'object' " +
"? __coffeescriptShare : {}; " +
"var share = __coffeescriptShare;\n");
// If the file begins with "use strict", we need to keep that as the first
// statement.
source = source.replace(/^(?:((['"])use strict\2;)\n)?/, function (match, useStrict) {
if (match) {
// There's a "use strict"; we keep this as the first statement and insert
// our header at the end of the line that it's on. This doesn't change
// line numbers or the part of the line that previous may have been
// annotated, so we don't need to update the source map.
return useStrict + " " + header;
} else {
// There's no use strict, so we can just add the header at the very
// beginning. This adds a line to the file, so we update the source map to
// add a single un-annotated line to the beginning.
sourceMap.mappings = ";" + sourceMap.mappings;
return header;
}
});
return {
source: source,
sourceMap: sourceMap
};
};
var CoffeeCompiler = function () {
var self = this;
// Maps from a cache key (encoding the source hash, exports, and the other
// options passed to coffee.compile) to a {source,sourceMap} object (both
// strings). Note that this is the result of both coffee.compile and the
// post-processing that we do.
self._cache = new LRU({
max: CACHE_SIZE,
// Cache is measured in bytes.
length: function (value) {
return value.source.length + sourceMapLength(value.sourceMap);
}
});
self._diskCache = null;
// For testing.
self._callCount = 0;
};
_.extend(CoffeeCompiler.prototype, {
processFilesForTarget: function (inputFiles) {
var self = this;
var cacheMisses = [];
inputFiles.forEach(function (inputFile) {
var source = inputFile.getContentsAsString();
var outputFilePath = inputFile.getPathInPackage() + ".js";
var extension = inputFile.getExtension();
var literate = extension !== 'coffee';
var options = {
bare: true,
filename: inputFile.getPathInPackage(),
literate: literate,
// Return a source map.
sourceMap: true,
// Include the original source in the source map (sourcesContent field).
inline: true,
// This becomes the "file" field of the source map.
generatedFile: "/" + outputFilePath,
// This becomes the "sources" field of the source map.
sourceFiles: [inputFile.getDisplayPath()]
};
var cacheKey = JSON.stringify([inputFile.getSourceHash(),
inputFile.getDeclaredExports(),
options]);
var sourceWithMap = self._cache.get(cacheKey);
if (! sourceWithMap) {
cacheMisses.push(inputFile.getDisplayPath());
try {
var output = coffee.compile(source, options);
} catch (e) {
inputFile.error({
message: e.message,
line: e.location && (e.location.first_line + 1),
column: e.location && (e.location.first_column + 1)
});
return;
}
var stripped = stripExportedVars(
output.js,
_.pluck(inputFile.getDeclaredExports(), 'name'));
console.log('>>>> ', typeof output.sourceMap, typeof output.v3SourceMap);
sourceWithMap = addSharedHeader(stripped, output.v3SourceMap);
self._cache.set(cacheKey, sourceWithMap);
}
inputFile.addJavaScript({
path: outputFilePath,
sourcePath: inputFile.getPathInPackage(),
data: sourceWithMap.source,
sourceMap: sourceWithMap.sourceMap,
bare: inputFile.getFileOptions().bare
});
});
// Rewrite the cache to disk.
// XXX BBP we should just write individual entries separately.
self._writeCache();
if (CACHE_DEBUG) {
cacheMisses.sort();
console.log("Ran coffee.compile (#%s) on: %s",
++self._callCount, JSON.stringify(cacheMisses));
}
},
setDiskCacheDirectory: function (diskCache) {
var self = this;
if (self._diskCache)
throw Error("setDiskCacheDirectory called twice?");
self._diskCache = diskCache;
self._readCache();
},
// XXX BBP this is an inefficiently designed cache that will cause quadratic
// behavior due to writing the whole cache on each write, and has no error
// handling, and uses sync, and has an exists/read race condition, and might
// not work on Windows
_cacheFile: function () {
var self = this;
return path.join(self._diskCache, 'cache.json');
},
_readCache: function () {
var self = this;
var cacheFile = self._cacheFile();
if (! fs.existsSync(cacheFile))
return;
var cacheJSON = JSON.parse(fs.readFileSync(cacheFile));
_.each(cacheJSON, function (value, cacheKey) {
self._cache.set(cacheKey, value);
});
if (CACHE_DEBUG) {
console.log("Loaded coffeescript cache");
}
},
_writeCache: function () {
var self = this;
if (! self._diskCache)
return;
var cacheJSON = {};
self._cache.forEach(function (value, cacheKey) {
cacheJSON[cacheKey] = value;
});
fs.writeFileSync(self._cacheFile(), JSON.stringify(cacheJSON));
}
});
Plugin.registerCompiler({
extensions: ['coffee', 'litcoffee', 'coffee.md']
}, function () {
return new CoffeeCompiler();
});
function sourceMapLength(sm) {
if (! sm) return 0;
// sum the length of sources and the mappings, the size of
// metadata is ignored, but it is not a big deal
return sm.mappings.length
+ (sm.sourcesContent || []).reduce(function (soFar, current) {
return soFar + (current ? current.length : 0);
}, 0);
};