@sngn/eslint-plugin-split-and-sort-imports
Version:
Autofixes eslint 'sort-imports' rule and splits multiple imports into multiple lines
770 lines (653 loc) • 22.2 kB
JavaScript
import { options as options_sort_minimal } from "./sort-options-minimal.js";
import { schema } from "./sort-schema.js";
import { AST_NODE_TYPES } from "@typescript-eslint/types";
/** @typedef {import("../generated/sort-schema.d.ts").Group} Group */
/** @typedef {import("../generated/sort-schema.d.ts").SortSchema} SortSchema */
/** @typedef {import("@typescript-eslint/types").TSESTree.Comment} Comment */
/** @typedef {import("@typescript-eslint/types").TSESTree.ImportClause} ImportClause */
/** @typedef {import("@typescript-eslint/types").TSESTree.ImportDeclaration} ImportDeclaration */
/** @typedef {import("@typescript-eslint/types").TSESTree.ImportSpecifier} ImportSpecifier */
/** @typedef {import("@typescript-eslint/types").TSESTree.Node} Node */
/** @typedef {import("@typescript-eslint/types").TSESTree.ProgramStatement} ProgramStatement */
/** @typedef {import("@typescript-eslint/types").TSESTree.Program} Program */
/** @typedef {import("@typescript-eslint/types").TSESTree.Range} Range */
/** @typedef {import("@typescript-eslint/types").TSESTree.SourceLocation} SourceLocation */
/** @typedef {import("@typescript-eslint/types").TSESTree.TSModuleBlock} TSModuleBlock */
/** @typedef {import("eslint").Rule.RuleContext} RuleContext */
/** @typedef {import("estree").Comment} CommentX */
/** @typedef {import("estree").ImportDeclaration} ImportDeclarationX */
/** @typedef {import("estree").Node} NodeX */
/** @typedef {import("estree").Program} ProgramX */
/** @typedef {import("estree").SourceLocation} SourceLocationX */
/** @typedef {(Program|ProgramStatement)} ExtractableNode */
/** @typedef {("asset"|"named"|"namedClass"|"namespace"|"sideEffect"|"style"|"type")} ImportGroup */
/** @typedef {ProgramStatement[]} OtherDeclarations */
/**
* @typedef {object} Extracted
* @property {ImportDeclaration[]} ids
* @property {OtherDeclarations} ods
* @property {[ExtractableNode, Extracted][]} sub
*/
/** @typedef {(ImportDeclaration | ProgramStatement | string)} TemplateItem */
const labelMarker = "___<<<label>>>___";
const rx_labelmarker = new RegExp(`^${labelMarker}`);
const rx_labelmarker_g = new RegExp(labelMarker, "g");
/** @type {SortSchema} */
const defaultOptionsArray = [options_sort_minimal];
const defaultOptions0 = defaultOptionsArray[0];
const defaultOptions = /** @type {Required<typeof defaultOptions0>} */ (defaultOptions0);
/**
* Flattens a sub array, so that so entries have no subs
* @param {[ExtractableNode, Extracted][]} acc
* @param {[ExtractableNode, Extracted]} sb - sub entry
* @returns {[ExtractableNode, Extracted][]}
*/
function flatten_sub(acc, sb) {
acc.push([
sb[0],
{
ids: sb[1].ids,
ods: sb[1].ods,
sub: [],
},
]);
if (sb[1].sub.length) {
acc = sb[1].sub.reduce(flatten_sub, acc);
}
return acc;
}
/**
* @param {SourceLocation} loc
* @returns {string}
*/
function locToString(loc) {
return `--${loc.start.line}/${loc.start.column}--${loc.end.line}/${loc.end.column}--`;
}
/**
* @param {ExtractableNode} node
* @returns {Extracted}
*/
function extract_and_file(node) {
/** @type {ImportDeclaration[]} */
const ids = [];
/** @type {OtherDeclarations} */
const ods_pre = [];
/** @type {[ExtractableNode, Extracted][]} */
const sub_pre = [];
if ("body" in node && Array.isArray(node.body)) {
node.body.forEach((bodyNode) => {
if (bodyNode.type === AST_NODE_TYPES.ImportDeclaration) {
ids.push(bodyNode);
} else {
ods_pre.push(bodyNode);
if ("body" in bodyNode && Array.isArray(bodyNode.body)) {
const nb_extracted = extract_and_file(bodyNode);
if (nb_extracted.ids.length || nb_extracted.sub.length) {
sub_pre.push([bodyNode, nb_extracted]);
}
}
}
});
}
const rangeLast = ids.at(-1)?.range ?? [NaN, NaN];
const ods =
ids.length ?
/* prettier-ignore */
ods_pre.filter((d) => (/** @type {Node} */ (d).range.at(0) ?? NaN) < rangeLast[1])
: [];
const sub = sub_pre.reduce(flatten_sub, []);
return { ids, ods, sub };
}
/**
* @param {object} _params Parameters
* @param {RuleContext} _params.context
*/
function handlerProgram(_params) {
//const { context } = params;
/** @type {Extracted} */
const extracted = { ids: [], ods: [], sub: [] };
/**
* @param {ProgramX} program
*/
function funcProgram(program) {
const extracted_program = extract_and_file(/** @type {Program} */ (program));
Object.assign(extracted, extracted_program);
}
return {
extracted,
func: funcProgram,
};
}
/**
* @param {object} params Parameters
* @param {RuleContext} params.context
* @returns {typeof defaultOptions} - 'Unified' options - options passed to plugin augmented with default options
*/
function getOptions({ context }) {
const contextOptionsArray = /** @type {SortSchema} */ (context.options);
const contextOptions = contextOptionsArray.at(0);
const unified_groups =
contextOptions?.groups ?
Object.entries(contextOptions.groups).reduce(
(acc, [key, groupdef]) => {
acc[key] = {
...(defaultOptions.groups[
/** @type {keyof defaultOptions["groups"]} */ (key)
] ?? {}),
...groupdef,
};
return acc;
},
{ ...defaultOptions.groups },
)
: defaultOptions.groups;
return {
...defaultOptions,
...contextOptions,
groups: unified_groups,
};
}
/**
* @param {RuleContext} context
* @param {ImportDeclaration} d
* @returns {string} - name of an import group
*/
function getGroup(context, d) {
const moduleName = String(d.source.value ?? "no_module_name");
const options = getOptions({ context });
const { groups: oGroups } = options;
const groups = Object.entries(oGroups ?? {}).reduce(
(acc, [gname, cg]) => {
acc[gname] = {
...(acc[gname] ?? {}),
...cg,
};
return acc;
},
{ ...defaultOptions.groups },
);
/** @type {keyof typeof groups} */
let rv;
/* eslint-disable-next-line dot-notation */
const useGroup_type = (groups["type"]?.priority ?? 0) >= 0;
const specifier = d.specifiers.at(0);
if (
useGroup_type &&
(d.importKind === "type" ||
/** @type {undefined|ImportSpecifier} */ (specifier)?.importKind === "type")
) {
rv = "type";
} else {
/** @type {(undefined | keyof typeof groups)} */
let group = undefined;
const groupsWithRxSource = Object.entries(groups)
.filter(([_gname, g]) => !!g.rxSource && (g.priority ?? 0) >= 0)
.sort((a, b) => {
const prioA = a[1].priority ?? 0;
const prioB = b[1].priority ?? 0;
const diff = prioA - prioB;
return (
diff === 0 ?
a[0] < b[0] ? -1
: a[0] === b[0] ? 0
: 1
: diff
);
});
for (let i = 0; i < groupsWithRxSource.length; i++) {
const groupEntry = /** @type {[string, Group]} */ (groupsWithRxSource[i]);
const groupOptions = groupEntry[1];
const rxSource = /** @type {string} */ (groupOptions.rxSource);
const i_lastSlash = rxSource.lastIndexOf("/") ?? -1;
const rx = new RegExp(
rxSource.slice(1, i_lastSlash),
i_lastSlash > -1 ? rxSource.slice(i_lastSlash + 1) : "",
);
if (rx.test(moduleName)) {
group = groupEntry[0];
break;
}
}
if (!group) {
if (d.specifiers?.length) {
/* eslint-disable-next-line dot-notation */
const prio_default = groups["default"]?.priority ?? 0;
/* eslint-disable-next-line dot-notation */
const prio_named = groups["named"]?.priority ?? 0;
/* eslint-disable-next-line dot-notation */
const prio_namedClass = groups["namedClass"]?.priority ?? 0;
const specifier = /** @type {ImportClause} */ (d.specifiers.at(0));
const lname = specifier.local.name;
const lname_is_lowercase = lname[0] === (lname[0] ?? "").toLowerCase();
switch (specifier.type) {
case "ImportDefaultSpecifier":
group =
lname_is_lowercase ?
prio_default > 0 && prio_default > prio_named ?
"default"
: "named"
: prio_default > 0 && prio_default > prio_namedClass ?
prio_named >= prio_default ?
"named"
: "default"
: prio_namedClass >= 0 ?
prio_named >= prio_namedClass ?
"named"
: "namedClass"
: "named";
break;
case "ImportNamespaceSpecifier":
group = "namespace";
break;
//case "ImportSpecifier":
default:
group =
lname_is_lowercase ? "named"
: prio_namedClass >= 0 ?
prio_named >= prio_namedClass ?
"named"
: "namedClass"
: "named";
break;
}
} else {
group = "sideEffect";
}
}
rv = group;
}
return rv;
}
/**
* @param {ReturnType<typeof getOptions>} _options
*/
function sorter_noSortWithoutLName(_options) {
/**
* @param {ImportDeclaration} a
* @param {ImportDeclaration} b
* @returns {number}
*/
return function (a, b) {
const a_lname = a.specifiers?.at(0)?.local?.name;
const a_src = a.source.value;
const a_cmp = a_lname ?? a_src;
const b_lname = b.specifiers?.at(0)?.local?.name;
const b_src = b.source.value;
const b_cmp = b_lname ?? b_src;
return (
a_lname && !b_lname ? 1
: !a_lname && b_lname ? -1
: !(a_lname || b_lname) ? 0
: a_cmp < b_cmp ? -1
: a_cmp === b_cmp ? 0
: 1
);
};
}
/**
* @param {ReturnType<typeof getOptions>} _options
*/
function sorter(_options) {
/**
* @param {ImportDeclaration} a - Parameter description.
* @param {ImportDeclaration} b - Parameter description.
* @returns {number}
*/
return function (a, b) {
const a_lname = a.specifiers?.at(0)?.local?.name;
const a_src = a.source.value;
const a_cmp = a_lname ?? a_src;
const b_lname = b.specifiers?.at(0)?.local?.name;
const b_src = b.source.value;
const b_cmp = b_lname ?? b_src;
return (
a_lname && !b_lname ? 1
: !a_lname && b_lname ? -1
: a_cmp < b_cmp ? -1
: a_cmp === b_cmp ? 0
: 1
);
};
}
/**
* Sorts ImportDeclarations into groups
* @param {object} params Parameters
* @param {RuleContext} params.context
* @param {Extracted} params.extracted
* @returns {Record<string, ImportDeclaration[]>} - keys are input groups
*/
function getGroups(params) {
const { context, extracted } = params;
const options = getOptions(params);
const { ids } = extracted;
const { groups: oGroups } = options;
const groups = Object.entries(oGroups ?? {}).reduce(
(acc, [gname, cg]) => {
acc[gname] = {
...(acc[gname] ?? {}),
...cg,
};
return acc;
},
{ ...(options.groups ?? {}) },
);
/** @type {Set<string>} */
const touchedGroups = new Set();
/* distribute to groups */
const templategroups = ids.reduce((acc, d) => {
const gname = getGroup(context, d);
const gname_arr = acc[gname] ?? /** @type {ImportDeclaration[]} */ ([]);
gname_arr.push(d);
touchedGroups.add(gname);
acc[gname] = gname_arr;
return acc;
}, /** @type {Record<string, ImportDeclaration[]>} */ ({}));
/* sort templategroups */
[...touchedGroups].forEach((gname) => {
const group = /** @type {Group} */ (groups[gname]);
const templategroup = /** @type {ImportDeclaration[]} */ (templategroups[gname]);
///** @type {ImportDeclaration[]} */(templategroups[g]).sort(["asset", "type"].includes(g) ? sorter : sorter_noSortWithoutLName);
templategroup.sort(
group.sortWhenNoLocalName ? sorter(options) : sorter_noSortWithoutLName(options),
);
});
return templategroups;
}
/**
* @param {object} params Parameters
* @param {RuleContext} params.context
* @param {Extracted} params.extracted
* @returns {TemplateItem[]}
*/
function createTemplate(params) {
const { context, extracted } = params;
const groups = getGroups(params);
const options = getOptions(params);
const options_default = /** @type {typeof options_sort_minimal} */ (defaultOptions);
const {
groupOrder = options_default.groupOrder,
separateGroups = options_default.separateGroups,
useLabels = options_default.useLabels,
} = options;
const { ods: otherDeclarations } = extracted;
const nonEmptyGroups = Object.entries(groups).filter(([_gname, idarr]) => idarr.length);
/** @type {Set<string>} */
const unprocessedGroups_names = nonEmptyGroups.reduce((acc, [gname]) => {
return acc.add(gname);
}, new Set());
const separator = separateGroups ? "" : undefined;
/**
* @param {(undefined | TemplateItem)[]} acc
* @param {string} gname - group name
* @returns {(undefined | TemplateItem)[]}
*/
function grouporderreducer(acc, gname) {
const group = groups[gname] ?? [];
if (group.length) {
const groupLabel =
useLabels ?
/** @type {Record<string, Group>} */ (options.groups)[gname]?.label
: undefined;
const labelComment_content = `// ${groupLabel}`;
const labelComment_content_with_marker = labelMarker + labelComment_content;
const labelComment_exists = (function () {
/** @type {boolean} */
let rv;
if (useLabels && groupLabel) {
const sourceCode = context.sourceCode;
const first_id = /** @type {ImportDeclarationX} */ (group[0]);
const comments = sourceCode.getCommentsBefore(first_id);
if (comments.length) {
const first_comment = /** @type {Comment} */ (
/** @type {unknown} */ (comments[0])
);
const content = sourceCode
.getText(/** @type {NodeX} */ (/** @type {unknown} */ (first_comment)))
.trim();
rv = content === labelComment_content;
} else {
rv = false;
}
} else {
rv = true;
}
return rv;
})();
const useLabels_addition =
labelComment_exists ? [] : [labelComment_content_with_marker];
acc = [...acc, ...useLabels_addition, ...group, separator];
}
/* mark group as processed */
unprocessedGroups_names.delete(gname);
return acc;
}
const arr = groupOrder
.reduce(grouporderreducer, /** @type {(undefined | TemplateItem)[]} */ ([]))
.concat(
[...unprocessedGroups_names]
.sort()
.reduce(grouporderreducer, /** @type {(undefined | TemplateItem)[]} */ ([])),
)
.concat(["", ...otherDeclarations]);
const arr_filtered = /** @type {TemplateItem[]} */ (
arr.filter((item) => typeof item !== "undefined")
);
const assembled = arr_filtered.reduce((acc, v) => {
const pushEntry = (acc.length && v !== acc.at(-1)) || (!acc.length && v !== "");
if (pushEntry) {
acc.push(v);
}
return acc;
}, /** @type {typeof arr_filtered} */ ([]));
const rv = assembled.at(-1) === "" ? assembled.slice(0, -1) : assembled;
return rv;
}
/**
* @param {object} params Parameters
* @param {RuleContext} params.context
* @param {Extracted} params.extracted
*/
function report(params) {
const { context, extracted } = params;
const { ids, ods } = extracted;
const id0 = /** @type {ImportDeclaration} */ (ids.at(0));
const id_1 = /** @type {ImportDeclaration} */ (ids.at(-1));
const od0 = /** @type {undefined | Node} */ (ods.at(0));
const od_1 = /** @type {undefined | Node} */ (ods.at(-1));
const options = getOptions(params);
const sourceCode = context.sourceCode;
const template = createTemplate(params);
/** @type {Comment | Node } */
const first_node =
od0 ?
id0.range[0] < od0.range[0] ?
id0
: od0
: id0;
/** @type {Comment | Node } */
const last_node =
od_1 ?
id_1.range[0] < od_1.range[0] ?
od_1
: id_1
: id_1;
let first_loc_start = first_node.loc.start;
let first_range0 = first_node.range[0];
let last_loc_end = last_node.loc.end;
let last_range1 = last_node.range[1];
/** @type {Set<string>} */
const comments_processed = new Set();
const generated = template
.map((d) => {
if (typeof d === "string") {
return d;
} else {
/** @type {(CommentX | NodeX)[]} */
let arr;
const comments_before = sourceCode.getCommentsBefore(/** @type {NodeX} */ (d));
const comments_after = sourceCode.getCommentsAfter(/** @type {NodeX} */ (d));
const comments_after_reduced = comments_after.reduce((acc, c) => {
if (c.loc?.start.line === d.loc.end.line) {
acc.push(c);
}
return acc;
}, /** @type {typeof comments_after} */ ([]));
/* mark as processed */
comments_after_reduced.forEach((c) => {
comments_processed.add(locToString(/** @type {SourceLocation} */ (c.loc)));
});
if (d === first_node) {
/** @type {(CommentX | NodeX)[]} */
const local_arr = [/** @type {NodeX} */ (d)];
for (let i = comments_before.length; i > 0; i--) {
const comment = /** @type {CommentX} */ (comments_before[i - 1]);
if (comment.range && comment.loc) {
const c_loc = comment.loc;
const comment_on_same_or_above_line = [
c_loc.end.line,
c_loc.end.line + 1,
].includes(first_loc_start.line);
const comment_unprocessed = !comments_processed.has(locToString(c_loc));
if (comment_on_same_or_above_line) {
if (comment_unprocessed) {
first_loc_start = comment.loc.start;
first_range0 = /** @type {number} */ (comment.range?.at(0));
local_arr.push(comment);
}
} else {
break;
}
} else {
break;
}
}
arr = local_arr.reverse();
} else {
if (d === last_node && comments_after_reduced.length) {
last_loc_end = /** @type {SourceLocationX} */ (
/** @type {CommentX} */ (comments_after_reduced.at(-1)).loc
).end;
last_range1 = /** @type {number} */ (
/** @type {Range} */ (
/** @type {CommentX} */ (comments_after_reduced.at(-1)).range
).at(1)
);
}
const comments_before_filtered = comments_before.filter(
(c) => c.loc && !comments_processed.has(locToString(c.loc)),
);
arr = [...comments_before_filtered, /** @type {NodeX} */ (d)];
}
const sc_arr = arr.reduce((acc, node, i, a) => {
const sc = sourceCode.getText(/** @type {NodeX} */ (node));
if (i === 0) {
acc += sc;
} else {
const last_loc = /** @type {SourceLocation} */ (
/** @type {typeof node} */ (a.at(i - 1)).loc
);
const loc = /** @type {SourceLocation} */ (node.loc);
const last_end_column = last_loc.end.column;
const last_end_line = last_loc.end.line;
const start_column = loc.start.column;
const start_line = loc.start.line;
if (last_end_line === start_line) {
const diff = start_column - last_end_column;
for (let i = 0; i < diff; i++) {
acc += " ";
}
acc += sc;
} else {
acc += `\n${sc}`;
}
}
return acc;
}, "");
const sc_comments_after = comments_after_reduced
.map((c) => sourceCode.getText(/** @type {NodeX} */ (/** @type {unknown} */ (c))))
.join(" ");
return [sc_arr, sc_comments_after].filter((e) => e).join(" ");
}
})
.join("\n");
const loc = {
end: last_loc_end,
start: first_loc_start,
};
/** @type {Range} */
const range = [first_range0, last_range1];
const idx_end = sourceCode.getIndexFromLoc(loc.end);
const idx_start = sourceCode.getIndexFromLoc(loc.start);
const current_sc = sourceCode.getText().slice(idx_start, idx_end);
const compareCode =
options.useLabels ?
generated
.split("\n")
.filter((l) => !rx_labelmarker.test(l))
.join("\n")
: generated;
const reportCode =
options.useLabels ? generated.replaceAll(rx_labelmarker_g, "") : generated;
if (compareCode !== current_sc && reportCode !== current_sc) {
context.report({
fix(fixer) {
return fixer.replaceTextRange(range, reportCode);
},
loc,
messageId: "sort-imports",
});
}
}
/**
* @param {object} params Parameters
* @param {RuleContext} params.context
* @param {Extracted} params.extracted
*/
function handlerProgramExit(params) {
const { context, extracted } = params;
/**
* @param {ProgramX} _program
*/
return function (_program) {
if (extracted.ids.length) {
report({
context,
extracted,
});
}
if (extracted.sub.length) {
extracted.sub.forEach((sb) => {
report({
context,
extracted: sb[1],
});
});
}
};
}
/** @type {import("eslint").Rule.RuleModule} */
const ruleModule = {
create(context) {
const h_program = handlerProgram({ context });
const h_ProgramExit = handlerProgramExit({
context,
...h_program,
});
return {
"Program": h_program.func,
"Program:exit": h_ProgramExit,
};
},
meta: {
docs: {
description: "Enforce sorted import declarations within modules",
},
fixable: "code",
messages: {
"sort-imports": "Sort imports",
},
schema,
type: "suggestion",
},
};
export default ruleModule;