UNPKG

monk-import-export

Version:

Simple import & export sorting ESLint plugin

638 lines (637 loc) 29.3 kB
'use strict'; // A “chunk” is a sequence of statements of a certain type with only comments // and whitespace between. function extractChunks(programNode, isPartOfChunk) { const chunks = []; let chunk = []; let lastNode = undefined; for (const node of programNode.body) { const result = isPartOfChunk(node, lastNode); switch (result) { case 'PartOfChunk': chunk.push(node); break; case 'PartOfNewChunk': if (chunk.length > 0) { chunks.push(chunk); } chunk = [node]; break; case 'NotPartOfChunk': if (chunk.length > 0) { chunks.push(chunk); chunk = []; } break; // istanbul ignore next default: throw new Error(`Unknown chunk result: ${result}`); } lastNode = node; } if (chunk.length > 0) { chunks.push(chunk); } return chunks; } function maybeReportSorting(context, sorted, start, end) { const sourceCode = context.getSourceCode(); const original = sourceCode.getText().slice(start, end); if (original !== sorted) { context.report({ messageId: 'sort', loc: { start: sourceCode.getLocFromIndex(start), end: sourceCode.getLocFromIndex(end) }, fix: fixer => fixer.replaceTextRange([start, end], sorted) }); } } function printSortedItems(sortedItems, originalItems, sourceCode) { const newline = guessNewline(sourceCode); const sorted = sortedItems .map(groups => groups.map(groupItems => groupItems.map(item => item.code).join(newline)).join(newline)) .join(newline + newline); // Edge case: If the last import/export (after sorting) ends with a line // comment and there’s code (or a multiline block comment) on the same line, // add a newline so we don’t accidentally comment stuff out. const flattened = flatMap(sortedItems, groups => [].concat(...groups)); const lastSortedItem = flattened[flattened.length - 1]; const lastOriginalItem = originalItems[originalItems.length - 1]; const nextToken = lastSortedItem.needsNewline ? sourceCode.getTokenAfter(lastOriginalItem.node, { includeComments: true, filter: token => !isLineComment(token) && !(isBlockComment(token) && token.loc.end.line === lastOriginalItem.node.loc.end.line) }) : undefined; const maybeNewline = nextToken != null && nextToken.loc.start.line === lastOriginalItem.node.loc.end.line ? newline : ''; return sorted + maybeNewline; } // Wrap the import/export nodes in `passedChunk` in objects with more data about // the import/export. Most importantly there’s a `code` property that contains // the node as a string, with comments (if any). Finding the corresponding // comments is the hard part. function getImportExportItems(passedChunk, sourceCode, isSideEffectImport, getSpecifiers) { const chunk = handleLastSemicolon(passedChunk, sourceCode); return chunk.map((node, nodeIndex) => { const lastLine = nodeIndex === 0 ? node.loc.start.line - 1 : chunk[nodeIndex - 1].loc.end.line; // Get all comments before the import/export, except: // // - Comments on another line for the first import/export. // - Comments that belong to the previous import/export (if any) – that is, // comments that are on the same line as the previous import/export. But // multiline block comments always belong to this import/export, not the // previous. const commentsBefore = sourceCode .getCommentsBefore(node) .filter(comment => comment.loc.start.line <= node.loc.start.line && comment.loc.end.line > lastLine && (nodeIndex > 0 || comment.loc.start.line > lastLine)); // Get all comments after the import/export that are on the same line. // Multiline block comments belong to the _next_ import/export (or the // following code in case of the last import/export). const commentsAfter = sourceCode.getCommentsAfter(node).filter(comment => comment.loc.end.line === node.loc.end.line); const before = printCommentsBefore(node, commentsBefore, sourceCode); const after = printCommentsAfter(node, commentsAfter, sourceCode); // Print the indentation before the import/export or its first comment, if // any, to support indentation in `<script>` tags. const indentation = getIndentation(commentsBefore.length > 0 ? commentsBefore[0] : node, sourceCode); // Print spaces after the import/export or its last comment, if any, to // avoid producing a sort error just because you accidentally added a few // trailing spaces among the imports/exports. const trailingSpaces = getTrailingSpaces(commentsAfter.length > 0 ? commentsAfter[commentsAfter.length - 1] : node, sourceCode); const code = indentation + before + printWithSortedSpecifiers(node, sourceCode, getSpecifiers) + after + trailingSpaces; const all = [...commentsBefore, node, ...commentsAfter]; const [start] = all[0].range; const [, end] = all[all.length - 1].range; const source = getSource(node); return { node, code, start: start - indentation.length, end: end + trailingSpaces.length, isSideEffectImport: isSideEffectImport(node, sourceCode), source, index: nodeIndex, needsNewline: commentsAfter.length > 0 && isLineComment(commentsAfter[commentsAfter.length - 1]) }; }); } // Parsers think that a semicolon after a statement belongs to that statement. // But in a semicolon-free code style it might belong to the next statement: // // import x from "x" // ;[].forEach() // // If the last import/export of a chunk ends with a semicolon, and that // semicolon isn’t located on the same line as the `from` string, adjust the // node to end at the `from` string instead. // // In the above example, the import is adjusted to end after `"x"`. function handleLastSemicolon(chunk, sourceCode) { const lastIndex = chunk.length - 1; const lastNode = chunk[lastIndex]; const [nextToLastToken, lastToken] = sourceCode.getLastTokens(lastNode, { count: 2 }); const lastIsSemicolon = isPunctuator(lastToken, ';'); if (!lastIsSemicolon) { return chunk; } const semicolonBelongsToNode = nextToLastToken.loc.end.line === lastToken.loc.start.line || // If there’s no more code after the last import/export the semicolon has to // belong to the import/export, even if it is not on the same line. sourceCode.getTokenAfter(lastToken) == null; if (semicolonBelongsToNode) { return chunk; } // Preserve the start position, but use the end position of the `from` string. const newLastNode = Object.assign(Object.assign({}, lastNode), { range: [lastNode.range[0], nextToLastToken.range[1]], loc: { start: lastNode.loc.start, end: nextToLastToken.loc.end } }); return chunk.slice(0, lastIndex).concat(newLastNode); } function printWithSortedSpecifiers(node, sourceCode, getSpecifiers) { const allTokens = getAllTokens(node, sourceCode); const openBraceIndex = allTokens.findIndex(token => isPunctuator(token, '{')); const closeBraceIndex = allTokens.findIndex(token => isPunctuator(token, '}')); const specifiers = getSpecifiers(node); if (openBraceIndex === -1 || closeBraceIndex === -1 || specifiers.length <= 1) { return printTokens(allTokens); } const specifierTokens = allTokens.slice(openBraceIndex + 1, closeBraceIndex); const itemsResult = getSpecifierItems(specifierTokens, sourceCode); const items = itemsResult.items.map((originalItem, index) => (Object.assign(Object.assign({}, originalItem), { node: specifiers[index] }))); const sortedItems = sortSpecifierItems(items); const newline = guessNewline(sourceCode); // `allTokens[closeBraceIndex - 1]` wouldn’t work because `allTokens` contains // comments and whitespace. const hasTrailingComma = isPunctuator(sourceCode.getTokenBefore(allTokens[closeBraceIndex]), ','); const lastIndex = sortedItems.length - 1; const sorted = flatMap(sortedItems, (item, index) => { const previous = index === 0 ? undefined : sortedItems[index - 1]; // Add a newline if the item needs one, unless the previous item (if any) // already ends with a newline. const maybeNewline = previous != null && needsStartingNewline(item.before) && !(previous.after.length > 0 && isNewline(previous.after[previous.after.length - 1])) ? [{ type: 'Newline', code: newline }] : []; if (index < lastIndex || hasTrailingComma) { return [...maybeNewline, ...item.before, ...item.specifier, { type: 'Comma', code: ',' }, ...item.after]; } const nonBlankIndex = item.after.findIndex(token => !isNewline(token) && !isSpaces(token)); // Remove whitespace and newlines at the start of `.after` if the item had a // comma before, but now hasn’t to avoid blank lines and excessive // whitespace before `}`. const after = !item.hadComma ? item.after : nonBlankIndex === -1 ? [] : item.after.slice(nonBlankIndex); return [...maybeNewline, ...item.before, ...item.specifier, ...after]; }); const maybeNewline = needsStartingNewline(itemsResult.after) && !isNewline(sorted[sorted.length - 1]) ? [{ type: 'Newline', code: newline }] : []; return printTokens([ ...allTokens.slice(0, openBraceIndex + 1), ...itemsResult.before, ...sorted, ...maybeNewline, ...itemsResult.after, ...allTokens.slice(closeBraceIndex) ]); } // Turns a list of tokens between the `{` and `}` of an import/export specifiers // list into an object with the following properties: // // - before: Array of tokens – whitespace and comments after the `{` that do not // belong to any specifier. // - after: Array of tokens – whitespace and comments before the `}` that do not // belong to any specifier. // - items: Array of specifier items. // // Each specifier item looks like this: // // - before: Array of tokens – whitespace and comments before the specifier. // - after: Array of tokens – whitespace and comments after the specifier. // - specifier: Array of tokens – identifiers, whitespace and comments of the // specifier. // - hadComma: A Boolean representing if the specifier had a comma originally. // // We have to do carefully preserve all original whitespace this way in order to // be compatible with other stylistic ESLint rules. function getSpecifierItems(tokens) { const result = { before: [], after: [], items: [] }; let current = makeEmptyItem(); for (const token of tokens) { switch (current.state) { case 'before': switch (token.type) { case 'Newline': current.before.push(token); // All whitespace and comments before the first newline or // identifier belong to the `{`, not the first specifier. if (result.before.length === 0 && result.items.length === 0) { result.before = current.before; current = makeEmptyItem(); } break; case 'Spaces': case 'Block': case 'Line': current.before.push(token); break; // We’ve reached an identifier. default: // All whitespace and comments before the first newline or // identifier belong to the `{`, not the first specifier. if (result.before.length === 0 && result.items.length === 0) { result.before = current.before; current = makeEmptyItem(); } current.state = 'specifier'; current.specifier.push(token); } break; case 'specifier': switch (token.type) { case 'Punctuator': // There can only be comma punctuators, but future-proof by checking. // istanbul ignore else if (isPunctuator(token, ',')) { current.hadComma = true; current.state = 'after'; } else { current.specifier.push(token); } break; // When consuming the specifier part, we eat every token until a comma // or to the end, basically. default: current.specifier.push(token); } break; case 'after': switch (token.type) { // Only whitespace and comments after a specifier that are on the same // belong to the specifier. case 'Newline': current.after.push(token); result.items.push(current); current = makeEmptyItem(); break; case 'Spaces': case 'Line': current.after.push(token); break; case 'Block': // Multiline block comments belong to the next specifier. if (hasNewline(token.code)) { result.items.push(current); current = makeEmptyItem(); current.before.push(token); } else { current.after.push(token); } break; // We’ve reached another specifier – time to process that one. default: result.items.push(current); current = makeEmptyItem(); current.state = 'specifier'; current.specifier.push(token); } break; // istanbul ignore next default: throw new Error(`Unknown state: ${current.state}`); } } // We’ve reached the end of the tokens. Handle what’s currently in `current`. switch (current.state) { // If the last specifier has a trailing comma and some of the remaining // whitespace and comments are on the same line we end up here. If so we // want to put that whitespace and comments in `result.after`. case 'before': result.after = current.before; break; // If the last specifier has no trailing comma we end up here. Move all // trailing comments and whitespace from `.specifier` to `.after`, and // comments and whitespace that don’t belong to the specifier to // `result.after`. The last non-comment and non-whitespace token is usually // an identifier, but in this case it’s a keyword: // // export { z, d as default } from "a" case 'specifier': { const lastIdentifierIndex = findLastIndex(current.specifier, token2 => isIdentifier(token2) || isKeyword(token2)); const specifier = current.specifier.slice(0, lastIdentifierIndex + 1); const after = current.specifier.slice(lastIdentifierIndex + 1); // If there’s a newline, put everything up to and including (hence the `+ // 1`) that newline in the specifiers’s `.after`. const newlineIndexRaw = after.findIndex(token2 => isNewline(token2)); const newlineIndex = newlineIndexRaw === -1 ? -1 : newlineIndexRaw + 1; // If there’s a multiline block comment, put everything _befor_ that // comment in the specifiers’s `.after`. const multilineBlockCommentIndex = after.findIndex(token2 => isBlockComment(token2) && hasNewline(token2.code)); const sliceIndex = // If both a newline and a multiline block comment exists, choose the // earlier one. newlineIndex >= 0 && multilineBlockCommentIndex >= 0 ? Math.min(newlineIndex, multilineBlockCommentIndex) : newlineIndex >= 0 ? newlineIndex : multilineBlockCommentIndex >= 0 ? multilineBlockCommentIndex : // If there are no newlines, move the last whitespace into `result.after`. endsWithSpaces(after) ? after.length - 1 : -1; current.specifier = specifier; current.after = sliceIndex === -1 ? after : after.slice(0, sliceIndex); result.items.push(current); result.after = sliceIndex === -1 ? [] : after.slice(sliceIndex); break; } // If the last specifier has a trailing comma and all remaining whitespace // and comments are on the same line we end up here. If so we want to move // the final whitespace to `result.after`. case 'after': if (endsWithSpaces(current.after)) { const last = current.after.pop(); result.after = [last]; } result.items.push(current); break; // istanbul ignore next default: throw new Error(`Unknown state: ${current.state}`); } return result; } function makeEmptyItem() { return { // "before" | "specifier" | "after" state: 'before', before: [], after: [], specifier: [], hadComma: false }; } // If a specifier item starts with a line comment or a singleline block comment // it needs a newline before that. Otherwise that comment can end up belonging // to the _previous_ specifier after sorting. function needsStartingNewline(tokens) { const before = tokens.filter(token => !isSpaces(token)); if (before.length === 0) { return false; } const firstToken = before[0]; return isLineComment(firstToken) || (isBlockComment(firstToken) && !hasNewline(firstToken.code)); } function endsWithSpaces(tokens) { const last = tokens.length > 0 ? tokens[tokens.length - 1] : undefined; return last == null ? false : isSpaces(last); } const NEWLINE = /(\r?\n)/; function hasNewline(string) { return NEWLINE.test(string); } function guessNewline(sourceCode) { const match = NEWLINE.exec(sourceCode.text); return match == null ? '\n' : match[0]; } function parseWhitespace(whitespace) { const allItems = whitespace.split(NEWLINE); // Remove blank lines. `allItems` contains alternating `spaces` (which can be // the empty string) and `newline` (which is either "\r\n" or "\n"). So in // practice `allItems` grows like this as there are more newlines in // `whitespace`: // // [spaces] // [spaces, newline, spaces] // [spaces, newline, spaces, newline, spaces] // [spaces, newline, spaces, newline, spaces, newline, spaces] // // If there are 5 or more items we have at least one blank line. If so, keep // the first `spaces`, the first `newline` and the last `spaces`. const items = allItems.length >= 5 ? allItems.slice(0, 2).concat(allItems.slice(-1)) : allItems; return (items .map((spacesOrNewline, index) => (index % 2 === 0 ? { type: 'Spaces', code: spacesOrNewline } : { type: 'Newline', code: spacesOrNewline })) // Remove empty spaces since it makes debugging easier. .filter(token => token.code !== '')); } function removeBlankLines(whitespace) { return printTokens(parseWhitespace(whitespace)); } // Returns `sourceCode.getTokens(node)` plus whitespace and comments. All tokens // have a `code` property with `sourceCode.getText(token)`. function getAllTokens(node, sourceCode) { const tokens = sourceCode.getTokens(node); const lastTokenIndex = tokens.length - 1; return flatMap(tokens, (token, tokenIndex) => { const newToken = Object.assign(Object.assign({}, token), { code: sourceCode.getText(token) }); if (tokenIndex === lastTokenIndex) { return [newToken]; } const comments = sourceCode.getCommentsAfter(token); const last = comments.length > 0 ? comments[comments.length - 1] : token; const nextToken = tokens[tokenIndex + 1]; return [ newToken, ...flatMap(comments, (comment, commentIndex) => { const previous = commentIndex === 0 ? token : comments[commentIndex - 1]; return [...parseWhitespace(sourceCode.text.slice(previous.range[1], comment.range[0])), Object.assign(Object.assign({}, comment), { code: sourceCode.getText(comment) })]; }), ...parseWhitespace(sourceCode.text.slice(last.range[1], nextToken.range[0])) ]; }); } // Prints tokens that are enhanced with a `code` property – like those returned // by `getAllTokens` and `parseWhitespace`. function printTokens(tokens) { return tokens.map(token => token.code).join(''); } // `comments` is a list of comments that occur before `node`. Print those and // the whitespace between themselves and between `node`. function printCommentsBefore(node, comments, sourceCode) { const lastIndex = comments.length - 1; return comments .map((comment, index) => { const next = index === lastIndex ? node : comments[index + 1]; return sourceCode.getText(comment) + removeBlankLines(sourceCode.text.slice(comment.range[1], next.range[0])); }) .join(''); } // `comments` is a list of comments that occur after `node`. Print those and // the whitespace between themselves and between `node`. function printCommentsAfter(node, comments, sourceCode) { return comments .map((comment, index) => { const previous = index === 0 ? node : comments[index - 1]; return removeBlankLines(sourceCode.text.slice(previous.range[1], comment.range[0])) + sourceCode.getText(comment); }) .join(''); } function getIndentation(node, sourceCode) { const tokenBefore = sourceCode.getTokenBefore(node, { includeComments: true }); if (tokenBefore == null) { const text = sourceCode.text.slice(0, node.range[0]); const lines = text.split(NEWLINE); return lines[lines.length - 1]; } const text = sourceCode.text.slice(tokenBefore.range[1], node.range[0]); const lines = text.split(NEWLINE); return lines.length > 1 ? lines[lines.length - 1] : ''; } function getTrailingSpaces(node, sourceCode) { const tokenAfter = sourceCode.getTokenAfter(node, { includeComments: true }); if (tokenAfter == null) { const text = sourceCode.text.slice(node.range[1]); const lines = text.split(NEWLINE); return lines[0]; } const text = sourceCode.text.slice(node.range[1], tokenAfter.range[0]); const lines = text.split(NEWLINE); return lines[0]; } function sortImportExportItems(items) { return items.slice().sort((itemA, itemB) => // If both items are side effect imports, keep their original order. itemA.isSideEffectImport && itemB.isSideEffectImport ? itemA.index - itemB.index : // If one of the items is a side effect import, move it first. itemA.isSideEffectImport ? -1 : itemB.isSideEffectImport ? 1 : // Compare the `from` part. compare(itemA.source.source, itemB.source.source) || // The `.source` has been slightly tweaked. To stay fully deterministic, // also sort on the original value. compare(itemA.source.originalSource, itemB.source.originalSource) || // Then put type imports/exports before regular ones. compare(itemA.source.kind, itemB.source.kind) || // Keep the original order if the sources are the same. It’s not worth // trying to compare anything else, and you can use `import/no-duplicates` // to get rid of the problem anyway. itemA.index - itemB.index); } function sortSpecifierItems(items) { return items.slice().sort((itemA, itemB) => // Put type imports/exports before regular ones. compare(getImportExportKind(itemA.node), getImportExportKind(itemB.node)) || // Then compare by imported or exported name (external interface name). // import { a as b } from "a" // ^ // export { b as a } // ^ compare((itemA.node.imported || itemA.node.exported).name, (itemB.node.imported || itemB.node.exported).name) || // Then compare by the file-local name. // import { a as b } from "a" // ^ // export { b as a } // ^ compare(itemA.node.local.name, itemB.node.local.name) || // Keep the original order if the names are the same. It’s not worth // trying to compare anything else, `import {a, a} from "mod"` is a syntax // error anyway (but @babel/eslint-parser kind of supports it). // istanbul ignore next itemA.index - itemB.index); } const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true }); function compare(a, b) { return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0); } function isIdentifier(node) { return node.type === 'Identifier'; } function isKeyword(node) { return node.type === 'Keyword'; } function isPunctuator(node, value) { return node.type === 'Punctuator' && node.value === value; } function isBlockComment(node) { return node.type === 'Block'; } function isLineComment(node) { return node.type === 'Line'; } function isSpaces(node) { return node.type === 'Spaces'; } function isNewline(node) { return node.type === 'Newline'; } function getSource(node) { const source = node.source.value; return { // Sort by directory level rather than by string length. source: source // Treat `.` as `./`, `..` as `../`, `../..` as `../../` etc. .replace(/^[./]*\.$/, '$&/') // Make `../` sort after `../../` but before `../a` etc. // Why a comma? See the next comment. .replace(/^[./]*\/$/, '$&,') // Make `.` and `/` sort before any other punctation. // The default order is: _ - , x x x . x x x / x x x // We’re changing it to: . / , x x x _ x x x - x x x .replace(/[./_-]/g, char => { switch (char) { case '.': return '_'; case '/': return '-'; case '_': return '.'; case '-': return '/'; // istanbul ignore next default: throw new Error(`Unknown source substitution character: ${char}`); } }), originalSource: source, kind: getImportExportKind(node) }; } function getImportExportKind(node) { // `type` and `typeof` imports, as well as `type` exports (there are no // `typeof` exports). In Flow, import specifiers can also have a kind. Default // to "value" (like TypeScript) to make regular imports/exports come after the // type imports/exports. return node.importKind || node.exportKind || 'value'; } // Like `Array.prototype.findIndex`, but searches from the end. function findLastIndex(array, fn) { for (let index = array.length - 1; index >= 0; index--) { if (fn(array[index], index, array)) { return index; } } // There are currently no usages of `findLastIndex` where nothing is found. // istanbul ignore next return -1; } // Like `Array.prototype.flatMap`, had it been available. function flatMap(array, fn) { return [].concat(...array.map(fn)); } module.exports = { extractChunks, flatMap, getImportExportItems, isPunctuator, maybeReportSorting, printSortedItems, printWithSortedSpecifiers, sortImportExportItems };