diff --git a/README.md b/README.md index 421be91..eb9cbfd 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,57 @@ If you have a special project setup that does not have a babel config in the pro } ``` +### Sibling and subpath aliases + +By default, this plugin enforce relative paths when importing sibling and subpath files (e.g. `import from./sibling` and `import from ./subpath/file`). +You can change this behaviour with the `forSiblings` and `forSubpaths` options: + +```json +"rules": { + "@dword-design/import-alias/prefer-alias": [ + "error", + { + "alias": { + "@": "./src", + "@components: "./components" + }, + "forSiblings": ..., + "forSubpaths": ... + } + ] +} +``` + +#### `forSiblings` + +The `forSiblings` option can take a boolean or an object with a property `forMaxNestingLevel` of type number. + +When setting the option to `true`, all sibling imports will be enforced to use aliases. +When setting the option to an object, you can specify a maximum nesting level for which sibling imports would be enforced. +For example, setting the option to `{ forMaxNestingLevel: 0 }` will enforce aliases for all sibling imports that are at the root level of the project, and enforce relative paths for all other sibling imports. + +Here are some examples, considering `@` as an alias for `.`: + +| | `false` (default) | `true` | `{ forMaxNestingLevel: 0 }` | `{ forMaxNestingLevel: 1 }` | +|------------------------------------------|-------------------|------------------------|-----------------------------|-----------------------------| +| `./foo.js` that `import './bar'` | `import ./bar` | `import @/bar` | `import @/bar` | `import @/bar` | +| `./sub/foo.js` that `import './bar'` | `import ./bar` | `import @/sub/bar` | `import ./bar` | `import @/sub/bar` | +| `./sub/sub/foo.js` that `import './bar'` | `import ./bar` | `import @/sub/sub/bar` | `import ./bar` | `import ./bar` | + +#### `forSubpaths` + +The `forSubpaths` option can take a boolean or an object with the properties `fromInside` and `fromOutside` of type boolean. + +When setting the option to `true`, all subpath imports will be enforced to use aliases. +When setting the option to an object, you can specify whether subpath should be enforced to use aliases or relative paths if the calling file is located inside or outside the alias that match the imported file. + +Here are some examples, considering `@components` as an alias for `./components`: + +| | `false` (default) | `true` | `{ fromInside: true }` | `{ fromOutside: true }` | +|-----------------------------------------------|---------------------------|------------------------------|------------------------------|--------------------------| +| `./foo.js` that `import ./components/bar` | `import ./components/bar` | `import @components/bar` | `import ./components/bar` | `import @components/bar` | +| `./components/foo.js` that `import ./sub/bar` | `import ./sub/bar` | `import @components/sub/bar` | `import @components/sub/bar` | `import ./sub/bar` | + ## Contribute diff --git a/src/rules/prefer-alias.js b/src/rules/prefer-alias.js index 5397f15..81d1f1e 100644 --- a/src/rules/prefer-alias.js +++ b/src/rules/prefer-alias.js @@ -6,6 +6,10 @@ import P from 'path' const isParentImport = path => /^(\.\/)?\.\.\//.test(path) +const isSiblingImport = path => /^\.\/[^/]+$/.test(path) + +const isSubpathImport = path => /^\.\/.+\//.test(path) + const findMatchingAlias = (sourcePath, currentFile, options) => { const resolvePath = options.resolvePath || defaultResolvePath @@ -23,6 +27,44 @@ const findMatchingAlias = (sourcePath, currentFile, options) => { return undefined } +const getImportType = importWithoutAlias => { + if (importWithoutAlias |> isSiblingImport) { + return 'sibling' + } + if (importWithoutAlias |> isSubpathImport) { + return 'subpath' + } + + return 'parent' +} + +const getSiblingsMaxNestingLevel = options => { + if (options.forSiblings === true) { + return Infinity + } + if (options.forSiblings) { + return options.forSiblings.ofMaxNestingLevel + } + + return -1 +} + +const optionForSubpathsMatches = (options, currentFileIsInsideAlias) => { + if (options.forSubpaths === true) { + return true + } + if (options.forSubpaths) { + if (currentFileIsInsideAlias && options.forSubpaths.fromInside) { + return true + } + if (!currentFileIsInsideAlias && options.forSubpaths.fromOutside) { + return true + } + } + + return false +} + export default { create: context => { const currentFile = context.getFilename() @@ -51,6 +93,8 @@ export default { ) } + const siblingsMaxNestingLevel = getSiblingsMaxNestingLevel(options) + const resolvePath = options.resolvePath || defaultResolvePath return { @@ -60,24 +104,48 @@ export default { const hasAlias = options.alias |> keys - |> some(alias => sourcePath |> startsWith(`${alias}/`)) - // relative parent - if (sourcePath |> isParentImport) { - const matchingAlias = findMatchingAlias( - sourcePath, - currentFile, - options, + |> some( + alias => + (sourcePath |> startsWith(`${alias}/`)) || sourcePath === alias, ) + + const importWithoutAlias = resolvePath(sourcePath, currentFile, options) + + const importType = getImportType(importWithoutAlias) + + const matchingAlias = findMatchingAlias( + sourcePath, + currentFile, + options, + ) + + const currentFileNestingLevel = + matchingAlias && + P.relative(matchingAlias.path, currentFile).split(P.sep).length - 1 + + const currentFileIsInsideAlias = + matchingAlias && + !(P.relative(matchingAlias.path, currentFile) |> startsWith('..')) + + const shouldAlias = + !hasAlias && + ((importWithoutAlias |> isParentImport) || + ((importWithoutAlias |> isSiblingImport) && + currentFileNestingLevel <= siblingsMaxNestingLevel) || + ((importWithoutAlias |> isSubpathImport) && + optionForSubpathsMatches(options, currentFileIsInsideAlias))) + if (shouldAlias) { if (!matchingAlias) { return undefined } const absoluteImportPath = P.resolve(folder, sourcePath) - const rewrittenImport = `${matchingAlias.name}/${ - P.relative(matchingAlias.path, absoluteImportPath) - |> replace(/\\/g, '/') - }` + const rewrittenImport = + `${matchingAlias.name}/${ + P.relative(matchingAlias.path, absoluteImportPath) + |> replace(/\\/g, '/') + }` |> replace(/\/$/, '') return context.report({ fix: fixer => @@ -85,20 +153,29 @@ export default { [node.source.range[0] + 1, node.source.range[1] - 1], rewrittenImport, ), - message: `Unexpected parent import '${sourcePath}'. Use '${rewrittenImport}' instead`, + message: `Unexpected ${importType} import '${sourcePath}'. Use '${rewrittenImport}' instead`, node, }) } - const importWithoutAlias = resolvePath(sourcePath, currentFile, options) - if (!(importWithoutAlias |> isParentImport) && hasAlias) { + const isDirectAlias = + options.alias |> keys |> some(alias => sourcePath === alias) + + const shouldUnalias = + hasAlias && + !isDirectAlias && + (((importWithoutAlias |> isSiblingImport) && + currentFileNestingLevel > siblingsMaxNestingLevel) || + ((importWithoutAlias |> isSubpathImport) && + !optionForSubpathsMatches(options, currentFileIsInsideAlias))) + if (shouldUnalias) { return context.report({ fix: fixer => fixer.replaceTextRange( [node.source.range[0] + 1, node.source.range[1] - 1], importWithoutAlias, ), - message: `Unexpected subpath import via alias '${sourcePath}'. Use '${importWithoutAlias}' instead`, + message: `Unexpected ${importType} import via alias '${sourcePath}'. Use '${importWithoutAlias}' instead`, node, }) } @@ -116,6 +193,46 @@ export default { alias: { type: 'object', }, + forSiblings: { + anyOf: [ + { + default: false, + type: 'boolean', + }, + { + additionalProperties: false, + properties: { + ofMaxNestingLevel: { + minimum: 0, + type: 'number', + }, + }, + type: 'object', + }, + ], + }, + forSubpaths: { + anyOf: [ + { + default: false, + type: 'boolean', + }, + { + additionalProperties: false, + properties: { + fromInside: { + default: false, + type: 'boolean', + }, + fromOutside: { + default: false, + type: 'boolean', + }, + }, + type: 'object', + }, + ], + }, }, type: 'object', }, diff --git a/src/rules/prefer-alias.spec.js b/src/rules/prefer-alias.spec.js index 5bb45fd..223dcaf 100644 --- a/src/rules/prefer-alias.spec.js +++ b/src/rules/prefer-alias.spec.js @@ -43,6 +43,322 @@ const lint = (code, options = {}) => { export default tester( { + 'alias for siblings': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@': '.' } }, + ], + ], + }), + 'foo.js': '', + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: true, + }, + ], + }, + }, + }), + ).toEqual({ + messages: ["Unexpected sibling import './foo'. Use '@/foo' instead"], + output: "import foo from '@/foo'", + }) + }, + 'alias for siblings with max nested level': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@': '.' } }, + ], + ], + }), + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 0, + }, + }, + ], + }, + }, + filename: 'bar.js', + }), + ).toEqual({ + messages: ["Unexpected sibling import './foo'. Use '@/foo' instead"], + output: "import foo from '@/foo'", + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 0, + }, + }, + ], + }, + }, + filename: 'sub/bar.js', + }).messages, + ).toEqual([]) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 1, + }, + }, + ], + }, + }, + filename: 'sub/bar.js', + }), + ).toEqual({ + messages: [ + "Unexpected sibling import './foo'. Use '@/sub/foo' instead", + ], + output: "import foo from '@/sub/foo'", + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 1, + }, + }, + ], + }, + }, + filename: 'sub/sub/bar.js', + }).messages, + ).toEqual([]) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 2, + }, + }, + ], + }, + }, + filename: 'sub/sub/bar.js', + }), + ).toEqual({ + messages: [ + "Unexpected sibling import './foo'. Use '@/sub/sub/foo' instead", + ], + output: "import foo from '@/sub/sub/foo'", + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: { + ofMaxNestingLevel: 2, + }, + }, + ], + }, + }, + filename: 'sub/sub/sub/bar.js', + }).messages, + ).toEqual([]) + }, + 'alias for siblings, nested': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@': '.' } }, + ], + ], + }), + 'sub/foo.js': '', + }) + expect( + lint("import foo from './foo'", { + eslintConfig: { + rules: { + 'self/self': [ + 'error', + { + forSiblings: true, + }, + ], + }, + }, + filename: 'sub/bar.js', + }), + ).toEqual({ + messages: [ + "Unexpected sibling import './foo'. Use '@/sub/foo' instead", + ], + output: "import foo from '@/sub/foo'", + }) + }, + 'alias for subpaths': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './components' } }, + ], + ], + }), + }) + + const eslintConfig = { + rules: { + 'self/self': [ + 'error', + { + forSubpaths: true, + }, + ], + }, + } + expect( + lint("import foo from './components/foo'", { + eslintConfig, + }), + ).toEqual({ + messages: [ + "Unexpected subpath import './components/foo'. Use '@components/foo' instead", + ], + output: "import foo from '@components/foo'", + }) + expect( + lint("import foo from './sub/foo'", { + eslintConfig, + filename: './components/bar.js', + }), + ).toEqual({ + messages: [ + "Unexpected subpath import './sub/foo'. Use '@components/sub/foo' instead", + ], + output: "import foo from '@components/sub/foo'", + }) + }, + 'alias for subpaths from inside': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './components' } }, + ], + ], + }), + }) + + const eslintConfig = { + rules: { + 'self/self': [ + 'error', + { + forSubpaths: { + fromInside: true, + }, + }, + ], + }, + } + expect( + lint("import foo from './sub/foo'", { + eslintConfig, + filename: './components/bar.js', + }), + ).toEqual({ + messages: [ + "Unexpected subpath import './sub/foo'. Use '@components/sub/foo' instead", + ], + output: "import foo from '@components/sub/foo'", + }) + expect( + lint("import foo from './components/foo'", { + eslintConfig, + }).messages, + ).toEqual([]) + }, + 'alias for subpaths from outside': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './components' } }, + ], + ], + }), + }) + + const eslintConfig = { + rules: { + 'self/self': [ + 'error', + { + forSubpaths: { + fromOutside: true, + }, + }, + ], + }, + } + expect( + lint("import foo from './components/foo'", { + eslintConfig, + }), + ).toEqual({ + messages: [ + "Unexpected subpath import './components/foo'. Use '@components/foo' instead", + ], + output: "import foo from '@components/foo'", + }) + expect( + lint("import foo from './sub/foo'", { + eslintConfig, + filename: './components/bar.js', + }).messages, + ).toEqual([]) + }, 'alias parent': async () => { await outputFiles({ '.babelrc.json': JSON.stringify({ @@ -73,7 +389,7 @@ export default tester( }) expect(lint("import foo from '@/foo'")).toEqual({ messages: [ - "Unexpected subpath import via alias '@/foo'. Use './foo' instead", + "Unexpected sibling import via alias '@/foo'. Use './foo' instead", ], output: "import foo from './foo'", }) @@ -183,6 +499,52 @@ export default tester( output: "import foo from '@/foo'", }) }, + 'direct import of an alias from a parent': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './sub/components' } }, + ], + ], + }), + }) + expect(lint("import { foo } from '@components'").messages).toEqual([]) + }, + 'direct import of an alias from a sibling': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './components' } }, + ], + ], + }), + }) + expect(lint("import { foo } from '@components'").messages).toEqual([]) + }, + 'direct import of an alias from another one': async () => { + await outputFiles({ + '.babelrc.json': JSON.stringify({ + plugins: [ + [ + packageName`babel-plugin-module-resolver`, + { alias: { '@components': './components', '@hooks': './hooks' } }, + ], + ], + }), + }) + expect( + lint("import { foo } from '../hooks'", { + filename: 'components/bar.js', + }), + ).toEqual({ + messages: ["Unexpected parent import '../hooks'. Use '@hooks' instead"], + output: "import { foo } from '@hooks'", + }) + }, external: async () => { await fs.outputFile( '.babelrc.json',