@bastidood/eslint-plugin-imsort
Version:
An opinionated ESLint plugin for sorting imports.
522 lines (513 loc) • 20.7 kB
JavaScript
// 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
};