UNPKG

eslint-config-chain-able

Version:
336 lines (310 loc) 10.4 kB
/** * @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]/; // ------------------------------------------------------------------------------ // 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; } // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ /** * Reports issues where the context's source code differs from the Prettier formatted version. * @param {RuleContext} context - The ESLint rule context. * @param {string} prettierSource - The Prettier formatted source. * @returns {void} */ function reportDifferences(context, 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 source = context.getSourceCode().text; const results = diff(source, prettierSource); 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(); } } 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) { reportReplace(context, offset, aheadDeleteText, aheadInsertText); } else if (!aheadDeleteText && aheadInsertText) { reportInsert(context, offset, aheadInsertText); } else if (aheadDeleteText && !aheadInsertText) { reportDelete(context, offset, aheadDeleteText); } offset += aheadDeleteText.length; } } /** * 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.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 prettierOptions = context.options[0] === 'fb' ? FB_PRETTIER_OPTIONS : context.options[0]; const pragma = context.options[1] ? context.options[1].slice(1) // Remove leading @ : null; const sourceCode = context.getSourceCode(); const source = sourceCode.text; // 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 {}; } } return { Program() { if (!prettier) { // Prettier is expensive to load, so only load it if needed. prettier = require('prettier'); } const prettierSource = prettier.format(source, prettierOptions); if (source !== prettierSource) { reportDifferences(context, prettierSource); } } }; } } };