Skip to content

Commit 4493b07

Browse files
committed
Refactor import and export rules in tsl-dx
1 parent cb0012c commit 4493b07

File tree

7 files changed

+324
-302
lines changed

7 files changed

+324
-302
lines changed

.pkgs/configs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
"@tsconfig/node24": "^24.0.4",
1818
"@tsconfig/strictest": "^2.0.8",
1919
"tsl": "^1.0.29",
20-
"tsl-dx": "^0.7.1"
20+
"tsl-dx": "^0.7.2"
2121
}
2222
}

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@
3838
"@local/eff": "workspace:*",
3939
"@tsconfig/node24": "^24.0.4",
4040
"@tsconfig/strictest": "^2.0.8",
41-
"@types/node": "^25.3.0",
41+
"@types/node": "^25.3.1",
4242
"ansis": "^4.2.0",
43-
"dprint": "^0.51.1",
43+
"dprint": "^0.52.0",
4444
"effect": "^3.19.19",
4545
"publint": "^0.3.17",
4646
"skott": "^0.35.7",
@@ -57,7 +57,7 @@
5757
"typescript": "^5.9.3",
5858
"vitest": "^4.0.18"
5959
},
60-
"packageManager": "pnpm@10.30.1",
60+
"packageManager": "pnpm@10.30.2",
6161
"engines": {
6262
"node": ">=24.0.0"
6363
}

packages/eslint-plugin-function/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@
3636
"dependencies": {
3737
"@eslint-react/ast": "^2.13.0",
3838
"@eslint-react/shared": "^2.13.0",
39-
"@typescript-eslint/scope-manager": "^8.56.0",
40-
"@typescript-eslint/type-utils": "^8.56.0",
41-
"@typescript-eslint/types": "^8.56.0",
42-
"@typescript-eslint/utils": "^8.56.0",
39+
"@typescript-eslint/scope-manager": "^8.56.1",
40+
"@typescript-eslint/type-utils": "^8.56.1",
41+
"@typescript-eslint/types": "^8.56.1",
42+
"@typescript-eslint/utils": "^8.56.1",
4343
"string-ts": "^2.3.1",
4444
"ts-api-utils": "^2.4.0",
4545
"ts-pattern": "^5.9.0",
46-
"typescript-eslint": "^8.56.0"
46+
"typescript-eslint": "^8.56.1"
4747
},
4848
"devDependencies": {
4949
"@local/eff": "workspace:*",
50-
"@types/node": "^25.3.0",
50+
"@types/node": "^25.3.1",
5151
"dedent": "^1.7.1",
52-
"eslint": "^10.0.1",
52+
"eslint": "^10.0.2",
5353
"tsdown": "^0.20.3",
5454
"tsl": "^1.0.29"
5555
},

