UNPKG

@bastidood/eslint-plugin-imsort

Version:

An opinionated ESLint plugin for sorting imports.

602 lines (591 loc) 22.7 kB
// src/rule.ts import { enumerate } from "itertools"; // src/utils/compare.ts function compareStrings(a, b, mode) { return a.localeCompare(b, void 0, { numeric: true, sensitivity: mode }); } function compareIdentifiers(a, b) { return compareStrings(a, b, "base"); } function compareSources(a, b) { return compareStrings(a, b, "variant"); } function areIdentifiersEqual(a, b) { return a.imported === b.imported && (a.isTypeOnly ?? false) === (b.isTypeOnly ?? false); } var UnknownImportGroupKindError = class extends Error { constructor() { super(`Unknown import group kind`); this.name = "UnknownImportGroupKindError"; } }; function getGroupKey(classification) { switch (classification.kind) { case "runtime-namespaced": return 0; case "registry-namespaced": return 1; case "generic-namespaced": return 2; case "bare-import": return 3; case "dollar-aliased": return 4; case "tilde-aliased": return classification.isRoot ? 5 : 4; case "at-aliased": return 5; case "parent-relative": return 60 - classification.depth; case "current-directory": if (classification.isBareSlash) return 6; return 60 + classification.depth; default: throw new UnknownImportGroupKindError(); } } // 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/classify-import-group.ts var RUNTIME_NAMESPACES = /* @__PURE__ */ new Set([ "node", "bun", "deno", "cloudflare", "workerd", "wrangler" ]); var REGISTRY_NAMESPACES = /* @__PURE__ */ new Set(["npm", "jsr", "esm", "unpkg", "cdn"]); function classifyImportGroup(source) { const colonIndex = source.indexOf(":"); if (colonIndex > 0) { const namespace = source.slice(0, colonIndex).toLowerCase(); const beforeColon = source.slice(0, colonIndex); if (RUNTIME_NAMESPACES.has(namespace)) return { kind: "runtime-namespaced", namespace }; if (REGISTRY_NAMESPACES.has(namespace)) return { kind: "registry-namespaced", namespace }; if (/^[a-zA-Z][a-zA-Z0-9_-]*$/u.test(beforeColon)) return { kind: "generic-namespaced", namespace }; } const dollarMatch = source.match(/^\$([a-zA-Z0-9_-]*)\//u); if (dollarMatch !== null) { const [, alias] = dollarMatch; return { kind: "dollar-aliased", alias: `$${typeof alias === "undefined" ? "" : alias}` }; } if (source.startsWith("@/")) return { kind: "at-aliased" }; if (source.startsWith("~/")) return { kind: "tilde-aliased", isRoot: true }; const tildeMatch = source.match(/^~(?!\/)[a-zA-Z0-9_-]+\//u); if (tildeMatch !== null) return { kind: "tilde-aliased", isRoot: false }; const parentMatch = source.match(/^(\.\.\/)+/u); if (parentMatch !== null) { const [matchStr] = parentMatch; const depth = matchStr.split("../").length - 1; return { kind: "parent-relative", depth }; } if (source === "..") return { kind: "parent-relative", depth: 1 }; if (source === ".") return { kind: "current-directory", depth: 0, isBareSlash: false }; if (source.startsWith("./") || source === "./") { const isBareSlash = source === "./"; const pathParts = source.split("/"); const depth = Math.max(0, pathParts.length - 2); return { kind: "current-directory", depth, isBareSlash }; } return { kind: "bare-import", isScoped: source.startsWith("@") }; } // src/utils/detect-formatting-preferences.ts function detectFormattingPreferences(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 createIdentifier(imported, local, isTypeOnly, individualTypeSpecifiers) { const identifier = { imported }; if (typeof local !== "undefined" && local !== imported) identifier.local = local; if (isTypeOnly || individualTypeSpecifiers.has(imported)) identifier.isTypeOnly = true; return identifier; } 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") identifiers.push( createIdentifier( namespaceSpec.local.name, void 0, isTypeOnly, individualTypeSpecifiers ) ); } 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") identifiers.push( createIdentifier( defaultSpec.local.name, void 0, isTypeOnly, individualTypeSpecifiers ) ); const namedSpecs = node.specifiers.filter( (spec) => spec.type === "ImportSpecifier" ); for (const spec of namedSpecs) if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") identifiers.push( createIdentifier( spec.imported.name, spec.local.name, isTypeOnly, individualTypeSpecifiers ) ); } 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") identifiers.push( createIdentifier( spec.imported.name, spec.local.name, isTypeOnly, individualTypeSpecifiers ) ); } return { source, text, line, type, identifiers, isTypeOnly }; } // src/utils/import-key.ts function formatIdentifierKey(id) { const localSuffix = typeof id.local === "undefined" ? "" : `:${id.local}`; const typeSuffix = id.isTypeOnly === true ? ":type" : ""; return `${id.imported}${localSuffix}${typeSuffix}`; } function formatIdentifiersKey(identifiers) { return identifiers.map(formatIdentifierKey).join(","); } function generateImportKey(importInfo) { return `${importInfo.source}:${importInfo.type}:${formatIdentifiersKey(importInfo.identifiers)}`; } // 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/sort-imports-in-group.ts var TYPE_ORDER = { "side-effect": 0, namespace: 1, default: 2, named: 3 }; function stripTypePrefix(id) { return id.startsWith("type ") ? id.slice(5) : id; } function sortImportsInGroup(imports) { return imports.sort((a, b) => { const aTypeOrder = TYPE_ORDER[a.type] ?? 999; const bTypeOrder = TYPE_ORDER[b.type] ?? 999; const typeDiff = aTypeOrder - bTypeOrder; if (typeDiff !== 0) return typeDiff; const [aFirstIdObj] = a.identifiers; const [bFirstIdObj] = b.identifiers; const aFirstId = aFirstIdObj?.imported ?? a.source; const bFirstId = bFirstIdObj?.imported ?? b.source; const aCompareId = stripTypePrefix(aFirstId); const bCompareId = stripTypePrefix(bFirstId); const identifierComparison = compareIdentifiers(aCompareId, bCompareId); if (identifierComparison !== 0) return identifierComparison; const sourceComparison = compareSources(a.source, b.source); 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) { if (!("parent" in anc) || !("range" in anc)) continue; const { parent, range } = anc; if (typeof parent === "object" && parent !== null && "type" in parent && parent.type === "Program" && typeof range !== "undefined") [containerKey] = 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 getImportGroupKey(importInfo) { const classification = classifyImportGroup(importInfo.source); return getGroupKey(classification); } 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 = getImportGroupKey(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 = getImportGroupKey(importInfo); const prevGroupKey = getImportGroupKey(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; } const allIdentifiersMatch = importInfo.identifiers.every( (currentId, j) => { const expectedId = expectedImport.identifiers[j]; return typeof expectedId !== "undefined" && areIdentifiersEqual(currentId, expectedId); } ); if (!allIdentifiersMatch) { needsBlock = true; 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 = generateImportKey(info); 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 = generateImportKey(importInfo); const indentation = indentationByKey.get(key) ?? ""; const preferences = detectFormattingPreferences( 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 };