Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/configs/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
Expand Down
Original file line number Diff line number Diff line change
@@ -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";',
},
],
})
})
207 changes: 207 additions & 0 deletions lib/plugins/import-extensions/rules/ban-inline-type-imports.ts
Original file line number Diff line number Diff line change
@@ -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};`
}
2 changes: 2 additions & 0 deletions lib/plugins/import-extensions/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
8 changes: 5 additions & 3 deletions tests/fixtures/codestyle/input/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
11 changes: 7 additions & 4 deletions tests/fixtures/codestyle/output/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
Loading