From dc003c02ead5463ccbb81e4413607e0dfcaa8dde Mon Sep 17 00:00:00 2001 From: Jeff Date: Thu, 29 Aug 2024 14:56:50 -0600 Subject: [PATCH] Rewrite sorting rule and utilities --- src/rules/new-order.ts | 449 ++++++++++++++++++++++++++++++ src/utils/compare.ts | 127 +++++++++ src/utils/get-comment.ts | 44 +++ src/utils/get-group-number.ts | 18 ++ src/utils/get-lines-between.ts | 13 + src/utils/get-node-range.ts | 48 ++++ src/utils/is-partition-comment.ts | 18 ++ src/utils/pairwise.ts | 12 + src/utils/sort-nodes.ts | 6 + src/utils/types.ts | 28 ++ src/utils/use-groups.ts | 18 ++ tests/rules/new-order.test.ts | 156 +++++++++++ vite.config.mjs | 2 +- 13 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 src/rules/new-order.ts create mode 100644 src/utils/compare.ts create mode 100644 src/utils/get-comment.ts create mode 100644 src/utils/get-group-number.ts create mode 100644 src/utils/get-lines-between.ts create mode 100644 src/utils/get-node-range.ts create mode 100644 src/utils/is-partition-comment.ts create mode 100644 src/utils/pairwise.ts create mode 100644 src/utils/sort-nodes.ts create mode 100644 src/utils/types.ts create mode 100644 src/utils/use-groups.ts create mode 100644 tests/rules/new-order.test.ts diff --git a/src/rules/new-order.ts b/src/rules/new-order.ts new file mode 100644 index 0000000..b54bb97 --- /dev/null +++ b/src/rules/new-order.ts @@ -0,0 +1,449 @@ +import { builtinModules } from 'node:module' + +import { ESLintUtils, type TSESTree, type TSESLint, AST_NODE_TYPES } from '@typescript-eslint/utils' + +import { compare } from '../utils/compare.js' +import { getCommentBefore } from '../utils/get-comment.js' +import { getGroupNumber } from '../utils/get-group-number.js' +import { getLinesBetween } from '../utils/get-lines-between.js' +import { getNodeRange } from '../utils/get-node-range.js' +import { pairwise } from '../utils/pairwise.js' +import { sortNodes } from '../utils/sort-nodes.js' +import type { Options, SortingNode } from '../utils/types' +import { useGroups } from '../utils/use-groups.js' + +// eslint-disable-next-line new-cap +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/stormwarning/eslint-plugin-import-sorting/blob/main/docs/rules/${name}.md`, +) + +export default createRule({ + name: 'order', + meta: { + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Enforce a convention in the order of `import` statements.', + }, + messages: { + 'needs-newline': + 'There should be at least one empty line between {{left}} and {{right}}', + 'extra-newline': 'There should be no empty line between {{left}} and {{right}}', + // 'extra-newline-in-group': 'There should be no empty line within import group', + 'out-of-order': '{{right}} should occur before {{left}}', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + let { settings, sourceCode } = context + let options: Options = { + groups: [ + 'unassigned', + 'builtin', + 'framework', + 'external', + 'internal', + 'local', + 'style', + 'object', + 'unknown', + ], + ignoreCase: true, + newlinesBetween: 'always', + order: 'asc', + type: 'natural', + } + let nodes: SortingNode[] = [] + + function registerNode( + node: + | TSESTree.TSImportEqualsDeclaration + | TSESTree.VariableDeclaration + | TSESTree.ImportDeclaration, + ) { + let name: string + + if (node.type === AST_NODE_TYPES.ImportDeclaration) { + name = node.source.value + } else if (node.type === AST_NODE_TYPES.TSImportEqualsDeclaration) { + name = + node.moduleReference.type === AST_NODE_TYPES.TSExternalModuleReference + ? // @ts-expect-error -- `value` is not in the type definition. + `${node.moduleReference.expression.value}` + : sourceCode.text.slice(...node.moduleReference.range) + } else { + let decl = node.declarations[0].init as TSESTree.CallExpression + let declValue = (decl.arguments[0] as TSESTree.Literal).value + name = declValue!.toString() + } + + nodes.push({ + group: computeGroup(node, settings, sourceCode), + node, + name, + }) + } + + return { + TSImportEqualsDeclaration: registerNode, + ImportDeclaration: registerNode, + VariableDeclaration(node) { + if ( + node.declarations[0].init && + node.declarations[0].init.type === AST_NODE_TYPES.CallExpression && + node.declarations[0].init.callee.type === AST_NODE_TYPES.Identifier && + node.declarations[0].init.callee.name === 'require' && + node.declarations[0].init.arguments[0]?.type === AST_NODE_TYPES.Literal + ) { + registerNode(node) + } + }, + 'Program:exit'() { + let hasContentBetweenNodes = (left: SortingNode, right: SortingNode): boolean => + sourceCode.getTokensBetween( + left.node, + getCommentBefore(right.node, sourceCode) ?? right.node, + { + includeComments: true, + }, + ).length > 0 + + let fix = ( + fixer: TSESLint.RuleFixer, + nodesToFix: SortingNode[], + ): TSESLint.RuleFix[] => { + let fixes: TSESLint.RuleFix[] = [] + + let grouped: Record = {} + + for (let node of nodesToFix) { + let groupNumber = getGroupNumber(options.groups, node) + + grouped[groupNumber] = + groupNumber in grouped + ? sortNodes([...grouped[groupNumber], node], options) + : [node] + } + + let formatted = Object.keys(grouped) + .sort((a, b) => Number(a) - Number(b)) + .reduce( + (accumulator: SortingNode[], group: string) => [ + ...accumulator, + ...grouped[group], + ], + [], + ) + + for (let max = formatted.length, index = 0; index < max; index++) { + let node = formatted.at(index)! + + fixes.push( + fixer.replaceTextRange( + getNodeRange(nodesToFix.at(index)!.node, sourceCode), + sourceCode.text.slice(...getNodeRange(node.node, sourceCode)), + ), + ) + + if (options.newlinesBetween !== 'ignore') { + let nextNode = formatted.at(index + 1) + + if (nextNode) { + let linesBetweenImports = getLinesBetween( + sourceCode, + nodesToFix.at(index)!, + nodesToFix.at(index + 1)!, + ) + + if ( + (options.newlinesBetween === 'always' && + getGroupNumber(options.groups, node) === + getGroupNumber(options.groups, nextNode) && + linesBetweenImports !== 0) || + (options.newlinesBetween === 'never' && linesBetweenImports > 0) + ) { + fixes.push( + fixer.removeRange([ + getNodeRange(nodesToFix.at(index)!.node, sourceCode).at( + 1, + )!, + getNodeRange( + nodesToFix.at(index + 1)!.node, + sourceCode, + ).at(0)! - 1, + ]), + ) + } + + if ( + options.newlinesBetween === 'always' && + getGroupNumber(options.groups, node) !== + getGroupNumber(options.groups, nextNode) && + linesBetweenImports > 1 + ) { + fixes.push( + fixer.replaceTextRange( + [ + getNodeRange( + nodesToFix.at(index)!.node, + sourceCode, + ).at(1)!, + getNodeRange( + nodesToFix.at(index + 1)!.node, + sourceCode, + ).at(0)! - 1, + ], + '\n', + ), + ) + } + + if ( + options.newlinesBetween === 'always' && + getGroupNumber(options.groups, node) !== + getGroupNumber(options.groups, nextNode) && + linesBetweenImports === 0 + ) { + fixes.push( + fixer.insertTextAfterRange( + getNodeRange(nodesToFix.at(index)!.node, sourceCode), + '\n', + ), + ) + } + } + } + } + + return fixes + } + + let splittedNodes: SortingNode[][] = [[]] + + for (let node of nodes) { + let lastNode = splittedNodes.at(-1)?.at(-1) + + if (lastNode && hasContentBetweenNodes(lastNode, node)) { + splittedNodes.push([node]) + } else { + splittedNodes.at(-1)!.push(node) + } + } + + for (let nodeList of splittedNodes) { + pairwise(nodeList, (left, right) => { + let leftNumber = getGroupNumber(options.groups, left) + let rightNumber = getGroupNumber(options.groups, right) + + let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right) + + if ( + !( + isSideEffectImport(left.node, sourceCode) && + isSideEffectImport(right.node, sourceCode) + ) && + !hasContentBetweenNodes(left, right) && + (leftNumber > rightNumber || + (leftNumber === rightNumber && compare(left, right, options) > 0)) + ) { + context.report({ + messageId: 'out-of-order', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: (fixer) => fix(fixer, nodeList), + }) + } + + if (options.newlinesBetween === 'never' && numberOfEmptyLinesBetween > 0) { + context.report({ + messageId: 'extra-newline', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: (fixer) => fix(fixer, nodeList), + }) + } + + if (options.newlinesBetween === 'always') { + if (leftNumber < rightNumber && numberOfEmptyLinesBetween === 0) { + context.report({ + messageId: 'needs-newline', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: (fixer) => fix(fixer, nodeList), + }) + } else if ( + numberOfEmptyLinesBetween > 1 || + (leftNumber === rightNumber && numberOfEmptyLinesBetween > 0) + ) { + context.report({ + messageId: 'extra-newline', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: (fixer) => fix(fixer, nodeList), + }) + } + } + }) + } + }, + } + }, +}) + +function computeGroup( + node: + | TSESTree.TSImportEqualsDeclaration + | TSESTree.VariableDeclaration + | TSESTree.ImportDeclaration, + settings: TSESLint.SharedConfigurationSettings, + sourceCode: TSESLint.SourceCode, +) { + let groups = [ + 'unassigned', + 'builtin', + 'framework', + 'external', + 'internal', + 'local', + 'style', + 'object', + 'unknown', + ] + let { getGroup, defineGroup } = useGroups(groups) + + if ( + node.type === AST_NODE_TYPES.ImportDeclaration || + node.type === AST_NODE_TYPES.VariableDeclaration + ) { + let value: string + let frameworkPatterns = validateSetting(settings, 'import-sorting/framework-patterns') + let internalPatterns = validateSetting(settings, 'import-sorting/internal-patterns') + + if (node.type === AST_NODE_TYPES.ImportDeclaration) { + value = node.source.value + } else { + let decl = node.declarations[0].init as TSESTree.CallExpression + let declValue = (decl.arguments[0] as TSESTree.Literal).value + value = declValue!.toString() + } + + if (isSideEffectImport(node, sourceCode)) defineGroup('unassigned') + if (isBuiltin(value)) defineGroup('builtin') + if (isStyle(value)) defineGroup('style') + if (isFramework(value, frameworkPatterns)) defineGroup('framework') + if (isInternal(value, internalPatterns)) defineGroup('internal') + if (isExternal(value)) defineGroup('external') + if (isLocal(value)) defineGroup('local') + } + + return getGroup() +} + +function isBuiltin(name: string) { + let bunModules = [ + 'bun', + 'bun:ffi', + 'bun:jsc', + 'bun:sqlite', + 'bun:test', + 'bun:wrap', + 'detect-libc', + 'undici', + 'ws', + ] + let builtinPrefixOnlyModules = ['sea', 'sqlite', 'test'] + + return ( + builtinModules.includes(name.startsWith('node:') ? name.split('node:')[1] : name) || + builtinPrefixOnlyModules.some((module) => `node:${module}` === name) || + bunModules.includes(name) + ) +} + +function isSideEffectImport(node: TSESTree.Node, sourceCode: TSESLint.SourceCode) { + return ( + node.type === AST_NODE_TYPES.ImportDeclaration && + node.specifiers.length === 0 && + /* Avoid matching on named imports without specifiers. */ + !/}\s*from\s+/.test(sourceCode.getText(node)) + ) +} + +function isStyle(name: string) { + return ['.less', '.scss', '.sass', '.styl', '.pcss', '.css', '.sss'].some((extension) => + name.endsWith(extension), + ) +} + +function isFramework(name: string, pattern: string | string[]) { + if (Array.isArray(pattern)) { + return pattern.some((item) => new RegExp(item).test(name)) + } + + return new RegExp(pattern).test(name) +} + +function isInternal(name: string, pattern: string | string[]) { + if (Array.isArray(pattern)) { + return pattern.some((item) => new RegExp(item).test(name)) + } + + return new RegExp(pattern).test(name) +} + +const moduleRegExp = /^\w/ +function isModule(name: string) { + return moduleRegExp.test(name) +} + +const scopedRegExp = /^@[^/]+\/?[^/]+/ +function isScoped(name: string) { + return scopedRegExp.test(name) +} + +function isExternal(name: string) { + return isModule(name) || isScoped(name) +} + +function isLocal(name: string) { + return name.startsWith('.') +} + +function assertString(value: unknown, setting: string) { + if (typeof value !== 'string') + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Invalid value for '${setting}': '${value}'.\nExpected 'string', got '${typeof value}' instead.`, + ) +} + +function validateSetting(settings: TSESLint.SharedConfigurationSettings, setting: string) { + let value = settings[setting] as string | string[] + + if (!value) return '' + if (Array.isArray(value)) { + for (let item of value) { + assertString(item, setting) + } + + return value + } + + assertString(value, setting) + + return value +} diff --git a/src/utils/compare.ts b/src/utils/compare.ts new file mode 100644 index 0000000..eb29637 --- /dev/null +++ b/src/utils/compare.ts @@ -0,0 +1,127 @@ +import type { SortingNode } from './types' + +interface BaseCompareOptions { + /** + * Custom function to get the value of the node. By default, returns the node's name. + */ + nodeValueGetter?: (node: SortingNode) => string + order: 'desc' | 'asc' +} + +interface AlphabeticalCompareOptions extends BaseCompareOptions { + type: 'alphabetical' + ignoreCase?: boolean +} + +interface LineLengthCompareOptions extends BaseCompareOptions { + maxLineLength?: number + type: 'line-length' +} + +interface NaturalCompareOptions extends BaseCompareOptions { + ignoreCase?: boolean + type: 'natural' +} + +export type CompareOptions = + | AlphabeticalCompareOptions + // | LineLengthCompareOptions + | NaturalCompareOptions + +export function compare(a: SortingNode, b: SortingNode, options: CompareOptions): number { + /** Don't sort unsassigned imports. */ + if (a.group === 'unassigned' || b.group === 'unassigned') return 0 + + if (b.dependencies?.includes(a.name)) { + return -1 + } + + if (a.dependencies?.includes(b.name)) { + return 1 + } + + let orderCoefficient = options.order === 'asc' ? 1 : -1 + let sortingFunction: (a: SortingNode, b: SortingNode) => number + + let formatString = + options.type === 'line-length' || !options.ignoreCase + ? (string: string) => string + : (string: string) => string.toLowerCase() + + let nodeValueGetter = options.nodeValueGetter ?? ((node: SortingNode) => node.name) + + if (options.type === 'alphabetical') { + sortingFunction = (aNode, bNode) => + formatString(nodeValueGetter(aNode)).localeCompare(formatString(nodeValueGetter(bNode))) + } else if (options.type === 'natural') { + let prepareNumeric = (string: string) => { + let formattedNumberPattern = /^[+-]?[\d ,_]+(\.[\d ,_]+)?$/ + if (formattedNumberPattern.test(string)) { + return string.replaceAll(/[ ,_]/g, '') + } + + return string + } + + sortingFunction = (aNode, bNode) => { + let aImport = stripProtocol(nodeValueGetter(aNode)) + let bImport = stripProtocol(nodeValueGetter(bNode)) + + if (aImport.startsWith('.') && bImport.startsWith('.')) { + return compareDotSegments(aImport, bImport) + } + + return compareString(aImport, bImport) + } + // + // naturalCompare( + // prepareNumeric(formatString(nodeValueGetter(aNode))), + // prepareNumeric(formatString(nodeValueGetter(bNode))), + // ) + } else { + sortingFunction = (aNode, bNode) => { + let aSize = aNode.size + let bSize = bNode.size + + let { maxLineLength } = options + + if (maxLineLength) { + let isTooLong = (size: number, node: SortingNode) => + size > maxLineLength && node.hasMultipleImportDeclarations + + if (isTooLong(aSize, aNode)) { + aSize = nodeValueGetter(aNode).length + 10 + } + + if (isTooLong(bSize, bNode)) { + bSize = nodeValueGetter(bNode).length + 10 + } + } + + return aSize - bSize + } + } + + return orderCoefficient * sortingFunction(a, b) +} + +function stripProtocol(name: string) { + return name.replace(/^(node|bun):/, '') +} + +function compareString(first: string, second: string) { + return first.localeCompare(second, 'en', { numeric: true }) +} + +function compareDotSegments(first: string, second: string) { + let regex = /\.+(?=\/)/g + + let firstCount = (first.match(regex) ?? []).join('').length + let secondCount = (second.match(regex) ?? []).join('').length + + if (secondCount < firstCount) return -1 + if (firstCount < secondCount) return 1 + + // If segment length is the same, compare the path alphabetically. + return compareString(first, second) +} diff --git a/src/utils/get-comment.ts b/src/utils/get-comment.ts new file mode 100644 index 0000000..bd2c898 --- /dev/null +++ b/src/utils/get-comment.ts @@ -0,0 +1,44 @@ +import { AST_TOKEN_TYPES, type TSESLint, type TSESTree } from '@typescript-eslint/utils' + +export function getCommentBefore( + node: TSESTree.Node, + source: TSESLint.SourceCode, +): TSESTree.Comment | undefined { + let [tokenBefore, tokenOrCommentBefore] = source.getTokensBefore(node, { + filter: ({ value, type }) => + !(type === AST_TOKEN_TYPES.Punctuator && [',', ';'].includes(value)), + includeComments: true, + count: 2, + }) as Array + + if ( + (tokenOrCommentBefore?.type === AST_TOKEN_TYPES.Block || + tokenOrCommentBefore?.type === AST_TOKEN_TYPES.Line) && + node.loc.start.line - tokenOrCommentBefore.loc.end.line <= 1 && + tokenBefore?.loc.end.line !== tokenOrCommentBefore.loc.start.line + ) { + return tokenOrCommentBefore + } + + return undefined +} + +export function getCommentAfter( + node: TSESTree.Node, + source: TSESLint.SourceCode, +): TSESTree.Comment | undefined { + let token = source.getTokenAfter(node, { + filter: ({ value, type }) => + !(type === AST_TOKEN_TYPES.Punctuator && [',', ';'].includes(value)), + includeComments: true, + }) + + if ( + (token?.type === AST_TOKEN_TYPES.Block || token?.type === AST_TOKEN_TYPES.Line) && + node.loc.end.line === token.loc.end.line + ) { + return token + } + + return undefined +} diff --git a/src/utils/get-group-number.ts b/src/utils/get-group-number.ts new file mode 100644 index 0000000..c4d4463 --- /dev/null +++ b/src/utils/get-group-number.ts @@ -0,0 +1,18 @@ +import type { Group, SortingNode } from './types.js' + +export function getGroupNumber(groups: Group[], node: SortingNode): number { + for (let max = groups.length, index = 0; index < max; index++) { + let currentGroup = groups[index] + + if ( + node.group === currentGroup || + (Array.isArray(currentGroup) && + typeof node.group === 'string' && + currentGroup.includes(node.group)) + ) { + return index + } + } + + return groups.length +} diff --git a/src/utils/get-lines-between.ts b/src/utils/get-lines-between.ts new file mode 100644 index 0000000..e298455 --- /dev/null +++ b/src/utils/get-lines-between.ts @@ -0,0 +1,13 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import type { SortingNode } from './types.js' + +export function getLinesBetween( + source: TSESLint.SourceCode, + left: SortingNode, + right: SortingNode, +) { + let linesBetween = source.lines.slice(left.node.loc.end.line, right.node.loc.start.line - 1) + + return linesBetween.filter((line) => line.trim().length === 0).length +} diff --git a/src/utils/get-node-range.ts b/src/utils/get-node-range.ts new file mode 100644 index 0000000..bd5a227 --- /dev/null +++ b/src/utils/get-node-range.ts @@ -0,0 +1,48 @@ +import { ASTUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils' + +import { getCommentBefore } from './get-comment.js' +import { isPartitionComment } from './is-partition-comment.js' + +export function getNodeRange( + node: TSESTree.Node, + sourceCode: TSESLint.SourceCode, + additionalOptions?: { + partitionComment?: string[] | boolean | string + }, +): TSESTree.Range { + let start = node.range.at(0)! + let end = node.range.at(1)! + + let raw = sourceCode.text.slice(start, end) + + if (ASTUtils.isParenthesized(node, sourceCode)) { + let bodyOpeningParen = sourceCode.getTokenBefore(node, ASTUtils.isOpeningParenToken)! + + let bodyClosingParen = sourceCode.getTokenAfter(node, ASTUtils.isClosingParenToken)! + + start = bodyOpeningParen.range.at(0)! + end = bodyClosingParen.range.at(1)! + } + + let comment = getCommentBefore(node, sourceCode) + + if (raw.endsWith(';') || raw.endsWith(',')) { + let tokensAfter = sourceCode.getTokensAfter(node, { + includeComments: true, + count: 2, + }) + + if (node.loc.start.line === tokensAfter.at(1)?.loc.start.line) { + end -= 1 + } + } + + if ( + comment && + !isPartitionComment(additionalOptions?.partitionComment ?? false, comment.value) + ) { + start = comment.range.at(0)! + } + + return [start, end] +} diff --git a/src/utils/is-partition-comment.ts b/src/utils/is-partition-comment.ts new file mode 100644 index 0000000..bf196f4 --- /dev/null +++ b/src/utils/is-partition-comment.ts @@ -0,0 +1,18 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { minimatch } from 'minimatch' + +export const isPartitionComment = ( + partitionComment: string[] | boolean | string, + comment: string, +) => + (Array.isArray(partitionComment) && + partitionComment.some((pattern) => + minimatch(comment.trim(), pattern, { + nocomment: true, + }), + )) ?? + (typeof partitionComment === 'string' && + minimatch(comment.trim(), partitionComment, { + nocomment: true, + })) ?? + partitionComment === true diff --git a/src/utils/pairwise.ts b/src/utils/pairwise.ts new file mode 100644 index 0000000..fc3f510 --- /dev/null +++ b/src/utils/pairwise.ts @@ -0,0 +1,12 @@ +export function pairwise(nodes: T[], callback: (left: T, right: T, iteration: number) => void) { + if (nodes.length > 1) { + for (let index = 1; index < nodes.length; index++) { + let left = nodes.at(index - 1) + let right = nodes.at(index) + + if (left && right) { + callback(left, right, index - 1) + } + } + } +} diff --git a/src/utils/sort-nodes.ts b/src/utils/sort-nodes.ts new file mode 100644 index 0000000..1839b8b --- /dev/null +++ b/src/utils/sort-nodes.ts @@ -0,0 +1,6 @@ +import { compare, type CompareOptions } from './compare.js' +import type { SortingNode } from './types' + +export function sortNodes(nodes: T[], options: CompareOptions): T[] { + return [...nodes].sort((a, b) => compare(a, b, options)) +} diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..7ca9ff1 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,28 @@ +import type { TSESTree } from '@typescript-eslint/utils' + +export interface SortingNode { + name: string + node: Node + dependencies?: string[] + group?: string + hasMultipleImportDeclarations?: boolean +} + +export type Group = + | 'unassigned' + | 'builtin' + | 'framework' + | 'external' + | 'internal' + | 'local' + | 'style' + | 'object' + | 'unknown' + +export interface Options { + groups: Group[] + ignoreCase: boolean + newlinesBetween: 'ignore' | 'always' | 'never' + order: 'asc' | 'desc' + type: 'alphabetical' | 'natural' +} diff --git a/src/utils/use-groups.ts b/src/utils/use-groups.ts new file mode 100644 index 0000000..395f346 --- /dev/null +++ b/src/utils/use-groups.ts @@ -0,0 +1,18 @@ +import type { Group } from './types' + +export function useGroups(groups: string[]) { + let group: undefined | string + // For lookup performance + let groupsSet = new Set(groups.flat()) + + let defineGroup = (value: Group, override = false) => { + if ((!group || override) && groupsSet.has(value)) { + group = value + } + } + + return { + getGroup: () => group ?? 'unknown', + defineGroup, + } +} diff --git a/tests/rules/new-order.test.ts b/tests/rules/new-order.test.ts new file mode 100644 index 0000000..fc0534c --- /dev/null +++ b/tests/rules/new-order.test.ts @@ -0,0 +1,156 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import dedent from 'dedent' +import { afterAll, describe, it } from 'vitest' + +import orderRule from '../../src/rules/new-order.js' + +const RULE_NAME = 'new-order' + +describe(RULE_NAME, () => { + RuleTester.describeSkip = describe.skip + RuleTester.afterAll = afterAll + RuleTester.describe = describe + RuleTester.itOnly = it.only + RuleTester.itSkip = it.skip + RuleTester.it = it + + let ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, + // Use this after upgrade to eslint@9. + // languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { + 'import-sorting/framework-patterns': [/^react(\/|-dom|-router|$)/.source, 'prop-types'], + 'import-sorting/internal-patterns': /^~/.source, + }, + }) + + describe('sorting by natural order', () => { + ruleTester.run('sorts imports', orderRule, { + valid: [ + { + name: 'without errors', + code: dedent` + import { a1, a2 } from 'a' + import { b1 } from 'b' + `, + }, + ], + invalid: [ + { + name: 'fixes import order', + code: dedent` + import { b1 } from 'b' + import { a1, a2 } from 'a' + `, + output: dedent` + import { a1, a2 } from 'a' + import { b1 } from 'b' + `, + errors: [ + { + messageId: 'out-of-order', + data: { + left: 'b', + right: 'a', + }, + }, + ], + }, + ], + }) + + ruleTester.run('sorts imports into groups', orderRule, { + valid: [ + { + name: 'without errors', + code: dedent` + import 'sideEffects.js' + + import fs from 'node:fs' + + import { useState } from 'react' + + import { x, y, z } from '@scope/package' + import { a, b, c } from 'package' + import Thing, { method } from 'package/path' + import flatten from 'react-keyed-flatten-children' + + import { Component } from '~/components' + + import moduleB from '../../../module-b.js' + import moduleD from '../../module-d.js' + import twoThings from '../get-things/get2Things.js' + import tenThings from '../get-things/get10Things.js' + import moduleC from '../HoverCard/module-c.js' + import moduleA from '../Select/module-a.js' + import Module from './index.js' + + import styles from './component.module.css' + `, + }, + ], + invalid: [ + { + name: 'groups unassigned modules without sorting', + code: dedent` + import { a1 } from 'a' + import './zero.js' + import './one.js' + `, + output: dedent` + import './zero.js' + import './one.js' + + import { a1 } from 'a' + `, + errors: [{ messageId: 'out-of-order' }], + }, + { + name: 'groups builtin modules together', + code: dedent` + import path from 'path' + import { t1 } from 't' + import url from 'node:url' + `, + output: dedent` + import path from 'path' + import url from 'node:url' + + import { t1 } from 't' + `, + errors: [{ messageId: 'needs-newline' }, { messageId: 'out-of-order' }], + }, + { + name: 'sorts local paths by dot segments', + code: dedent` + import tenThings from '../get-things/get10Things.js' + import twoThings from '../get-things/get2Things.js' + import moduleC from '../Hovercard/module-c.js' + import Module from './index.js' + import moduleB from '../../module-b.js' + import moduleD from '../../../module-d.js' + import moduleA from '../Select/module-a.js' + `, + output: dedent` + import moduleD from '../../../module-d.js' + import moduleB from '../../module-b.js' + import twoThings from '../get-things/get2Things.js' + import tenThings from '../get-things/get10Things.js' + import moduleC from '../Hovercard/module-c.js' + import moduleA from '../Select/module-a.js' + import Module from './index.js' + `, + errors: [ + { messageId: 'out-of-order' }, + { messageId: 'out-of-order' }, + { messageId: 'out-of-order' }, + ], + }, + ], + }) + }) +}) diff --git a/vite.config.mjs b/vite.config.mjs index b99436b..61a008e 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -15,6 +15,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: 'tests/**/*.ts', + include: 'tests/**/*.test.ts', }, })