core/quality/eslint/sort-imports.eslint.rule.ts
core/quality/eslint/sort-imports.eslint.rule.ts is a file in GovLab Quality. 117 lines of code and 38 definitions.
import type { Rule } from "eslint";
import { compareText } from "#core/comparators/text.comparator";
import { govlabMeta } from "#core/factories/eslint.factory";
type ImportDecl = Extract<Rule.Node, { type: "ImportDeclaration" }>;
type Spec = ImportDecl["specifiers"][number];
const NAMESPACE = "ImportNamespaceSpecifier";
const NAMED = "ImportSpecifier";
const GROUP_SIDE_EFFECT = 0;
const GROUP_NAMESPACE = 1;
const GROUP_MULTI_NAMED = 2;
const GROUP_SINGLE = 3;
const MIN_MEMBERS = 2;
const MIN_DECLS = 2;
const localName = (spec: Spec | undefined): string => spec?.local.name ?? "";
const memberGroup = (decl: ImportDecl): number => {
if (decl.specifiers.length === 0) {
return GROUP_SIDE_EFFECT;
}
if (decl.specifiers.some((spec) => spec.type === NAMESPACE)) {
return GROUP_NAMESPACE;
}
return decl.specifiers.filter((spec) => spec.type === NAMED).length > 1 ? GROUP_MULTI_NAMED : GROUP_SINGLE;
};
const declRank = (decl: ImportDecl): [number, string] => [memberGroup(decl), localName(decl.specifiers[0])];
const before = (a: [number, string], b: [number, string]): boolean =>
a[0] < b[0] || (a[0] === b[0] && compareText(a[1], b[1]) < 0);
const namedSpecs = (decl: ImportDecl): Spec[] => decl.specifiers.filter((spec) => spec.type === NAMED);
const swapText =
(context: Rule.RuleContext, nodes: Spec[], sorted: Spec[]) =>
(fixer: Rule.RuleFixer): Rule.Fix[] =>
nodes.map((node, index) => fixer.replaceText(node, context.sourceCode.getText(sorted[index])));
const reorderDecls =
(context: Rule.RuleContext, decls: ImportDecl[], sorted: ImportDecl[]) =>
(fixer: Rule.RuleFixer): Rule.Fix[] =>
decls.map((decl, index) => fixer.replaceText(decl, context.sourceCode.getText(sorted[index])));
const reportMemberOrder = (context: Rule.RuleContext, named: Spec[]): void => {
const sorted = [...named].sort((a, b) => compareText(localName(a), localName(b)));
let attached = false;
for (let i = 1; i < named.length; i += 1) {
const current = named[i];
const prev = named[i - 1];
if (current && prev && compareText(localName(current), localName(prev)) < 0) {
context.report({
...(attached ? {} : { fix: swapText(context, named, sorted) }),
messageId: "members",
node: current,
});
attached = true;
}
}
};
const rangeAt = (node: { range?: [number, number] | undefined } | undefined, index: 0 | 1): number =>
node?.range?.[index] ?? -1;
const hasInterleavedComments = (context: Rule.RuleContext, decls: ImportDecl[]): boolean => {
const [first] = decls;
const last = decls.at(-1);
const start = rangeAt(first, 0);
const end = rangeAt(last, 1);
return context.sourceCode
.getAllComments()
.some((comment) => rangeAt(comment, 0) > start && rangeAt(comment, 1) < end);
};
const reportDeclOrder = (context: Rule.RuleContext, decls: ImportDecl[]): void => {
const commentsBetween = hasInterleavedComments(context, decls);
const sorted = [...decls].sort((a, b) => (before(declRank(a), declRank(b)) ? -1 : 1));
let attached = false;
for (let i = 1; i < decls.length; i += 1) {
const current = decls[i];
const prev = decls[i - 1];
if (current && prev && before(declRank(current), declRank(prev))) {
const canFix = !commentsBetween && !attached;
context.report({
...(canFix ? { fix: reorderDecls(context, decls, sorted) } : {}),
messageId: "declarations",
node: current,
});
attached = true;
}
}
};
export default {
create(context: Rule.RuleContext): Rule.RuleListener {
const decls: ImportDecl[] = [];
const onImport = (node: Rule.Node): void => {
if (node.type !== "ImportDeclaration") {
return;
}
decls.push(node);
const named = namedSpecs(node);
if (named.length >= MIN_MEMBERS) {
reportMemberOrder(context, named);
}
};
const onExit = (): void => {
if (decls.length >= MIN_DECLS) {
reportDeclOrder(context, decls);
}
};
const handlers: [string, (node: Rule.Node) => void][] = [
["ImportDeclaration", onImport],
["Program:exit", onExit],
];
return Object.fromEntries(handlers);
},
meta: govlabMeta({
canonical: ["sort-order"],
description:
"Require sorted import members and sorted import declarations (declaration order fixed when the import block has no interleaved comments)",
fixable: "code",
messages: {
declarations: "Import declarations should be sorted.",
members: "Import members should be sorted alphabetically.",
},
ruleId: "sort_imports",
type: "suggestion",
}),
} satisfies Rule.RuleModule;