UNPKG

@ianvs/prettier-plugin-sort-imports

Version:

A prettier plugins to sort imports in provided RegEx order

392 lines (391 loc) 18.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.testingOnly = exports.attachCommentsToOutputNodes = exports.getCommentRegistryFromImportDeclarations = exports.CommentAssociation = void 0; const types_1 = require("@babel/types"); const has_ignore_next_node_1 = require("./has-ignore-next-node"); const SpecifierTypes = [ 'ImportSpecifier', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier', ]; function nodeId(node) { if (node.type === 'ImportSpecifier') { return `${node.type}::${node.start}::${node.imported.type === 'StringLiteral' ? node.imported.value : node.imported.name}`; } if (node.type === 'CommentLine') { return `${node.type}::${node.start}::${node.loc?.start.line}`; } return `${node.type}::${node.start}`; } var CommentAssociation; (function (CommentAssociation) { CommentAssociation["leading"] = "leading"; CommentAssociation["inner"] = "inner"; CommentAssociation["trailing"] = "trailing"; })(CommentAssociation || (exports.CommentAssociation = CommentAssociation = {})); const CommentAssociationByKey = { leadingComments: CommentAssociation.leading, innerComments: CommentAssociation.inner, trailingComments: CommentAssociation.trailing, }; const CommentAssociationByValue = { [CommentAssociation.leading]: 'leadingComments', [CommentAssociation.inner]: 'innerComments', [CommentAssociation.trailing]: 'trailingComments', }; const orderedCommentKeysToRegister = [ 'innerComments', 'trailingComments', 'leadingComments', ]; /** Magic number so that Specifier-linked comments are processed after other Comment-types */ const MAX_COUNT_OF_LIKELY_IMPORT_STATEMENTS = 10000; /** Lower number priority will be processed earlier! */ var DeferredCommentClaimPriorityAdjustment; (function (DeferredCommentClaimPriorityAdjustment) { DeferredCommentClaimPriorityAdjustment[DeferredCommentClaimPriorityAdjustment["leadingSpecifier"] = 10000] = "leadingSpecifier"; DeferredCommentClaimPriorityAdjustment[DeferredCommentClaimPriorityAdjustment["leadingAboveAllImports"] = 20000] = "leadingAboveAllImports"; /** This must stay a trailing comment, because it might be a directive preceding `} from "./foo"` */ DeferredCommentClaimPriorityAdjustment[DeferredCommentClaimPriorityAdjustment["trailingCommentForSpecifier"] = 30000] = "trailingCommentForSpecifier"; })(DeferredCommentClaimPriorityAdjustment || (DeferredCommentClaimPriorityAdjustment = {})); const debugLog = undefined; // undefined as any, because typescript is too smart // const debugLog: typeof console.debug = console.debug; /** * Private helper for populating a comment-registry * * Walking the AST can find the same comment in multiple places, * so we need to collect them all, and attach them in our preferred order. */ const attachCommentsToRegistryMap = ({ commentRegistry, deferredCommentClaims, attachmentKey, comments, owner, firstImport, }) => { let commentCounter = 0; for (const comment of comments) { const commentId = nodeId(comment); if (commentRegistry.has(commentId)) { // This comment was already definitively registered to be paired with a different node, so we'll skip it debugLog?.('Comment already registered', commentId); continue; } // This comment node is needs to be attached somewhere. const ownerIsSpecifier = SpecifierTypes.includes(owner.type); const commentEntry = { owner, ownerIsSpecifier, commentId, comment, association: CommentAssociationByKey[attachmentKey], processingPriority: commentCounter++, }; if (attachmentKey === 'innerComments') { // InnerComments are always attached to their original owner commentRegistry.set(commentId, commentEntry); continue; } else if (attachmentKey === 'trailingComments') { // Trailing comments might be on the same line "attached" // Detect if this comment is on same line as the owner // Or they might be double-counted (once in trailingComments and once in leadingComments of the next node) const isSameLineAsCurrentOwner = owner.loc?.start.line === comment.loc?.start.line; debugLog?.({ isSameLineAsCurrentOwner, owner, comment, }); if (isSameLineAsCurrentOwner) { commentRegistry.set(commentId, commentEntry); } else { // This comment is actually either a leading comment on the next node, // or it's an unrelated comment following the imports // or it's a trailing comment on the last specifier inside a declaration if (ownerIsSpecifier) { // Specifier comments will just vanish if not present on an output node. deferredCommentClaims.push({ ...commentEntry, needsLastSpecifierOwner: true, processingPriority: commentEntry.processingPriority + DeferredCommentClaimPriorityAdjustment.trailingCommentForSpecifier, }); } else { // [Intentional empty block] - top-level comments will be attached as a leading attachment, // on another node or will be preserved automatically by babel & fall to bottom of imports } } continue; // Unnecessary, but explicit } else if (attachmentKey === 'leadingComments') { const currentOwnerIsFirstImport = nodeId(owner) === nodeId(firstImport); const endsBeforeOwner = (comment.loc?.end.line || 0) < (owner.loc?.start.line || 0); if (currentOwnerIsFirstImport && endsBeforeOwner) { debugLog?.('Found a disconnected leading comment', { comment, owner, owner_loc: owner.loc, comment_loc: comment.loc, }); // This comment is probably a disconnected comment before all imports deferredCommentClaims.push({ ...commentEntry, needsTopOfFileOwner: true, association: CommentAssociation.trailing, processingPriority: commentEntry.processingPriority + DeferredCommentClaimPriorityAdjustment.leadingAboveAllImports, }); } else { if (ownerIsSpecifier) { debugLog?.('Deferring leading specifier comment attachment', commentEntry.commentId, commentEntry.comment.value); deferredCommentClaims.push({ ...commentEntry, processingPriority: commentEntry.processingPriority + DeferredCommentClaimPriorityAdjustment.leadingSpecifier, }); } else { debugLog?.('Attaching', attachmentKey, commentId, owner?.imported?.name, comment.value); commentRegistry.set(commentId, commentEntry); } } continue; // Unnecessary, but explicit } else { throw new Error(`Unimplemented attachmentKey ${attachmentKey} for ${nodeId(owner)}`); } } }; /** * Utility that walks ImportDeclarations and the associated comment nodes * It returns a list of CommentEntry objects that tell you which nodes comments should be associated with */ const getCommentRegistryFromImportDeclarations = ({ firstImport, outputNodes, }) => { if (outputNodes.length === 0) { // Nothing to do if there are no outputs return []; } const commentRegistry = new Map(); const deferredCommentClaims = []; /** * Babel isn't as aggressive in pairing comments as both leading and trailing with Specifiers (as it does with ImportDeclarations) * This table helps us re-parent to the best specifier. * This registry is keyed by (original) line number, first witnessed specifier for a given line number wins. */ const specifierRegistry = new Map(); outputNodes .map((n) => n.specifiers) .flat() .forEach((specifier) => { if (specifierRegistry.has(specifier.loc?.start.line || 0)) { return; } specifierRegistry.set(specifier.loc?.start.line || 0, specifier); }); // Detach all comments, but keep their state. // The babel renderer would otherwise move them around based on their original attachment. // Register them in a specific order: inner, trailing (i.e. same-line), leading // so that our highest-confidence attachment gets priority for (const attachmentKey of orderedCommentKeysToRegister) { debugLog?.('==============================================================', attachmentKey); for (const declarationNode of outputNodes) { attachCommentsToRegistryMap({ commentRegistry, deferredCommentClaims, attachmentKey, comments: Array.from(declarationNode[attachmentKey] || []), owner: declarationNode, firstImport, }); for (const specifierNode of declarationNode.specifiers) { attachCommentsToRegistryMap({ commentRegistry, deferredCommentClaims, attachmentKey, comments: Array.from(specifierNode[attachmentKey] || []), owner: specifierNode, firstImport, }); } } } // Sort the deferred claims, so they get attached by increasing priority-number deferredCommentClaims.sort((a, b) => a.processingPriority - b.processingPriority); // Merge in any comments that were orphaned, so they get reattached to their original owner for (const entry of deferredCommentClaims) { const { commentId } = entry; debugLog?.('Processing deferred comment claim', commentId, entry.comment.value); if (!commentRegistry.has(commentId)) { if (entry.ownerIsSpecifier) { // Find the best specifier to attach to const line = entry.comment.loc?.start.line || 0; const owner = specifierRegistry.get(line) || entry.owner; const hasNewOwner = nodeId(owner) !== nodeId(entry.owner); const shouldPatchAssociation = entry.association === CommentAssociation.leading && hasNewOwner; const targetAssociation = shouldPatchAssociation ? CommentAssociation.trailing : entry.association; debugLog?.('Reattaching', commentId, entry.association, targetAssociation, entry.comment.value, { hasNewOwner, owner, entry_owner: entry.owner }); commentRegistry.set(commentId, { ...entry, owner, association: targetAssociation, }); } else { debugLog?.('Attaching orphan entry', commentId, entry); commentRegistry.set(commentId, entry); } } else { debugLog?.(`Skipping already-attached ${commentId} ${entry.ownerIsSpecifier ? 'Specifier' : 'Declaration'} ${entry.comment.value}`); } } const allCommentEntries = Array.from(commentRegistry.values()); allCommentEntries.sort((a, b) => a.processingPriority - b.processingPriority); return allCommentEntries; }; exports.getCommentRegistryFromImportDeclarations = getCommentRegistryFromImportDeclarations; function attachCommentsToOutputNodes(commentEntriesFromRegistry, outputNodes, /** Original declaration, not the re-sorted output-node! */ firstImport, { provideGapAfterTopOfFileComments } = {}) { if (outputNodes.length === 0) { // attachCommentsToOutputNodes implies that there's at least one output node so this shouldn't happen throw new Error("Fatal Internal Error: Can't attach comments to empty output"); } const newFirstImport = outputNodes[0]; /** Store a mapping of Specifier to ImportDeclaration */ const parentNodeId = (specifier) => `parent::${nodeId(specifier)}`; const outputRegistry = new Map(); // Collect entries for every declaration, specifier, and parent of specifier in a single table for mapping for (const outputNode of outputNodes) { outputRegistry.set(nodeId(outputNode), outputNode); if (outputNode.type === 'ImportDeclaration') { for (const specifier of outputNode.specifiers) { outputRegistry.set(nodeId(specifier), specifier); outputRegistry.set(parentNodeId(specifier), outputNode); } } } let hasPatchedNewFirstImportLocation = false; /** * Put the first import in the right spot (where the original first import started). * Otherwise, comments at the top of the file will not be formatted correctly. * * This is a little tricky, because the new first import might have leading comments, * and we have to move the node and all comments the same distance * * This works since late 2022, Babel uses `loc` (if-present) to hint how to render for some cases. */ const patchNewFirstImportLocationOnlyOnce = () => { if (hasPatchedNewFirstImportLocation) { return; } let commentHeight = getHeightOfLeadingComments(newFirstImport); const originalLoc = newFirstImport.loc; if (firstImport.loc && originalLoc) { newFirstImport.loc = { start: { ...firstImport.loc?.start, line: firstImport.loc?.start.line + commentHeight, }, end: { ...firstImport.loc?.end, line: firstImport.loc?.end.line + commentHeight, }, filename: '', identifierName: '', }; const moveDist = originalLoc.start.line - newFirstImport.loc.start.line; for (const commentType of orderedCommentKeysToRegister) { newFirstImport[commentType]?.forEach((c) => { if (c.loc) { c.loc.start.line -= moveDist; c.loc.end.line -= moveDist; } }); } } hasPatchedNewFirstImportLocation = true; }; const topOfFileComments = []; for (const commentEntry of commentEntriesFromRegistry) { const { owner, comment, association, needsTopOfFileOwner, needsLastSpecifierOwner, } = commentEntry; if (needsTopOfFileOwner) { ensureEmptyStatementAtFront(outputNodes); patchNewFirstImportLocationOnlyOnce(); topOfFileComments.push(comment); } let ownerNode = needsTopOfFileOwner ? outputNodes[0] : outputRegistry.get(nodeId(owner)); if (needsLastSpecifierOwner) { // get the owner (a specifier) and find its declaration node const parentDeclaration = outputRegistry.get(parentNodeId(owner)); if (!parentDeclaration || (parentDeclaration.specifiers?.length || 0) === 0) { throw new Error("Fatal Internal Error: Couldn't find parent declaration for a specifier"); } // Select the last specifier in the declaration const lastSpecifier = parentDeclaration.specifiers[parentDeclaration.specifiers.length - 1]; ownerNode = lastSpecifier; // Start the comment on the line below the owner, to avoid gaps if (comment.loc?.start.line !== undefined && ownerNode.loc?.end.line) { comment.loc.start.line = ownerNode.loc?.end.line + 1; } } if (!ownerNode) { // Shouldn't be possible if you called this helper with the right inputs! throw new Error("Fatal Internal Error: Couldn't find owner node"); } // Since we mucked with the loc of the newFirstImport, we need to be careful to // keep its comments in the right place, so adjust their loc too if (ownerNode === newFirstImport && association !== CommentAssociation.leading && comment.loc && ownerNode.loc && !needsLastSpecifierOwner) { comment.loc.start.line = ownerNode.loc.start.line; } // addComments(ownerNode, association, [comment]); -- using Babel's addComments will reverse the comments if you iteratively attach them, so push them directly const attachment = CommentAssociationByValue[association]; const commentCollection = (ownerNode[attachment] = ownerNode[attachment] || []); commentCollection.push(comment); } if (provideGapAfterTopOfFileComments && hasPatchedNewFirstImportLocation && topOfFileComments.length && // We did have some relevant comments !(0, has_ignore_next_node_1.hasIgnoreNextNode)(topOfFileComments) // None of the comments told us to prettier-ignore it ) { (newFirstImport.loc || { start: { line: 0 } }).start.line++; } } exports.attachCommentsToOutputNodes = attachCommentsToOutputNodes; function ensureEmptyStatementAtFront(outputNodes) { if (outputNodes[0].type === 'EmptyStatement') { return; } const dummy = (0, types_1.emptyStatement)(); dummy.loc = { start: { line: 0, column: 0, index: 0 }, end: { line: 0, column: 0, index: 0 }, filename: '', identifierName: '', }; outputNodes.unshift(dummy); } function getHeightOfLeadingComments(node) { if (Array.isArray(node.leadingComments) && node.leadingComments.length && node.leadingComments[0].loc && node.loc) { return Math.max(0, // Use Math.max to avoid negative heights (shouldn't be possible) node.loc.start.line - node.leadingComments[0].loc.start.line); } return 0; } exports.testingOnly = { nodeId, };