diff --git a/lib/coffee-script/coffee-script.js b/lib/coffee-script/coffee-script.js index f01db2a0..defee4f0 100644 --- a/lib/coffee-script/coffee-script.js +++ b/lib/coffee-script/coffee-script.js @@ -1,5 +1,5 @@ (function() { - var Lexer, compile, ext, extensions, fs, lexer, loadFile, parser, path, vm, _i, _len, + var Lexer, SourceMap, compile, count, ext, extensions, fs, lexer, loadFile, parser, path, vm, _i, _len, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, __hasProp = {}.hasOwnProperty; @@ -11,8 +11,12 @@ parser = require('./parser').parser; + SourceMap = require('./sourcemap').SourceMap; + vm = require('vm'); + count = require('./helpers').count; + extensions = ['.coffee', '.litcoffee']; loadFile = function(module, filename) { @@ -36,13 +40,28 @@ exports.helpers = require('./helpers'); exports.compile = compile = function(code, options) { - var footer, js, merge; + var currentColumn, currentLine, footer, fragment, fragments, js, merge, newLines, _j, _len1; if (options == null) { options = {}; } merge = exports.helpers.merge; try { - js = (parser.parse(lexer.tokenize(code, options))).compile(options); + fragments = (parser.parse(lexer.tokenize(code, options))).compileToFragments(options); + currentLine = 0; + currentColumn = 0; + js = ""; + for (_j = 0, _len1 = fragments.length; _j < _len1; _j++) { + fragment = fragments[_j]; + if (options.sourceMap) { + if (fragment.locationData) { + options.sourceMap.addMapping([fragment.locationData.first_line, fragment.locationData.first_column], [currentLine, currentColumn]); + } + newLines = count(fragment.code, "\n"); + currentLine += newLines; + currentColumn = fragment.code.length - (newLines ? fragment.code.lastIndexOf("\n") : 0); + } + js += fragment.code; + } if (!options.header) { return js; } @@ -56,6 +75,24 @@ return "" + js + "\n// " + footer + "\n"; }; + exports.sourceMap = function(code, options) { + var merge; + if (options == null) { + options = {}; + } + merge = exports.helpers.merge; + try { + options.sourceMap = new SourceMap(); + exports.compile(code, options); + return options.sourceMap; + } catch (err) { + if (options.filename) { + err.message = "In " + options.filename + ", " + err.message; + } + throw err; + } + }; + exports.tokens = function(code, options) { return lexer.tokenize(code, options); }; diff --git a/lib/coffee-script/command.js b/lib/coffee-script/command.js index a9bf636f..7c74ad2e 100644 --- a/lib/coffee-script/command.js +++ b/lib/coffee-script/command.js @@ -1,5 +1,5 @@ (function() { - var BANNER, CoffeeScript, EventEmitter, SWITCHES, coffee_exts, compileJoin, compileOptions, compilePath, compileScript, compileStdio, exec, exists, forkNode, fs, helpers, hidden, joinTimeout, lint, notSources, optionParser, optparse, opts, outputPath, parseOptions, path, printLine, printTokens, printWarn, removeSource, sourceCode, sources, spawn, timeLog, unwatchDir, usage, version, wait, watch, watchDir, watchers, writeJs, _ref, + var BANNER, CoffeeScript, EventEmitter, SWITCHES, coffee_exts, compileJoin, compileOptions, compilePath, compileScript, compileStdio, exec, exists, forkNode, fs, helpers, hidden, joinTimeout, lint, notSources, optionParser, optparse, opts, outputPath, parseOptions, path, printLine, printTokens, printWarn, removeSource, sourceCode, sourcemap, sources, spawn, timeLog, unwatchDir, usage, version, wait, watch, watchDir, watchers, writeJs, _ref, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; fs = require('fs'); @@ -12,6 +12,8 @@ CoffeeScript = require('./coffee-script'); + sourcemap = require('./sourcemap'); + _ref = require('child_process'), spawn = _ref.spawn, exec = _ref.exec; EventEmitter = require('events').EventEmitter; @@ -34,7 +36,7 @@ BANNER = 'Usage: coffee [options] path/to/script.coffee -- [args]\n\nIf called without options, `coffee` will run your script.'; - SWITCHES = [['-b', '--bare', 'compile without a top-level function wrapper'], ['-c', '--compile', 'compile to JavaScript and save as .js files'], ['-e', '--eval', 'pass a string from the command line as input'], ['-h', '--help', 'display this help message'], ['-i', '--interactive', 'run an interactive CoffeeScript REPL'], ['-j', '--join [FILE]', 'concatenate the source CoffeeScript before compiling'], ['-l', '--lint', 'pipe the compiled JavaScript through JavaScript Lint'], ['-n', '--nodes', 'print out the parse tree that the parser produces'], ['--nodejs [ARGS]', 'pass options directly to the "node" binary'], ['-o', '--output [DIR]', 'set the output directory for compiled JavaScript'], ['-p', '--print', 'print out the compiled JavaScript'], ['-s', '--stdio', 'listen for and compile scripts over stdio'], ['-t', '--tokens', 'print out the tokens that the lexer/rewriter produce'], ['-v', '--version', 'display the version number'], ['-w', '--watch', 'watch scripts for changes and rerun commands']]; + SWITCHES = [['-b', '--bare', 'compile without a top-level function wrapper'], ['-c', '--compile', 'compile to JavaScript and save as .js files'], ['-e', '--eval', 'pass a string from the command line as input'], ['-h', '--help', 'display this help message'], ['-i', '--interactive', 'run an interactive CoffeeScript REPL'], ['-j', '--join [FILE]', 'concatenate the source CoffeeScript before compiling'], ['-l', '--lint', 'pipe the compiled JavaScript through JavaScript Lint'], ['-m', '--maps', 'generate source map and save as .map files'], ['-n', '--nodes', 'print out the parse tree that the parser produces'], ['--nodejs [ARGS]', 'pass options directly to the "node" binary'], ['-o', '--output [DIR]', 'set the output directory for compiled JavaScript'], ['-p', '--print', 'print out the compiled JavaScript'], ['-s', '--stdio', 'listen for and compile scripts over stdio'], ['-t', '--tokens', 'print out the tokens that the lexer/rewriter produce'], ['-v', '--version', 'display the version number'], ['-w', '--watch', 'watch scripts for changes and rerun commands']]; opts = {}; @@ -182,8 +184,8 @@ CoffeeScript.emit('success', task); if (o.print) { return printLine(t.output.trim()); - } else if (o.compile) { - return writeJs(t.file, t.output, base); + } else if (o.compile || o.maps) { + return writeJs(base, t.file, t.output, t.options.sourceMap); } else if (o.lint) { return lint(t.file, t.output); } @@ -374,30 +376,49 @@ } }; - outputPath = function(source, base) { + outputPath = function(source, base, extension) { var baseDir, dir, filename, srcDir; - filename = path.basename(source, path.extname(source)) + '.js'; + if (extension == null) { + extension = ".js"; + } + filename = path.basename(source, path.extname(source)) + extension; srcDir = path.dirname(source); baseDir = base === '.' ? srcDir : srcDir.substring(base.length); dir = opts.output ? path.join(opts.output, baseDir) : srcDir; return path.join(dir, filename); }; - writeJs = function(source, js, base) { - var compile, jsDir, jsPath; - jsPath = outputPath(source, base); + writeJs = function(base, sourcePath, js, sourceMap) { + var compile, jsDir, jsPath, sourceMapPath; + if (sourceMap == null) { + sourceMap = null; + } + jsPath = outputPath(sourcePath, base); + sourceMapPath = outputPath(sourcePath, base, ".map"); jsDir = path.dirname(jsPath); compile = function() { - if (js.length <= 0) { - js = ' '; - } - return fs.writeFile(jsPath, js, function(err) { - if (err) { - return printLine(err.message); - } else if (opts.compile && opts.watch) { - return timeLog("compiled " + source); + if (opts.compile) { + if (js.length <= 0) { + js = ' '; } - }); + if (sourceMap) { + js = ("//@ sourceMappingURL=" + (path.basename(sourceMapPath)) + "\n") + js; + } + fs.writeFile(jsPath, js, function(err) { + if (err) { + return printLine(err.message); + } else if (opts.compile && opts.watch) { + return timeLog("compiled " + sourcePath); + } + }); + } + if (sourceMap) { + return fs.writeFile(sourceMapPath, sourcemap.generateV3SourceMap(sourceMap), function(err) { + if (err) { + return printLine("Could not write source map: " + err.message); + } + }); + } }; return exists(jsDir, function(itExists) { if (itExists) { @@ -451,7 +472,7 @@ optionParser = new optparse.OptionParser(SWITCHES, BANNER); o = opts = optionParser.parse(process.argv.slice(2)); o.compile || (o.compile = !!o.output); - o.run = !(o.compile || o.print || o.lint); + o.run = !(o.compile || o.print || o.lint || o.maps); o.print = !!(o.print || (o["eval"] || o.stdio && o.compile)); sources = o["arguments"]; for (i = _i = 0, _len = sources.length; _i < _len; i = ++_i) { @@ -461,11 +482,13 @@ }; compileOptions = function(filename) { - var literate; + var literate, sourceMap; literate = path.extname(filename) === '.litcoffee'; + sourceMap = opts.maps ? new sourcemap.SourceMap() : void 0; return { filename: filename, literate: literate, + sourceMap: sourceMap, bare: opts.bare, header: opts.compile }; diff --git a/lib/coffee-script/nodes.js b/lib/coffee-script/nodes.js index f3ee39f1..e4650aba 100644 --- a/lib/coffee-script/nodes.js +++ b/lib/coffee-script/nodes.js @@ -40,7 +40,7 @@ function CodeFragment(parent, code) { var _ref2; - this.code = code; + this.code = "" + code; this.locationData = parent != null ? parent.locationData : void 0; this.type = (parent != null ? (_ref2 = parent.constructor) != null ? _ref2.name : void 0 : void 0) || 'unknown'; } diff --git a/lib/coffee-script/sourcemap.js b/lib/coffee-script/sourcemap.js new file mode 100644 index 00000000..08bd2d00 --- /dev/null +++ b/lib/coffee-script/sourcemap.js @@ -0,0 +1,232 @@ +(function() { + var BASE64_CHARS, LineMapping, MAX_BASE64_VALUE, VLQ_CONTINUATION_BIT, VLQ_SHIFT, VLQ_VALUE_MASK, decodeBase64Char, encodeBase64Char; + + LineMapping = (function() { + + function LineMapping(generatedLine) { + this.generatedLine = generatedLine; + this.columnMap = {}; + this.columnMappings = []; + } + + LineMapping.prototype.addMapping = function(generatedColumn, _arg) { + var sourceColumn, sourceLine; + sourceLine = _arg[0], sourceColumn = _arg[1]; + if (this.columnMap[generatedColumn]) { + return; + } + this.columnMap[generatedColumn] = { + generatedLine: this.generatedLine, + generatedColumn: generatedColumn, + sourceLine: sourceLine, + sourceColumn: sourceColumn + }; + this.columnMappings.push(this.columnMap[generatedColumn]); + return this.columnMappings.sort(function(a, b) { + return a.generatedColumn - b.generatedColumn; + }); + }; + + LineMapping.prototype.getSourcePosition = function(generatedColumn) { + var answer, columnMapping, lastColumnMapping, _i, _len, _ref; + answer = null; + lastColumnMapping = null; + _ref = this.columnMappings; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + columnMapping = _ref[_i]; + if (columnMapping.generatedColumn > generatedColumn) { + break; + } else { + lastColumnMapping = columnMapping; + } + } + if (lastColumnMapping) { + return answer = [lastColumnMapping.sourceLine, lastColumnMapping.sourceColumn]; + } + }; + + return LineMapping; + + })(); + + exports.SourceMap = (function() { + + function SourceMap() { + this.generatedLines = []; + } + + SourceMap.prototype.addMapping = function(sourceLocation, generatedLocation) { + var generatedColumn, generatedLine, lineMapping; + generatedLine = generatedLocation[0], generatedColumn = generatedLocation[1]; + lineMapping = this.generatedLines[generatedLine]; + if (!lineMapping) { + lineMapping = this.generatedLines[generatedLine] = new LineMapping(generatedLine); + } + return lineMapping.addMapping(generatedColumn, sourceLocation); + }; + + SourceMap.prototype.getSourcePosition = function(_arg) { + var answer, generatedColumn, generatedLine, lineMapping; + generatedLine = _arg[0], generatedColumn = _arg[1]; + answer = null; + lineMapping = this.generatedLines[generatedLine]; + if (!lineMapping) { + + } else { + answer = lineMapping.getSourcePosition(generatedColumn); + } + return answer; + }; + + SourceMap.prototype.forEachMapping = function(fn) { + var columnMapping, generatedLineNumber, lineMapping, _i, _len, _ref, _results; + _ref = this.generatedLines; + _results = []; + for (generatedLineNumber = _i = 0, _len = _ref.length; _i < _len; generatedLineNumber = ++_i) { + lineMapping = _ref[generatedLineNumber]; + if (lineMapping) { + _results.push((function() { + var _j, _len1, _ref1, _results1; + _ref1 = lineMapping.columnMappings; + _results1 = []; + for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) { + columnMapping = _ref1[_j]; + _results1.push(fn(columnMapping)); + } + return _results1; + })()); + } else { + _results.push(void 0); + } + } + return _results; + }; + + return SourceMap; + + })(); + + exports.generateV3SourceMap = function(sourceMap, sourceFile, generatedFile) { + var answer, lastGeneratedColumnWritten, lastSourceColumnWritten, lastSourceLineWritten, mappings, needComma, writingGeneratedLine; + if (sourceFile == null) { + sourceFile = null; + } + if (generatedFile == null) { + generatedFile = null; + } + writingGeneratedLine = 0; + lastGeneratedColumnWritten = 0; + lastSourceLineWritten = 0; + lastSourceColumnWritten = 0; + needComma = false; + mappings = ""; + sourceMap.forEachMapping(function(mapping) { + while (writingGeneratedLine < mapping.generatedLine) { + lastGeneratedColumnWritten = 0; + needComma = false; + mappings += ";"; + writingGeneratedLine++; + } + if (needComma) { + mappings += ","; + needComma = false; + } + mappings += exports.vlqEncodeValue(mapping.generatedColumn - lastGeneratedColumnWritten); + lastGeneratedColumnWritten = mapping.generatedColumn; + mappings += exports.vlqEncodeValue(0); + mappings += exports.vlqEncodeValue(mapping.sourceLine - lastSourceLineWritten); + lastSourceLineWritten = mapping.sourceLine; + mappings += exports.vlqEncodeValue(mapping.sourceColumn - lastSourceColumnWritten); + lastSourceColumnWritten = mapping.sourceColumn; + return needComma = true; + }); + answer = { + version: 3, + file: generatedFile, + sourceRoot: "", + source: [sourceFile], + names: [], + mappings: mappings + }; + return JSON.stringify(answer); + }; + + exports.loadV3SourceMap = function(sourceMap) { + return todo(); + }; + + BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + + MAX_BASE64_VALUE = BASE64_CHARS.length - 1; + + encodeBase64Char = function(value) { + if (value > MAX_BASE64_VALUE) { + throw new Error("Cannot encode value " + value + " > " + MAX_BASE64_VALUE); + } else if (value < 0) { + throw new Error("Cannot encode value " + value + " < 0"); + } + return BASE64_CHARS[value]; + }; + + decodeBase64Char = function(char) { + var value; + value = BASE64_CHARS.indexOf(char); + if (value === -1) { + throw new Error("Invalid Base 64 character: " + char); + } + return value; + }; + + VLQ_SHIFT = 5; + + VLQ_CONTINUATION_BIT = 1 << VLQ_SHIFT; + + VLQ_VALUE_MASK = VLQ_CONTINUATION_BIT - 1; + + exports.vlqEncodeValue = function(value) { + var answer, nextVlqChunk, signBit, valueToEncode; + signBit = value < 0 ? 1 : 0; + valueToEncode = (Math.abs(value) << 1) + signBit; + answer = ""; + while (valueToEncode || !answer) { + nextVlqChunk = valueToEncode & VLQ_VALUE_MASK; + valueToEncode = valueToEncode >> VLQ_SHIFT; + if (valueToEncode) { + nextVlqChunk |= VLQ_CONTINUATION_BIT; + } + answer += encodeBase64Char(nextVlqChunk); + } + return answer; + }; + + exports.vlqDecodeValue = function(str, offset) { + var consumed, continuationShift, done, nextChunkValue, nextVlqChunk, position, signBit, value; + if (offset == null) { + offset = 0; + } + position = offset; + done = false; + value = 0; + continuationShift = 0; + while (!done) { + nextVlqChunk = decodeBase64Char(str[position]); + position += 1; + nextChunkValue = nextVlqChunk & VLQ_VALUE_MASK; + value += nextChunkValue << continuationShift; + if (!(nextVlqChunk & VLQ_CONTINUATION_BIT)) { + done = true; + } + continuationShift += VLQ_SHIFT; + } + consumed = position - offset; + signBit = value & 1; + value = value >> 1; + if (signBit) { + value = -value; + } + return [value, consumed]; + }; + +}).call(this); + +// Generated by CoffeeScript 1.5.0-pre diff --git a/src/coffee-script.coffee b/src/coffee-script.coffee index e92b0ae1..b956fb13 100644 --- a/src/coffee-script.coffee +++ b/src/coffee-script.coffee @@ -6,11 +6,13 @@ # If included on a webpage, it will automatically sniff out, compile, and # execute all scripts present in `text/coffeescript` tags. -fs = require 'fs' -path = require 'path' -{Lexer} = require './lexer' -{parser} = require './parser' -vm = require 'vm' +fs = require 'fs' +path = require 'path' +{Lexer} = require './lexer' +{parser} = require './parser' +{SourceMap} = require './sourcemap' +vm = require 'vm' +{count} = require './helpers' # The file extensions that are considered to be CoffeeScript. extensions = ['.coffee', '.litcoffee'] @@ -36,7 +38,25 @@ exports.helpers = require './helpers' exports.compile = compile = (code, options = {}) -> {merge} = exports.helpers try - js = (parser.parse lexer.tokenize(code, options)).compile options + fragments = (parser.parse lexer.tokenize(code, options)).compileToFragments options + + currentLine = 0 + currentColumn = 0 + js = "" + for fragment in fragments + # Update the sourcemap with data from each fragment + if options.sourceMap + if fragment.locationData + options.sourceMap.addMapping( + [fragment.locationData.first_line, fragment.locationData.first_column], + [currentLine, currentColumn]) + newLines = count fragment.code, "\n" + currentLine += newLines + currentColumn = fragment.code.length - (if newLines then fragment.code.lastIndexOf "\n" else 0) + + # Copy the code from each fragment into the final JavaScript. + js += fragment.code + return js unless options.header catch err err.message = "In #{options.filename}, #{err.message}" if options.filename @@ -44,6 +64,19 @@ exports.compile = compile = (code, options = {}) -> footer = "Generated by CoffeeScript #{@VERSION}" "#{js}\n// #{footer}\n" +# Generates a source map for a string of CoffeeScript code. +# Returns a SourceMap object. +exports.sourceMap = (code, options = {}) -> + {merge} = exports.helpers + try + options.sourceMap = new SourceMap() + exports.compile code, options + return options.sourceMap + catch err + err.message = "In #{options.filename}, #{err.message}" if options.filename + throw err + + # Tokenize a string of CoffeeScript code, and return the array of tokens. exports.tokens = (code, options) -> lexer.tokenize code, options diff --git a/src/command.coffee b/src/command.coffee index 0b8496ce..a231311d 100644 --- a/src/command.coffee +++ b/src/command.coffee @@ -10,6 +10,7 @@ path = require 'path' helpers = require './helpers' optparse = require './optparse' CoffeeScript = require './coffee-script' +sourcemap = require './sourcemap' {spawn, exec} = require 'child_process' {EventEmitter} = require 'events' @@ -39,6 +40,7 @@ SWITCHES = [ ['-i', '--interactive', 'run an interactive CoffeeScript REPL'] ['-j', '--join [FILE]', 'concatenate the source CoffeeScript before compiling'] ['-l', '--lint', 'pipe the compiled JavaScript through JavaScript Lint'] + ['-m', '--maps', 'generate source map and save as .map files'] ['-n', '--nodes', 'print out the parse tree that the parser produces'] [ '--nodejs [ARGS]', 'pass options directly to the "node" binary'] ['-o', '--output [DIR]', 'set the output directory for compiled JavaScript'] @@ -131,9 +133,11 @@ compileScript = (file, input, base) -> compileJoin() else t.output = CoffeeScript.compile t.input, t.options + CoffeeScript.emit 'success', task if o.print then printLine t.output.trim() - else if o.compile then writeJs t.file, t.output, base + else if o.compile || o.maps + writeJs base, t.file, t.output, t.options.sourceMap else if o.lint then lint t.file, t.output catch err CoffeeScript.emit 'failure', err, task @@ -247,8 +251,8 @@ removeSource = (source, base, removeJs) -> timeLog "removed #{source}" # Get the corresponding output JavaScript path for a source file. -outputPath = (source, base) -> - filename = path.basename(source, path.extname(source)) + '.js' +outputPath = (source, base, extension=".js") -> + filename = path.basename(source, path.extname(source)) + extension srcDir = path.dirname source baseDir = if base is '.' then srcDir else srcDir.substring base.length dir = if opts.output then path.join opts.output, baseDir else srcDir @@ -257,16 +261,27 @@ outputPath = (source, base) -> # Write out a JavaScript source file with the compiled code. By default, files # are written out in `cwd` as `.js` files with the same name, but the output # directory can be customized with `--output`. -writeJs = (source, js, base) -> - jsPath = outputPath source, base +# +# If source maps were also requested, this will write `.map` files into the same +# directory as the `.js` files. +writeJs = (base, sourcePath, js, sourceMap = null) -> + jsPath = outputPath sourcePath, base + sourceMapPath = outputPath sourcePath, base, ".map" + jsDir = path.dirname jsPath compile = -> - js = ' ' if js.length <= 0 - fs.writeFile jsPath, js, (err) -> - if err - printLine err.message - else if opts.compile and opts.watch - timeLog "compiled #{source}" + if opts.compile + js = ' ' if js.length <= 0 + if sourceMap then js = "//@ sourceMappingURL=#{path.basename sourceMapPath}\n" + js + fs.writeFile jsPath, js, (err) -> + if err + printLine err.message + else if opts.compile and opts.watch + timeLog "compiled #{sourcePath}" + if sourceMap + fs.writeFile sourceMapPath, (sourcemap.generateV3SourceMap sourceMap), (err) -> + if err + printLine "Could not write source map: #{err.message}" exists jsDir, (itExists) -> if itExists then compile() else exec "mkdir -p #{jsDir}", compile @@ -303,7 +318,7 @@ parseOptions = -> optionParser = new optparse.OptionParser SWITCHES, BANNER o = opts = optionParser.parse process.argv[2..] o.compile or= !!o.output - o.run = not (o.compile or o.print or o.lint) + o.run = not (o.compile or o.print or o.lint or o.maps) o.print = !! (o.print or (o.eval or o.stdio and o.compile)) sources = o.arguments sourceCode[i] = null for source, i in sources @@ -312,7 +327,8 @@ parseOptions = -> # The compile-time options to pass to the CoffeeScript compiler. compileOptions = (filename) -> literate = path.extname(filename) is '.litcoffee' - {filename, literate, bare: opts.bare, header: opts.compile} + sourceMap = if opts.maps then new sourcemap.SourceMap() + {filename, literate, sourceMap, bare: opts.bare, header: opts.compile} # Start up a new Node.js instance with the arguments in `--nodejs` passed to # the `node` binary, preserving the other options. diff --git a/src/nodes.coffee b/src/nodes.coffee index e77c836f..2fcb0ee5 100644 --- a/src/nodes.coffee +++ b/src/nodes.coffee @@ -33,7 +33,8 @@ PARANOID = false # came from. CodeFragments can be assembled together into working code just by catting together # all the CodeFragments' `code` snippets, in order. exports.CodeFragment = class CodeFragment - constructor: (parent, @code) -> + constructor: (parent, code) -> + @code = "#{code}" @locationData = parent?.locationData @type = parent?.constructor?.name or 'unknown' diff --git a/src/sourcemap.coffee b/src/sourcemap.coffee new file mode 100644 index 00000000..14029b34 --- /dev/null +++ b/src/sourcemap.coffee @@ -0,0 +1,245 @@ +#### LineMapping + +# Hold data about mappings for one line of generated source code. + +class LineMapping + constructor: (@generatedLine) -> + # columnMap keeps track of which columns we've already mapped. + @columnMap = {} + + # columnMappings is an array of all column mappings, sorted by generated-column. + @columnMappings = [] + + addMapping: (generatedColumn, [sourceLine, sourceColumn]) -> + if @columnMap[generatedColumn] + # We already have a mapping for this column. + return + + @columnMap[generatedColumn] = { + generatedLine: @generatedLine + generatedColumn + sourceLine + sourceColumn + } + + @columnMappings.push @columnMap[generatedColumn] + @columnMappings.sort (a,b) -> a.generatedColumn - b.generatedColumn + + getSourcePosition: (generatedColumn) -> + answer = null + lastColumnMapping = null + for columnMapping in @columnMappings + if columnMapping.generatedColumn > generatedColumn + break + else + lastColumnMapping = columnMapping + if lastColumnMapping + answer = [lastColumnMapping.sourceLine, lastColumnMapping.sourceColumn] + +#### SourceMap + +# Maps locations in a generated source file back to locations in the original source file. +# +# This is intentionally agnostic towards how a source map might be represented on disk. A +# SourceMap can be converted to a "v3" style sourcemap with `#generateV3SourceMap()`, for example +# but the SourceMap class itself knows nothing about v3 source maps. + +class exports.SourceMap + constructor: () -> + # `generatedLines` is an array of LineMappings, one per generated line. + @generatedLines = [] + + # Adds a mapping to this SourceMap. + # + # `sourceLocation` and `generatedLocation` are both [line, column] arrays. + # If there is already a mapping for the specified `generatedLine` and + # `generatedColumn`, then this will have no effect. + addMapping: (sourceLocation, generatedLocation) -> + [generatedLine, generatedColumn] = generatedLocation + + lineMapping = @generatedLines[generatedLine] + if not lineMapping + lineMapping = @generatedLines[generatedLine] = new LineMapping(generatedLine) + + lineMapping.addMapping generatedColumn, sourceLocation + + # Returns [sourceLine, sourceColumn], or null if no mapping could be found. + getSourcePosition: ([generatedLine, generatedColumn]) -> + answer = null + lineMapping = @generatedLines[generatedLine] + if not lineMapping + # TODO: Search backwards for the line? + else + answer = lineMapping.getSourcePosition generatedColumn + + answer + + + # `fn` will be called once for every recorded mapping, in the order in + # which they occur in the generated source. `fn` will be passed an object + # with four properties: sourceLine, sourceColumn, generatedLine, and + # generatedColumn. + forEachMapping: (fn) -> + for lineMapping, generatedLineNumber in @generatedLines + if lineMapping + for columnMapping in lineMapping.columnMappings + fn(columnMapping) + + +#### generateV3SourceMap + +# Builds a V3 source map from a SourceMap object. +# Returns the generated JSON as a string. + +exports.generateV3SourceMap = (sourceMap, sourceFile=null, generatedFile=null) -> + writingGeneratedLine = 0 + lastGeneratedColumnWritten = 0 + lastSourceLineWritten = 0 + lastSourceColumnWritten = 0 + needComma = no + + mappings = "" + + sourceMap.forEachMapping (mapping) -> + while writingGeneratedLine < mapping.generatedLine + lastGeneratedColumnWritten = 0 + needComma = no + mappings += ";" + writingGeneratedLine++ + + # Write a comma if we've already written a segment on this line. + if needComma + mappings += "," + needComma = no + + # Write the next segment. + # Segments can be 1, 4, or 5 values. If just one, then it is a generated column which + # doesn't match anything in the source code. + # + # Fields are all zero-based, and relative to the previous occurence unless otherwise noted: + # * starting-column in generated source, relative to previous occurence for the current line. + # * index into the "sources" list + # * starting line in the original source + # * starting column in the original source + # * index into the "names" list associated with this segment. + + # Add the generated start-column + mappings += exports.vlqEncodeValue(mapping.generatedColumn - lastGeneratedColumnWritten) + lastGeneratedColumnWritten = mapping.generatedColumn + + # Add the index into the sources list + mappings += exports.vlqEncodeValue(0) + + # Add the source start-line + mappings += exports.vlqEncodeValue(mapping.sourceLine - lastSourceLineWritten) + lastSourceLineWritten = mapping.sourceLine + + # Add the source start-column + mappings += exports.vlqEncodeValue(mapping.sourceColumn - lastSourceColumnWritten) + lastSourceColumnWritten = mapping.sourceColumn + + # TODO: Do we care about symbol names for CoffeeScript? Probably not. + + needComma = yes + + answer = { + version: 3 + file: generatedFile + sourceRoot: "" + source: [sourceFile] + names: [] + mappings + } + + return JSON.stringify answer + +# Load a SourceMap from a JSON string. Returns the SourceMap object. +exports.loadV3SourceMap = (sourceMap) -> + todo() + +#### Base64 encoding helpers + +BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +MAX_BASE64_VALUE = BASE64_CHARS.length - 1 + +encodeBase64Char = (value) -> + if value > MAX_BASE64_VALUE + throw new Error "Cannot encode value #{value} > #{MAX_BASE64_VALUE}" + else if value < 0 + throw new Error "Cannot encode value #{value} < 0" + BASE64_CHARS[value] + +decodeBase64Char = (char) -> + value = BASE64_CHARS.indexOf char + if value == -1 + throw new Error "Invalid Base 64 character: #{char}" + value + +#### Base 64 VLQ encoding/decoding helpers + +# Note that SourceMap VLQ encoding is "backwards". MIDI style VLQ encoding puts the +# most-significant-bit (MSB) from the original value into the MSB of the VLQ encoded value +# (see http://en.wikipedia.org/wiki/File:Uintvar_coding.svg). SourceMap VLQ does things +# the other way around, with the least significat four bits of the original value encoded +# into the first byte of the VLQ encoded value. + +VLQ_SHIFT = 5 +VLQ_CONTINUATION_BIT = 1 << VLQ_SHIFT # 0010 0000 +VLQ_VALUE_MASK = VLQ_CONTINUATION_BIT - 1 # 0001 1111 + +# Encode a value as Base 64 VLQ. +exports.vlqEncodeValue = (value) -> + # Least significant bit represents the sign. + signBit = if value < 0 then 1 else 0 + + # Next bits are the actual value + valueToEncode = (Math.abs(value) << 1) + signBit + + answer = "" + # Make sure we encode at least one character, even if valueToEncode is 0. + while valueToEncode || !answer + nextVlqChunk = valueToEncode & VLQ_VALUE_MASK + valueToEncode = valueToEncode >> VLQ_SHIFT + + if valueToEncode + nextVlqChunk |= VLQ_CONTINUATION_BIT + + answer += encodeBase64Char(nextVlqChunk) + + return answer + +# Decode a Base 64 VLQ value. +# +# Returns `[value, consumed]` where `value` is the decoded value, and `consumed` is the number +# of characters consumed from `str`. +exports.vlqDecodeValue = (str, offset=0) -> + position = offset + done = false + + value = 0 + continuationShift = 0 + + while !done + nextVlqChunk = decodeBase64Char(str[position]) + position += 1 + + nextChunkValue = nextVlqChunk & VLQ_VALUE_MASK + value += (nextChunkValue << continuationShift) + + if !(nextVlqChunk & VLQ_CONTINUATION_BIT) + # We'll be done after this character. + done = true + + # Bits are encoded least-significant first (opposite of MIDI VLQ). Increase the + # continuationShift, so the next byte will end up where it should in the value. + continuationShift += VLQ_SHIFT + + consumed = position - offset + + # Least significant bit represents the sign. + signBit = value & 1 + value = value >> 1 + + if signBit then value = -value + + return [value, consumed] \ No newline at end of file diff --git a/test/sourcemap.coffee b/test/sourcemap.coffee new file mode 100644 index 00000000..84b0592a --- /dev/null +++ b/test/sourcemap.coffee @@ -0,0 +1,39 @@ +sourcemap = require '../src/sourcemap' + +vlqEncodedValues = [ + [1, "C"], + [-1, "D"], + [2, "E"], + [-2, "F"], + [0, "A"], + [16, "gB"], + [948, "o7B"] +] + +test "vlqEncodeValue tests", -> + for pair in vlqEncodedValues + eq (sourcemap.vlqEncodeValue pair[0]), pair[1] + +test "vlqDecodeValue tests", -> + for pair in vlqEncodedValues + arrayEq (sourcemap.vlqDecodeValue pair[1]), [pair[0], pair[1].length] + +test "vlqDecodeValue with offset", -> + for pair in vlqEncodedValues + # Try with an offset, and some cruft at the end. + arrayEq (sourcemap.vlqDecodeValue ("abc" + pair[1] + "efg"), 3), [pair[0], pair[1].length] + +test "SourceMap tests", -> + map = new sourcemap.SourceMap() + map.addMapping [0, 0], [0, 0] + map.addMapping [1, 5], [2, 4] + map.addMapping [1, 6], [2, 7] + map.addMapping [1, 9], [2, 8] + map.addMapping [3, 0], [3, 4] + eq (sourcemap.generateV3SourceMap map, "source.coffee", "source.js"), '{"version":3,"file":"source.js","sourceRoot":"","source":["source.coffee"],"names":[],"mappings":"AAAA;;IACK,GAAC,CAAG;IAET"}' + + # Look up a generated column - should get back the original source position. + arrayEq map.getSourcePosition([2,8]), [1,9] + + # Look up a point futher along on the same line - should get back the same source position. + arrayEq map.getSourcePosition([2,10]), [1,9]