UNPKG

@bastidood/eslint-plugin-imsort

Version:

An opinionated ESLint plugin for sorting imports.

522 lines (513 loc) 20.7 kB
// src/rule.ts import { enumerate } from "itertools"; // src/utils/sort.ts function sortIdentifiers(identifiers) { return [...identifiers].sort((a, b) => { const [aFirst] = a.imported; const [bFirst] = b.imported; if (typeof aFirst !== "undefined" && typeof bFirst !== "undefined") { const aLower = aFirst.toLowerCase(); const bLower = bFirst.toLowerCase(); if (aLower === bLower && aFirst !== bFirst) { const aIsUpper = aFirst === aFirst.toUpperCase() && aFirst !== aFirst.toLowerCase(); const bIsUpper = bFirst === bFirst.toUpperCase() && bFirst !== bFirst.toLowerCase(); if (aIsUpper && !bIsUpper) return -1; if (!aIsUpper && bIsUpper) return 1; } } return a.imported.localeCompare(b.imported, void 0, { numeric: true, sensitivity: "case" }); }); } function areIdentifiersSorted(identifiers) { if (identifiers.length <= 1) return true; const sorted = sortIdentifiers(identifiers); return identifiers.every( (id, index) => id.imported === sorted[index]?.imported ); } // src/utils/detect-formatting-preferences.ts function detectFormattingPreferences(sourceText, importText) { if (typeof importText !== "undefined") { const singleQuoteMatch = importText.match(/from\s*'[^']*'/u); const doubleQuoteMatch = importText.match(/from\s*"[^"]*"/u); const sideEffectSingleQuote = importText.match(/import\s*'[^']*'/u); const sideEffectDoubleQuote = importText.match(/import\s*"[^"]*"/u); if ((singleQuoteMatch || sideEffectSingleQuote) && !(doubleQuoteMatch || sideEffectDoubleQuote)) return { useSingleQuotes: true, useTrailingComma: false }; if ((doubleQuoteMatch || sideEffectDoubleQuote) && !(singleQuoteMatch || sideEffectSingleQuote)) return { useSingleQuotes: false, useTrailingComma: false }; } return { useSingleQuotes: true, useTrailingComma: false }; } // src/utils/extract-import-info.ts function parseIndividualTypeSpecifiers(text) { const typeSpecifiers = /* @__PURE__ */ new Set(); const braceMatch = text.match(/\{\s*([^}]+)\s*\}/u); if (typeof braceMatch === "undefined" || braceMatch === null) return typeSpecifiers; const [_, importContent] = braceMatch; if (typeof importContent === "undefined") return typeSpecifiers; const specifiers = importContent.split(",").map((s) => s.trim()); for (const specifier of specifiers) { const typeMatch = specifier.match(/^\s*type\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/u); if (typeMatch !== null && typeof typeMatch[1] !== "undefined") typeSpecifiers.add(typeMatch[1]); } return typeSpecifiers; } function extractImportInfo(node, sourceText) { if (typeof node.source.value !== "string") throw new Error("Import source must be a string"); const source = node.source.value; if (typeof node.range === "undefined" || node.loc === null || typeof node.loc === "undefined") throw new Error("Node must have range and location information"); const text = sourceText.slice(node.range[0], node.range[1]); const { line } = node.loc.start; const isTypeOnly = ( // @ts-expect-error - importKind is available with TypeScript parser node.importKind === "type" || /^\s*import\s+type\s+/u.test(text) ); const individualTypeSpecifiers = parseIndividualTypeSpecifiers(text); let type; const identifiers = []; if (node.specifiers.length === 0) { type = "side-effect"; } else if (node.specifiers.some((spec) => spec.type === "ImportNamespaceSpecifier")) { type = "namespace"; const namespaceSpec = node.specifiers.find( (spec) => spec.type === "ImportNamespaceSpecifier" ); if (namespaceSpec && namespaceSpec.type === "ImportNamespaceSpecifier") { const identifier = { imported: namespaceSpec.local.name }; if (isTypeOnly || individualTypeSpecifiers.has(namespaceSpec.local.name)) identifier.isTypeOnly = true; identifiers.push(identifier); } } else if (node.specifiers.some((spec) => spec.type === "ImportDefaultSpecifier")) { type = "default"; const defaultSpec = node.specifiers.find( (spec) => spec.type === "ImportDefaultSpecifier" ); if (defaultSpec && defaultSpec.type === "ImportDefaultSpecifier") { const identifier = { imported: defaultSpec.local.name }; if (isTypeOnly || individualTypeSpecifiers.has(defaultSpec.local.name)) identifier.isTypeOnly = true; identifiers.push(identifier); } const namedSpecs = node.specifiers.filter( (spec) => spec.type === "ImportSpecifier" ); for (const spec of namedSpecs) if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") { const imported = spec.imported.name; const local = spec.local.name; const identifier = { imported }; if (imported !== local) identifier.local = local; const specifierIsTypeOnly = individualTypeSpecifiers.has(imported); if (isTypeOnly || specifierIsTypeOnly) identifier.isTypeOnly = true; identifiers.push(identifier); } } else { type = "named"; const namedSpecs = node.specifiers.filter( (spec) => spec.type === "ImportSpecifier" ); for (const spec of namedSpecs) if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") { const imported = spec.imported.name; const local = spec.local.name; const identifier = { imported }; if (imported !== local) identifier.local = local; const specifierIsTypeOnly = individualTypeSpecifiers.has(imported); if (isTypeOnly || specifierIsTypeOnly) identifier.isTypeOnly = true; identifiers.push(identifier); } } return { source, text, line, type, identifiers, isTypeOnly }; } // src/utils/generate-import-statement.ts function formatIdentifier(identifier, suppressTypePrefix = false) { const typePrefix = !suppressTypePrefix && identifier.isTypeOnly === true ? "type " : ""; return typeof identifier.local === "undefined" ? `${typePrefix}${identifier.imported}` : `${typePrefix}${identifier.imported} as ${identifier.local}`; } function generateImportStatement(importInfo, preferences) { const { source, type, identifiers, isTypeOnly } = importInfo; const statementTypePrefix = isTypeOnly ? "type " : ""; const suppressIndividualTypes = isTypeOnly; const quote = preferences.useSingleQuotes ? "'" : '"'; let importStatement; switch (type) { case "side-effect": importStatement = `import ${statementTypePrefix}${quote}${source}${quote};`; break; case "namespace": { const [identifier] = identifiers; if (typeof identifier === "undefined") throw new Error("Namespace identifier is required"); importStatement = `import ${statementTypePrefix}* as ${identifier.imported} from ${quote}${source}${quote};`; break; } case "default": { if (identifiers.length === 1) { const [identifier] = identifiers; if (typeof identifier === "undefined") throw new Error("Default identifier is required"); const formattedIdentifier = formatIdentifier( identifier, suppressIndividualTypes ); importStatement = `import ${statementTypePrefix}${formattedIdentifier} from ${quote}${source}${quote};`; } else { const [defaultId, ...namedIds] = identifiers; if (typeof defaultId === "undefined") throw new Error("Default identifier is required"); const sortedNamedIds = sortIdentifiers(namedIds); const defaultFormatted = formatIdentifier( defaultId, suppressIndividualTypes ); const namedImportsStr = sortedNamedIds.map((id) => formatIdentifier(id, suppressIndividualTypes)).join(", "); importStatement = `import ${statementTypePrefix}${defaultFormatted}, { ${namedImportsStr} } from ${quote}${source}${quote};`; } break; } case "named": { const sortedIdentifiers = sortIdentifiers(identifiers); const namedImportsStr = sortedIdentifiers.map((id) => formatIdentifier(id, suppressIndividualTypes)).join(", "); importStatement = `import ${statementTypePrefix}{ ${namedImportsStr} } from ${quote}${source}${quote};`; break; } default: return importInfo.text; } return importStatement; } // src/utils/get-import-group-priority.ts function getImportGroupPriority(source) { if (/^(node|bun|deno|cloudflare|workerd|wrangler):/iu.test(source)) return 0; if (/^(npm|jsr|esm|unpkg|cdn):/iu.test(source)) return 1; if (/^[a-zA-Z][a-zA-Z0-9_-]*:/iu.test(source)) return 2; if (/^\$[a-zA-Z0-9_-]*\//u.test(source)) return 4; if (/^~(?!\/)[a-zA-Z0-9_-]+\//u.test(source)) return 4; if (/^(?:@\/|~\/)/u.test(source)) return 5; const parentMatches = source.match(/^(\.\.\/)+/u); if (parentMatches) { const depth = parentMatches[0].split("../").length - 1; return 60 - depth; } if (source === "..") return 59; if (source === "./") return 6; if (source.startsWith("./")) { const pathParts = source.split("/"); const depth = pathParts.length - 2; if (depth === 0) return 60; return 60 + depth; } return 3; } // src/utils/sort-imports-in-group.ts function sortImportsInGroup(imports) { return imports.sort((a, b) => { const typeOrder = { "side-effect": 0, // Side-effect imports floated to top namespace: 1, default: 2, named: 3 }; const aTypeOrder = typeOrder[a.type] ?? 999; const bTypeOrder = typeOrder[b.type] ?? 999; const typeDiff = aTypeOrder - bTypeOrder; if (typeDiff !== 0) return typeDiff; const aFirstId = a.identifiers[0]?.imported ?? a.source; const bFirstId = b.identifiers[0]?.imported ?? b.source; if (typeof aFirstId !== "undefined" && typeof bFirstId !== "undefined") { const aCompareId = aFirstId.startsWith("type ") ? aFirstId.slice(5) : aFirstId; const bCompareId = bFirstId.startsWith("type ") ? bFirstId.slice(5) : bFirstId; const identifierComparison = aCompareId.localeCompare( bCompareId, void 0, { numeric: true, sensitivity: "base" } ); if (identifierComparison !== 0) return identifierComparison; } const sourceComparison = a.source.localeCompare(b.source, void 0, { numeric: true, sensitivity: "variant" }); if (sourceComparison !== 0) return sourceComparison; return 0; }); } // src/rule.ts function assertHasRange(node) { if (typeof node.range === "undefined") throw new Error("AST nodes must have range information"); } function getTopLevelContainerKey(node, sourceCode) { const ancestors = sourceCode.getAncestors(node); let containerKey = -1; for (const anc of ancestors) { const parent = anc?.parent; if (typeof parent !== "undefined" && parent !== null && parent.type === "Program") { if (typeof anc.range !== "undefined") [containerKey] = anc.range; } } return containerKey; } function areIdentifiersSorted2(importInfo) { switch (importInfo.type) { case "side-effect": case "namespace": return true; // These don't have sortable identifiers case "default": { if (importInfo.identifiers.length <= 1) return true; const namedIdentifiers = importInfo.identifiers.slice(1); return areIdentifiersSorted(namedIdentifiers); } case "named": return areIdentifiersSorted(importInfo.identifiers); default: return true; } } function getGroupKey(importInfo) { const basePriority = getImportGroupPriority(importInfo.source); return basePriority; } function hasBlankLineBetween(prevNode, currentNode, text) { assertHasRange(prevNode); assertHasRange(currentNode); const textBetween = text.slice(prevNode.range[1], currentNode.range[0]); const newlineCount = (textBetween.match(/\n/gu) || []).length; return newlineCount >= 2; } function extractIndentation(sourceText, importRange) { const lineStart = sourceText.lastIndexOf("\n", importRange[0]) + 1; const lineEnd = sourceText.indexOf("\n", importRange[0]); const lineEndPos = lineEnd === -1 ? sourceText.length : lineEnd; const fullLine = sourceText.slice(lineStart, lineEndPos); const match = fullLine.match(/^(\s*)/u); return typeof match !== "undefined" && match !== null && typeof match[1] !== "undefined" ? match[1] : ""; } var sortImports = { meta: { type: "layout", docs: { description: "Sort and group imports according to specified rules", category: "Stylistic Issues", recommended: true }, fixable: "code", schema: [] }, create(context) { const text = context.sourceCode.getText(); const importData = []; return { // Collect all ImportDeclaration nodes regardless of AST shape ImportDeclaration(node) { const importInfo = extractImportInfo(node, text); assertHasRange(node); const indentation = extractIndentation(text, node.range); importData.push({ node, info: importInfo, indentation }); }, // After finishing traversal, process the collected imports "Program:exit"(_node) { if (importData.length === 0) return; const orderedImportData = [...importData].sort((a, b) => { assertHasRange(a.node); assertHasRange(b.node); return a.node.range[0] - b.node.range[0]; }); const importsByBlock = /* @__PURE__ */ new Map(); for (const item of orderedImportData) { assertHasRange(item.node); const blockKey = getTopLevelContainerKey( item.node, context.sourceCode ); const arr = importsByBlock.get(blockKey); if (typeof arr === "undefined") importsByBlock.set(blockKey, [item]); else arr.push(item); } function computeExpected(imports) { const groups = /* @__PURE__ */ new Map(); for (const importInfo of imports) { const groupKey = getGroupKey(importInfo); const existingGroup = groups.get(groupKey); if (typeof existingGroup === "undefined") groups.set(groupKey, [importInfo]); else existingGroup.push(importInfo); } const sortedGroups = Array.from(groups.entries()).sort(([a], [b]) => a - b).map(([_, groupImports]) => sortImportsInGroup(groupImports)); return { expectedImports: sortedGroups.flat(), sortedGroups }; } let needsReordering = false; const blockFixData = []; for (const [blockKey, items] of importsByBlock.entries()) { const imports = items.map(({ info }) => info); const importNodes = items.map(({ node }) => node); const { expectedImports, sortedGroups } = computeExpected(imports); let needsBlock = false; if (imports.length === expectedImports.length) for (const [i, importInfo] of enumerate(imports)) { if (typeof importInfo === "undefined") continue; if (!areIdentifiersSorted2(importInfo)) { needsBlock = true; break; } if (i > 0) { const prevNode = importNodes[i - 1]; const currentNode = importNodes[i]; const prevImportInfo = imports[i - 1]; if (typeof prevNode !== "undefined" && typeof currentNode !== "undefined" && typeof prevImportInfo !== "undefined") { const currentGroupKey = getGroupKey(importInfo); const prevGroupKey = getGroupKey(prevImportInfo); if (currentGroupKey !== prevGroupKey && !hasBlankLineBetween(prevNode, currentNode, text)) { needsBlock = true; break; } } } const expectedImport = expectedImports[i]; if (typeof expectedImport !== "undefined") { if (importInfo.source !== expectedImport.source || importInfo.type !== expectedImport.type || importInfo.identifiers.length !== expectedImport.identifiers.length) { needsBlock = true; break; } for (const [j, currentId] of enumerate( importInfo.identifiers )) { const expectedId = expectedImport.identifiers[j]; if (typeof expectedId === "undefined" || currentId.imported !== expectedId.imported || (currentId.isTypeOnly ?? false) !== (expectedId.isTypeOnly ?? false)) { needsBlock = true; break; } } if (needsBlock) break; } } else needsBlock = true; if (needsBlock) { needsReordering = true; const [firstImport] = importNodes; const lastImport = importNodes[importNodes.length - 1]; if (typeof firstImport !== "undefined" && typeof lastImport !== "undefined") blockFixData.push({ blockKey, firstImport, lastImport, sortedGroups, orderedItems: items }); } } if (!needsReordering) return; const [firstBlock] = blockFixData.sort( (a, b) => (a.firstImport.range?.[0] ?? 0) - (b.firstImport.range?.[0] ?? 0) ); if (typeof firstBlock === "undefined") return; context.report({ node: firstBlock.firstImport, message: "Imports should be sorted and grouped according to the specified rules", fix(fixer) { const fixes = []; for (const block of blockFixData) { const indentationByKey = /* @__PURE__ */ new Map(); for (const { info, indentation } of block.orderedItems) { const key = `${info.source}:${info.type}:${info.identifiers.map( (id) => `${id.imported}${typeof id.local === "undefined" ? "" : `:${id.local}`}${id.isTypeOnly === true ? ":type" : ""}` ).join(",")}`; if (!indentationByKey.has(key)) indentationByKey.set(key, indentation); } const sortedStatements = []; for (const [groupIndex, groupImports] of enumerate( block.sortedGroups )) { if (groupIndex > 0) sortedStatements.push(""); for (const importInfo of groupImports) { const key = `${importInfo.source}:${importInfo.type}:${importInfo.identifiers.map( (id) => `${id.imported}${typeof id.local === "undefined" ? "" : `:${id.local}`}${id.isTypeOnly === true ? ":type" : ""}` ).join(",")}`; const indentation = indentationByKey.get(key) ?? ""; const preferences = detectFormattingPreferences( text, importInfo.text ); const importStatement = generateImportStatement( importInfo, preferences ); sortedStatements.push(indentation + importStatement); } } if (typeof block.firstImport.range === "undefined" || typeof block.lastImport.range === "undefined") continue; const replacement = sortedStatements.join("\n"); const firstImportLineStart = text.lastIndexOf("\n", block.firstImport.range[0]) + 1; const lastImportLineEnd = text.indexOf( "\n", block.lastImport.range[1] ); const lastImportEnd = lastImportLineEnd === -1 ? text.length : lastImportLineEnd; fixes.push( fixer.replaceTextRange( [firstImportLineStart, lastImportEnd], replacement ) ); } return fixes; } }); } }; } }; // src/index.ts var index_default = { meta: { name: "@bastidood/eslint-plugin-imsort", version: "0.10.1", namespace: "@bastidood/imsort" }, rules: { "sort-imports": sortImports }, configs: { all: { rules: { "@bastidood/imsort/sort-imports": "error" } } } }; export { index_default as default };