11import { unit } from "@local/eff" ;
2- import { P , match } from "ts-pattern" ;
2+ import { match } from "ts-pattern" ;
33import { type AST , defineRule } from "tsl" ;
44import ts from "typescript" ;
55
66export 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
1216interface 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 {
3740export 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