packages/tsl-dx/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"ts-pattern": "^5.9.0"
3333
},
3434
"devDependencies": {
35+
"@liautaud/typezod": "^2.0.0",
3536
"@local/configs": "workspace:*",
3637
"@local/eff": "workspace:*",
3738
"dedent": "^1.7.1",

packages/tsl-dx/src/rules/no-duplicate-imports.test.ts

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,6 @@ test("no-duplicate-import", () => {
9696
},
9797
],
9898
},
99-
{
100-
code: tsx`
101-
import defer * as ns1 from "mod";
102-
import defer * as ns2 from "mod";
103-
`,
104-
errors: [
105-
{
106-
line: 2,
107-
message: messages.default({ source: '"mod"' }),
108-
suggestions: [],
109-
},
110-
],
111-
},
11299
{
113100
// Namespace import with named imports
114101
code: tsx`
@@ -119,14 +106,7 @@ test("no-duplicate-import", () => {
119106
{
120107
line: 2,
121108
message: messages.default({ source: "'module'" }),
122-
suggestions: [
123-
{
124-
message: "Merge duplicate imports",
125-
output: tsx`
126-
import * as ns, { A } from 'module';\n
127-
`,
128-
},
129-
],
109+
suggestions: [],
130110
},
131111
],
132112
},
@@ -140,14 +120,7 @@ test("no-duplicate-import", () => {
140120
{
141121
line: 2,
142122
message: messages.default({ source: "'module'" }),
143-
suggestions: [
144-
{
145-
message: "Merge duplicate imports",
146-
output: tsx`
147-
import Default, * as ns from 'module';\n
148-
`,
149-
},
150-
],
123+
suggestions: [],
151124
},
152125
],
153126
},
@@ -173,21 +146,14 @@ test("no-duplicate-import", () => {
173146
],
174147
},
175148
{
176-
// Three import defer statements (no suggestions)
177149
code: tsx`
178-
import defer { foo1 } from 'module';
179-
import defer { foo2 } from 'module';
180-
import defer { foo3 } from 'module';
150+
import * as astUtils from "@typescript-eslint/utils/ast-utils";
151+
import { getStaticValue } from "@typescript-eslint/utils/ast-utils";
181152
`,
182153
errors: [
183154
{
184155
line: 2,
185-
message: messages.default({ source: "'module'" }),
186-
suggestions: [],
187-
},
188-
{
189-
line: 3,
190-
message: messages.default({ source: "'module'" }),
156+
message: messages.default({ source: '"@typescript-eslint/utils/ast-utils"' }),
191157
suggestions: [],
192158
},
193159
],
Lines changed: 66 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import { unit } from "@local/eff";
2-
import { P, match } from "ts-pattern";
2+
import { match } from "ts-pattern";
33
import { type AST, defineRule } from "tsl";
44
import ts from "typescript";
55

66
export const messages = {
77
default: (p: { source: string }) => `Duplicate import from module ${p.source}.`,
88
} as const;
99

10-
type ImportKind = 0 | 1 | 2; // 0: import, 1: import type, 2: import defer
10+
type ImportKind = "value" | "type" | "defer";
11+
12+
type NamedBindings =
13+
| { kind: "named"; imports: string[] }
14+
| { kind: "namespace"; name: string };
1115

1216
interface ImportInfo {
1317
node: AST.ImportDeclaration;
1418
kind: ImportKind;
15-
defaultImport: string | unit;
16-
namedImports: string[];
17-
namespaceImport: string | unit;
1819
source: string;
20+
defaultImport: string | unit;
21+
bindings: NamedBindings;
1922
}
2023

2124
/**
@@ -37,99 +40,83 @@ interface ImportInfo {
3740
export const noDuplicateImports = defineRule(() => {
3841
return {
3942
name: "dx/no-duplicate-imports",
40-
createData(): { imports: [ImportInfo[], ImportInfo[], ImportInfo[]] } {
41-
return { imports: [[], [], []] };
43+
createData(): { imports: Map<ImportKind, ImportInfo[]> } {
44+
return { imports: new Map([["value", []], ["type", []], ["defer", []]]) };
4245
},
4346
visitor: {
4447
ImportDeclaration(ctx, node) {
4548
if (node.importClause == null) return; // skip side-effect imports
46-
const importKind = getImportKind(node);
49+
const importKind = match(node.importClause.phaseModifier)
50+
.with(ts.SyntaxKind.TypeKeyword, () => "type" as const)
51+
.with(ts.SyntaxKind.DeferKeyword, () => "defer" as const)
52+
.otherwise(() => "value" as const);
4753
const importSource = node.moduleSpecifier.getText();
4854
const importInfo = {
4955
node,
5056
source: importSource,
5157
kind: importKind,
52-
...decodeImportClause(node.importClause),
58+
defaultImport: node.importClause.name?.getText(),
59+
bindings: match(node.importClause.namedBindings)
60+
.with({ kind: ts.SyntaxKind.NamedImports }, (nb) => ({
61+
kind: "named" as const,
62+
imports: nb.elements.map((el) => el.getText()),
63+
}))
64+
.with({ kind: ts.SyntaxKind.NamespaceImport }, (nb) => ({
65+
kind: "namespace" as const,
66+
name: nb.name.getText(),
67+
}))
68+
.otherwise(() => ({ kind: "named" as const, imports: [] })),
5369
} as const satisfies ImportInfo;
54-
const existingImports = ctx.data.imports[importKind];
70+
const existingImports = ctx.data.imports.get(importKind)!;
5571
const duplicateImport = existingImports.find((imp) => imp.source === importInfo.source);
56-
if (duplicateImport != null) {
57-
ctx.report({
58-
node,
59-
message: messages.default({ source: importInfo.source }),
60-
suggestions: importKind > 1
61-
? [] // no auto fix for two import defer statements
62-
: [
63-
{
64-
message: "Merge duplicate imports",
65-
changes: [
66-
{
67-
node,
68-
newText: "",
69-
},
70-
{
71-
node: duplicateImport.node,
72-
newText: buildMergedImport(duplicateImport, importInfo),
73-
},
74-
],
75-
},
76-
],
77-
});
72+
if (duplicateImport == null) {
73+
existingImports.push(importInfo);
7874
return;
7975
}
80-
existingImports.push(importInfo);
76+
ctx.report({
77+
node,
78+
message: messages.default({ source: importInfo.source }),
79+
suggestions: buildSuggestions(duplicateImport, importInfo),
80+
});
8181
},
8282
},
8383
};
8484
});
8585

86-
function getImportKind(node: AST.ImportDeclaration): ImportKind {
87-
return match<ts.ImportPhaseModifierSyntaxKind | unit, ImportKind>(node.importClause?.phaseModifier)
88-
.with(P.nullish, () => 0)
89-
.with(ts.SyntaxKind.TypeKeyword, () => 1)
90-
.with(ts.SyntaxKind.DeferKeyword, () => 2)
91-
.otherwise(() => 0);
92-
}
93-
94-
function decodeImportClause(node: AST.ImportClause) {
95-
const { name, namedBindings } = node;
96-
return {
97-
defaultImport: name?.getText(),
98-
namedImports: namedBindings != null
99-
&& ts.isNamedImports(namedBindings)
100-
? namedBindings.elements.map((el) => el.getText())
101-
: [],
102-
namespaceImport: namedBindings != null
103-
&& ts.isNamespaceImport(namedBindings)
104-
? namedBindings.name.getText()
105-
: unit,
106-
} as const;
107-
}
108-
109-
function buildMergedImport(a: ImportInfo, b: ImportInfo): string {
110-
const parts: string[] = [];
111-
// Default import
112-
if (a.defaultImport != null) {
113-
parts.push(a.defaultImport);
114-
} else if (b.defaultImport != null) {
115-
parts.push(b.defaultImport);
86+
function buildSuggestions(existing: ImportInfo, incoming: ImportInfo) {
87+
if (
88+
incoming.kind === "defer"
89+
|| incoming.bindings.kind === "namespace"
90+
|| existing.bindings.kind === "namespace"
91+
) {
92+
return [];
11693
}
117-
// Namespace import
118-
if (a.namespaceImport != null) {
119-
parts.push(`* as ${a.namespaceImport}`);
120-
} else if (b.namespaceImport != null) {
121-
parts.push(`* as ${b.namespaceImport}`);
94+
// Both bindings are guaranteed to be "named" here
95+
const parts: string[] = [];
96+
const defaultImport = existing.defaultImport ?? incoming.defaultImport;
97+
if (defaultImport != null) {
98+
parts.push(defaultImport);
12299
}
123-
// Named imports
124-
const namedImports = Array.from(new Set([...a.namedImports, ...b.namedImports]));
125-
// Construct named imports part
126-
if (namedImports.length > 0) {
127-
parts.push(`{ ${namedImports.join(", ")} }`);
100+
const mergedImports = Array.from(
101+
new Set([
102+
...existing.bindings.imports,
103+
...incoming.bindings.imports,
104+
]),
105+
);
106+
if (mergedImports.length > 0) {
107+
parts.push(`{ ${mergedImports.join(", ")} }`);
128108
}
129-
const importKindPrefix = match<ImportKind, string>(a.kind)
130-
.with(0, () => "import")
131-
.with(1, () => "import type")
132-
.with(2, () => "import defer")
133-
.exhaustive();
134-
return `${importKindPrefix} ${parts.join(", ")} from ${a.source};`;
109+
const importKindPrefix = incoming.kind === "value" ? "import" : "import type";
110+
return [
111+
{
112+
message: "Merge duplicate imports",
113+
changes: [
114+
{ node: incoming.node, newText: "" },
115+
{
116+
node: existing.node,
117+
newText: `${importKindPrefix} ${parts.join(", ")} from ${existing.source};`,
118+
},
119+
],
120+
},
121+
];
135122
}

0 commit comments

Comments
 (0)