Skip to content

Commit 0d73639

Browse files
authored
Merge pull request #4 from Rel1cx/tsl-module
Improve suggestions for duplicate exports and imports rules
2 parents a69c170 + 10b1630 commit 0d73639

File tree

7 files changed

+219
-40
lines changed

7 files changed

+219
-40
lines changed

packages/tsl-module/docs/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44

55
| Variable | Description |
66
| ------ | ------ |
7-
| [noDuplicateExports](variables/noDuplicateExports.md) | Rule to disallow duplicate exports from the same module. Combine multiple export statements from the same module into a single statement. |
8-
| [noDuplicateImports](variables/noDuplicateImports.md) | Rule to disallow duplicate imports from the same module. Combine multiple import statements from the same module into a single statement. |
7+
| [noDuplicateExports](variables/noDuplicateExports.md) | Rule to detect and merge duplicate `export from` statements from the same module. |
8+
| [noDuplicateImports](variables/noDuplicateImports.md) | Rule to detect and merge duplicate `import from` statements from the same module. |

packages/tsl-module/docs/variables/noDuplicateExports.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
const noDuplicateExports: (options?: "off") => Rule<unknown>;
77
```
88

9-
Rule to disallow duplicate exports from the same module. Combine multiple export statements from the same module into a single statement.
9+
Rule to detect and merge duplicate `export from` statements from the same module.
1010

1111
## Parameters
1212

packages/tsl-module/docs/variables/noDuplicateImports.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
const noDuplicateImports: (options?: "off") => Rule<unknown>;
77
```
88

9-
Rule to disallow duplicate imports from the same module. Combine multiple import statements from the same module into a single statement.
9+
Rule to detect and merge duplicate `import from` statements from the same module.
1010

1111
## Parameters
1212

packages/tsl-module/src/rules/no-duplicate-exports.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ test("no-duplicate-exports", () => {
1616
{
1717
line: 2,
1818
message: messages.noDuplicateExports({ source: "'module'" }),
19+
suggestions: [
20+
{
21+
message: "Merge duplicate exports",
22+
output: tsx`\nexport { A, B } from 'module';`,
23+
},
24+
],
1925
},
2026
],
2127
},
@@ -28,6 +34,12 @@ test("no-duplicate-exports", () => {
2834
{
2935
line: 2,
3036
message: messages.noDuplicateExports({ source: "'module'" }),
37+
suggestions: [
38+
{
39+
message: "Merge duplicate exports",
40+
output: tsx`\nexport type { A, B } from 'module';`,
41+
},
42+
],
3143
},
3244
],
3345
},

packages/tsl-module/src/rules/no-duplicate-exports.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { defineRule } from "tsl";
1+
import { type AST, defineRule } from "tsl";
2+
import ts from "typescript";
23

34
export const messages = {
4-
noDuplicateExports: (p: { source: string }) =>
5-
`Duplicate export to module ${p.source}. Combine into a single export statement.`,
5+
noDuplicateExports: (p: { source: string }) => `Duplicate export from module ${p.source}.`,
66
} as const;
77

