diff --git a/CHANGELOG.md b/CHANGELOG.md index 81438c768..fadb3e1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Added - [`no-named-as-default-member`] to `warnings` canned config - add [`no-extraneous-dependencies`] rule ([#241], thanks [@jfmengels]) +- add [`extensions`] rule ([#250], thanks [@lo1tuma]) ## [1.5.0] - 2016-04-18 ### Added @@ -144,7 +145,9 @@ for info on changes for earlier releases. [`no-namespace`]: ./docs/rules/no-namespace.md [`no-named-as-default-member`]: ./docs/rules/no-named-as-default-member.md [`no-extraneous-dependencies`]: ./docs/rules/no-extraneous-dependencies.md +[`extensions`]: ./docs/rules/extensions.md +[#250]: https://github.com/benmosher/eslint-plugin-import/pull/250 [#243]: https://github.com/benmosher/eslint-plugin-import/pull/243 [#241]: https://github.com/benmosher/eslint-plugin-import/pull/241 [#239]: https://github.com/benmosher/eslint-plugin-import/pull/239 @@ -185,3 +188,4 @@ for info on changes for earlier releases. [@singles]: https://github.com/singles [@jfmengels]: https://github.com/jfmengels [@dmnd]: https://github.com/dmnd +[@lo1tuma]: https://github.com/lo1tuma diff --git a/README.md b/README.md index 6673f2282..8a654dbf0 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,12 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a * Ensure all imports appear before other statements ([`imports-first`]) * Report repeated import of the same module in multiple places ([`no-duplicates`]) * Report namespace imports ([`no-namespace`]) +* Ensure consistent use of file extension within the import path ([`extensions`]) [`imports-first`]: ./docs/rules/imports-first.md [`no-duplicates`]: ./docs/rules/no-duplicates.md [`no-namespace`]: ./docs/rules/no-namespace.md +[`extensions`]: ./docs/rules/extensions.md ## Installation diff --git a/docs/rules/extensions.md b/docs/rules/extensions.md new file mode 100644 index 000000000..1bd8dbb6b --- /dev/null +++ b/docs/rules/extensions.md @@ -0,0 +1,85 @@ +# extensions - Ensure consistent use of file extension within the import path + +Some file resolve algorithms allow you to omit the file extension within the import source path. For example the `node` resolver can resolve `./foo/bar` to the absolute path `/User/someone/foo/bar.js` because the `.js` extension is resolved automatically by default. Depending on the resolver you can configure more extensions to get resolved automatically. + +In order to provide a consistent use of file extensions across your code base, this rule can enforce or disallow the use of certain file extensions. + +## Rule Details + +This rule has one option which could be either a string or an object. If it is `"never"` (the default value) the rule forbids the use for any extension. If `"always"` then the rule enforces the use of extensions for all import statements. + +By providing an object you can configure each extension separately, so for example `{ "js": "always", "json": "never" }` would always enforce the use of the `.js` extension but never allow the use of the `.json` extension. + +### Exception + +When disallowing the use of certain extensions this rule makes an exception and allows the use of extension when the file would not be resolvable without extension. + +For example, given the following folder structure: + +``` +├── foo +│   ├── bar.js +│   ├── bar.json +``` + +and this import statement: + +```js +import bar from './foo/bar.json'; +``` + +then the extension can’t be omitted because it would then resolve to `./foo/bar.js`. + +### Examples + +The following patterns are considered problems when configuration set to "never": + +```js +import foo from './foo.js'; + +import bar from './bar.json'; + +import Component from './Component.jsx' + +import express from 'express/index.js'; +``` + +The following patterns are not considered problems when configuration set to "never": + +```js +import foo from './foo'; + +import bar from './bar'; + +import Component from './Component' + +import express from 'express/index'; +``` + +The following patterns are considered problems when configuration set to "always": + +```js +import foo from './foo'; + +import bar from './bar'; + +import Component from './Component' + +import express from 'express'; +``` + +The following patterns are not considered problems when configuration set to "always": + +```js +import foo from './foo.js'; + +import bar from './bar.json'; + +import Component from './Component.jsx' + +import express from 'express/index.js'; +``` + +## When Not To Use It + +If you are not concerned about a consistent usage of file extension. diff --git a/package.json b/package.json index 33acac1f4..70e6599d2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "es6-symbol": "*", "eslint-import-resolver-node": "^0.2.0", "lodash.cond": "^4.3.0", + "lodash.endswith": "^4.0.1", "object-assign": "^4.0.1", "pkg-up": "^1.0.0" } diff --git a/src/index.js b/src/index.js index 7d1dd0502..f968fa2f5 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ export const rules = { 'namespace': require('./rules/namespace'), 'no-namespace': require('./rules/no-namespace'), 'export': require('./rules/export'), + 'extensions': require('./rules/extensions'), 'no-named-as-default': require('./rules/no-named-as-default'), 'no-named-as-default-member': require('./rules/no-named-as-default-member'), diff --git a/src/rules/extensions.js b/src/rules/extensions.js new file mode 100644 index 000000000..5a8c04edd --- /dev/null +++ b/src/rules/extensions.js @@ -0,0 +1,66 @@ +import path from 'path' +import resolve from '../core/resolve' +import endsWith from 'lodash.endswith' + +module.exports = function (context) { + const configuration = context.options[0] || 'never' + + function isUseOfExtensionEnforced(extension) { + if (typeof configuration === 'object') { + return configuration[extension] === 'always' + } + + return configuration === 'always' + } + + function isResolvableWithoutExtension(file) { + const extension = path.extname(file) + const fileWithoutExtension = file.slice(0, -extension.length) + const resolvedFileWithoutExtension = resolve(fileWithoutExtension, context) + + return resolvedFileWithoutExtension === resolve(file, context) + } + + function checkFileExtension(node) { + const { source } = node + const importPath = source.value + const resolvedPath = resolve(importPath, context) + const extension = path.extname(resolvedPath).substring(1) + + if (!endsWith(importPath, extension)) { + if (isUseOfExtensionEnforced(extension)) { + context.report({ + node: source, + message: `Missing file extension "${extension}" for "${importPath}"`, + }) + } + } else { + if (!isUseOfExtensionEnforced(extension) && isResolvableWithoutExtension(importPath)) { + context.report({ + node: source, + message: `Unexpected use of file extension "${extension}" for "${importPath}"`, + }) + } + } + } + + return { + ImportDeclaration: checkFileExtension, + } +} + +module.exports.schema = [ + { + oneOf: [ + { + enum: [ 'always', 'never' ], + }, + { + type: 'object', + patternProperties: { + '.*': { enum: [ 'always', 'never' ] }, + }, + }, + ], + }, +] diff --git a/tests/files/bar.json b/tests/files/bar.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/files/bar.json @@ -0,0 +1 @@ +{} diff --git a/tests/files/bar.jsx b/tests/files/bar.jsx new file mode 100644 index 000000000..7b8595488 --- /dev/null +++ b/tests/files/bar.jsx @@ -0,0 +1 @@ +export default null diff --git a/tests/files/file.with.dot.js b/tests/files/file.with.dot.js new file mode 100644 index 000000000..7b8595488 --- /dev/null +++ b/tests/files/file.with.dot.js @@ -0,0 +1 @@ +export default null diff --git a/tests/src/rules/extensions.js b/tests/src/rules/extensions.js new file mode 100644 index 000000000..62b05ca0e --- /dev/null +++ b/tests/src/rules/extensions.js @@ -0,0 +1,121 @@ +import { RuleTester } from 'eslint' +import rule from 'rules/extensions'; +import { test } from '../utils'; + +const ruleTester = new RuleTester() + +ruleTester.run('extensions', rule, { + valid: [ + test({ code: 'import a from "a"' }), + test({ code: 'import dot from "./file.with.dot"' }), + test({ + code: 'import a from "a/index.js"', + options: [ 'always' ] + }), + test({ + code: 'import dot from "./file.with.dot.js"', + options: [ 'always' ] + }), + test({ + code: [ + 'import a from "a"', + 'import packageConfig from "./package.json"', + ].join('\n'), + options: [ { json: 'always', js: 'never' } ] + }), + test({ + code: [ + 'import lib from "./bar"', + 'import component from "./bar.jsx"', + 'import data from "./bar.json"' + ].join('\n'), + options: [ 'never' ], + settings: { 'import/resolve': { 'extensions': [ '.js', '.jsx', '.json' ] } } + }) + ], + + invalid: [ + test({ + code: 'import a from "a/index.js"', + errors: [ { + message: 'Unexpected use of file extension "js" for "a/index.js"', + line: 1, + column: 15 + } ] + }), + test({ + code: 'import a from "a"', + options: [ 'always' ], + errors: [ { + message: 'Missing file extension "js" for "a"', + line: 1, + column: 15 + } ] + }), + test({ + code: 'import dot from "./file.with.dot"', + options: [ "always" ], + errors: [ + { + message: 'Missing file extension "js" for "./file.with.dot"', + line: 1, + column: 17 + } + ] + }), + test({ + code: [ + 'import a from "a/index.js"', + 'import packageConfig from "./package"', + ].join('\n'), + options: [ { json: 'always', js: 'never' } ], + settings: { 'import/resolve': { 'extensions': [ '.js', '.json' ] } }, + errors: [ + { + message: 'Unexpected use of file extension "js" for "a/index.js"', + line: 1, + column: 15 + }, + { + message: 'Missing file extension "json" for "./package"', + line: 2, + column: 27 + } + ] + }), + test({ + code: [ + 'import lib from "./bar.js"', + 'import component from "./bar.jsx"', + 'import data from "./bar.json"' + ].join('\n'), + options: [ 'never' ], + settings: { 'import/resolve': { 'extensions': [ '.js', '.jsx', '.json' ] } }, + errors: [ + { + message: 'Unexpected use of file extension "js" for "./bar.js"', + line: 1, + column: 17 + } + ] + }), + test({ + code: [ + 'import lib from "./bar.js"', + 'import component from "./bar.jsx"', + 'import data from "./bar.json"' + ].join('\n'), + options: [ { json: 'always', js: 'never', jsx: 'never' } ], + settings: { 'import/resolve': { 'extensions': [ '.js', '.jsx', '.json' ] } }, + errors: [ + { + message: 'Unexpected use of file extension "js" for "./bar.js"', + line: 1, + column: 17 + } + ] + }) + + ] +}) +