var assert = require("assert"); var sourceMap = require("source-map"); var normalizeOptions = require("./options").normalize; var secretKey = require("private").makeUniqueKey(); var types = require("./types"); var isString = types.builtInTypes.string; var comparePos = require("./util").comparePos; var Mapping = require("./mapping"); // Goals: // 1. Minimize new string creation. // 2. Keep (de)identation O(lines) time. // 3. Permit negative indentations. // 4. Enforce immutability. // 5. No newline characters. var useSymbol = typeof Symbol === "function"; var secretKey = "recastLinesSecret"; if (useSymbol) { secretKey = Symbol.for(secretKey); } function getSecret(lines) { return lines[secretKey]; } function Lines(infos, sourceFileName) { assert.ok(this instanceof Lines); assert.ok(infos.length > 0); if (sourceFileName) { isString.assert(sourceFileName); } else { sourceFileName = null; } setSymbolOrKey(this, secretKey, { infos: infos, mappings: [], name: sourceFileName, cachedSourceMap: null }); this.length = infos.length; this.name = sourceFileName; if (sourceFileName) { getSecret(this).mappings.push(new Mapping(this, { start: this.firstPos(), end: this.lastPos() })); } } function setSymbolOrKey(obj, key, value) { if (useSymbol) { return obj[key] = value; } Object.defineProperty(obj, key, { value: value, enumerable: false, writable: false, configurable: true }); return value; } // Exposed for instanceof checks. The fromString function should be used // to create new Lines objects. exports.Lines = Lines; var Lp = Lines.prototype; function copyLineInfo(info) { return { line: info.line, indent: info.indent, locked: info.locked, sliceStart: info.sliceStart, sliceEnd: info.sliceEnd }; } var fromStringCache = {}; var hasOwn = fromStringCache.hasOwnProperty; var maxCacheKeyLen = 10; function countSpaces(spaces, tabWidth) { var count = 0; var len = spaces.length; for (var i = 0; i < len; ++i) { switch (spaces.charCodeAt(i)) { case 9: // '\t' assert.strictEqual(typeof tabWidth, "number"); assert.ok(tabWidth > 0); var next = Math.ceil(count / tabWidth) * tabWidth; if (next === count) { count += tabWidth; } else { count = next; } break; case 11: // '\v' case 12: // '\f' case 13: // '\r' case 0xfeff: // zero-width non-breaking space // These characters contribute nothing to indentation. break; case 32: // ' ' default: // Treat all other whitespace like ' '. count += 1; break; } } return count; } exports.countSpaces = countSpaces; var leadingSpaceExp = /^\s*/; // As specified here: http://www.ecma-international.org/ecma-262/6.0/#sec-line-terminators var lineTerminatorSeqExp = /\u000D\u000A|\u000D(?!\u000A)|\u000A|\u2028|\u2029/; /** * @param {Object} options - Options object that configures printing. */ function fromString(string, options) { if (string instanceof Lines) return string; string += ""; var tabWidth = options && options.tabWidth; var tabless = string.indexOf("\t") < 0; var locked = !! (options && options.locked); var cacheable = !options && tabless && (string.length <= maxCacheKeyLen); assert.ok(tabWidth || tabless, "No tab width specified but encountered tabs in string\n" + string); if (cacheable && hasOwn.call(fromStringCache, string)) return fromStringCache[string]; var lines = new Lines(string.split(lineTerminatorSeqExp).map(function(line) { var spaces = leadingSpaceExp.exec(line)[0]; return { line: line, indent: countSpaces(spaces, tabWidth), // Boolean indicating whether this line can be reindented. locked: locked, sliceStart: spaces.length, sliceEnd: line.length }; }), normalizeOptions(options).sourceFileName); if (cacheable) fromStringCache[string] = lines; return lines; } exports.fromString = fromString; function isOnlyWhitespace(string) { return !/\S/.test(string); } Lp.toString = function(options) { return this.sliceString(this.firstPos(), this.lastPos(), options); }; Lp.getSourceMap = function(sourceMapName, sourceRoot) { if (!sourceMapName) { // Although we could make up a name or generate an anonymous // source map, instead we assume that any consumer who does not // provide a name does not actually want a source map. return null; } var targetLines = this; function updateJSON(json) { json = json || {}; isString.assert(sourceMapName); json.file = sourceMapName; if (sourceRoot) { isString.assert(sourceRoot); json.sourceRoot = sourceRoot; } return json; } var secret = getSecret(targetLines); if (secret.cachedSourceMap) { // Since Lines objects are immutable, we can reuse any source map // that was previously generated. Nevertheless, we return a new // JSON object here to protect the cached source map from outside // modification. return updateJSON(secret.cachedSourceMap.toJSON()); } var smg = new sourceMap.SourceMapGenerator(updateJSON()); var sourcesToContents = {}; secret.mappings.forEach(function(mapping) { var sourceCursor = mapping.sourceLines.skipSpaces( mapping.sourceLoc.start ) || mapping.sourceLines.lastPos(); var targetCursor = targetLines.skipSpaces( mapping.targetLoc.start ) || targetLines.lastPos(); while (comparePos(sourceCursor, mapping.sourceLoc.end) < 0 && comparePos(targetCursor, mapping.targetLoc.end) < 0) { var sourceChar = mapping.sourceLines.charAt(sourceCursor); var targetChar = targetLines.charAt(targetCursor); assert.strictEqual(sourceChar, targetChar); var sourceName = mapping.sourceLines.name; // Add mappings one character at a time for maximum resolution. smg.addMapping({ source: sourceName, original: { line: sourceCursor.line, column: sourceCursor.column }, generated: { line: targetCursor.line, column: targetCursor.column } }); if (!hasOwn.call(sourcesToContents, sourceName)) { var sourceContent = mapping.sourceLines.toString(); smg.setSourceContent(sourceName, sourceContent); sourcesToContents[sourceName] = sourceContent; } targetLines.nextPos(targetCursor, true); mapping.sourceLines.nextPos(sourceCursor, true); } }); secret.cachedSourceMap = smg; return smg.toJSON(); }; Lp.bootstrapCharAt = function(pos) { assert.strictEqual(typeof pos, "object"); assert.strictEqual(typeof pos.line, "number"); assert.strictEqual(typeof pos.column, "number"); var line = pos.line, column = pos.column, strings = this.toString().split(lineTerminatorSeqExp), string = strings[line - 1]; if (typeof string === "undefined") return ""; if (column === string.length && line < strings.length) return "\n"; if (column >= string.length) return ""; return string.charAt(column); }; Lp.charAt = function(pos) { assert.strictEqual(typeof pos, "object"); assert.strictEqual(typeof pos.line, "number"); assert.strictEqual(typeof pos.column, "number"); var line = pos.line, column = pos.column, secret = getSecret(this), infos = secret.infos, info = infos[line - 1], c = column; if (typeof info === "undefined" || c < 0) return ""; var indent = this.getIndentAt(line); if (c < indent) return " "; c += info.sliceStart - indent; if (c === info.sliceEnd && line < this.length) return "\n"; if (c >= info.sliceEnd) return ""; return info.line.charAt(c); }; Lp.stripMargin = function(width, skipFirstLine) { if (width === 0) return this; assert.ok(width > 0, "negative margin: " + width); if (skipFirstLine && this.length === 1) return this; var secret = getSecret(this); var lines = new Lines(secret.infos.map(function(info, i) { if (info.line && (i > 0 || !skipFirstLine)) { info = copyLineInfo(info); info.indent = Math.max(0, info.indent - width); } return info; })); if (secret.mappings.length > 0) { var newMappings = getSecret(lines).mappings; assert.strictEqual(newMappings.length, 0); secret.mappings.forEach(function(mapping) { newMappings.push(mapping.indent(width, skipFirstLine, true)); }); } return lines; }; Lp.indent = function(by) { if (by === 0) return this; var secret = getSecret(this); var lines = new Lines(secret.infos.map(function(info) { if (info.line && ! info.locked) { info = copyLineInfo(info); info.indent += by; } return info })); if (secret.mappings.length > 0) { var newMappings = getSecret(lines).mappings; assert.strictEqual(newMappings.length, 0); secret.mappings.forEach(function(mapping) { newMappings.push(mapping.indent(by)); }); } return lines; }; Lp.indentTail = function(by) { if (by === 0) return this; if (this.length < 2) return this; var secret = getSecret(this); var lines = new Lines(secret.infos.map(function(info, i) { if (i > 0 && info.line && ! info.locked) { info = copyLineInfo(info); info.indent += by; } return info; })); if (secret.mappings.length > 0) { var newMappings = getSecret(lines).mappings; assert.strictEqual(newMappings.length, 0); secret.mappings.forEach(function(mapping) { newMappings.push(mapping.indent(by, true)); }); } return lines; }; Lp.lockIndentTail = function () { if (this.length < 2) { return this; } var infos = getSecret(this).infos; return new Lines(infos.map(function (info, i) { info = copyLineInfo(info); info.locked = i > 0; return info; })); }; Lp.getIndentAt = function(line) { assert.ok(line >= 1, "no line " + line + " (line numbers start from 1)"); var secret = getSecret(this), info = secret.infos[line - 1]; return Math.max(info.indent, 0); }; Lp.guessTabWidth = function() { var secret = getSecret(this); if (hasOwn.call(secret, "cachedTabWidth")) { return secret.cachedTabWidth; } var counts = []; // Sparse array. var lastIndent = 0; for (var line = 1, last = this.length; line <= last; ++line) { var info = secret.infos[line - 1]; var sliced = info.line.slice(info.sliceStart, info.sliceEnd); // Whitespace-only lines don't tell us much about the likely tab // width of this code. if (isOnlyWhitespace(sliced)) { continue; } var diff = Math.abs(info.indent - lastIndent); counts[diff] = ~~counts[diff] + 1; lastIndent = info.indent; } var maxCount = -1; var result = 2; for (var tabWidth = 1; tabWidth < counts.length; tabWidth += 1) { if (hasOwn.call(counts, tabWidth) && counts[tabWidth] > maxCount) { maxCount = counts[tabWidth]; result = tabWidth; } } return secret.cachedTabWidth = result; }; // Determine if the list of lines has a first line that starts with a // // or /* comment. If this is the case, the code may need to be wrapped in // parens to avoid ASI issues. Lp.startsWithComment = function () { var secret = getSecret(this); if (secret.infos.length === 0) { return false; } var firstLineInfo = secret.infos[0], sliceStart = firstLineInfo.sliceStart, sliceEnd = firstLineInfo.sliceEnd, firstLine = firstLineInfo.line.slice(sliceStart, sliceEnd).trim(); return firstLine.length === 0 || firstLine.slice(0, 2) === "//" || firstLine.slice(0, 2) === "/*"; }; Lp.isOnlyWhitespace = function() { return isOnlyWhitespace(this.toString()); }; Lp.isPrecededOnlyByWhitespace = function(pos) { var secret = getSecret(this); var info = secret.infos[pos.line - 1]; var indent = Math.max(info.indent, 0); var diff = pos.column - indent; if (diff <= 0) { // If pos.column does not exceed the indentation amount, then // there must be only whitespace before it. return true; } var start = info.sliceStart; var end = Math.min(start + diff, info.sliceEnd); var prefix = info.line.slice(start, end); return isOnlyWhitespace(prefix); }; Lp.getLineLength = function(line) { var secret = getSecret(this), info = secret.infos[line - 1]; return this.getIndentAt(line) + info.sliceEnd - info.sliceStart; }; Lp.nextPos = function(pos, skipSpaces) { var l = Math.max(pos.line, 0), c = Math.max(pos.column, 0); if (c < this.getLineLength(l)) { pos.column += 1; return skipSpaces ? !!this.skipSpaces(pos, false, true) : true; } if (l < this.length) { pos.line += 1; pos.column = 0; return skipSpaces ? !!this.skipSpaces(pos, false, true) : true; } return false; }; Lp.prevPos = function(pos, skipSpaces) { var l = pos.line, c = pos.column; if (c < 1) { l -= 1; if (l < 1) return false; c = this.getLineLength(l); } else { c = Math.min(c - 1, this.getLineLength(l)); } pos.line = l; pos.column = c; return skipSpaces ? !!this.skipSpaces(pos, true, true) : true; }; Lp.firstPos = function() { // Trivial, but provided for completeness. return { line: 1, column: 0 }; }; Lp.lastPos = function() { return { line: this.length, column: this.getLineLength(this.length) }; }; Lp.skipSpaces = function(pos, backward, modifyInPlace) { if (pos) { pos = modifyInPlace ? pos : { line: pos.line, column: pos.column }; } else if (backward) { pos = this.lastPos(); } else { pos = this.firstPos(); } if (backward) { while (this.prevPos(pos)) { if (!isOnlyWhitespace(this.charAt(pos)) && this.nextPos(pos)) { return pos; } } return null; } else { while (isOnlyWhitespace(this.charAt(pos))) { if (!this.nextPos(pos)) { return null; } } return pos; } }; Lp.trimLeft = function() { var pos = this.skipSpaces(this.firstPos(), false, true); return pos ? this.slice(pos) : emptyLines; }; Lp.trimRight = function() { var pos = this.skipSpaces(this.lastPos(), true, true); return pos ? this.slice(this.firstPos(), pos) : emptyLines; }; Lp.trim = function() { var start = this.skipSpaces(this.firstPos(), false, true); if (start === null) return emptyLines; var end = this.skipSpaces(this.lastPos(), true, true); assert.notStrictEqual(end, null); return this.slice(start, end); }; Lp.eachPos = function(callback, startPos, skipSpaces) { var pos = this.firstPos(); if (startPos) { pos.line = startPos.line, pos.column = startPos.column } if (skipSpaces && !this.skipSpaces(pos, false, true)) { return; // Encountered nothing but spaces. } do callback.call(this, pos); while (this.nextPos(pos, skipSpaces)); }; Lp.bootstrapSlice = function(start, end) { var strings = this.toString().split( lineTerminatorSeqExp ).slice( start.line - 1, end.line ); strings.push(strings.pop().slice(0, end.column)); strings[0] = strings[0].slice(start.column); return fromString(strings.join("\n")); }; Lp.slice = function(start, end) { if (!end) { if (!start) { // The client seems to want a copy of this Lines object, but // Lines objects are immutable, so it's perfectly adequate to // return the same object. return this; } // Slice to the end if no end position was provided. end = this.lastPos(); } var secret = getSecret(this); var sliced = secret.infos.slice(start.line - 1, end.line); if (start.line === end.line) { sliced[0] = sliceInfo(sliced[0], start.column, end.column); } else { assert.ok(start.line < end.line); sliced[0] = sliceInfo(sliced[0], start.column); sliced.push(sliceInfo(sliced.pop(), 0, end.column)); } var lines = new Lines(sliced); if (secret.mappings.length > 0) { var newMappings = getSecret(lines).mappings; assert.strictEqual(newMappings.length, 0); secret.mappings.forEach(function(mapping) { var sliced = mapping.slice(this, start, end); if (sliced) { newMappings.push(sliced); } }, this); } return lines; }; function sliceInfo(info, startCol, endCol) { var sliceStart = info.sliceStart; var sliceEnd = info.sliceEnd; var indent = Math.max(info.indent, 0); var lineLength = indent + sliceEnd - sliceStart; if (typeof endCol === "undefined") { endCol = lineLength; } startCol = Math.max(startCol, 0); endCol = Math.min(endCol, lineLength); endCol = Math.max(endCol, startCol); if (endCol < indent) { indent = endCol; sliceEnd = sliceStart; } else { sliceEnd -= lineLength - endCol; } lineLength = endCol; lineLength -= startCol; if (startCol < indent) { indent -= startCol; } else { startCol -= indent; indent = 0; sliceStart += startCol; } assert.ok(indent >= 0); assert.ok(sliceStart <= sliceEnd); assert.strictEqual(lineLength, indent + sliceEnd - sliceStart); if (info.indent === indent && info.sliceStart === sliceStart && info.sliceEnd === sliceEnd) { return info; } return { line: info.line, indent: indent, // A destructive slice always unlocks indentation. locked: false, sliceStart: sliceStart, sliceEnd: sliceEnd }; } Lp.bootstrapSliceString = function(start, end, options) { return this.slice(start, end).toString(options); }; Lp.sliceString = function(start, end, options) { if (!end) { if (!start) { // The client seems to want a copy of this Lines object, but // Lines objects are immutable, so it's perfectly adequate to // return the same object. return this; } // Slice to the end if no end position was provided. end = this.lastPos(); } options = normalizeOptions(options); var infos = getSecret(this).infos; var parts = []; var tabWidth = options.tabWidth; for (var line = start.line; line <= end.line; ++line) { var info = infos[line - 1]; if (line === start.line) { if (line === end.line) { info = sliceInfo(info, start.column, end.column); } else { info = sliceInfo(info, start.column); } } else if (line === end.line) { info = sliceInfo(info, 0, end.column); } var indent = Math.max(info.indent, 0); var before = info.line.slice(0, info.sliceStart); if (options.reuseWhitespace && isOnlyWhitespace(before) && countSpaces(before, options.tabWidth) === indent) { // Reuse original spaces if the indentation is correct. parts.push(info.line.slice(0, info.sliceEnd)); continue; } var tabs = 0; var spaces = indent; if (options.useTabs) { tabs = Math.floor(indent / tabWidth); spaces -= tabs * tabWidth; } var result = ""; if (tabs > 0) { result += new Array(tabs + 1).join("\t"); } if (spaces > 0) { result += new Array(spaces + 1).join(" "); } result += info.line.slice(info.sliceStart, info.sliceEnd); parts.push(result); } return parts.join(options.lineTerminator); }; Lp.isEmpty = function() { return this.length < 2 && this.getLineLength(1) < 1; }; Lp.join = function(elements) { var separator = this; var separatorSecret = getSecret(separator); var infos = []; var mappings = []; var prevInfo; function appendSecret(secret) { if (secret === null) return; if (prevInfo) { var info = secret.infos[0]; var indent = new Array(info.indent + 1).join(" "); var prevLine = infos.length; var prevColumn = Math.max(prevInfo.indent, 0) + prevInfo.sliceEnd - prevInfo.sliceStart; prevInfo.line = prevInfo.line.slice( 0, prevInfo.sliceEnd) + indent + info.line.slice( info.sliceStart, info.sliceEnd); // If any part of a line is indentation-locked, the whole line // will be indentation-locked. prevInfo.locked = prevInfo.locked || info.locked; prevInfo.sliceEnd = prevInfo.line.length; if (secret.mappings.length > 0) { secret.mappings.forEach(function(mapping) { mappings.push(mapping.add(prevLine, prevColumn)); }); } } else if (secret.mappings.length > 0) { mappings.push.apply(mappings, secret.mappings); } secret.infos.forEach(function(info, i) { if (!prevInfo || i > 0) { prevInfo = copyLineInfo(info); infos.push(prevInfo); } }); } function appendWithSeparator(secret, i) { if (i > 0) appendSecret(separatorSecret); appendSecret(secret); } elements.map(function(elem) { var lines = fromString(elem); if (lines.isEmpty()) return null; return getSecret(lines); }).forEach(separator.isEmpty() ? appendSecret : appendWithSeparator); if (infos.length < 1) return emptyLines; var lines = new Lines(infos); getSecret(lines).mappings = mappings; return lines; }; exports.concat = function(elements) { return emptyLines.join(elements); }; Lp.concat = function(other) { var args = arguments, list = [this]; list.push.apply(list, args); assert.strictEqual(list.length, args.length + 1); return emptyLines.join(list); }; // The emptyLines object needs to be created all the way down here so that // Lines.prototype will be fully populated. var emptyLines = fromString("");