@ianvs/prettier-plugin-sort-imports
Version:
A prettier plugins to sort imports in provided RegEx order
392 lines (391 loc) • 18.6 kB
JavaScript
;
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,
};