From a98cac3a0040e04f870e19712ffaa79c8fccdce4 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 14 Dec 2025 13:53:12 +0800 Subject: [PATCH] feat: add rules --- eslint-typegen.d.ts | 25 +- pnpm-workspace.yaml | 3 + src/index.ts | 6 + src/rules/consistent-type-specifier-style.ts | 271 +++++++++++++++++++ src/rules/import-dedupe.ts | 50 ++++ src/rules/newline-after-import.ts | 226 ++++++++++++++++ 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/rules/consistent-type-specifier-style.ts create mode 100644 src/rules/import-dedupe.ts create mode 100644 src/rules/newline-after-import.ts diff --git a/eslint-typegen.d.ts b/eslint-typegen.d.ts index 913dc47..0edba7a 100644 --- a/eslint-typegen.d.ts +++ b/eslint-typegen.d.ts @@ -9,11 +9,26 @@ declare module 'eslint' { } export interface RuleOptions { + /** + * Enforce or ban the use of inline type-only markers for named imports. + * @see https://github.com/sxzz/eslint-plugin-ii/blob/main/src/rules/consistent-type-specifier-style.test.ts + */ + 'import/consistent-type-specifier-style'?: Linter.RuleEntry /** * Ensure all imports appear before other statements. * @see https://github.com/sxzz/eslint-plugin-ii/blob/main/src/rules/first.test.ts */ 'import/first'?: Linter.RuleEntry + /** + * Fix duplication in imports + * @see https://github.com/sxzz/eslint-plugin-ii/blob/main/src/rules/import-dedupe.test.ts + */ + 'import/import-dedupe'?: Linter.RuleEntry<[]> + /** + * Enforce a newline after import statements. + * @see https://github.com/sxzz/eslint-plugin-ii/blob/main/src/rules/newline-after-import.test.ts + */ + 'import/newline-after-import'?: Linter.RuleEntry /** * Forbid default exports. * @see https://github.com/sxzz/eslint-plugin-ii/blob/main/src/rules/no-default-export.test.ts @@ -32,5 +47,13 @@ export interface RuleOptions { } /* ======= Declarations ======= */ +// ----- import/consistent-type-specifier-style ----- +type ImportConsistentTypeSpecifierStyle = []|[("prefer-top-level" | "prefer-inline")] // ----- import/first ----- -type ImportFirst = []|[("absolute-first" | "disable-absolute-first")] \ No newline at end of file +type ImportFirst = []|[("absolute-first" | "disable-absolute-first")] +// ----- import/newline-after-import ----- +type ImportNewlineAfterImport = []|[{ + count?: number + exactCount?: boolean + considerComments?: boolean +}] \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 443833b..c498b4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,8 @@ packages: [] +ignoredBuiltDependencies: + - esbuild + trustPolicy: no-downgrade trustPolicyExclude: - chokidar@4.0.3 diff --git a/src/index.ts b/src/index.ts index 80b7728..2aecae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ import { version } from '../package.json' +import consistentTypeSpecifierStyle from './rules/consistent-type-specifier-style' import first from './rules/first' +import importDedupe from './rules/import-dedupe' +import newlineAfterImport from './rules/newline-after-import' import noDefaultExport from './rules/no-default-export' import noMutableExports from './rules/no-mutable-exports' import noNamedDefault from './rules/no-named-default' @@ -12,6 +15,9 @@ export const plugin = { }, // @keep-sorted rules: { + 'consistent-type-specifier-style': consistentTypeSpecifierStyle, + 'import-dedupe': importDedupe, + 'newline-after-import': newlineAfterImport, 'no-default-export': noDefaultExport, 'no-mutable-exports': noMutableExports, 'no-named-default': noNamedDefault, diff --git a/src/rules/consistent-type-specifier-style.ts b/src/rules/consistent-type-specifier-style.ts new file mode 100644 index 0000000..d61b5ed --- /dev/null +++ b/src/rules/consistent-type-specifier-style.ts @@ -0,0 +1,271 @@ +import { createEslintRule, getValue } from '../utils' +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' + +function isComma(token: TSESTree.Token): token is TSESTree.PunctuatorToken { + return token.type === 'Punctuator' && token.value === ',' +} + +function removeSpecifiers( + fixes: TSESLint.RuleFix[], + fixer: TSESLint.RuleFixer, + sourceCode: Readonly, + specifiers: TSESTree.ImportSpecifier[], +) { + for (const specifier of specifiers) { + // remove the trailing comma + const token = sourceCode.getTokenAfter(specifier) + if (token && isComma(token)) { + fixes.push(fixer.remove(token)) + } + fixes.push(fixer.remove(specifier)) + } +} + +function getImportText( + node: TSESTree.ImportDeclaration, + sourceCode: Readonly, + specifiers: TSESTree.ImportSpecifier[], + kind: 'type' | 'typeof', +) { + const sourceString = sourceCode.getText(node.source) + if (specifiers.length === 0) { + return '' + } + + const names = specifiers.map((s) => { + const importedName = getValue(s.imported) + if (importedName === s.local.name) { + return importedName + } + return `${importedName} as ${s.local.name}` + }) + // insert a fresh top-level import + return `import ${kind} {${names.join(', ')}} from ${sourceString};` +} + +export type Options = [('prefer-inline' | 'prefer-top-level')?] + +type MessageId = 'inline' | 'topLevel' + +export default createEslintRule({ + name: 'consistent-type-specifier-style', + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce or ban the use of inline type-only markers for named imports.', + }, + fixable: 'code', + schema: [ + { + type: 'string', + enum: ['prefer-top-level', 'prefer-inline'], + default: 'prefer-top-level', + }, + ], + messages: { + inline: + 'Prefer using inline {{kind}} specifiers instead of a top-level {{kind}}-only import.', + topLevel: + 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.', + }, + }, + defaultOptions: [], + create(context) { + const { sourceCode } = context + + if (context.options[0] === 'prefer-inline') { + return { + ImportDeclaration(node) { + if (node.importKind === 'value' || node.importKind == null) { + // top-level value / unknown is valid + return + } + + if ( + // no specifiers (import type {} from '') have no specifiers to mark as inline + 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 + } + + context.report({ + node, + messageId: 'inline', + data: { + kind: node.importKind, + }, + fix(fixer) { + const kindToken = sourceCode.getFirstToken(node, { skip: 1 }) + + return [ + kindToken ? fixer.remove(kindToken) : [], + node.specifiers.map((specifier) => + fixer.insertTextBefore(specifier, `${node.importKind} `), + ), + ].flat() + }, + }) + }, + } + } + + // prefer-top-level + return { + ImportDeclaration(node) { + if ( + // already top-level is valid + node.importKind === 'type' || + // @ts-expect-error - flow type + node.importKind === 'typeof' || + // 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 typeofSpecifiers: TSESTree.ImportSpecifier[] = [] + const valueSpecifiers: TSESTree.ImportSpecifier[] = [] + + let defaultSpecifier: TSESTree.ImportDefaultSpecifier | null = null + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') { + defaultSpecifier = specifier + continue + } + + if (!('importKind' in specifier)) { + continue + } + + if (specifier.importKind === 'type') { + typeSpecifiers.push(specifier) + } else if ( + // @ts-expect-error - flow type + specifier.importKind === 'typeof' + ) { + typeofSpecifiers.push(specifier) + } else if ( + specifier.importKind === 'value' || + specifier.importKind == null + ) { + valueSpecifiers.push(specifier) + } + } + + const typeImport = getImportText( + node, + sourceCode, + typeSpecifiers, + 'type', + ) + const typeofImport = getImportText( + node, + sourceCode, + typeofSpecifiers, + 'typeof', + ) + const newImports = `${typeImport}\n${typeofImport}`.trim() + + if ( + typeSpecifiers.length + typeofSpecifiers.length === + node.specifiers.length + ) { + // all specifiers have inline specifiers - so we replace the entire import + const kind = [ + typeSpecifiers.length > 0 ? ('type' as const) : [], + typeofSpecifiers.length > 0 ? ('typeof' as const) : [], + ].flat() + + context.report({ + node, + messageId: 'topLevel', + data: { + kind: kind.join('/'), + }, + fix(fixer) { + return fixer.replaceText(node, newImports) + }, + }) + } else { + // remove specific specifiers and insert new imports for them + for (const specifier of [...typeSpecifiers, ...typeofSpecifiers]) { + context.report({ + node: specifier, + messageId: 'topLevel', + data: { + kind: specifier.importKind, + }, + fix(fixer) { + const fixes: TSESLint.RuleFix[] = [] + + // 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, sourceCode, typeSpecifiers) + removeSpecifiers(fixes, fixer, sourceCode, typeofSpecifiers) + + // 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'; + const maybeComma = sourceCode.getTokenAfter( + valueSpecifiers.at(-1)!, + )! + if (isComma(maybeComma)) { + fixes.push(fixer.remove(maybeComma)) + } + } 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 = sourceCode.getTokenAfter( + defaultSpecifier, + isComma, + ) + const closingBrace = sourceCode.getTokenAfter( + node.specifiers.at(-1)!, + (token) => + token.type === 'Punctuator' && token.value === '}', + ) + fixes.push( + fixer.removeRange([ + comma!.range[0], + closingBrace!.range[1], + ]), + ) + } + + return [ + ...fixes, + // insert the new imports after the old declaration + fixer.insertTextAfter(node, `\n${newImports}`), + ] + }, + }) + } + } + }, + } + }, +}) diff --git a/src/rules/import-dedupe.ts b/src/rules/import-dedupe.ts new file mode 100644 index 0000000..2b1f016 --- /dev/null +++ b/src/rules/import-dedupe.ts @@ -0,0 +1,50 @@ +import { createEslintRule } from '../utils' + +export const RULE_NAME = 'import-dedupe' +export type MessageIds = 'importDedupe' +export type Options = [] + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Fix duplication in imports', + }, + fixable: 'code', + schema: [], + messages: { + importDedupe: 'Expect no duplication in imports', + }, + }, + defaultOptions: [], + create: (context) => { + return { + ImportDeclaration(node) { + if (node.specifiers.length <= 1) return + + const names = new Set() + node.specifiers.forEach((n) => { + const id = n.local.name + if (names.has(id)) { + context.report({ + node, + loc: { + start: n.loc.end, + end: n.loc.start, + }, + messageId: 'importDedupe', + fix(fixer) { + const s = n.range[0] + let e = n.range[1] + if (context.getSourceCode().text[e] === ',') e += 1 + return fixer.removeRange([s, e]) + }, + }) + } + names.add(id) + }) + }, + } + }, +}) diff --git a/src/rules/newline-after-import.ts b/src/rules/newline-after-import.ts new file mode 100644 index 0000000..e79e54e --- /dev/null +++ b/src/rules/newline-after-import.ts @@ -0,0 +1,226 @@ +/** Rule to enforce new line after import not followed by another import. */ + +import { createEslintRule } from '../utils' +import type { TSESTree } from '@typescript-eslint/utils' + +function getLineDifference( + node: TSESTree.Node, + nextNode: TSESTree.Comment | TSESTree.Node, +) { + return nextNode.loc.start.line - node.loc.end.line +} + +function isClassWithDecorator( + node: TSESTree.Node, +): node is TSESTree.ClassDeclaration & { decorators: TSESTree.Decorator[] } { + return node.type === 'ClassDeclaration' && !!node.decorators?.length +} + +function isExportDefaultClass( + node: TSESTree.Node, +): node is TSESTree.ExportDefaultDeclaration { + return ( + node.type === 'ExportDefaultDeclaration' && + node.declaration.type === 'ClassDeclaration' + ) +} + +function isExportNameClass( + node: TSESTree.Node, +): node is TSESTree.ExportNamedDeclaration & { + declaration: TSESTree.ClassDeclaration +} { + return ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'ClassDeclaration' + ) +} + +export type Options = [ + { + count?: number + exactCount?: boolean + considerComments?: boolean + }?, +] + +export type MessageId = 'newline' + +export default createEslintRule({ + name: 'newline-after-import', + meta: { + type: 'layout', + docs: { + description: 'Enforce a newline after import statements.', + }, + fixable: 'whitespace', + schema: [ + { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 1, + }, + exactCount: { type: 'boolean' }, + considerComments: { type: 'boolean' }, + }, + additionalProperties: false, + }, + ], + messages: { + newline: + 'Expected {{count}} empty line{{lineSuffix}} after {{type}} statement not followed by another {{type}}.', + }, + }, + defaultOptions: [], + create(context) { + const options = { + count: 1, + exactCount: false, + considerComments: false, + ...context.options[0], + } + + function checkForNewLine( + node: TSESTree.Statement, + nextNode: TSESTree.Node, + type: 'import' | 'require', + ) { + if (isExportDefaultClass(nextNode) || isExportNameClass(nextNode)) { + const classNode = nextNode.declaration + + if (isClassWithDecorator(classNode)) { + nextNode = classNode.decorators[0] + } + } else if (isClassWithDecorator(nextNode)) { + nextNode = nextNode.decorators[0] + } + + const lineDifference = getLineDifference(node, nextNode) + const EXPECTED_LINE_DIFFERENCE = options.count + 1 + + if ( + lineDifference < EXPECTED_LINE_DIFFERENCE || + (options.exactCount && lineDifference !== EXPECTED_LINE_DIFFERENCE) + ) { + let column = node.loc.start.column + + if (node.loc.start.line !== node.loc.end.line) { + column = 0 + } + + context.report({ + loc: { + line: node.loc.end.line, + column, + }, + messageId: 'newline', + data: { + count: options.count, + lineSuffix: options.count > 1 ? 's' : '', + type, + }, + fix: + options.exactCount && EXPECTED_LINE_DIFFERENCE < lineDifference + ? undefined + : (fixer) => + fixer.insertTextAfter( + node, + '\n'.repeat(EXPECTED_LINE_DIFFERENCE - lineDifference), + ), + }) + } + } + + function commentAfterImport( + node: TSESTree.Node, + nextComment: TSESTree.Comment, + type: 'import' | 'require', + ) { + const lineDifference = getLineDifference(node, nextComment) + const EXPECTED_LINE_DIFFERENCE = options.count + 1 + + if (lineDifference < EXPECTED_LINE_DIFFERENCE) { + let column = node.loc.start.column + + if (node.loc.start.line !== node.loc.end.line) { + column = 0 + } + + context.report({ + loc: { + line: node.loc.end.line, + column, + }, + messageId: 'newline', + data: { + count: options.count, + lineSuffix: options.count > 1 ? 's' : '', + type, + }, + fix: + options.exactCount && EXPECTED_LINE_DIFFERENCE < lineDifference + ? undefined + : (fixer) => + fixer.insertTextAfter( + node, + '\n'.repeat(EXPECTED_LINE_DIFFERENCE - lineDifference), + ), + }) + } + } + + function checkImport( + node: TSESTree.ImportDeclaration | TSESTree.TSImportEqualsDeclaration, + ) { + const { parent } = node + + if (!parent || !('body' in parent) || !parent.body) { + return + } + + const root = parent as TSESTree.Program + + const nodePosition = root.body.indexOf(node) + const nextNode = root.body[nodePosition + 1] + const endLine = node.loc.end.line + + let nextComment: TSESTree.Comment | undefined + + if (root.comments !== undefined && options.considerComments) { + nextComment = root.comments.find( + (o) => + o.loc.start.line >= endLine && + o.loc.start.line <= endLine + options.count + 1, + ) + } + + // skip "export import"s + if ( + node.type === 'TSImportEqualsDeclaration' && + // @ts-expect-error - legacy parser type + node.isExport + ) { + return + } + + if (nextComment) { + commentAfterImport(node, nextComment, 'import') + } else if ( + nextNode && + nextNode.type !== 'ImportDeclaration' && + (nextNode.type !== 'TSImportEqualsDeclaration' || + // @ts-expect-error - legacy parser type + nextNode.isExport) + ) { + checkForNewLine(node, nextNode, 'import') + } + } + + return { + ImportDeclaration: checkImport, + TSImportEqualsDeclaration: checkImport, + } + }, +}) -- 2.51.2