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