Created
November 27, 2017 19:52
-
-
Save jakub300/53a580e79357cca5a34f06c7a5c43129 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @fileoverview Runs `prettier` as an ESLint rule. | |
* @author Andres Suarez | |
*/ | |
'use strict'; | |
// ------------------------------------------------------------------------------ | |
// Requirements | |
// ------------------------------------------------------------------------------ | |
const diff = require('fast-diff'); | |
const docblock = require('jest-docblock'); | |
// ------------------------------------------------------------------------------ | |
// Constants | |
// ------------------------------------------------------------------------------ | |
// Preferred Facebook style. | |
const FB_PRETTIER_OPTIONS = { | |
singleQuote: true, | |
trailingComma: 'all', | |
bracketSpacing: false, | |
jsxBracketSameLine: true, | |
parser: 'flow' | |
}; | |
const LINE_ENDING_RE = /\r\n|[\r\n\u2028\u2029]/; | |
const OPERATION_INSERT = 'insert'; | |
const OPERATION_DELETE = 'delete'; | |
const OPERATION_REPLACE = 'replace'; | |
// ------------------------------------------------------------------------------ | |
// Privates | |
// ------------------------------------------------------------------------------ | |
// Lazily-loaded Prettier. | |
let prettier; | |
// ------------------------------------------------------------------------------ | |
// Helpers | |
// ------------------------------------------------------------------------------ | |
/** | |
* Gets the location of a given index in the source code for a given context. | |
* @param {RuleContext} context - The ESLint rule context. | |
* @param {number} index - An index in the source code. | |
* @returns {Object} An object containing numeric `line` and `column` keys. | |
*/ | |
function getLocFromIndex(context, index) { | |
// If `sourceCode.getLocFromIndex` is available from ESLint, use it - added | |
// in ESLint 3.16.0. | |
const sourceCode = context.getSourceCode(); | |
if (typeof sourceCode.getLocFromIndex === 'function') { | |
return sourceCode.getLocFromIndex(index); | |
} | |
const text = sourceCode.getText(); | |
if (typeof index !== 'number') { | |
throw new TypeError('Expected `index` to be a number.'); | |
} | |
if (index < 0 || index > text.length) { | |
throw new RangeError('Index out of range.'); | |
} | |
// Loosely based on | |
// https://github.com/eslint/eslint/blob/18a519fa/lib/ast-utils.js#L408-L438 | |
const lineEndingPattern = /\r\n|[\r\n\u2028\u2029]/g; | |
let offset = 0; | |
let line = 0; | |
let match; | |
while ((match = lineEndingPattern.exec(text))) { | |
const next = match.index + match[0].length; | |
if (index < next) { | |
break; | |
} | |
line++; | |
offset = next; | |
} | |
return { | |
line: line + 1, | |
column: index - offset | |
}; | |
} | |
/** | |
* Converts invisible characters to a commonly recognizable visible form. | |
* @param {string} str - The string with invisibles to convert. | |
* @returns {string} The converted string. | |
*/ | |
function showInvisibles(str) { | |
let ret = ''; | |
for (let i = 0; i < str.length; i++) { | |
switch (str[i]) { | |
case ' ': | |
ret += '·'; // Middle Dot, \u00B7 | |
break; | |
case '\n': | |
ret += '⏎'; // Return Symbol, \u23ce | |
break; | |
case '\t': | |
ret += '↹'; // Left Arrow To Bar Over Right Arrow To Bar, \u21b9 | |
break; | |
default: | |
ret += str[i]; | |
break; | |
} | |
} | |
return ret; | |
} | |
/** | |
* Generate results for differences between source code and formatted version. | |
* @param {string} source - The original source. | |
* @param {string} prettierSource - The Prettier formatted source. | |
* @returns {Array} - An array contains { operation, offset, insertText, deleteText } | |
*/ | |
function generateDifferences(source, prettierSource) { | |
// fast-diff returns the differences between two texts as a series of | |
// INSERT, DELETE or EQUAL operations. The results occur only in these | |
// sequences: | |
// /-> INSERT -> EQUAL | |
// EQUAL | /-> EQUAL | |
// \-> DELETE | | |
// \-> INSERT -> EQUAL | |
// Instead of reporting issues at each INSERT or DELETE, certain sequences | |
// are batched together and are reported as a friendlier "replace" operation: | |
// - A DELETE immediately followed by an INSERT. | |
// - Any number of INSERTs and DELETEs where the joining EQUAL of one's end | |
// and another's beginning does not have line endings (i.e. issues that occur | |
// on contiguous lines). | |
const results = diff(source, prettierSource); | |
const differences = []; | |
const batch = []; | |
let offset = 0; // NOTE: INSERT never advances the offset. | |
while (results.length) { | |
const result = results.shift(); | |
const op = result[0]; | |
const text = result[1]; | |
switch (op) { | |
case diff.INSERT: | |
case diff.DELETE: | |
batch.push(result); | |
break; | |
case diff.EQUAL: | |
if (results.length) { | |
if (batch.length) { | |
if (LINE_ENDING_RE.test(text)) { | |
flush(); | |
offset += text.length; | |
} else { | |
batch.push(result); | |
} | |
} else { | |
offset += text.length; | |
} | |
} | |
break; | |
default: | |
throw new Error(`Unexpected fast-diff operation "${op}"`); | |
} | |
if (batch.length && !results.length) { | |
flush(); | |
} | |
} | |
return differences; | |
function flush() { | |
let aheadDeleteText = ''; | |
let aheadInsertText = ''; | |
while (batch.length) { | |
const next = batch.shift(); | |
const op = next[0]; | |
const text = next[1]; | |
switch (op) { | |
case diff.INSERT: | |
aheadInsertText += text; | |
break; | |
case diff.DELETE: | |
aheadDeleteText += text; | |
break; | |
case diff.EQUAL: | |
aheadDeleteText += text; | |
aheadInsertText += text; | |
break; | |
} | |
} | |
if (aheadDeleteText && aheadInsertText) { | |
differences.push({ | |
offset, | |
operation: OPERATION_REPLACE, | |
insertText: aheadInsertText, | |
deleteText: aheadDeleteText | |
}); | |
} else if (!aheadDeleteText && aheadInsertText) { | |
differences.push({ | |
offset, | |
operation: OPERATION_INSERT, | |
insertText: aheadInsertText | |
}); | |
} else if (aheadDeleteText && !aheadInsertText) { | |
differences.push({ | |
offset, | |
operation: OPERATION_DELETE, | |
deleteText: aheadDeleteText | |
}); | |
} | |
offset += aheadDeleteText.length; | |
} | |
} | |
// ------------------------------------------------------------------------------ | |
// Rule Definition | |
// ------------------------------------------------------------------------------ | |
/** | |
* Reports an "Insert ..." issue where text must be inserted. | |
* @param {RuleContext} context - The ESLint rule context. | |
* @param {number} offset - The source offset where to insert text. | |
* @param {string} text - The text to be inserted. | |
* @returns {void} | |
*/ | |
function reportInsert(context, offset, text) { | |
const pos = getLocFromIndex(context, offset); | |
const range = [offset, offset]; | |
context.report({ | |
message: 'Insert `{{ code }}`', | |
data: { code: showInvisibles(text) }, | |
loc: { start: pos, end: pos }, | |
fix(fixer) { | |
return fixer.insertTextAfterRange(range, text); | |
} | |
}); | |
} | |
/** | |
* Reports a "Delete ..." issue where text must be deleted. | |
* @param {RuleContext} context - The ESLint rule context. | |
* @param {number} offset - The source offset where to delete text. | |
* @param {string} text - The text to be deleted. | |
* @returns {void} | |
*/ | |
function reportDelete(context, offset, text) { | |
const start = getLocFromIndex(context, offset); | |
const end = getLocFromIndex(context, offset + text.length); | |
const range = [offset, offset + text.length]; | |
context.report({ | |
message: 'Delete `{{ code }}`', | |
data: { code: showInvisibles(text) }, | |
loc: { start, end }, | |
fix(fixer) { | |
return fixer.removeRange(range); | |
} | |
}); | |
} | |
/** | |
* Reports a "Replace ... with ..." issue where text must be replaced. | |
* @param {RuleContext} context - The ESLint rule context. | |
* @param {number} offset - The source offset where to replace deleted text | |
with inserted text. | |
* @param {string} deleteText - The text to be deleted. | |
* @param {string} insertText - The text to be inserted. | |
* @returns {void} | |
*/ | |
function reportReplace(context, offset, deleteText, insertText) { | |
const start = getLocFromIndex(context, offset); | |
const end = getLocFromIndex(context, offset + deleteText.length); | |
const range = [offset, offset + deleteText.length]; | |
context.report({ | |
message: 'Replace `{{ deleteCode }}` with `{{ insertCode }}`', | |
data: { | |
deleteCode: showInvisibles(deleteText), | |
insertCode: showInvisibles(insertText) | |
}, | |
loc: { start, end }, | |
fix(fixer) { | |
return fixer.replaceTextRange(range, insertText); | |
} | |
}); | |
} | |
// ------------------------------------------------------------------------------ | |
// Module Definition | |
// ------------------------------------------------------------------------------ | |
module.exports = { | |
showInvisibles, | |
generateDifferences, | |
rules: { | |
prettier: { | |
meta: { | |
fixable: 'code', | |
schema: [ | |
// Prettier options: | |
{ | |
anyOf: [ | |
{ enum: [null, 'fb'] }, | |
{ type: 'object', properties: {}, additionalProperties: true } | |
] | |
}, | |
// Pragma: | |
{ type: 'string', pattern: '^@\\w+$' } | |
] | |
}, | |
create(context) { | |
const pragma = context.options[1] | |
? context.options[1].slice(1) // Remove leading @ | |
: null; | |
const sourceCode = context.getSourceCode(); | |
const source = sourceCode.getText(sourceCode.ast); | |
// The pragma is only valid if it is found in a block comment at the very | |
// start of the file. | |
if (pragma) { | |
// ESLint 3.x reports the shebang as a "Line" node, while ESLint 4.x | |
// reports it as a "Shebang" node. This works for both versions: | |
const hasShebang = source.startsWith('#!'); | |
const allComments = sourceCode.getAllComments(); | |
const firstComment = hasShebang ? allComments[1] : allComments[0]; | |
if ( | |
!( | |
firstComment && | |
firstComment.type === 'Block' && | |
firstComment.loc.start.line === (hasShebang ? 2 : 1) && | |
firstComment.loc.start.column === 0 | |
) | |
) { | |
return {}; | |
} | |
const parsed = docblock.parse(firstComment.value); | |
if (parsed[pragma] !== '') { | |
return {}; | |
} | |
} | |
if (prettier && prettier.clearConfigCache) { | |
prettier.clearConfigCache(); | |
} | |
return { | |
Program(node) { | |
if (!prettier) { | |
// Prettier is expensive to load, so only load it if needed. | |
prettier = require('prettier'); | |
} | |
console.log(node.loc.start); | |
const start = sourceCode.getIndexFromLoc(node.loc.start); | |
const eslintPrettierOptions = | |
context.options[0] === 'fb' | |
? FB_PRETTIER_OPTIONS | |
: context.options[0]; | |
const prettierRcOptions = | |
prettier.resolveConfig && prettier.resolveConfig.sync | |
? prettier.resolveConfig.sync(context.getFilename()) | |
: null; | |
const prettierOptions = Object.assign( | |
{}, | |
prettierRcOptions, | |
eslintPrettierOptions | |
); | |
const prettierSource = prettier.format(source, prettierOptions); | |
if (source !== prettierSource) { | |
const differences = generateDifferences(source, prettierSource); | |
differences.forEach(difference => { | |
switch (difference.operation) { | |
case OPERATION_INSERT: | |
reportInsert( | |
context, | |
start + difference.offset, | |
difference.insertText | |
); | |
break; | |
case OPERATION_DELETE: | |
reportDelete( | |
context, | |
start + difference.offset, | |
difference.deleteText | |
); | |
break; | |
case OPERATION_REPLACE: | |
reportReplace( | |
context, | |
start + difference.offset, | |
difference.deleteText, | |
difference.insertText | |
); | |
break; | |
} | |
}); | |
} | |
} | |
}; | |
} | |
} | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment