1import type { CreateRule, ESTree, Fixer, Visitor } from "@oxlint/plugins"
2
3const jsExtensions = [".js", ".jsx", ".mjs", ".cjs"]
4const extensionMap: Record<string, string> = {
5 ".js": ".ts",
6 ".jsx": ".tsx",
7 ".mjs": ".mts",
8 ".cjs": ".cts"
9}
10
11function isRelativeImport(source: string): boolean {
12 return source.startsWith("./") || source.startsWith("../")
13}
14
15function getJsExtension(source: string): string | undefined {
16 for (const ext of jsExtensions) {
17 if (source.endsWith(ext)) {
18 return ext
19 }
20 }
21 return undefined
22}
23
24const rule: CreateRule = {
25 meta: {
26 type: "problem",
27 docs: {
28 description:
29 "Disallow .js, .jsx, .mjs and .cjs extensions in relative imports, use .ts, .tsx, .mts or .cts instead"
30 },
31 fixable: "code"
32 },
33 create(context) {
34 function checkSource(source: ESTree.StringLiteral) {
35 const value = source.value
36 if (!isRelativeImport(value)) return
37
38 const ext = getJsExtension(value)
39 if (!ext) return
40
41 const tsExt = extensionMap[ext]
42 const fixedSource = value.slice(0, -ext.length) + tsExt
43
44 context.report({
45 node: source,
46 message: `Use "${tsExt}" extension instead of "${ext}" for relative imports`,
47 fix: (fixer: Fixer) => fixer.replaceTextRange(source.range, JSON.stringify(fixedSource))
48 })
49 }
50
51 function handleImportDeclaration(node: ESTree.ImportDeclaration) {
52 checkSource(node.source)
53 }
54
55 function handleExportAllDeclaration(node: ESTree.ExportAllDeclaration) {
56 checkSource(node.source)
57 }
58
59 function handleExportNamedDeclaration(node: ESTree.ExportNamedDeclaration) {
60 if (node.source) {
61 checkSource(node.source)
62 }
63 }
64
65 return {
66 ImportDeclaration: handleImportDeclaration,
67 ExportAllDeclaration: handleExportAllDeclaration,
68 ExportNamedDeclaration: handleExportNamedDeclaration
69 } as Visitor
70 }
71}
72
73export default rule