inference-playground / eslint-rules /enforce-extensions.js
Thomas G. Lopes
custom rule
7357f85
import fs from "fs";
import path from "path";
export default {
meta: {
type: "suggestion",
docs: {
description: "Enforce file extensions in import statements",
},
fixable: "code",
schema: [
{
type: "object",
properties: {
ignorePaths: {
type: "array",
items: { type: "string" },
},
includePaths: {
type: "array",
items: { type: "string" },
description: "Path patterns to include (e.g., '$lib/')",
},
tsToJs: {
type: "boolean",
description: "Convert .ts files to .js when importing",
},
aliases: {
type: "object",
description: "Map of path aliases to their actual paths (e.g., {'$lib': 'src/lib'})",
},
},
additionalProperties: false,
},
],
messages: {
missingExtension: "Import should include a file extension",
noFileFound: "Import is missing extension and no matching file was found",
},
},
create(context) {
const options = context.options[0] || {};
const ignorePaths = options.ignorePaths || [];
const includePaths = options.includePaths || [];
const tsToJs = options.tsToJs !== undefined ? options.tsToJs : true; // Default to true
const aliases = options.aliases || {};
// Get the project root directory
const projectRoot = process.cwd();
// Utility function to resolve file paths
function resolveImportPath(importPath, currentFilePath) {
// Handle relative paths
if (importPath.startsWith("./") || importPath.startsWith("../")) {
return path.resolve(path.dirname(currentFilePath), importPath);
}
// Handle aliased paths
for (const [alias, aliasPath] of Object.entries(aliases)) {
// Check if the import starts with this alias
if (importPath === alias || importPath.startsWith(`${alias}/`)) {
// Replace the alias with the actual path
const relativePath = importPath === alias ? "" : importPath.slice(alias.length + 1); // +1 for the slash
// Convert the aliasPath to an absolute path
let absoluteAliasPath = aliasPath;
if (!path.isAbsolute(absoluteAliasPath)) {
absoluteAliasPath = path.resolve(projectRoot, aliasPath);
}
return path.join(absoluteAliasPath, relativePath);
}
}
return null;
}
// Find the file extension by checking which file exists
function findActualFile(basePath) {
if (!basePath) return null;
try {
// Get the directory and base name
const dir = path.dirname(basePath);
const base = path.basename(basePath);
// If the directory doesn't exist, return early
if (!fs.existsSync(dir)) {
return null;
}
// Read all files in the directory
const files = fs.readdirSync(dir);
// Look for files that match our base name plus any extension
for (const file of files) {
const fileParts = path.parse(file);
// If we find a file that matches our base name
if (fileParts.name === base) {
// Handle TypeScript to JavaScript conversion
if (tsToJs && fileParts.ext === ".ts") {
return {
actualPath: path.join(dir, file),
importExt: ".js", // Import as .js even though it's a .ts file
};
}
// Otherwise use the actual extension
return {
actualPath: path.join(dir, file),
importExt: fileParts.ext,
};
}
}
} catch (error) {
// If there's an error checking file existence, return null
console.error("Error checking files:", error);
}
return null;
}
return {
ImportDeclaration(node) {
const source = node.source.value;
// Check if it's a relative import or matches a manually specified include path
const isRelativeImport = source.startsWith("./") || source.startsWith("../");
const isAliasedPath = Object.keys(aliases).some(alias => source === alias || source.startsWith(`${alias}/`));
const isIncludedPath = includePaths.some(pattern => source.startsWith(pattern));
// Skip if it's not a relative import, aliased path, or included path
if (!isRelativeImport && !isAliasedPath && !isIncludedPath) {
return;
}
// Skip ignored paths
if (ignorePaths.some(path => source.includes(path))) {
return;
}
// Check if the import already has an extension
const hasExtension = path.extname(source) !== "";
if (!hasExtension) {
// Get current file path to resolve the import
const currentFilePath = context.getFilename();
// Try to determine the correct file by checking what exists
const resolvedPath = resolveImportPath(source, currentFilePath);
const fileInfo = findActualFile(resolvedPath);
context.report({
node,
messageId: fileInfo ? "missingExtension" : "noFileFound",
fix(fixer) {
// Only provide a fix if we found a file
if (fileInfo) {
// Replace the string literal with one that includes the extension
return fixer.replaceText(node.source, `"${source}${fileInfo.importExt}"`);
}
// Otherwise, don't try to fix
return null;
},
});
}
},
};
},
};