8+
type ReExportDeclaration = AST.ExportDeclaration & { exportClause: {}; moduleSpecifier: {} };
9+
10+
function isReExportDeclaration(node: AST.ExportDeclaration): node is ReExportDeclaration {
11+
return node.exportClause != null && node.moduleSpecifier != null;
12+
}
13+
814
/**
9-
* Rule to disallow duplicate exports from the same module. Combine multiple export statements from the same module into a single statement.
15+
* Rule to detect and merge duplicate `export from` statements from the same module.
1016
*
1117
* @todo Add autofix to merge duplicate exports automatically.
1218
*
@@ -40,26 +46,55 @@ export const messages = {
4046
export const noDuplicateExports = defineRule(() => {
4147
return {
4248
name: "module/no-duplicate-exports",
43-
createData() {
44-
return [
45-
new Set<string>(), // for export
46-
new Set<string>(), // for export type
47-
] as const;
49+
createData(): { exports: ReExportDeclaration[] } {
50+
return { exports: [] };
4851
},
4952
visitor: {
5053
ExportDeclaration(ctx, node) {
51-
if (node.moduleSpecifier == null) return; // skip non-re-export exports
52-
const exportSource = node.moduleSpecifier.getText();
53-
const seen = ctx.data[node.isTypeOnly ? 1 : 0];
54-
if (seen.has(exportSource)) {
54+
if (!isReExportDeclaration(node)) return; // skip non-re-export exports
55+
const source = node.moduleSpecifier.getText();
56+
const duplicateExport = ctx.data.exports
57+
.find((exp) => exp.isTypeOnly === node.isTypeOnly && exp.moduleSpecifier.getText() === source);
58+
if (duplicateExport != null) {
5559
ctx.report({
5660
node,
57-
message: messages.noDuplicateExports({ source: exportSource }),
61+
message: messages.noDuplicateExports({ source }),
62+
suggestions: buildSuggestions(duplicateExport, node),
5863
});
5964
return;
6065
}
61-
seen.add(exportSource);
66+
ctx.data.exports.push(node);
6267
},
6368
},
6469
};
6570
});
71+
72+
function buildSuggestions(a: ReExportDeclaration, b: ReExportDeclaration) {
73+
switch (true) {
74+
case ts.isNamedExports(a.exportClause)
75+
&& ts.isNamedExports(b.exportClause): {
76+
const aElements = a.exportClause.elements.map((el) => el.getText());
77+
const bElements = b.exportClause.elements.map((el) => el.getText());
78+
const parts = Array.from(new Set([...aElements, ...bElements])).sort();
79+
return [
80+
{
81+
message: "Merge duplicate exports",
82+
changes: [
83+
{
84+
node: a,
85+
newText: "",
86+
},
87+
{
88+
node: b,
89+
// dprint-ignore
90+
newText: `export ${a.isTypeOnly ? "type " : ""}{ ${parts.join(", ")} } from ${a.moduleSpecifier.getText()};`,
91+
},
92+
],
93+
},
94+
];
95+
}
96+
// TODO: handle other exportClause kinds if needed
97+
default:
98+
return [];
99+
}
100+
}

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ test("no-duplicate-import", () => {
1616
{
1717
line: 2,
1818
message: messages.noDuplicateImports({ source: "'module'" }),
19+
suggestions: [
20+
{
21+
message: "Merge duplicate imports",
22+
output: tsx`
23+
import { A, B } from 'module';\n
24+
`,
25+
},
26+
],
1927
},
2028
],
2129
},
@@ -28,6 +36,14 @@ test("no-duplicate-import", () => {
2836
{
2937
line: 2,
3038
message: messages.noDuplicateImports({ source: "'module'" }),
39+
suggestions: [
40+
{
41+
message: "Merge duplicate imports",
42+
output: tsx`
43+
import type { A, B } from 'module';\n
44+
`,
45+
},
46+
],
3147
},
3248
],
3349
},
@@ -40,6 +56,49 @@ test("no-duplicate-import", () => {
4056
{
4157
line: 2,
4258
message: messages.noDuplicateImports({ source: "'module'" }),
59+
suggestions: [
60+
{
61+
message: "Merge duplicate imports",
62+
output: tsx`
63+
import defer { foo } from 'module';\n
64+
`,
65+
},
66+
],
67+
},
68+
],
69+
},
70+
{
71+
code: tsx`
72+
import foo, { type bar, baz } from 'module';
73+
import foo2, { type qux, quux } from 'module';
74+
import foo3, { corge } from 'module';
75+
`,
76+
errors: [
77+
{
78+
line: 2,
79+
message: messages.noDuplicateImports({ source: "'module'" }),
80+
suggestions: [
81+
{
82+
message: "Merge duplicate imports",
83+
output: tsx`
84+
import foo, { type bar, baz, type qux, quux } from 'module';\n
85+
import foo3, { corge } from 'module';
86+
`,
87+
},
88+
],
89+
},
90+
{
91+
line: 3,
92+
message: messages.noDuplicateImports({ source: "'module'" }),
93+
suggestions: [
94+
{
95+
message: "Merge duplicate imports",
96+
output: tsx`
97+
import foo, { type bar, baz, corge } from 'module';
98+
import foo2, { type qux, quux } from 'module';\n
99+
`,
100+
},
101+
],
43102
},
44103
],
45104
},

packages/tsl-module/src/rules/no-duplicate-imports.ts

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
import type { unit } from "@let/eff";
1+
import { unit } from "@let/eff";
22
import { P, match } from "ts-pattern";
3-
import { defineRule } from "tsl";
3+
import { type AST, defineRule } from "tsl";
44
import ts from "typescript";
55

66
export const messages = {
7-
noDuplicateImports: (p: { source: string }) =>
8-
`Duplicate import from module ${p.source}. Combine into a single import statement.`,
7+
noDuplicateImports: (p: { source: string }) => `Duplicate import from module ${p.source}.`,
98
} as const;
109

10+
type ImportKind = 0 | 1 | 2; // 0: import, 1: import type, 2: import defer
11+
12+
interface ImportInfo {
13+
node: AST.ImportDeclaration;
14+
kind: ImportKind;
15+
defaultImport: string | unit;
16+
namedImports: string[];
17+
namespaceImport: string | unit;
18+
source: string;
19+
}
20+
1121
/**
12-
* Rule to disallow duplicate imports from the same module. Combine multiple import statements from the same module into a single statement.
22+
* Rule to detect and merge duplicate `import from` statements from the same module.
1323
*
1424
* @todo Add autofix to merge duplicate imports automatically.
1525
*
@@ -43,33 +53,96 @@ export const messages = {
4353
export const noDuplicateImports = defineRule(() => {
4454
return {
4555
name: "module/no-duplicate-imports",
46-
createData() {
47-
return [
48-
new Set<string>(), // for import
49-
new Set<string>(), // for import type
50-
new Set<string>(), // for import defer
51-
] as const;
56+
createData(): { imports: [ImportInfo[], ImportInfo[], ImportInfo[]] } {
57+
return { imports: [[], [], []] };
5258
},
5359
visitor: {
5460
ImportDeclaration(ctx, node) {
5561
if (node.importClause == null) return; // skip side-effect imports
62+
const importKind = getImportKind(node);
5663
const importSource = node.moduleSpecifier.getText();
57-
const seen = ctx.data[
58-
match<ts.ImportPhaseModifierSyntaxKind | unit, 0 | 1 | 2>(node.importClause.phaseModifier)
59-
.with(P.nullish, () => 0)
60-
.with(ts.SyntaxKind.TypeKeyword, () => 1)
61-
.with(ts.SyntaxKind.DeferKeyword, () => 2)
62-
.otherwise(() => 0)
63-
];
64-
if (seen.has(importSource)) {
64+
const importInfo = {
65+
node,
66+
source: importSource,
67+
kind: importKind,
68+
...decodeImportClause(node.importClause),
69+
} as const satisfies ImportInfo;
70+
const existingImports = ctx.data.imports[importKind];
71+
const duplicateImport = existingImports.find((imp) => imp.source === importInfo.source);
72+
if (duplicateImport != null) {
6573
ctx.report({
6674
node,
67-
message: messages.noDuplicateImports({ source: importSource }),
75+
message: messages.noDuplicateImports({ source: importInfo.source }),
76+
suggestions: [
77+
{
78+
message: "Merge duplicate imports",
79+
changes: [
80+
{
81+
node,
82+
newText: "",
83+
},
84+
{
85+
node: duplicateImport.node,
86+
newText: buildMergedImport(duplicateImport, importInfo),
87+
},
88+
],
89+
},
90+
],
6891
});
6992
return;
7093
}
71-
seen.add(importSource);
94+
existingImports.push(importInfo);
7295
},
7396
},
7497
};
7598
});
99+
100+
function getImportKind(node: AST.ImportDeclaration): ImportKind {
101+
return match<ts.ImportPhaseModifierSyntaxKind | unit, ImportKind>(node.importClause?.phaseModifier)
102+
.with(P.nullish, () => 0)
103+
.with(ts.SyntaxKind.TypeKeyword, () => 1)
104+
.with(ts.SyntaxKind.DeferKeyword, () => 2)
105+
.otherwise(() => 0);
106+
}
107+
108+
function decodeImportClause(node: AST.ImportClause) {
109+
const { name, namedBindings } = node;
110+
return {
111+
defaultImport: name?.getText(),
112+
namedImports: namedBindings != null
113+
&& ts.isNamedImports(namedBindings)
114+
? namedBindings.elements.map((el) => el.getText())
115+
: [],
116+
namespaceImport: namedBindings != null
117+
&& ts.isNamespaceImport(namedBindings)
118+
? namedBindings.name.getText()
119+
: unit,
120+
} as const;
121+
}
122+
123+
function buildMergedImport(a: ImportInfo, b: ImportInfo): string {
124+
const parts: string[] = [];
125+
// Default import
126+
if (a.defaultImport != null) {
127+
parts.push(a.defaultImport);
128+
} else if (b.defaultImport != null) {
129+
parts.push(b.defaultImport);
130+
}
131+
// Namespace import
132+
if (a.namespaceImport != null) {
133+
parts.push(`* as ${a.namespaceImport}`);
134+
} else if (b.namespaceImport != null) {
135+
parts.push(`* as ${b.namespaceImport}`);
136+
}
137+
// Named imports
138+
const namedImports = Array.from(new Set([...a.namedImports, ...b.namedImports]));
139+
if (namedImports.length > 0) {
140+
parts.push(`{ ${namedImports.join(", ")} }`);
141+
}
142+
const importKindPrefix = match<ImportKind, string>(a.kind)
143+
.with(0, () => "import")
144+
.with(1, () => "import type")
145+
.with(2, () => "import defer")
146+
.exhaustive();
147+
return `${importKindPrefix} ${parts.join(", ")} from ${a.source};`;
148+
}

0 commit comments

Comments
 (0)