11import path from 'path' ;
2-
2+ import fs from 'fs' ;
33import minimatch from 'minimatch' ;
44import resolve from 'eslint-module-utils/resolve' ;
55import { isBuiltIn , isExternalModule , isScoped } from '../core/importType' ;
@@ -91,13 +91,14 @@ function buildProperties(context) {
9191
9292module . exports = {
9393 meta : {
94- type : 'suggestion ' ,
94+ type : 'problem ' ,
9595 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 ,
9899 url : docsUrl ( 'extensions' ) ,
99100 } ,
100-
101+ fixable : 'code' ,
101102 schema : {
102103 anyOf : [
103104 {
@@ -133,6 +134,12 @@ module.exports = {
133134 } ,
134135 ] ,
135136 } ,
137+ messages : {
138+ missingExtension :
139+ 'Missing file extension for "{{importPath}}" (expected {{expected}}).' ,
140+ unexpectedExtension :
141+ 'Unexpected file extension "{{extension}}" in import of "{{importPath}}".' ,
142+ } ,
136143 } ,
137144
138145 create ( context ) {
@@ -151,9 +158,14 @@ module.exports = {
151158 return getModifier ( extension ) === 'never' ;
152159 }
153160
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 ) ;
157169 const resolvedFileWithoutExtension = resolve ( fileWithoutExtension , context ) ;
158170
159171 return resolvedFileWithoutExtension === resolve ( file , context ) ;
@@ -177,11 +189,19 @@ module.exports = {
177189 }
178190 }
179191
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+
180199 function checkFileExtension ( source , node ) {
181200 // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor
182201 if ( ! source || ! source . value ) { return ; }
183202
184203 const importPathWithQueryString = source . value ;
204+ const currentDir = path . dirname ( context . getFilename ( ) ) ;
185205
186206 // If not undefined, the user decided if rules are enforced on this import
187207 const overrideAction = computeOverrideAction (
@@ -203,10 +223,7 @@ module.exports = {
203223 if ( ! overrideAction && isExternalRootModule ( importPath ) ) { return ; }
204224
205225 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 ) ;
210227
211228 // determine if this is a module
212229 const isPackage = isExternalModule (
@@ -215,23 +232,38 @@ module.exports = {
215232 context ,
216233 ) || isScoped ( importPath ) ;
217234
218- if ( ! extension || ! importPath . endsWith ( `.${ extension } ` ) ) {
235+ // Case 1: Missing extension.
236+ if ( ! extensionWithDot || ! importPath . endsWith ( extensionWithDot ) ) {
219237 // ignore type-only imports and exports
220238 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 ) ) {
224241 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+ } ,
228251 } ) ;
229252 }
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 ) ) {
232257 context . report ( {
233258 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+ } ,
235267 } ) ;
236268 }
237269 }
0 commit comments