1import type { CreateRule, ESTree, Visitor } from "@oxlint/plugins"
2import * as fs from "node:fs"
3import * as path from "node:path"
4
5interface RuleOptions {
6 checkPatterns?: Array<string>
7 checkRelativeIndexImports?: boolean
8}
9
10const extensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
11
12function getModuleName(specifier: ESTree.ImportSpecifier): string {
13 return specifier.imported.type === "Identifier"
14 ? specifier.imported.name
15 : specifier.imported.value
16}
17
18function isRelativeImport(source: string): boolean {
19 return source.startsWith("./") || source.startsWith("../")
20}
21
22function hasIndexFile(dirPath: string): boolean {
23 for (const ext of extensions) {
24 if (fs.existsSync(path.join(dirPath, `index${ext}`))) {
25 return true
26 }
27 }
28 return false
29}
30
31function isIndexImport(importPath: string): boolean {
32 const basename = path.basename(importPath)
33 return basename === "index" || /^index\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(basename)
34}
35
36function resolvesToBarrel(importSource: string, currentFile: string): boolean {
37 const dir = path.dirname(currentFile)
38 const resolved = path.resolve(dir, importSource)
39
40 if (isIndexImport(importSource)) {
41 return true
42 }
43
44 if (hasIndexFile(resolved)) {
45 return true
46 }
47
48 return false
49}
50
51function createBarrelMatcher(options: RuleOptions): (source: string, currentFile: string) => boolean {
52 const patterns = (options.checkPatterns ?? []).map((p) => new RegExp(p))
53 const checkRelative = options.checkRelativeIndexImports !== false
54
55 return (source: string, currentFile: string): boolean => {
56 if (isRelativeImport(source)) {
57 return checkRelative && resolvesToBarrel(source, currentFile)
58 }
59
60 for (const pattern of patterns) {
61 if (pattern.test(source)) {
62 return true
63 }
64 }
65
66 return false
67 }
68}
69
70const rule: CreateRule = {
71 meta: {
72 type: "suggestion",
73 docs: {
74 description: "Disallow importing from barrel files (index.ts), encourage importing specific modules instead"
75 },
76 schema: [
77 {
78 type: "object",
79 properties: {
80 checkPatterns: {
81 type: "array",
82 items: { type: "string" },
83 description: "Regex patterns to match barrel imports"
84 },
85 checkRelativeIndexImports: {
86 type: "boolean",
87 description: "Whether to check relative imports that resolve to index files"
88 }
89 },
90 additionalProperties: false
91 }
92 ]
93 },
94 create(context) {
95 const currentFile = context.filename
96 const options: RuleOptions = (context.options[0] as RuleOptions) ?? {}
97 const isBarrelImport = createBarrelMatcher(options)
98
99 return {
100 ImportDeclaration(node: ESTree.ImportDeclaration) {
101 if (node.importKind === "type") return
102
103 const importSource = node.source.value
104
105 if (!isBarrelImport(importSource, currentFile)) return
106
107 const namespaceSpecifiers: Array<ESTree.ImportNamespaceSpecifier> = []
108 const namedValueSpecifiers: Array<ESTree.ImportSpecifier> = []
109
110 for (const specifier of node.specifiers) {
111 if (specifier.type === "ImportNamespaceSpecifier") {
112 namespaceSpecifiers.push(specifier)
113 } else if (specifier.type === "ImportSpecifier") {
114 if (specifier.importKind !== "type") {
115 namedValueSpecifiers.push(specifier)
116 }
117 }
118 }
119
120 for (const specifier of namespaceSpecifiers) {
121 context.report({
122 node: specifier,
123 message:
124 `Do not use namespace import from barrel file "${importSource}", import from specific modules instead`
125 })
126 }
127
128 for (const specifier of namedValueSpecifiers) {
129 const moduleName = getModuleName(specifier)
130 const localName = specifier.local.name
131 const message = isRelativeImport(importSource)
132 ? `Do not import "${moduleName}" from barrel file "${importSource}", import from specific module instead`
133 : `Use import * as ${localName} from "${importSource}/${moduleName}" instead`
134 context.report({
135 node: specifier,
136 message
137 })
138 }
139 }
140 } as Visitor
141 }
142}
143
144export default rule