UNPKG

canonical

Version:

Canonical code style linter and formatter for JavaScript, SCSS and CSS.

600 lines (517 loc) 19.8 kB
var utils = require('util'); var EventEmitter = require('events').EventEmitter; /** * Token assertions class. * * @name {TokenAssert} * @param {JsFile} file */ function TokenAssert(file) { EventEmitter.call(this); this._file = file; } utils.inherits(TokenAssert, EventEmitter); /** * Requires to have whitespace between specified tokens. Ignores newlines. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {String} [options.message] * @param {Number} [options.spaces] Amount of spaces between tokens. */ TokenAssert.prototype.whitespaceBetween = function(options) { options.atLeast = 1; this.spacesBetween(options); }; /** * Requires to have no whitespace between specified tokens. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {String} [options.message] * @param {Boolean} [options.disallowNewLine=false] */ TokenAssert.prototype.noWhitespaceBetween = function(options) { options.exactly = 0; this.spacesBetween(options); }; /** * Requires to have the whitespace between specified tokens with the provided options. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {String} [options.message] * @param {Object} [options.atLeast] At least how many spaces the tokens are apart * @param {Object} [options.atMost] At most how many spaces the tokens are apart * @param {Object} [options.exactly] Exactly how many spaces the tokens are apart * @param {Boolean} [options.disallowNewLine=false] */ TokenAssert.prototype.spacesBetween = function(options) { var token = options.token; var nextToken = options.nextToken; var atLeast = options.atLeast; var atMost = options.atMost; var exactly = options.exactly; if (!token || !nextToken) { return; } this._validateOptions(options); if (!options.disallowNewLine && token.loc.end.line !== nextToken.loc.start.line) { return; } // Only attempt to remove or add lines if there are no comments between the two nodes // as this prevents accidentally moving a valid token onto a line comment ed line var fixed = this._file.getNextToken(options.token, { includeComments: true }) === nextToken; var emitError = function(countPrefix, spaceCount) { if (fixed) { this._file.setWhitespaceBefore(nextToken, new Array(spaceCount + 1).join(' ')); } var msgPostfix = token.value + ' and ' + nextToken.value; if (!options.message) { if (exactly === 0) { // support noWhitespaceBetween options.message = 'Unexpected whitespace between ' + msgPostfix; } else if (exactly !== undefined) { // support whitespaceBetween (spaces option) options.message = spaceCount + ' spaces required between ' + msgPostfix; } else if (atLeast === 1 && atMost === undefined) { // support whitespaceBetween (no spaces option) options.message = 'Missing space between ' + msgPostfix; } else { options.message = countPrefix + ' ' + spaceCount + ' spaces required between ' + msgPostfix; } } this.emit('error', { message: options.message, line: token.loc.end.line, column: token.loc.end.column, fixed: fixed }); }.bind(this); var spacesBetween = Math.abs(nextToken.range[0] - token.range[1]); if (atLeast !== undefined && spacesBetween < atLeast) { emitError('at least', atLeast); } else if (atMost !== undefined && spacesBetween > atMost) { emitError('at most', atMost); } else if (exactly !== undefined && spacesBetween !== exactly) { emitError('exactly', exactly); } }; /** * Requires the specified line to have the expected indentation. * * @param {Object} options * @param {Number} options.lineNumber * @param {Number} options.actual * @param {Number} options.expected * @param {String} options.indentChar * @param {Boolean} [options.silent] if true, will suppress error emission but still fix whitespace */ TokenAssert.prototype.indentation = function(options) { var lineNumber = options.lineNumber; var actual = options.actual; var expected = options.expected; var indentChar = options.indentChar; if (actual === expected) { return; } if (!options.silent) { this.emit('error', { message: 'Expected indentation of ' + expected + ' characters', line: lineNumber, column: expected, fixed: true }); } var token = this._file.getFirstTokenOnLine(lineNumber, { includeComments: true }); var newWhitespace = (new Array(expected + 1)).join(indentChar); if (!token) { this._setEmptyLineIndentation(lineNumber, newWhitespace); return; } this._updateWhitespaceByLine(token, function(lines) { lines[lines.length - 1] = newWhitespace; return lines; }); if (token.isComment) { this._updateCommentWhitespace(token, indentChar, actual, expected); } }; /** * Updates the whitespace of a line by passing split lines to a callback function * for editing. * * @param {Object} token * @param {Function} callback */ TokenAssert.prototype._updateWhitespaceByLine = function(token, callback) { var lineBreak = this._file.getLineBreakStyle(); var lines = this._file.getWhitespaceBefore(token).split(/\r\n|\r|\n/); lines = callback(lines); this._file.setWhitespaceBefore(token, lines.join(lineBreak)); }; /** * Updates the whitespace of a line by passing split lines to a callback function * for editing. * * @param {Object} token * @param {Function} indentChar * @param {Number} actual * @param {Number} expected */ TokenAssert.prototype._updateCommentWhitespace = function(token, indentChar, actual, expected) { var difference = expected - actual; var tokenLines = token.value.split(/\r\n|\r|\n/); var i = 1; if (difference >= 0) { var lineWhitespace = (new Array(difference + 1)).join(indentChar); for (; i < tokenLines.length; i++) { tokenLines[i] = tokenLines[i] === '' ? '' : lineWhitespace + tokenLines[i]; } } else { for (; i < tokenLines.length; i++) { tokenLines[i] = tokenLines[i].substring(-difference); } } token.value = tokenLines.join(this._file.getLineBreakStyle()); }; /** * Fixes the indentation of a line that has no tokens on it * * @param {Number} lineNumber * @param {String} newWhitespace */ TokenAssert.prototype._setEmptyLineIndentation = function(lineNumber, newWhitespace) { var token; do { token = this._file.getFirstTokenOnLine(++lineNumber, { includeComments: true }); } while (!token); this._updateWhitespaceByLine(token, function(lines) { if (lines[0] !== '') { lines[0] = newWhitespace; } for (var i = 1; i < lines.length; i++) { lines[i] = newWhitespace; } return lines; }); }; /** * Requires tokens to be on the same line. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {Boolean} [options.stickToPreviousToken] * @param {String} [options.message] */ TokenAssert.prototype.sameLine = function(options) { options.exactly = 0; this.linesBetween(options); }; /** * Requires tokens to be on different lines. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {Object} [options.message] */ TokenAssert.prototype.differentLine = function(options) { options.atLeast = 1; this.linesBetween(options); }; /** * Requires tokens to have a certain amount of lines between them. * Set at least one of atLeast or atMost OR set exactly. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {Object} [options.message] * @param {Object} [options.atLeast] At least how many lines the tokens are apart * @param {Object} [options.atMost] At most how many lines the tokens are apart * @param {Object} [options.exactly] Exactly how many lines the tokens are apart * @param {Boolean} [options.stickToPreviousToken] When auto-fixing stick the * nextToken onto the previous token. */ TokenAssert.prototype.linesBetween = function(options) { var token = options.token; var nextToken = options.nextToken; var atLeast = options.atLeast; var atMost = options.atMost; var exactly = options.exactly; if (!token || !nextToken) { return; } this._validateOptions(options); // Only attempt to remove or add lines if there are no comments between the two nodes // as this prevents accidentally moving a valid token onto a line comment ed line var fixed = this._file.getNextToken(options.token, { includeComments: true }) === nextToken; var linesBetween = Math.abs(token.loc.end.line - nextToken.loc.start.line); var emitError = function(countPrefix, lineCount) { var msgPrefix = token.value + ' and ' + nextToken.value; if (!options.message) { if (exactly === 0) { // support sameLine options.message = msgPrefix + ' should be on the same line'; } else if (atLeast === 1 && atMost === undefined) { // support differentLine options.message = msgPrefix + ' should be on different lines'; } else { // support linesBetween options.message = msgPrefix + ' should have ' + countPrefix + ' ' + lineCount + ' line(s) between them'; } } if (fixed) { this._augmentLineCount(options, lineCount); } this.emit('error', { message: options.message, line: token.loc.end.line, column: token.loc.end.column, fixed: fixed }); }.bind(this); if (atLeast !== undefined && linesBetween < atLeast) { emitError('at least', atLeast); } else if (atMost !== undefined && linesBetween > atMost) { emitError('at most', atMost); } else if (exactly !== undefined && linesBetween !== exactly) { emitError('exactly', exactly); } }; /** * Throws errors if atLeast, atMost, and exactly options don't mix together properly or * if the tokens provided are equivalent. * * @param {Object} options * @param {Object} options.token * @param {Object} options.nextToken * @param {Object} [options.atLeast] At least how many spaces the tokens are apart * @param {Object} [options.atMost] At most how many spaces the tokens are apart * @param {Object} [options.exactly] Exactly how many spaces the tokens are apart * @throws {Error} If the options are non-sensical */ TokenAssert.prototype._validateOptions = function(options) { var token = options.token; var nextToken = options.nextToken; var atLeast = options.atLeast; var atMost = options.atMost; var exactly = options.exactly; if (token === nextToken) { throw new Error('You cannot specify the same token as both token and nextToken'); } if (atLeast === undefined && atMost === undefined && exactly === undefined) { throw new Error('You must specify at least one option'); } if (exactly !== undefined && (atLeast !== undefined || atMost !== undefined)) { throw new Error('You cannot specify atLeast or atMost with exactly'); } if (atLeast !== undefined && atMost !== undefined && atMost < atLeast) { throw new Error('atLeast and atMost are in conflict'); } }; /** * Augments token whitespace to contain the correct number of newlines while preserving indentation * * @param {Object} options * @param {Object} options.nextToken * @param {Boolean} [options.stickToPreviousToken] * @param {Number} lineCount */ TokenAssert.prototype._augmentLineCount = function(options, lineCount) { var token = options.nextToken; if (lineCount === 0) { if (options.stickToPreviousToken) { var nextToken = this._file.getNextToken(token, { includeComments: true }); this._file.setWhitespaceBefore(nextToken, this._file.getWhitespaceBefore(token)); } this._file.setWhitespaceBefore(token, ' '); return; } this._updateWhitespaceByLine(token, function(lines) { var currentLineCount = lines.length; var lastLine = lines[lines.length - 1]; if (currentLineCount <= lineCount) { // add additional lines that maintain the same indentation as the former last line for (; currentLineCount <= lineCount; currentLineCount++) { lines[lines.length - 1] = ''; lines.push(lastLine); } } else { // remove lines and then ensure that the new last line maintains the previous indentation lines = lines.slice(0, lineCount + 1); lines[lines.length - 1] = lastLine; } return lines; }); }; /** * Requires specific token before given. * * @param {Object} options * @param {Object} options.token * @param {Object} options.expectedTokenBefore * @param {String} [options.message] */ TokenAssert.prototype.tokenBefore = function(options) { var token = options.token; var actualTokenBefore = this._file.getPrevToken(token); var expectedTokenBefore = options.expectedTokenBefore; if (!actualTokenBefore) { this.emit('error', { message: expectedTokenBefore.value + ' was expected before ' + token.value + ' but document start found', line: token.loc.start.line, column: token.loc.start.column }); return; } // Only attempt to remove or add lines if there are no comments between the two nodes // as this prevents accidentally moving a valid token onto a line comment ed line var fixed = this._file.getPrevToken(options.token, {includeComments: true}) === actualTokenBefore; if ( actualTokenBefore.type !== expectedTokenBefore.type || actualTokenBefore.value !== expectedTokenBefore.value ) { if (fixed) { this._file.setWhitespaceBefore(token, expectedTokenBefore.value + this._file.getWhitespaceBefore(token)); } var message = options.message; if (!message) { var showTypes = expectedTokenBefore.value === actualTokenBefore.value; message = expectedTokenBefore.value + (showTypes ? ' (' + expectedTokenBefore.type + ')' : '') + ' was expected before ' + token.value + ' but ' + actualTokenBefore.value + (showTypes ? ' (' + actualTokenBefore.type + ')' : '') + ' found'; } this.emit('error', { message: message, line: actualTokenBefore.loc.end.line, column: actualTokenBefore.loc.end.column, fixed: fixed }); } }; /** * Disallows specific token before given. * * @param {Object} options * @param {Object} options.token * @param {Object} options.expectedTokenBefore * @param {String} [options.message] */ TokenAssert.prototype.noTokenBefore = function(options) { var token = options.token; var actualTokenBefore = this._file.getPrevToken(token); if (!actualTokenBefore) { // document start return; } var fixed = this._file.getPrevToken(options.token, { includeComments: true }) === actualTokenBefore; var expectedTokenBefore = options.expectedTokenBefore; if (actualTokenBefore.type === expectedTokenBefore.type && actualTokenBefore.value === expectedTokenBefore.value ) { if (fixed) { actualTokenBefore.value = ''; } this.emit('error', { message: options.message || 'Illegal ' + expectedTokenBefore.value + ' was found before ' + token.value, line: actualTokenBefore.loc.end.line, column: actualTokenBefore.loc.end.column, fixed: fixed }); } }; /** * Disallows specific token before given. * * @param {Object} options * @param {Boolean} options.ignoreEmptyLines */ TokenAssert.prototype.noTrailingSpaces = function(options) { var ignoreEmptyLines = options.ignoreEmptyLines; var lines = this._file.getLines(); for (var i = 0, l = lines.length; i < l; i++) { if (lines[i].match(/\s$/) && !(ignoreEmptyLines && lines[i].match(/^\s*$/))) { var fixed = false; var currentLineNumber = i + 1; var startLineNumber; var precendingToken; var targetToken; while (!precendingToken && currentLineNumber > 0) { precendingToken = this._file.getLastTokenOnLine(currentLineNumber, { includeComments: true }); currentLineNumber--; } if (precendingToken === null) { targetToken = this._file.getFirstToken({includeComments: true}); startLineNumber = 1; } else { targetToken = this._file.getNextToken(precendingToken, { includeComments: true }); startLineNumber = precendingToken.loc.end.line; if (precendingToken.isComment && precendingToken.loc.start.line <= (i + 1) && precendingToken.loc.end.line >= (i + 1)) { if (precendingToken.type === 'Block') { if (ignoreEmptyLines) { var blockLines = precendingToken.value.split(/\n/); for (var k = 0; k < blockLines.length; k++) { if (!blockLines[k].match(/^\s*$/) || k === 0) { blockLines[k] = blockLines[k].split(/\s$/).join(''); } } precendingToken.value = blockLines.join('\n'); } else { precendingToken.value = precendingToken.value.split(/\s\n/).join('\n'); } } else { precendingToken.value = precendingToken.value.split(/\s$/).join(''); } fixed = true; } } if (targetToken !== null && !fixed) { var eolCount = targetToken.loc.start.line - startLineNumber + 1; var targetIndent = ''; var targetLine = lines[targetToken.loc.start.line - 1]; for (var j = 0, whitespace = targetLine.charAt(j); whitespace.match(/\s/); j++, whitespace = targetLine.charAt(j)) { targetIndent += whitespace; } this._file.setWhitespaceBefore(targetToken, new Array(eolCount).join('\n') + targetIndent); fixed = true; } precendingToken = null; this.emit('error', { message: 'Illegal trailing whitespace', line: i + 1, column: lines[i].length, fixed: fixed }); } } }; module.exports = TokenAssert;