kitchensink
Version:
Dispatch's awesome components and style guide
512 lines (444 loc) • 16.5 kB
JavaScript
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
});
}
};
module.exports = TokenAssert;