@dword-design/eslint-plugin-import-alias
Version:
An ESLint plugin that enforces the use of import aliases. Also supports autofixing.
232 lines • 9.09 kB
JavaScript
import pathLib from "node:path";
import { loadOptions } from "@babel/core";
import defaults from "@dword-design/defaults";
import { ESLintUtils } from "@typescript-eslint/utils";
import { resolvePath as defaultResolvePath } from "babel-plugin-module-resolver";
import { mapValues, omit, orderBy, pick } from "lodash-es";
import micromatch from "micromatch";
import isParentImport from "./is-parent-import/index.js";
const ts = await import("typescript").then(module => module.default).catch(() => null);
const loadTsConfigPathsFromFile = (configPath, cwd, visitedConfigs = /* @__PURE__ */new Set()) => {
if (!ts || visitedConfigs.has(configPath)) {
return {};
}
visitedConfigs.add(configPath);
const configText = ts.sys.readFile(configPath);
if (!configText) {
return {};
}
const result = ts.parseConfigFileTextToJson(configPath, configText);
if (!result.config) {
return {};
}
const parsedConfig = ts.parseJsonConfigFileContent(result.config, ts.sys, pathLib.dirname(configPath), void 0, configPath);
const {
baseUrl,
paths = []
} = parsedConfig.options;
const projectReferences = parsedConfig.projectReferences ?? [];
const includePatterns = result.config.include ?? [];
const configDir = pathLib.dirname(configPath);
const basePath = baseUrl ? pathLib.resolve(configDir, baseUrl) : configDir;
const aliases = Object.fromEntries(Object.entries(paths).map(([key, values]) => {
const aliasKey = key.replace(/\/\*$/, "");
const absoluteAliasPath = pathLib.resolve(basePath, values[0].replace(/\/\*$/, ""));
const relativeAliasPath = pathLib.relative(cwd, absoluteAliasPath);
return [aliasKey, [{
configDir,
includePatterns,
path: `./${relativeAliasPath}`
}]];
}));
for (const reference of projectReferences) {
const referencePath = pathLib.resolve(pathLib.dirname(configPath), reference.path);
let referencedConfigPath = referencePath;
if (!referencedConfigPath.endsWith(".json")) {
referencedConfigPath = pathLib.join(referencePath, "tsconfig.json");
}
if (ts.sys.fileExists(referencedConfigPath)) {
const referencedAliases = loadTsConfigPathsFromFile(referencedConfigPath, cwd, visitedConfigs);
for (const [key, aliasInfos] of Object.entries(referencedAliases)) {
if (aliases[key]) {
for (const aliasInfo of aliasInfos) {
if (!aliases[key].some(a => a.path === aliasInfo.path)) {
aliases[key].push(aliasInfo);
}
}
} else {
aliases[key] = aliasInfos;
}
}
}
}
return aliases;
};
const loadTsConfigPaths = (currentFile, cwd) => {
if (!ts) {
return {};
}
const configPath = ts.findConfigFile(pathLib.dirname(currentFile), ts.sys.fileExists, "tsconfig.json");
if (!configPath) {
return {};
}
return loadTsConfigPathsFromFile(configPath, cwd);
};
const createRule = ESLintUtils.RuleCreator(() => "");
const findMatchingAlias = (sourcePath, currentFilename, options, {
cwd = "."
} = {}) => {
const absoluteSourcePath = pathLib.resolve(pathLib.dirname(currentFilename), sourcePath);
const matches = Object.entries(options.alias).flatMap(([aliasName, aliasInfos]) => aliasInfos.map(info => [aliasName, info])).filter(([, info]) => info.includePatterns.length > 0 ? micromatch.isMatch(pathLib.relative(info.configDir, currentFilename), info.includePatterns, {
cwd: info.configDir
}) : true).map(([aliasName, info]) => {
const resolvedRelativePath = options.resolvePath(`${aliasName}/`, currentFilename, {
alias: {
[aliasName]: info.path
},
cwd
});
if (!resolvedRelativePath) {
return null;
}
const path = pathLib.resolve(pathLib.dirname(currentFilename), resolvedRelativePath);
if (absoluteSourcePath.startsWith(path)) {
return {
name: aliasName,
path,
segmentCount: path.split(pathLib.sep).length
};
}
return null;
}).filter(match => !!match);
const sortedMatches = orderBy(matches, ["segmentCount"], ["desc"]);
return sortedMatches?.[0] ? omit(sortedMatches[0], ["segmentCount"]) : null;
};
const withNormalizedAliases = (options, {
cwd
}) => ({
...options,
alias: mapValues(options.alias, aliasPath => typeof aliasPath === "string" ? [{
configDir: cwd,
includePatterns: [],
path: aliasPath
}] : aliasPath)
});
export default createRule({
create: context => {
const folder = pathLib.dirname(context.filename);
if (context.filename === "<text>") return {};
const optionsFromRule = defaults(context.options[0] ?? {}, {
babelOptions: {},
shouldReadBabelConfig: true,
shouldReadTsConfig: true
});
const optionsFromBabelPlugin = optionsFromRule.shouldReadBabelConfig ? (() => {
const babelConfig = loadOptions({
filename: context.filename,
...optionsFromRule.babelOptions
});
const babelPlugin = babelConfig?.plugins?.find?.(iteratedPlugin => iteratedPlugin.key === "module-resolver") ?? null;
const babelPluginOptions = babelPlugin?.options ?? {};
return withNormalizedAliases(pick(babelPluginOptions, ["alias", "resolvePath"]), {
cwd: context.cwd
});
})() : {};
const options = defaults(withNormalizedAliases(omit(optionsFromRule, ["babelOptions"]), {
cwd: context.cwd
}), {
alias: optionsFromRule.shouldReadTsConfig ? loadTsConfigPaths(context.filename, context.cwd) : {}
}, withNormalizedAliases(optionsFromBabelPlugin, {
cwd: context.cwd
}), {
alias: {},
aliasForSubpaths: false,
cwd: context.cwd,
resolvePath: defaultResolvePath
});
if (Object.keys(options.alias).length === 0) {
throw new Error("No alias configured. You have to define aliases by either passing them to the babel-plugin-module-resolver plugin in your Babel config, defining them in your tsconfig.json paths, or passing them directly to the prefer-alias rule.");
}
return {
ImportDeclaration: node => {
const sourcePath = node.source.value;
const filteredAliases = Object.fromEntries(Object.entries(options.alias).flatMap(([aliasName, aliasInfos]) => aliasInfos.map(info => [aliasName, info])).filter(([, info]) => info.includePatterns.length > 0 ? micromatch.isMatch(pathLib.relative(info.configDir, context.filename), info.includePatterns, {
cwd: info.configDir
}) : true).map(([aliasName, info]) => [aliasName, info.path]));
const hasAlias = Object.keys(filteredAliases).some(alias => sourcePath.startsWith(`${alias}/`));
if (isParentImport(sourcePath)) {
const matchingAlias = findMatchingAlias(sourcePath, context.filename, pick(options, ["alias", "resolvePath"]), {
cwd: context.cwd
});
if (!matchingAlias) {
return;
}
const absoluteImportPath = pathLib.resolve(folder, sourcePath);
const relativePath = pathLib.relative(matchingAlias.path, absoluteImportPath).replaceAll("\\", "/");
const rewrittenImport = [matchingAlias.name, ...(relativePath ? [relativePath] : [])].join("/");
return context.report({
data: {
rewrittenImport,
sourcePath
},
fix: fixer => fixer.replaceTextRange([node.source.range[0] + 1, node.source.range[1] - 1], rewrittenImport),
messageId: "parentImport",
node
});
}
const importWithoutAlias = options.resolvePath(sourcePath, context.filename, {
alias: filteredAliases,
cwd: options.cwd
});
if (importWithoutAlias && !isParentImport(importWithoutAlias) && hasAlias && !options.aliasForSubpaths) {
return context.report({
data: {
rewrittenImport: importWithoutAlias,
sourcePath
},
fix: fixer => fixer.replaceTextRange([node.source.range[0] + 1, node.source.range[1] - 1], importWithoutAlias),
messageId: "subpathImport",
node
});
}
}
};
},
defaultOptions: [{}],
meta: {
docs: {
description: "Enforce usage of import aliases over relative parent imports"
},
fixable: "code",
messages: {
parentImport: "Unexpected parent import '{{sourcePath}}'. Use '{{rewrittenImport}}' instead",
subpathImport: "Unexpected subpath import via alias '{{sourcePath}}'. Use '{{rewrittenImport}}' instead"
},
schema: [{
additionalProperties: false,
properties: {
alias: {
type: "object"
},
aliasForSubpaths: {
default: false,
type: "boolean"
},
babelOptions: {
type: "object"
},
shouldReadBabelConfig: {
default: true,
type: "boolean"
},
shouldReadTsConfig: {
default: true,
type: "boolean"
}
},
type: "object"
}],
type: "suggestion"
},
name: "prefer-alias"
});