diff --git a/lib/configs/imports.ts b/lib/configs/imports.ts index e964f835..4b05a68d 100644 --- a/lib/configs/imports.ts +++ b/lib/configs/imports.ts @@ -39,6 +39,8 @@ export function imports(options: ConfigOptions): Linter.Config[] { rules: { // Require file extensions 'import-extensions/extensions': 'error', + // enforce explicit type imports (no inline types) + 'import-extensions/ban-inline-type-imports': 'error', // Sorting of imports 'sort-imports': 'off', 'perfectionist/sort-imports': [ diff --git a/lib/plugins/import-extensions/rules/ban-inline-type-imports.spec.ts b/lib/plugins/import-extensions/rules/ban-inline-type-imports.spec.ts new file mode 100644 index 00000000..4db072f2 --- /dev/null +++ b/lib/plugins/import-extensions/rules/ban-inline-type-imports.spec.ts @@ -0,0 +1,83 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import parser from '@typescript-eslint/parser' +import { RuleTester } from 'eslint' +import { test } from 'vitest' +import { rule } from './ban-inline-type-imports.ts' + +const ruleTester = new RuleTester({ + languageOptions: { + parser, + }, +}) + +test('ban-inline-type-imports', () => { + ruleTester.run('ban-inline-type-imports', rule, { + valid: [ + { + code: 'import { join } from "node:path"', + filename: '/a/src/test.ts', + }, + { + code: 'import * as main from "./main.js"', + filename: '/a/src/test.ts', + }, + { + code: 'import View from "./view.vue"', + filename: '/a/src/test.ts', + }, + { + code: 'import type MyClass from "./nested/child.ts"', + filename: '/a/src/test4ts', + }, + { + code: 'import type { some } from "../main.js"', + filename: '/a/src/test.ts', + }, + { + code: 'import Foo, { foo } from "../img/icon.svg?raw"', + filename: '/a/src/test.ts', + }, + ], + invalid: [ + // only inline types + { + code: 'import { type foo, type bar } from "./main.ts"', + filename: '/a/src/test.ts', + errors: [{ + messageId: 'preferTopLevel', + }], + output: 'import type {foo, bar} from "./main.ts";', + }, + // mixed type imports + { + code: 'import { Foo, type Bar } from "./main.ts";', + filename: '/a/src/test.ts', + errors: [{ + messageId: 'preferTopLevel', + }], + output: 'import { Foo } from "./main.ts";\nimport type {Bar} from "./main.ts";', + }, + { + code: 'import { type Foo, Bar } from "./main.ts";', + filename: '/a/src/test.ts', + errors: [{ + messageId: 'preferTopLevel', + }], + output: 'import { Bar } from "./main.ts";\nimport type {Foo} from "./main.ts";', + }, + // mixed type imports with default + { + code: 'import Foo, { type Bar } from "./main.ts";', + filename: '/a/src/test.ts', + errors: [{ + messageId: 'preferTopLevel', + }], + output: 'import Foo from "./main.ts";\nimport type {Bar} from "./main.ts";', + }, + ], + }) +}) diff --git a/lib/plugins/import-extensions/rules/ban-inline-type-imports.ts b/lib/plugins/import-extensions/rules/ban-inline-type-imports.ts new file mode 100644 index 00000000..9fcc04cb --- /dev/null +++ b/lib/plugins/import-extensions/rules/ban-inline-type-imports.ts @@ -0,0 +1,207 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 Ben Mosher + * SPDX-License-Identifier: MIT + */ + +import type { TSESTree } from '@typescript-eslint/types' +import type { AST, Rule, SourceCode } from 'eslint' +import type * as ESTree from 'estree' + +export const rule: Rule.RuleModule = { + meta: { + fixable: 'code', + type: 'suggestion', + docs: { + dialects: ['typescript'], + description: 'Ban the use of inline type-only markers for named imports.', + }, + messages: { + preferTopLevel: 'Prefer using a top-level type-only import instead of inline type specifiers.', + }, + }, + + create(context) { + return { + ImportDeclaration(node: ESTree.ImportDeclaration | TSESTree.ImportDeclaration) { + if ( + !('importKind' in node) + || node.importKind === 'type' + // no specifiers (import {} from '') cannot have inline - so is valid + || node.specifiers.length === 0 + || ( + node.specifiers.length === 1 + // default imports are both "inline" and "top-level" + && ( + node.specifiers[0].type === 'ImportDefaultSpecifier' + // namespace imports are both "inline" and "top-level" + || node.specifiers[0].type === 'ImportNamespaceSpecifier' + ) + ) + ) { + return + } + + const typeSpecifiers: TSESTree.ImportSpecifier[] = [] + const valueSpecifiers: TSESTree.ImportClause[] = [] + let defaultSpecifier: TSESTree.ImportDefaultSpecifier + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') { + defaultSpecifier = specifier + continue + } + + if (!('importKind' in specifier) || !specifier.importKind) { + valueSpecifiers.push(specifier) + continue + } + + if (specifier.importKind === 'type') { + typeSpecifiers.push(specifier) + } else if (specifier.importKind === 'value') { + valueSpecifiers.push(specifier) + } + } + + const typeImport = getImportText(node, context.sourceCode, typeSpecifiers) + const newImports = typeImport.trim() + + if (typeSpecifiers.length === node.specifiers.length) { + // all specifiers have inline specifiers - so we replace the entire import + context.report({ + node, + messageId: 'preferTopLevel', + fix(fixer) { + return fixer.replaceText(node, newImports) + }, + }) + } else { + // remove specific specifiers and insert new imports for them + for (const specifier of typeSpecifiers) { + context.report({ + node: specifier, + messageId: 'preferTopLevel', + fix(fixer) { + const fixes: Rule.Fix[] = [] + + // if there are no value specifiers, then the other report fixer will be called, not this one + if (valueSpecifiers.length > 0) { + // import { Value, type Type } from 'mod'; + + // we can just remove the type specifiers + removeSpecifiers(fixes, fixer, context.sourceCode, typeSpecifiers) + + // make the import nicely formatted by also removing the trailing comma after the last value import + // eg + // import { Value, type Type } from 'mod'; + // to + // import { Value } from 'mod'; + // not + // import { Value, } from 'mod'; + removeCommaAfterNode(fixes, fixer, context.sourceCode, valueSpecifiers[valueSpecifiers.length - 1]) + } else if (defaultSpecifier) { + // import Default, { type Type } from 'mod'; + + // remove the entire curly block so we don't leave an empty one behind + // NOTE - the default specifier *must* be the first specifier always! + // so a comma exists that we also have to clean up or else it's bad syntax + const comma = context.sourceCode.getTokenAfter(defaultSpecifier, isComma) + const closingBrace = context.sourceCode.getTokenAfter( + node.specifiers[node.specifiers.length - 1], + (token) => token.type === 'Punctuator' && token.value === '}', + ) + if (comma && closingBrace) { + fixes.push(fixer.removeRange([ + comma.range[0], + closingBrace.range[1], + ])) + } + } + + // insert the new imports after the old declaration + return fixes.concat(fixer.insertTextAfter(node, `\n${newImports}`)) + }, + }) + } + } + }, + } + }, +} + +/** + * Check if the given token is a comma + * + * @param token - The token to check + */ +function isComma(token: TSESTree.Token | AST.Token): boolean { + return token.type === 'Punctuator' && token.value === ',' +} + +/** + * Remove the given specifiers from the import declaration, along with any trailing commas if necessary. + * + * @param fixes - The array to which the generated fixes will be added + * @param fixer - The fixer object used to create the fixes + * @param sourceCode - The source code object used to analyze the code and find tokens + * @param specifiers - The specifiers to remove from the import declaration + */ +function removeSpecifiers(fixes: Rule.Fix[], fixer: Rule.RuleFixer, sourceCode: SourceCode, specifiers: TSESTree.ImportClause[]) { + for (const specifier of specifiers) { + removeCommaAfterNode(fixes, fixer, sourceCode, specifier) + fixes.push(fixer.remove(specifier)) + } +} + +/** + * Remove the trailing comma + * + * @param fixes - The array to which the generated fixes will be added + * @param fixer - The fixer object used to create the fixes + * @param sourceCode - The source code object used to analyze the code and find tokens + * @param node - The node after which to check for a comma and remove it if exists + */ +function removeCommaAfterNode(fixes: Rule.Fix[], fixer: Rule.RuleFixer, sourceCode: SourceCode, node: AST.Token | ESTree.Node) { + const token = sourceCode.getTokenAfter(node) + if (token && isComma(token)) { + const nextToken = sourceCode.getTokenAfter(token) + // get the empty space to remove double whitespace after removing the comma + const emptySpace = sourceCode.text + .slice(token.range[1], nextToken?.range[0] ?? token.range[1]) + .match(/^[ \t]*[\n\r]*/) + ?.[0] ?? '' + if (emptySpace) { + fixes.push(fixer.removeRange([token.range[0], token.range[1] + emptySpace.length])) + } else { + fixes.push(fixer.remove(token)) + } + } +} + +/** + * Get the text for a new top-level type-only import based on the given inline type specifiers. + * + * @param node - The original import declaration node containing the inline type specifiers + * @param sourceCode - The source code object used to analyze the code and find tokens + * @param specifiers - The inline type specifiers for which to generate the new import text + */ +function getImportText( + node: ESTree.ImportDeclaration | TSESTree.ImportDeclaration, + sourceCode: SourceCode, + specifiers: TSESTree.ImportSpecifier[], +) { + const sourceString = sourceCode.getText(node.source) + if (specifiers.length === 0) { + return '' + } + + const names = specifiers.map((s) => { + const name = 'name' in s.imported ? s.imported.name : s.imported.value + if (name === s.local.name) { + return name + } + return `${name} as ${s.local.name}` + }) + // insert a fresh top-level import + return `import type {${names.join(', ')}} from ${sourceString};` +} diff --git a/lib/plugins/import-extensions/rules/index.ts b/lib/plugins/import-extensions/rules/index.ts index a74a1a38..6fa000be 100644 --- a/lib/plugins/import-extensions/rules/index.ts +++ b/lib/plugins/import-extensions/rules/index.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { rule as banInlineTypeImports } from './ban-inline-type-imports.ts' import { rule as extensions } from './extensions.ts' export const rules = { + 'ban-inline-type-imports': banInlineTypeImports, extensions, } diff --git a/tests/fixtures/codestyle/input/imports.ts b/tests/fixtures/codestyle/input/imports.ts index 204fa5a6..94e4af76 100644 --- a/tests/fixtures/codestyle/input/imports.ts +++ b/tests/fixtures/codestyle/input/imports.ts @@ -22,12 +22,14 @@ import 'some-sideeffect' import type { AssertPredicate } from 'node:assert' import style from './my.module.css' -import { type Config, createConfig } from './config.ts'; - +import { type Config, createConfig } from './config.ts' import { type Bear, type Elefant, createBear, type Zoo, createZoo, -} from './zoo.ts'; +} from './zoo.ts' + +import MixedValueDefaultImport, { type MixedValueDefaultTypeImport } from './mixed-default-import.ts' +import { MixedValueImport, type MixedValueTypeImport } from './mixed-import.ts' diff --git a/tests/fixtures/codestyle/output/imports.ts b/tests/fixtures/codestyle/output/imports.ts index dfd9015c..38686f32 100644 --- a/tests/fixtures/codestyle/output/imports.ts +++ b/tests/fixtures/codestyle/output/imports.ts @@ -7,6 +7,10 @@ import type { ButtonVariant } from '@nextcloud/vue/components/NcButton' import type { useHotKey } from '@nextcloud/vue/composables/useHotKey' import type { AssertPredicate } from 'node:assert' import type { NcSelectOption } from '../composables/useNcSelectModel.ts' +import type { Config } from './config.ts' +import type { MixedValueDefaultTypeImport } from './mixed-default-import.ts' +import type { MixedValueTypeImport } from './mixed-import.ts' +import type { Bear, Elefant, Zoo } from './zoo.ts' import { t } from '@nextcloud/l10n' import { storeToRefs } from 'pinia' @@ -16,11 +20,10 @@ import IconBellRingOutline from 'vue-material-design-icons/BellRingOutline.vue' import SettingsFormGroup from './components/SettingsFormGroup.vue' import { a, y, z } from '../../../constants.js' import { useAppConfigStore } from './appConfig.store.ts' -import { type Config, createConfig } from './config.ts' +import { createConfig } from './config.ts' +import MixedValueDefaultImport from './mixed-default-import.ts' +import { MixedValueImport } from './mixed-import.ts' import { - type Bear, - type Elefant, - type Zoo, createBear, createZoo, } from './zoo.ts'