UNPKG

prettier-plugin-imports

Version:

A prettier plugins to sort imports in provided RegEx order

566 lines (503 loc) 18.5 kB
import { emptyStatement } from '@babel/types'; import { CommentAttachmentOptions, ImportOrLine, ImportRelated, SomeSpecifier, } from '../types'; import { hasIgnoreNextNode } from './has-ignore-next-node'; import type { Comment, ImportDeclaration } from '@babel/types'; const SpecifierTypes = [ 'ImportSpecifier', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier', ]; function nodeId(node: Comment | ImportRelated): string { 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}`; } export enum CommentAssociation { leading = 'leading', inner = 'inner', trailing = 'trailing', } const CommentAssociationByKey = { leadingComments: CommentAssociation.leading, innerComments: CommentAssociation.inner, trailingComments: CommentAssociation.trailing, } as const; const CommentAssociationByValue = { [CommentAssociation.leading]: 'leadingComments' as const, [CommentAssociation.inner]: 'innerComments' as const, [CommentAssociation.trailing]: 'trailingComments' as const, } as const; const orderedCommentKeysToRegister = [ 'innerComments', // Inner comments will be passed-through unchanged, so just collect them all first 'trailingComments', // Trailing comments might need to be paired with a node if they're on the same line, so collect them second 'leadingComments', ] as const; export interface CommentEntry { owner: ImportDeclaration | SomeSpecifier; ownerIsSpecifier: boolean; /** Special case for leaving comments at top-of-file */ needsTopOfFileOwner?: boolean; /** * Comments that follow the last specifier must stay at the bottom of their * import block! */ needsLastSpecifierOwner?: boolean; commentId: string; comment: Comment; association: CommentAssociation; /** * We need to defer some claims and prioritize them after initial processing - * higher is later processing */ processingPriority: number; } /** * 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! */ enum DeferredCommentClaimPriorityAdjustment { leadingSpecifier = MAX_COUNT_OF_LIKELY_IMPORT_STATEMENTS * 1, leadingAboveAllImports = MAX_COUNT_OF_LIKELY_IMPORT_STATEMENTS * 2, /** * This must stay a trailing comment, because it might be a directive * preceding `} from "./foo"` */ trailingCommentForSpecifier = MAX_COUNT_OF_LIKELY_IMPORT_STATEMENTS * 3, } const debugLog: typeof console.debug | undefined = undefined as any; // 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, }: { commentRegistry: Map<string, CommentEntry>; /** * This parameter lets us defer some comment attachments and process them * later. */ deferredCommentClaims: CommentEntry[]; attachmentKey: (typeof orderedCommentKeysToRegister)[number]; comments: Comment[]; owner: ImportDeclaration | SomeSpecifier; /** Original declaration, not the re-sorted output-node! */ firstImport: ImportDeclaration; }) => { 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: 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, // For top-of-file, always use trailing to preserve trailing blank line if present 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 as any)?.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 */ export const getCommentRegistryFromImportDeclarations = ({ firstImport, outputNodes, }: { /** Original declaration, not the re-sorted output-node! */ firstImport: ImportDeclaration; /** Constructed Output Nodes */ outputNodes: readonly ImportDeclaration[]; }): readonly CommentEntry[] => { if (outputNodes.length === 0) { // Nothing to do if there are no outputs return []; } const commentRegistry = new Map<string, CommentEntry>(); const deferredCommentClaims: CommentEntry[] = []; /** * 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<number, SomeSpecifier>(); 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; }; export function attachCommentsToOutputNodes( commentEntriesFromRegistry: readonly CommentEntry[], outputNodes: ImportOrLine[], /** Original declaration, not the re-sorted output-node! */ firstImport: ImportDeclaration, { leadingSeparator }: CommentAttachmentOptions = {}, ) { 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: SomeSpecifier) => `parent::${nodeId(specifier)}`; const outputRegistry = new Map<string, ImportRelated>(); // 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: Comment[] = []; 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 as SomeSpecifier), ) as ImportDeclaration | undefined; 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 as Comment[]).push(comment); } if ( leadingSeparator && hasPatchedNewFirstImportLocation && topOfFileComments.length && // We did have some relevant comments !hasIgnoreNextNode(topOfFileComments) // None of the comments told us to prettier-ignore it ) { (newFirstImport.loc || { start: { line: 0 } }).start.line++; } } function ensureEmptyStatementAtFront(outputNodes: ImportOrLine[]) { if (outputNodes[0].type === 'EmptyStatement') { return; } const dummy = 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: ImportOrLine) { 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; } export const testingOnly = { nodeId, };