1
1
import path from 'path' ;
2
-
2
+ import fs from 'fs' ;
3
3
import minimatch from 'minimatch' ;
4
4
import resolve from 'eslint-module-utils/resolve' ;
5
5
import { isBuiltIn , isExternalModule , isScoped } from '../core/importType' ;
@@ -91,13 +91,14 @@ function buildProperties(context) {
91
91
92
92
module . exports = {
93
93
meta : {
94
- type : 'suggestion ' ,
94
+ type : 'problem ' ,
95
95
docs : {
96
- category : 'Style guide' ,
97
- description : 'Ensure consistent use of file extension within the import path.' ,
96
+ description : 'Enforce that import statements either always include or never include allowed file extensions.' ,
97
+ category : 'Static Analysis' ,
98
+ recommended : false ,
98
99
url : docsUrl ( 'extensions' ) ,
99
100
} ,
100
-
101
+ fixable : 'code' ,
101
102
schema : {
102
103
anyOf : [
103
104
{
@@ -133,6 +134,12 @@ module.exports = {
133
134
} ,
134
135
] ,
135
136
} ,
137
+ messages : {
138
+ missingExtension :
139
+ 'Missing file extension for "{{importPath}}" (expected {{expected}}).' ,
140
+ unexpectedExtension :
141
+ 'Unexpected file extension "{{extension}}" in import of "{{importPath}}".' ,
142
+ } ,
136
143
} ,
137
144
138
145
create ( context ) {
@@ -151,9 +158,14 @@ module.exports = {
151
158
return getModifier ( extension ) === 'never' ;
152
159
}
153
160
154
- function isResolvableWithoutExtension ( file ) {
155
- const extension = path . extname ( file ) ;
156
- const fileWithoutExtension = file . slice ( 0 , - extension . length ) ;
161
+ // Updated: This helper now determines resolvability based on the passed options.
162
+ // If the configured option for the extension is "never", we return true immediately.
163
+ function isResolvableWithoutExtension ( file , ext ) {
164
+ if ( isUseOfExtensionForbidden ( ext ) ) {
165
+ return true ;
166
+ }
167
+ const fileExt = path . extname ( file ) ;
168
+ const fileWithoutExtension = file . slice ( 0 , - fileExt . length ) ;
157
169
const resolvedFileWithoutExtension = resolve ( fileWithoutExtension , context ) ;
158
170
159
171
return resolvedFileWithoutExtension === resolve ( file , context ) ;
@@ -177,11 +189,19 @@ module.exports = {
177
189
}
178
190
}
179
191
192
+ function getCandidateExtension ( importPath , currentDir ) {
193
+ const basePath = path . resolve ( currentDir , importPath ) ;
194
+ const keys = Object . keys ( props . pattern ) ;
195
+ const valid = keys . filter ( ( key ) => fs . existsSync ( `${ basePath } .${ key } ` ) ) ;
196
+ return valid . length === 1 ? `.${ valid [ 0 ] } ` : null ;
197
+ }
198
+
180
199
function checkFileExtension ( source , node ) {
181
200
// bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor
182
201
if ( ! source || ! source . value ) { return ; }
183
202
184
203
const importPathWithQueryString = source . value ;
204
+ const currentDir = path . dirname ( context . getFilename ( ) ) ;
185
205
186
206
// If not undefined, the user decided if rules are enforced on this import
187
207
const overrideAction = computeOverrideAction (
@@ -203,10 +223,7 @@ module.exports = {
203
223
if ( ! overrideAction && isExternalRootModule ( importPath ) ) { return ; }
204
224
205
225
const resolvedPath = resolve ( importPath , context ) ;
206
-
207
- // get extension from resolved path, if possible.
208
- // for unresolved, use source value.
209
- const extension = path . extname ( resolvedPath || importPath ) . substring ( 1 ) ;
226
+ const extensionWithDot = path . extname ( resolvedPath || importPath ) ;
210
227
211
228
// determine if this is a module
212
229
const isPackage = isExternalModule (
@@ -215,23 +232,38 @@ module.exports = {
215
232
context ,
216
233
) || isScoped ( importPath ) ;
217
234
218
- if ( ! extension || ! importPath . endsWith ( `.${ extension } ` ) ) {
235
+ // Case 1: Missing extension.
236
+ if ( ! extensionWithDot || ! importPath . endsWith ( extensionWithDot ) ) {
219
237
// ignore type-only imports and exports
220
238
if ( ! props . checkTypeImports && ( node . importKind === 'type' || node . exportKind === 'type' ) ) { return ; }
221
- const extensionRequired = isUseOfExtensionRequired ( extension , ! overrideAction && isPackage ) ;
222
- const extensionForbidden = isUseOfExtensionForbidden ( extension ) ;
223
- if ( extensionRequired && ! extensionForbidden ) {
239
+ const candidate = getCandidateExtension ( importPath , currentDir ) ;
240
+ if ( candidate && isUseOfExtensionRequired ( candidate . replace ( / ^ \. / , '' ) , isPackage ) ) {
224
241
context . report ( {
225
- node : source ,
226
- message :
227
- `Missing file extension ${ extension ? `"${ extension } " ` : '' } for "${ importPathWithQueryString } "` ,
242
+ node,
243
+ messageId : 'missingExtension' ,
244
+ data : {
245
+ importPath : importPathWithQueryString ,
246
+ expected : candidate ,
247
+ } ,
248
+ fix ( fixer ) {
249
+ return fixer . replaceText ( source , JSON . stringify ( importPathWithQueryString + candidate ) ) ;
250
+ } ,
228
251
} ) ;
229
252
}
230
- } else if ( extension ) {
231
- if ( isUseOfExtensionForbidden ( extension ) && isResolvableWithoutExtension ( importPath ) ) {
253
+ } else {
254
+ // Case 2: Unexpected extension provided.
255
+ const extension = extensionWithDot . substring ( 1 ) ;
256
+ if ( isUseOfExtensionForbidden ( extension ) && isResolvableWithoutExtension ( importPath , extension ) ) {
232
257
context . report ( {
233
258
node : source ,
234
- message : `Unexpected use of file extension "${ extension } " for "${ importPathWithQueryString } "` ,
259
+ messageId : 'unexpectedExtension' ,
260
+ data : {
261
+ extension,
262
+ importPath : importPathWithQueryString ,
263
+ } ,
264
+ fix ( fixer ) {
265
+ return fixer . replaceText ( source , JSON . stringify ( importPath . slice ( 0 , - extensionWithDot . length ) ) ) ;
266
+ } ,
235
267
} ) ;
236
268
}
237
269
}
0 commit comments