Skip to content

Commit

Permalink
Merge pull request #23 from gajus/gajus/add-require-extension
Browse files Browse the repository at this point in the history
feat: add require-extension rule
  • Loading branch information
gajus authored Jul 7, 2023
2 parents feb463a + fcd4598 commit e49f327
Show file tree
Hide file tree
Showing 27 changed files with 488 additions and 2 deletions.
54 changes: 54 additions & 0 deletions .README/rules/require-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
### `require-extension`

Adds `.js` extension to all imports and exports.

It resolves the following cases:

#### Relative imports

Relative imports that resolve to a file of the same name:

```js
import './foo'; // => import './foo.js';
```

Relative imports that resolve to an index file:

```js
import './foo'; // => import './foo/index.js';
```

The above examples would also work if the file extension was `.ts` or `.tsx`, i.e.

```js
import './foo'; // => import './foo.ts';
import './foo'; // => import './foo/index.tsx';
```

#### TypeScript paths

For this to work, you have to [configure `import/resolver`](https://www.npmjs.com/package/eslint-import-resolver-typescript):

```ts
settings: {
'import/resolver': {
typescript: {
project: path.resolve(__dirname, 'tsconfig.json'),
},
},
},
```

Imports that resolve to a file of the same name:

```js
import { foo } from '@/foo'; // => import { foo } from '@/foo.js';
```

Imports that resolve to an index file:

```js
import { foo } from '@/foo'; // => import { foo } from '@/foo/index.js';
```

<!-- assertions requireExtension -->
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/coverage
/dist
/node_modules
/pnpm-lock.yaml
*.log
.*
!.eslintignore
Expand All @@ -10,4 +11,4 @@
!.gitignore
!.husky
!.README
!.releaserc
!.releaserc
1 change: 1 addition & 0 deletions src/configs/recommended.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"canonical/import-specifier-newline": 1,
"canonical/no-restricted-strings": 0,
"canonical/no-use-extend-native": 2,
"canonical/require-extension": 0,
"canonical/sort-keys": [
2,
"asc",
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import noUseExtendNative from './rules/noUseExtendNative';
import preferImportAlias from './rules/preferImportAlias';
import preferInlineTypeImport from './rules/preferInlineTypeImport';
import preferUseMount from './rules/preferUseMount';
import requireExtension from './rules/requireExtension';
import sortKeys from './rules/sortKeys';
import virtualModule from './rules/virtualModule';

Expand All @@ -33,6 +34,7 @@ export = {
'prefer-import-alias': preferImportAlias,
'prefer-inline-type-import': preferInlineTypeImport,
'prefer-use-mount': preferUseMount,
'require-extension': requireExtension,
'sort-keys': sortKeys,
'virtual-module': virtualModule,
},
Expand All @@ -48,6 +50,7 @@ export = {
'no-use-extend-native': 0,
'prefer-inline-type-import': 0,
'prefer-use-mount': 0,
'require-extension': 0,
'sort-keys': 0,
},
};
201 changes: 201 additions & 0 deletions src/rules/requireExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { existsSync, lstatSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { type TSESTree } from '@typescript-eslint/utils';
import { type RuleFixer } from '@typescript-eslint/utils/dist/ts-eslint';
import resolveImport from 'eslint-module-utils/resolve';
import { createRule } from '../utilities';

const extensions = ['.js', '.ts', '.tsx'];

type Options = [];

type MessageIds = 'extensionMissing';

const isExistingFile = (fileName: string) => {
return existsSync(fileName) && lstatSync(fileName).isFile();
};

const fixRelativeImport = (
fixer: RuleFixer,
node: TSESTree.ImportDeclaration,
fileName: string,
overrideExtension: boolean = true,
) => {
const importPath = resolve(dirname(fileName), node.source.value);

for (const extension of extensions) {
if (isExistingFile(importPath + extension)) {
return fixer.replaceTextRange(
node.source.range,
`'${node.source.value + (overrideExtension ? '.js' : extension)}'`,
);
}
}

for (const extension of extensions) {
if (isExistingFile(resolve(importPath, 'index') + extension)) {
return fixer.replaceTextRange(
node.source.range,
`'${
node.source.value + '/index' + (overrideExtension ? '.js' : extension)
}'`,
);
}
}

return null;
};

const fixPathImport = (
fixer: RuleFixer,
node: TSESTree.ImportDeclaration,
fileName: string,
aliasPath: string,
resolvedImportPath: string,
overrideExtension: boolean = true,
) => {
const importPath = node.source.value.replace(aliasPath, '');

for (const extension of extensions) {
if (resolvedImportPath.endsWith(importPath + extension)) {
return fixer.replaceTextRange(
node.source.range,
`'${node.source.value + (overrideExtension ? '.js' : extension)}'`,
);
}
}

for (const extension of extensions) {
if (resolvedImportPath.endsWith(importPath + '/index' + extension)) {
return fixer.replaceTextRange(
node.source.range,
`'${
node.source.value + '/index' + (overrideExtension ? '.js' : extension)
}'`,
);
}
}

return null;
};

type AliasPaths = {
[key: string]: string[];
};

type TSConfig = {
compilerOptions: {
paths?: AliasPaths;
};
};

const findAliasPath = (aliasPaths: AliasPaths, importPath: string) => {
return Object.keys(aliasPaths).find((path) => {
if (!path.endsWith('*')) {
return false;
}

const pathWithoutWildcard = path.slice(0, -1);

return importPath.startsWith(pathWithoutWildcard);
});
};

const endsWith = (subject: string, needles: string[]) => {
return needles.some((needle) => {
return subject.endsWith(needle);
});
};

export default createRule<Options, MessageIds>({
create: (context) => {
return {
ImportDeclaration: (node) => {
const importPath = node.source.value;

const importPathHasExtension = endsWith(importPath, extensions);

if (importPathHasExtension) {
return;
}

if (importPath.startsWith('.')) {
context.report({
fix(fixer) {
return fixRelativeImport(fixer, node, context.getFilename());
},
messageId: 'extensionMissing',
node,
});

return;
}

// @ts-expect-error we know this setting exists
const project = (context.settings['import/resolver']?.typescript
?.project ?? null) as string | null;

if (typeof project !== 'string') {
return;
}

const tsconfig: TSConfig = JSON.parse(readFileSync(project, 'utf8'));

const paths = tsconfig?.compilerOptions?.paths;

if (!paths) {
return;
}

const aliasPath = findAliasPath(paths, importPath);

if (!aliasPath) {
return;
}

const aliasPathWithoutWildcard = aliasPath.slice(0, -1);

if (!aliasPathWithoutWildcard) {
throw new Error('Path without wildcard is empty');
}

const resolvedImportPath: string | null = resolveImport(
importPath,
context,
);

if (!resolvedImportPath) {
return;
}

context.report({
fix(fixer) {
return fixPathImport(
fixer,
node,
context.getFilename(),
aliasPathWithoutWildcard,
resolvedImportPath,
);
},
messageId: 'extensionMissing',
node,
});
},
};
},
defaultOptions: [],
meta: {
docs: {
description: '',
recommended: 'error',
},
fixable: 'code',
messages: {
extensionMissing: 'Must include file extension "{{extension}}"',
},
schema: [],
type: 'layout',
},
name: 'require-extension',
});
7 changes: 7 additions & 0 deletions tests/fixtures/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"rules": {
"@typescript-eslint/no-unused-vars": 0,
"import/extensions": 0,
"no-console": 0
}
}
1 change: 1 addition & 0 deletions tests/fixtures/requireExtension/pathsImport/foo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from '@/foo.js';
1 change: 1 addition & 0 deletions tests/fixtures/requireExtension/pathsImport/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from '@/foo';
13 changes: 13 additions & 0 deletions tests/fixtures/requireExtension/pathsImport/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"*"
]
}
},
"include": [
"."
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from '@/foo.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"*"
]
}
},
"include": [
"."
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from '@/foo/index.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from '@/foo';
13 changes: 13 additions & 0 deletions tests/fixtures/requireExtension/pathsImportWithIndex/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"*"
]
}
},
"include": [
"."
]
}
1 change: 1 addition & 0 deletions tests/fixtures/requireExtension/relativeImport/foo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from './foo.js';
1 change: 1 addition & 0 deletions tests/fixtures/requireExtension/relativeImport/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from './foo';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from './foo.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'FOO';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from './foo/index.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { foo } from './foo';
Loading

0 comments on commit e49f327

Please sign in to comment.