UNPKG

16.5 kBJavaScriptView Raw
1var utils = require('util');
2var EventEmitter = require('events').EventEmitter;
3
4/**
5 * Token assertions class.
6 *
7 * @name {TokenAssert}
8 * @param {JsFile} file
9 */
10function TokenAssert(file) {
11 EventEmitter.call(this);
12
13 this._file = file;
14}
15
16utils.inherits(TokenAssert, EventEmitter);
17
18/**
19 * Requires to have whitespace between specified tokens. Ignores newlines.
20 *
21 * @param {Object} options
22 * @param {Object} options.token
23 * @param {Object} options.nextToken
24 * @param {String} [options.message]
25 * @param {Number} [options.spaces] Amount of spaces between tokens.
26 */
27TokenAssert.prototype.whitespaceBetween = function(options) {
28 options.atLeast = 1;
29 this.spacesBetween(options);
30};
31
32/**
33 * Requires to have no whitespace between specified tokens.
34 *
35 * @param {Object} options
36 * @param {Object} options.token
37 * @param {Object} options.nextToken
38 * @param {String} [options.message]
39 * @param {Boolean} [options.disallowNewLine=false]
40 */
41TokenAssert.prototype.noWhitespaceBetween = function(options) {
42 options.exactly = 0;
43 this.spacesBetween(options);
44};
45
46/**
47 * Requires to have the whitespace between specified tokens with the provided options.
48 *
49 * @param {Object} options
50 * @param {Object} options.token
51 * @param {Object} options.nextToken
52 * @param {String} [options.message]
53 * @param {Object} [options.atLeast] At least how many spaces the tokens are apart
54 * @param {Object} [options.atMost] At most how many spaces the tokens are apart
55 * @param {Object} [options.exactly] Exactly how many spaces the tokens are apart
56 * @param {Boolean} [options.disallowNewLine=false]
57 */
58TokenAssert.prototype.spacesBetween = function(options) {
59 var token = options.token;
60 var nextToken = options.nextToken;
61 var atLeast = options.atLeast;
62 var atMost = options.atMost;
63 var exactly = options.exactly;
64
65 if (!token || !nextToken) {
66 return;
67 }
68
69 this._validateOptions(options);
70
71 if (!options.disallowNewLine && token.loc.end.line !== nextToken.loc.start.line) {
72 return;
73 }
74
75 // Only attempt to remove or add lines if there are no comments between the two nodes
76 // as this prevents accidentally moving a valid token onto a line comment ed line
77 var fixed = this._file.getNextToken(options.token, {
78 includeComments: true
79 }) === nextToken;
80
81 var emitError = function(countPrefix, spaceCount) {
82 if (fixed) {
83 this._file.setWhitespaceBefore(nextToken, new Array(spaceCount + 1).join(' '));
84 }
85
86 var msgPostfix = token.value + ' and ' + nextToken.value;
87
88 if (!options.message) {
89 if (exactly === 0) {
90 // support noWhitespaceBetween
91 options.message = 'Unexpected whitespace between ' + msgPostfix;
92 } else if (exactly !== undefined) {
93 // support whitespaceBetween (spaces option)
94 options.message = spaceCount + ' spaces required between ' + msgPostfix;
95 } else if (atLeast === 1 && atMost === undefined) {
96 // support whitespaceBetween (no spaces option)
97 options.message = 'Missing space between ' + msgPostfix;
98 } else {
99 options.message = countPrefix + ' ' + spaceCount + ' spaces required between ' + msgPostfix;
100 }
101 }
102
103 this.emit('error', {
104 message: options.message,
105 line: token.loc.end.line,
106 column: token.loc.end.column,
107 fixed: fixed
108 });
109 }.bind(this);
110
111 var spacesBetween = Math.abs(nextToken.range[0] - token.range[1]);
112 if (atLeast !== undefined && spacesBetween < atLeast) {
113 emitError('at least', atLeast);
114 } else if (atMost !== undefined && spacesBetween > atMost) {
115 emitError('at most', atMost);
116 } else if (exactly !== undefined && spacesBetween !== exactly) {
117 emitError('exactly', exactly);
118 }
119};
120
121/**
122 * Requires the specified line to have the expected indentation.
123 *
124 * @param {Object} options
125 * @param {Number} options.lineNumber
126 * @param {Number} options.actual
127 * @param {Number} options.expected
128 * @param {String} options.indentChar
129 * @param {Boolean} [options.silent] if true, will suppress error emission but still fix whitespace
130 */
131TokenAssert.prototype.indentation = function(options) {
132 var lineNumber = options.lineNumber;
133 var actual = options.actual;
134 var expected = options.expected;
135 var indentChar = options.indentChar;
136
137 if (actual === expected) {
138 return;
139 }
140
141 if (!options.silent) {
142 this.emit('error', {
143 message: 'Expected indentation of ' + expected + ' characters',
144 line: lineNumber,
145 column: expected,
146 fixed: true
147 });
148 }
149
150 var token = this._file.getFirstTokenOnLine(lineNumber, {
151 includeComments: true
152 });
153 var newWhitespace = (new Array(expected + 1)).join(indentChar);
154
155 if (!token) {
156 this._setEmptyLineIndentation(lineNumber, newWhitespace);
157 return;
158 }
159
160 this._updateWhitespaceByLine(token, function(lines) {
161 lines[lines.length - 1] = newWhitespace;
162
163 return lines;
164 });
165
166 if (token.isComment) {
167 this._updateCommentWhitespace(token, indentChar, actual, expected);
168 }
169};
170
171/**
172 * Updates the whitespace of a line by passing split lines to a callback function
173 * for editing.
174 *
175 * @param {Object} token
176 * @param {Function} callback
177 */
178TokenAssert.prototype._updateWhitespaceByLine = function(token, callback) {
179 var lineBreak = this._file.getLineBreakStyle();
180 var lines = this._file.getWhitespaceBefore(token).split(/\r\n|\r|\n/);
181
182 lines = callback(lines);
183 this._file.setWhitespaceBefore(token, lines.join(lineBreak));
184};
185
186/**
187 * Updates the whitespace of a line by passing split lines to a callback function
188 * for editing.
189 *
190 * @param {Object} token
191 * @param {Function} indentChar
192 * @param {Number} actual
193 * @param {Number} expected
194 */
195TokenAssert.prototype._updateCommentWhitespace = function(token, indentChar, actual, expected) {
196 var difference = expected - actual;
197 var tokenLines = token.value.split(/\r\n|\r|\n/);
198 var i = 1;
199 if (difference >= 0) {
200 var lineWhitespace = (new Array(difference + 1)).join(indentChar);
201 for (; i < tokenLines.length; i++) {
202 tokenLines[i] = tokenLines[i] === '' ? '' : lineWhitespace + tokenLines[i];
203 }
204 } else {
205 for (; i < tokenLines.length; i++) {
206 tokenLines[i] = tokenLines[i].substring(-difference);
207 }
208 }
209
210 token.value = tokenLines.join(this._file.getLineBreakStyle());
211};
212
213/**
214 * Fixes the indentation of a line that has no tokens on it
215 *
216 * @param {Number} lineNumber
217 * @param {String} newWhitespace
218 */
219TokenAssert.prototype._setEmptyLineIndentation = function(lineNumber, newWhitespace) {
220 var token;
221 do {
222 token = this._file.getFirstTokenOnLine(++lineNumber, {
223 includeComments: true
224 });
225 } while (!token);
226
227 this._updateWhitespaceByLine(token, function(lines) {
228 if (lines[0] !== '') {
229 lines[0] = newWhitespace;
230 }
231
232 for (var i = 1; i < lines.length; i++) {
233 lines[i] = newWhitespace;
234 }
235
236 return lines;
237 });
238};
239
240/**
241 * Requires tokens to be on the same line.
242 *
243 * @param {Object} options
244 * @param {Object} options.token
245 * @param {Object} options.nextToken
246 * @param {Boolean} [options.stickToPreviousToken]
247 * @param {String} [options.message]
248 */
249TokenAssert.prototype.sameLine = function(options) {
250 options.exactly = 0;
251
252 this.linesBetween(options);
253};
254
255/**
256 * Requires tokens to be on different lines.
257 *
258 * @param {Object} options
259 * @param {Object} options.token
260 * @param {Object} options.nextToken
261 * @param {Object} [options.message]
262 */
263TokenAssert.prototype.differentLine = function(options) {
264 options.atLeast = 1;
265
266 this.linesBetween(options);
267};
268
269/**
270 * Requires tokens to have a certain amount of lines between them.
271 * Set at least one of atLeast or atMost OR set exactly.
272 *
273 * @param {Object} options
274 * @param {Object} options.token
275 * @param {Object} options.nextToken
276 * @param {Object} [options.message]
277 * @param {Object} [options.atLeast] At least how many lines the tokens are apart
278 * @param {Object} [options.atMost] At most how many lines the tokens are apart
279 * @param {Object} [options.exactly] Exactly how many lines the tokens are apart
280 * @param {Boolean} [options.stickToPreviousToken] When auto-fixing stick the
281 * nextToken onto the previous token.
282 */
283TokenAssert.prototype.linesBetween = function(options) {
284 var token = options.token;
285 var nextToken = options.nextToken;
286 var atLeast = options.atLeast;
287 var atMost = options.atMost;
288 var exactly = options.exactly;
289
290 if (!token || !nextToken) {
291 return;
292 }
293
294 this._validateOptions(options);
295
296 // Only attempt to remove or add lines if there are no comments between the two nodes
297 // as this prevents accidentally moving a valid token onto a line comment ed line
298 var fixed = this._file.getNextToken(options.token, {
299 includeComments: true
300 }) === nextToken;
301
302 var linesBetween = Math.abs(token.loc.end.line - nextToken.loc.start.line);
303
304 var emitError = function(countPrefix, lineCount) {
305 var msgPrefix = token.value + ' and ' + nextToken.value;
306
307 if (!options.message) {
308 if (exactly === 0) {
309 // support sameLine
310 options.message = msgPrefix + ' should be on the same line';
311 } else if (atLeast === 1 && atMost === undefined) {
312 // support differentLine
313 options.message = msgPrefix + ' should be on different lines';
314 } else {
315 // support linesBetween
316 options.message = msgPrefix + ' should have ' + countPrefix + ' ' + lineCount + ' line(s) between them';
317 }
318 }
319
320 if (fixed) {
321 this._augmentLineCount(options, lineCount);
322 }
323
324 this.emit('error', {
325 message: options.message,
326 line: token.loc.end.line,
327 column: token.loc.end.column,
328 fixed: fixed
329 });
330 }.bind(this);
331
332 if (atLeast !== undefined && linesBetween < atLeast) {
333 emitError('at least', atLeast);
334 } else if (atMost !== undefined && linesBetween > atMost) {
335 emitError('at most', atMost);
336 } else if (exactly !== undefined && linesBetween !== exactly) {
337 emitError('exactly', exactly);
338 }
339};
340
341/**
342 * Throws errors if atLeast, atMost, and exactly options don't mix together properly or
343 * if the tokens provided are equivalent.
344 *
345 * @param {Object} options
346 * @param {Object} options.token
347 * @param {Object} options.nextToken
348 * @param {Object} [options.atLeast] At least how many spaces the tokens are apart
349 * @param {Object} [options.atMost] At most how many spaces the tokens are apart
350 * @param {Object} [options.exactly] Exactly how many spaces the tokens are apart
351 * @throws {Error} If the options are non-sensical
352 */
353TokenAssert.prototype._validateOptions = function(options) {
354 var token = options.token;
355 var nextToken = options.nextToken;
356 var atLeast = options.atLeast;
357 var atMost = options.atMost;
358 var exactly = options.exactly;
359
360 if (token === nextToken) {
361 throw new Error('You cannot specify the same token as both token and nextToken');
362 }
363
364 if (atLeast === undefined &&
365 atMost === undefined &&
366 exactly === undefined) {
367 throw new Error('You must specify at least one option');
368 }
369
370 if (exactly !== undefined && (atLeast !== undefined || atMost !== undefined)) {
371 throw new Error('You cannot specify atLeast or atMost with exactly');
372 }
373
374 if (atLeast !== undefined && atMost !== undefined && atMost < atLeast) {
375 throw new Error('atLeast and atMost are in conflict');
376 }
377};
378
379/**
380 * Augments token whitespace to contain the correct number of newlines while preserving indentation
381 *
382 * @param {Object} options
383 * @param {Object} options.nextToken
384 * @param {Boolean} [options.stickToPreviousToken]
385 * @param {Number} lineCount
386 */
387TokenAssert.prototype._augmentLineCount = function(options, lineCount) {
388 var token = options.nextToken;
389 if (lineCount === 0) {
390 if (options.stickToPreviousToken) {
391 var nextToken = this._file.getNextToken(token, {
392 includeComments: true
393 });
394 this._file.setWhitespaceBefore(nextToken, this._file.getWhitespaceBefore(token));
395 }
396
397 this._file.setWhitespaceBefore(token, ' ');
398 return;
399 }
400
401 this._updateWhitespaceByLine(token, function(lines) {
402 var currentLineCount = lines.length;
403 var lastLine = lines[lines.length - 1];
404
405 if (currentLineCount <= lineCount) {
406 // add additional lines that maintain the same indentation as the former last line
407 for (; currentLineCount <= lineCount; currentLineCount++) {
408 lines[lines.length - 1] = '';
409 lines.push(lastLine);
410 }
411 } else {
412 // remove lines and then ensure that the new last line maintains the previous indentation
413 lines = lines.slice(0, lineCount + 1);
414 lines[lines.length - 1] = lastLine;
415 }
416
417 return lines;
418 });
419};
420
421/**
422 * Requires specific token before given.
423 *
424 * @param {Object} options
425 * @param {Object} options.token
426 * @param {Object} options.expectedTokenBefore
427 * @param {String} [options.message]
428 */
429TokenAssert.prototype.tokenBefore = function(options) {
430 var token = options.token;
431 var actualTokenBefore = this._file.getPrevToken(token);
432 var expectedTokenBefore = options.expectedTokenBefore;
433
434 if (!actualTokenBefore) {
435 this.emit('error', {
436 message: expectedTokenBefore.value + ' was expected before ' + token.value + ' but document start found',
437 line: token.loc.start.line,
438 column: token.loc.start.column
439 });
440 return;
441 }
442
443 // Only attempt to remove or add lines if there are no comments between the two nodes
444 // as this prevents accidentally moving a valid token onto a line comment ed line
445 var fixed = this._file.getPrevToken(options.token, {includeComments: true}) === actualTokenBefore;
446
447 if (
448 actualTokenBefore.type !== expectedTokenBefore.type ||
449 actualTokenBefore.value !== expectedTokenBefore.value
450 ) {
451
452 if (fixed) {
453 this._file.setWhitespaceBefore(token, expectedTokenBefore.value + this._file.getWhitespaceBefore(token));
454 }
455
456 var message = options.message;
457 if (!message) {
458 var showTypes = expectedTokenBefore.value === actualTokenBefore.value;
459 message =
460 expectedTokenBefore.value + (showTypes ? ' (' + expectedTokenBefore.type + ')' : '') +
461 ' was expected before ' + token.value +
462 ' but ' + actualTokenBefore.value + (showTypes ? ' (' + actualTokenBefore.type + ')' : '') + ' found';
463 }
464
465 this.emit('error', {
466 message: message,
467 line: actualTokenBefore.loc.end.line,
468 column: actualTokenBefore.loc.end.column,
469 fixed: fixed
470 });
471 }
472};
473/**
474 * Disallows specific token before given.
475 *
476 * @param {Object} options
477 * @param {Object} options.token
478 * @param {Object} options.expectedTokenBefore
479 * @param {String} [options.message]
480 */
481TokenAssert.prototype.noTokenBefore = function(options) {
482 var token = options.token;
483 var actualTokenBefore = this._file.getPrevToken(token);
484 if (!actualTokenBefore) {
485 // document start
486 return;
487 }
488
489 var fixed = this._file.getPrevToken(options.token, {
490 includeComments: true
491 }) === actualTokenBefore;
492
493 var expectedTokenBefore = options.expectedTokenBefore;
494 if (actualTokenBefore.type === expectedTokenBefore.type &&
495 actualTokenBefore.value === expectedTokenBefore.value
496 ) {
497
498 if (fixed) {
499 actualTokenBefore.value = '';
500 }
501
502 this.emit('error', {
503 message: options.message || 'Illegal ' + expectedTokenBefore.value + ' was found before ' + token.value,
504 line: actualTokenBefore.loc.end.line,
505 column: actualTokenBefore.loc.end.column,
506 fixed: fixed
507 });
508 }
509};
510
511module.exports = TokenAssert;