diff --git a/package.json b/package.json index 1252032..4fa1a7e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "git", "url": "https://github.com/profico/eslint-plugin-profico.git" }, - "version": "2.0.0", + "version": "2.0.1-0", "description": "ESLint plugin and configurations by Profico", "main": "lib/index.js", "scripts": { diff --git a/src/rules/type-imports.ts b/src/rules/type-imports.ts new file mode 100644 index 0000000..d34aa3f --- /dev/null +++ b/src/rules/type-imports.ts @@ -0,0 +1,45 @@ +import { Rule } from "eslint"; +import { TSESTree } from "@typescript-eslint/utils"; +import { categorizeImports, processImportGroup } from "../utils/imports"; + +const typeImports: Rule.RuleModule = { + meta: { + fixable: "code", + type: "problem", + messages: { + mixedTypeValueImports: + "Type imports should be separated from value imports within each group.", + }, + docs: { + url: "https://github.com/profico/eslint-plugin-profico#type-imports", + }, + }, + create(context) { + return { + Program(node) { + const importNodes = node.body.filter( + (stmt): stmt is TSESTree.ImportDeclaration => + stmt.type === "ImportDeclaration", + ); + + if (importNodes.length === 0) { + return; + } + + const { absoluteImports, relativeImports } = + categorizeImports(importNodes); + + // Process absolute imports (may need trailing newline if relative imports exist) + processImportGroup( + absoluteImports, + context, + relativeImports.length > 0, + ); + // Process relative imports (never needs trailing newline as it's the last group) + processImportGroup(relativeImports, context, false); + }, + }; + }, +}; + +export default typeImports; diff --git a/src/tests/type-imports/invalid-cases/base.invalid-case.txt b/src/tests/type-imports/invalid-cases/base.invalid-case.txt new file mode 100644 index 0000000..d6e425e --- /dev/null +++ b/src/tests/type-imports/invalid-cases/base.invalid-case.txt @@ -0,0 +1,24 @@ +import type * as ReactTypes from 'react'; + +import React, { useState } from 'react'; +import Form, { FormProps, FormSubmitHandler } from 'components/Forms/Form'; +import type Lalalal from 'lalala/lalalal'; +import AddNicknameDialogHeader from 'components/SvgIcons/AddNicknameDialogHeader'; +import useToggleState from 'utils/hooks/useToggleState'; +import useBreakpoint from 'utils/hooks/useBreakpoint'; +import fetch from 'utils/static/fetchRelative'; +import colors from 'styles/themes/colors'; +import { type Kokos } from 'kokos/kokos'; +import { Grid, Button, Typography } from '@mui/material'; +import { PostNicknameData } from 'types/User'; +import { type Kokos } from 'kokos/trokos'; +import type NekiNoviType from './precooltyNekiNoviTypepe'; + +import NicknameInput from './NicknameInput'; +import type PreCoolType from './precooltype'; +import SuccessDialog from './SuccessDialog'; +import ConfirmDialog from './ConfirmDialog'; +import { something } from './something'; +import { type barakokula, someValue } from './barakokula'; + + diff --git a/src/tests/type-imports/invalid-cases/index.ts b/src/tests/type-imports/invalid-cases/index.ts new file mode 100644 index 0000000..6890e1e --- /dev/null +++ b/src/tests/type-imports/invalid-cases/index.ts @@ -0,0 +1,5 @@ +import path from "path"; + +import { readAllTxtFilesInDir } from "../../../utils/tests"; + +export default readAllTxtFilesInDir(path.join(__dirname, "."), ["index.ts"]); diff --git a/src/tests/type-imports/type-imports.test.ts b/src/tests/type-imports/type-imports.test.ts new file mode 100644 index 0000000..edd42c7 --- /dev/null +++ b/src/tests/type-imports/type-imports.test.ts @@ -0,0 +1,28 @@ +import { RuleTester } from "eslint"; + +import typeImports from "../../rules/type-imports"; + +import invalidCases from "./invalid-cases"; +import validCases from "./valid-cases"; + +const tester = new RuleTester({ + languageOptions: { + parser: require("@typescript-eslint/parser"), + parserOptions: { + sourceType: "module", + ecmaVersion: 2015, + }, + }, +}); + +tester.run("profico/type-imports", typeImports, { + valid: validCases, + invalid: invalidCases.map((code, index) => ({ + code, + output: validCases[index], + errors: [ + { messageId: "mixedTypeValueImports" }, + { messageId: "mixedTypeValueImports" }, + ], + })), +}); diff --git a/src/tests/type-imports/valid-cases/base.valid-case.txt b/src/tests/type-imports/valid-cases/base.valid-case.txt new file mode 100644 index 0000000..e68d6d5 --- /dev/null +++ b/src/tests/type-imports/valid-cases/base.valid-case.txt @@ -0,0 +1,23 @@ +import AddNicknameDialogHeader from 'components/SvgIcons/AddNicknameDialogHeader'; +import colors from 'styles/themes/colors'; +import useBreakpoint from 'utils/hooks/useBreakpoint'; +import useToggleState from 'utils/hooks/useToggleState'; +import fetch from 'utils/static/fetchRelative'; +import { Grid, Button, Typography } from '@mui/material'; +import Form, { FormProps, FormSubmitHandler } from 'components/Forms/Form'; +import React, { useState } from 'react'; +import { PostNicknameData } from 'types/User'; + +import type * as ReactTypes from 'react'; +import type Lalalal from 'lalala/lalalal'; +import { type Kokos } from 'kokos/kokos'; +import { type Kokos } from 'kokos/trokos'; + +import ConfirmDialog from './ConfirmDialog'; +import NicknameInput from './NicknameInput'; +import SuccessDialog from './SuccessDialog'; +import { something } from './something'; + +import type NekiNoviType from './precooltyNekiNoviTypepe'; +import type PreCoolType from './precooltype'; +import { type barakokula, someValue } from './barakokula'; diff --git a/src/tests/type-imports/valid-cases/index.ts b/src/tests/type-imports/valid-cases/index.ts new file mode 100644 index 0000000..6890e1e --- /dev/null +++ b/src/tests/type-imports/valid-cases/index.ts @@ -0,0 +1,5 @@ +import path from "path"; + +import { readAllTxtFilesInDir } from "../../../utils/tests"; + +export default readAllTxtFilesInDir(path.join(__dirname, "."), ["index.ts"]); diff --git a/src/utils/imports.ts b/src/utils/imports.ts index 46f0d28..cfa1735 100644 --- a/src/utils/imports.ts +++ b/src/utils/imports.ts @@ -8,6 +8,8 @@ import { Node, Statement, } from "estree"; +import { TSESTree } from "@typescript-eslint/utils"; +import type { SourceCode } from "eslint"; type GroupedImportsResult = { start: number; @@ -238,3 +240,254 @@ export function findImportsByPackageName( return importedSet; } + +// Type imports utility functions (for type-imports rule) +export function isTypeImportDeclaration( + importNode: TSESTree.ImportDeclaration, + sourceCode: SourceCode, +): boolean { + const specifiers = importNode.specifiers.filter( + (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", + ); + + if (importNode.importKind === "type") { + return true; + } + + const hasTypeSpecifiers = specifiers.some(spec => spec.importKind === "type"); + + if (hasTypeSpecifiers) { + return true; + } + + // Check if it's a pure type import (starts with "import type") + const sourceText = sourceCode.getText(importNode); + const isPureTypeImport = /^\s*import\s+type\b/.test(sourceText); + + if (isPureTypeImport) { + return true; + } + + return false; +} + +export function isValueImportDeclaration( + importNode: TSESTree.ImportDeclaration, +): boolean { + const specifiers = importNode.specifiers.filter( + (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", + ); + + if (importNode.importKind === "value") { + return true; + } + + const hasValueSpecifiers = specifiers.some( + spec => spec.importKind !== "type", + ); + + if (hasValueSpecifiers) { + return true; + } + + // Default imports or namespace imports go with value imports + const hasDefaultOrNamespace = importNode.specifiers.some( + spec => + spec.type === "ImportDefaultSpecifier" || + spec.type === "ImportNamespaceSpecifier", + ); + + if (hasDefaultOrNamespace) { + return true; + } + + if (specifiers.length === 0) { + return true; + } + + return false; +} + +export function separateImportsByType( + imports: TSESTree.ImportDeclaration[], + sourceCode: SourceCode, +): { + valueImports: TSESTree.ImportDeclaration[]; + typeImports: TSESTree.ImportDeclaration[]; +} { + const valueImports: TSESTree.ImportDeclaration[] = []; + const typeImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(importNode => { + if (isTypeImportDeclaration(importNode, sourceCode)) { + typeImports.push(importNode); + } else if (isValueImportDeclaration(importNode)) { + valueImports.push(importNode); + } else { + // Default case: treat as value import + valueImports.push(importNode); + } + }); + + return { valueImports, typeImports }; +} + +export function areImportsInterleaved( + valueImports: TSESTree.ImportDeclaration[], + typeImports: TSESTree.ImportDeclaration[], +): boolean { + if (valueImports.length === 0 || typeImports.length === 0) { + return false; + } + + // Sort imports by their position in the source code + const allImports = [...valueImports, ...typeImports].sort( + (a, b) => a.range![0] - b.range![0], + ); + + // Optimize membership checks using a Set + const typeSet = new Set(typeImports); + + // Check if type and value imports are interleaved + let foundType = false; + for (const importNode of allImports) { + const isTypeImport = typeSet.has(importNode); + + if (isTypeImport) { + foundType = true; + } else if (foundType) { + // Found a value import after a type import - they're interleaved + return true; + } + } + + return false; +} + +// Sort each group alphabetically by source, then by original position for stability +const sortBySource = ( + a: TSESTree.ImportDeclaration, + b: TSESTree.ImportDeclaration, +) => { + const sourceA = a.source.value?.toString() || ""; + const sourceB = b.source.value?.toString() || ""; + const bySource = sourceA.localeCompare(sourceB); + if (bySource !== 0) return bySource; + return (a.range?.[0] ?? 0) - (b.range?.[0] ?? 0); +}; + +export function sortImportsByType( + imports: TSESTree.ImportDeclaration[], +): TSESTree.ImportDeclaration[] { + const namespaceImports: TSESTree.ImportDeclaration[] = []; + const defaultImports: TSESTree.ImportDeclaration[] = []; + const namedImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(imp => { + const hasNamespaceImport = imp.specifiers.some( + spec => spec.type === "ImportNamespaceSpecifier", + ); + const hasDefaultImport = imp.specifiers.some( + spec => spec.type === "ImportDefaultSpecifier", + ); + const hasNamedImports = imp.specifiers.some( + spec => spec.type === "ImportSpecifier", + ); + + if (hasNamespaceImport) { + namespaceImports.push(imp); + } else if (hasDefaultImport && !hasNamedImports) { + defaultImports.push(imp); + } else { + namedImports.push(imp); + } + }); + + return [ + ...namespaceImports.sort(sortBySource), + ...defaultImports.sort(sortBySource), + ...namedImports.sort(sortBySource), + ]; +} + +export function generateFixedImportsText( + valueImports: TSESTree.ImportDeclaration[], + typeImports: TSESTree.ImportDeclaration[], + sourceCode: SourceCode, +): string { + const sortedValueImports = sortImportsByType(valueImports); + const sortedTypeImports = sortImportsByType(typeImports); + + const valueImportsText = sortedValueImports + .map(imp => sourceCode.getText(imp)) + .join("\n"); + const typeImportsText = sortedTypeImports + .map(imp => sourceCode.getText(imp)) + .join("\n"); + + // Add single blank line between value and type imports + return valueImportsText + "\n\n" + typeImportsText; +} + +export function getImportRange( + imports: TSESTree.ImportDeclaration[], +): [number, number] { + const startIndex = Math.min(...imports.map(imp => imp.range![0])); + const endIndex = Math.max(...imports.map(imp => imp.range![1])); + return [startIndex, endIndex]; +} + +export function categorizeImports(imports: TSESTree.ImportDeclaration[]): { + absoluteImports: TSESTree.ImportDeclaration[]; + relativeImports: TSESTree.ImportDeclaration[]; +} { + const absoluteImports: TSESTree.ImportDeclaration[] = []; + const relativeImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(importNode => { + if (isRelativeImport(importNode)) { + relativeImports.push(importNode); + } else { + absoluteImports.push(importNode); + } + }); + + return { absoluteImports, relativeImports }; +} + +export function processImportGroup( + imports: TSESTree.ImportDeclaration[], + context: Rule.RuleContext, + needsTrailingNewline = false, +) { + if (imports.length === 0) return; + + const { valueImports, typeImports } = separateImportsByType( + imports, + context.sourceCode, + ); + + if (areImportsInterleaved(valueImports, typeImports)) { + const firstTypeImport = typeImports[0]; + context.report({ + node: firstTypeImport, + messageId: "mixedTypeValueImports", + fix: fixer => { + const allImports = [...valueImports, ...typeImports]; + const [startIndex, endIndex] = getImportRange(allImports); + const replacement = generateFixedImportsText( + valueImports, + typeImports, + context.sourceCode, + ); + + // Add trailing newline if this group is followed by other imports + const finalReplacement = needsTrailingNewline + ? replacement + "\n" + : replacement; + + return fixer.replaceTextRange([startIndex, endIndex], finalReplacement); + }, + }); + } +} diff --git a/src/utils/linter-config.ts b/src/utils/linter-config.ts index 47f0c1e..de98ac8 100644 --- a/src/utils/linter-config.ts +++ b/src/utils/linter-config.ts @@ -2,6 +2,7 @@ import { Linter, Rule } from "eslint"; import lodashImports from "../rules/lodash-imports"; import groupedImports from "../rules/grouped-imports"; +import typeImports from "../rules/type-imports"; import recommendedConfig from "../configs/recommended"; import nestConfig from "../configs/nest"; import nextConfig from "../configs/next"; @@ -12,6 +13,7 @@ import orderedControllerParams from "../rules/ordered-controller-params"; type RegularRulesNames = | "lodash-imports" | "grouped-imports" + | "type-imports" | "dto-decorators" | "ordered-controller-params"; type FlatRulesNames = `profico/${RegularRulesNames}`; @@ -29,6 +31,7 @@ export function getRules( return { "profico/lodash-imports": lodashImports, "profico/grouped-imports": groupedImports, + "profico/type-imports": typeImports, "profico/dto-decorators": dtoDecorators, "profico/ordered-controller-params": orderedControllerParams, }; @@ -37,6 +40,7 @@ export function getRules( return { "lodash-imports": lodashImports, "grouped-imports": groupedImports, + "type-imports": typeImports, "dto-decorators": dtoDecorators, "ordered-controller-params": orderedControllerParams, };