Skip to content
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- LICENSE/ -->
## Contribute

Expand Down
147 changes: 132 additions & 15 deletions src/rules/prefer-alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -51,6 +93,8 @@ export default {
)
}

const siblingsMaxNestingLevel = getSiblingsMaxNestingLevel(options)

const resolvePath = options.resolvePath || defaultResolvePath

return {
Expand All @@ -60,45 +104,78 @@ 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 =>
fixer.replaceTextRange(
[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,
})
}
Expand All @@ -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',
},
Expand Down
Loading