UNPKG

12.5 kBJavaScriptView Raw
1/**
2 * @fileoverview Runs `prettier` as an ESLint rule.
3 * @author Andres Suarez
4 */
5
6'use strict';
7
8// ------------------------------------------------------------------------------
9// Requirements
10// ------------------------------------------------------------------------------
11
12const diff = require('fast-diff');
13const docblock = require('jest-docblock');
14
15// ------------------------------------------------------------------------------
16// Constants
17// ------------------------------------------------------------------------------
18
19// Preferred Facebook style.
20const FB_PRETTIER_OPTIONS = {
21 singleQuote: true,
22 trailingComma: 'all',
23 bracketSpacing: false,
24 jsxBracketSameLine: true,
25 parser: 'flow'
26};
27
28const LINE_ENDING_RE = /\r\n|[\r\n\u2028\u2029]/;
29
30const OPERATION_INSERT = 'insert';
31const OPERATION_DELETE = 'delete';
32const OPERATION_REPLACE = 'replace';
33
34// ------------------------------------------------------------------------------
35// Privates
36// ------------------------------------------------------------------------------
37
38// Lazily-loaded Prettier.
39let prettier;
40
41// ------------------------------------------------------------------------------
42// Helpers
43// ------------------------------------------------------------------------------
44
45/**
46 * Gets the location of a given index in the source code for a given context.
47 * @param {RuleContext} context - The ESLint rule context.
48 * @param {number} index - An index in the source code.
49 * @returns {Object} An object containing numeric `line` and `column` keys.
50 */
51function getLocFromIndex(context, index) {
52 // If `sourceCode.getLocFromIndex` is available from ESLint, use it - added
53 // in ESLint 3.16.0.
54 const sourceCode = context.getSourceCode();
55 if (typeof sourceCode.getLocFromIndex === 'function') {
56 return sourceCode.getLocFromIndex(index);
57 }
58 const text = sourceCode.getText();
59 if (typeof index !== 'number') {
60 throw new TypeError('Expected `index` to be a number.');
61 }
62 if (index < 0 || index > text.length) {
63 throw new RangeError('Index out of range.');
64 }
65 // Loosely based on
66 // https://github.com/eslint/eslint/blob/18a519fa/lib/ast-utils.js#L408-L438
67 const lineEndingPattern = /\r\n|[\r\n\u2028\u2029]/g;
68 let offset = 0;
69 let line = 0;
70 let match;
71 while ((match = lineEndingPattern.exec(text))) {
72 const next = match.index + match[0].length;
73 if (index < next) {
74 break;
75 }
76 line++;
77 offset = next;
78 }
79 return {
80 line: line + 1,
81 column: index - offset
82 };
83}
84
85/**
86 * Converts invisible characters to a commonly recognizable visible form.
87 * @param {string} str - The string with invisibles to convert.
88 * @returns {string} The converted string.
89 */
90function showInvisibles(str) {
91 let ret = '';
92 for (let i = 0; i < str.length; i++) {
93 switch (str[i]) {
94 case ' ':
95 ret += '·'; // Middle Dot, \u00B7
96 break;
97 case '\n':
98 ret += '⏎'; // Return Symbol, \u23ce
99 break;
100 case '\t':
101 ret += '↹'; // Left Arrow To Bar Over Right Arrow To Bar, \u21b9
102 break;
103 default:
104 ret += str[i];
105 break;
106 }
107 }
108 return ret;
109}
110
111/**
112 * Generate results for differences between source code and formatted version.
113 * @param {string} source - The original source.
114 * @param {string} prettierSource - The Prettier formatted source.
115 * @returns {Array} - An array contains { operation, offset, insertText, deleteText }
116 */
117function generateDifferences(source, prettierSource) {
118 // fast-diff returns the differences between two texts as a series of
119 // INSERT, DELETE or EQUAL operations. The results occur only in these
120 // sequences:
121 // /-> INSERT -> EQUAL
122 // EQUAL | /-> EQUAL
123 // \-> DELETE |
124 // \-> INSERT -> EQUAL
125 // Instead of reporting issues at each INSERT or DELETE, certain sequences
126 // are batched together and are reported as a friendlier "replace" operation:
127 // - A DELETE immediately followed by an INSERT.
128 // - Any number of INSERTs and DELETEs where the joining EQUAL of one's end
129 // and another's beginning does not have line endings (i.e. issues that occur
130 // on contiguous lines).
131
132 const results = diff(source, prettierSource);
133 const differences = [];
134
135 const batch = [];
136 let offset = 0; // NOTE: INSERT never advances the offset.
137 while (results.length) {
138 const result = results.shift();
139 const op = result[0];
140 const text = result[1];
141 switch (op) {
142 case diff.INSERT:
143 case diff.DELETE:
144 batch.push(result);
145 break;
146 case diff.EQUAL:
147 if (results.length) {
148 if (batch.length) {
149 if (LINE_ENDING_RE.test(text)) {
150 flush();
151 offset += text.length;
152 } else {
153 batch.push(result);
154 }
155 } else {
156 offset += text.length;
157 }
158 }
159 break;
160 default:
161 throw new Error(`Unexpected fast-diff operation "${op}"`);
162 }
163 if (batch.length && !results.length) {
164 flush();
165 }
166 }
167
168 return differences;
169
170 function flush() {
171 let aheadDeleteText = '';
172 let aheadInsertText = '';
173 while (batch.length) {
174 const next = batch.shift();
175 const op = next[0];
176 const text = next[1];
177 switch (op) {
178 case diff.INSERT:
179 aheadInsertText += text;
180 break;
181 case diff.DELETE:
182 aheadDeleteText += text;
183 break;
184 case diff.EQUAL:
185 aheadDeleteText += text;
186 aheadInsertText += text;
187 break;
188 }
189 }
190 if (aheadDeleteText && aheadInsertText) {
191 differences.push({
192 offset,
193 operation: OPERATION_REPLACE,
194 insertText: aheadInsertText,
195 deleteText: aheadDeleteText
196 });
197 } else if (!aheadDeleteText && aheadInsertText) {
198 differences.push({
199 offset,
200 operation: OPERATION_INSERT,
201 insertText: aheadInsertText
202 });
203 } else if (aheadDeleteText && !aheadInsertText) {
204 differences.push({
205 offset,
206 operation: OPERATION_DELETE,
207 deleteText: aheadDeleteText
208 });
209 }
210 offset += aheadDeleteText.length;
211 }
212}
213
214// ------------------------------------------------------------------------------
215// Rule Definition
216// ------------------------------------------------------------------------------
217
218/**
219 * Reports an "Insert ..." issue where text must be inserted.
220 * @param {RuleContext} context - The ESLint rule context.
221 * @param {number} offset - The source offset where to insert text.
222 * @param {string} text - The text to be inserted.
223 * @returns {void}
224 */
225function reportInsert(context, offset, text) {
226 const pos = getLocFromIndex(context, offset);
227 const range = [offset, offset];
228 context.report({
229 message: 'Insert `{{ code }}`',
230 data: { code: showInvisibles(text) },
231 loc: { start: pos, end: pos },
232 fix(fixer) {
233 return fixer.insertTextAfterRange(range, text);
234 }
235 });
236}
237
238/**
239 * Reports a "Delete ..." issue where text must be deleted.
240 * @param {RuleContext} context - The ESLint rule context.
241 * @param {number} offset - The source offset where to delete text.
242 * @param {string} text - The text to be deleted.
243 * @returns {void}
244 */
245function reportDelete(context, offset, text) {
246 const start = getLocFromIndex(context, offset);
247 const end = getLocFromIndex(context, offset + text.length);
248 const range = [offset, offset + text.length];
249 context.report({
250 message: 'Delete `{{ code }}`',
251 data: { code: showInvisibles(text) },
252 loc: { start, end },
253 fix(fixer) {
254 return fixer.removeRange(range);
255 }
256 });
257}
258
259/**
260 * Reports a "Replace ... with ..." issue where text must be replaced.
261 * @param {RuleContext} context - The ESLint rule context.
262 * @param {number} offset - The source offset where to replace deleted text
263 with inserted text.
264 * @param {string} deleteText - The text to be deleted.
265 * @param {string} insertText - The text to be inserted.
266 * @returns {void}
267 */
268function reportReplace(context, offset, deleteText, insertText) {
269 const start = getLocFromIndex(context, offset);
270 const end = getLocFromIndex(context, offset + deleteText.length);
271 const range = [offset, offset + deleteText.length];
272 context.report({
273 message: 'Replace `{{ deleteCode }}` with `{{ insertCode }}`',
274 data: {
275 deleteCode: showInvisibles(deleteText),
276 insertCode: showInvisibles(insertText)
277 },
278 loc: { start, end },
279 fix(fixer) {
280 return fixer.replaceTextRange(range, insertText);
281 }
282 });
283}
284
285// ------------------------------------------------------------------------------
286// Module Definition
287// ------------------------------------------------------------------------------
288
289module.exports = {
290 showInvisibles,
291 generateDifferences,
292 configs: {
293 recommended: {
294 extends: ['prettier'],
295 plugins: ['prettier'],
296 rules: {
297 'prettier/prettier': 'error'
298 }
299 }
300 },
301 rules: {
302 prettier: {
303 meta: {
304 fixable: 'code',
305 schema: [
306 // Prettier options:
307 {
308 anyOf: [
309 { enum: [null, 'fb'] },
310 { type: 'object', properties: {}, additionalProperties: true }
311 ]
312 },
313 // Pragma:
314 { type: 'string', pattern: '^@\\w+$' }
315 ]
316 },
317 create(context) {
318 const pragma = context.options[1]
319 ? context.options[1].slice(1) // Remove leading @
320 : null;
321
322 const sourceCode = context.getSourceCode();
323 const source = sourceCode.text;
324
325 // The pragma is only valid if it is found in a block comment at the very
326 // start of the file.
327 if (pragma) {
328 // ESLint 3.x reports the shebang as a "Line" node, while ESLint 4.x
329 // reports it as a "Shebang" node. This works for both versions:
330 const hasShebang = source.startsWith('#!');
331 const allComments = sourceCode.getAllComments();
332 const firstComment = hasShebang ? allComments[1] : allComments[0];
333 if (
334 !(
335 firstComment &&
336 firstComment.type === 'Block' &&
337 firstComment.loc.start.line === (hasShebang ? 2 : 1) &&
338 firstComment.loc.start.column === 0
339 )
340 ) {
341 return {};
342 }
343 const parsed = docblock.parse(firstComment.value);
344 if (parsed[pragma] !== '') {
345 return {};
346 }
347 }
348
349 if (prettier && prettier.clearConfigCache) {
350 prettier.clearConfigCache();
351 }
352
353 return {
354 Program() {
355 if (!prettier) {
356 // Prettier is expensive to load, so only load it if needed.
357 prettier = require('prettier');
358 }
359
360 const eslintPrettierOptions =
361 context.options[0] === 'fb'
362 ? FB_PRETTIER_OPTIONS
363 : context.options[0];
364 const prettierRcOptions =
365 prettier.resolveConfig && prettier.resolveConfig.sync
366 ? prettier.resolveConfig.sync(context.getFilename())
367 : null;
368 const prettierOptions = Object.assign(
369 {},
370 prettierRcOptions,
371 eslintPrettierOptions
372 );
373
374 const prettierSource = prettier.format(source, prettierOptions);
375 if (source !== prettierSource) {
376 const differences = generateDifferences(source, prettierSource);
377
378 differences.forEach(difference => {
379 switch (difference.operation) {
380 case OPERATION_INSERT:
381 reportInsert(
382 context,
383 difference.offset,
384 difference.insertText
385 );
386 break;
387 case OPERATION_DELETE:
388 reportDelete(
389 context,
390 difference.offset,
391 difference.deleteText
392 );
393 break;
394 case OPERATION_REPLACE:
395 reportReplace(
396 context,
397 difference.offset,
398 difference.deleteText,
399 difference.insertText
400 );
401 break;
402 }
403 });
404 }
405 }
406 };
407 }
408 }
409 }
410};