diff --git a/src/rules/prefer-array-to-sorted.test.ts b/src/rules/prefer-array-to-sorted.test.ts index b7a4d4e..72a568d 100644 --- a/src/rules/prefer-array-to-sorted.test.ts +++ b/src/rules/prefer-array-to-sorted.test.ts @@ -25,6 +25,14 @@ const ruleTester = new RuleTester({ sourceType: 'module' } }); +const tsSyntaxRuleTester = new TSRuleTester({ + languageOptions: { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + } + } +}); ruleTester.run( 'prefer-array-to-sorted (untyped)', @@ -40,7 +48,17 @@ ruleTester.run( // slice with different arguments 'arr.slice(1).sort();', - 'arr.slice(0, 5).sort();' + 'arr.slice(0, 5).sort();', + + 'const sorted = [...new Set([1, 2, 3])].sort();', + 'const sorted = [...new Set(arr)].sort();', + 'const sorted = [...new Map()].sort();', + 'const foundVersions = new Set(); const sorted = [...foundVersions].sort();', + 'let foundVersions = new Set(); const sorted = [...foundVersions].sort();', + 'const entries = new Map(); const sorted = [...entries].sort();', + 'const sorted = [...items.entries()].sort();', + 'const sorted = [...items.keys()].sort();', + 'const sorted = [...items.values()].sort();' ], invalid: [ @@ -174,6 +192,12 @@ ruleTester.run( } ] }, + { + code: 'const sorted = [...Object.entries(counts)].sort(([a], [b]) => a.localeCompare(b));', + output: + 'const sorted = Object.entries(counts).toSorted(([a], [b]) => a.localeCompare(b));', + errors: [{messageId: 'preferToSorted'}] + }, // Multiple arguments (edge case - sort() typically takes 0 or 1 arg) { @@ -244,6 +268,26 @@ ruleTester.run( } ); +tsSyntaxRuleTester.run( + 'prefer-array-to-sorted (typescript syntax)', + preferArrayToSorted, + { + valid: [ + 'function sortItems(items: Iterable) { return [...items].sort(); }', + 'function sortItems(items: Map) { return [...items.entries()].sort(); }', + 'const items: Set = new Set(); const sorted = [...items].sort();', + 'let items: Iterable; const sorted = [...items].sort();' + ], + invalid: [ + { + code: 'const items: string[] = []; const sorted = [...items].sort();', + output: 'const items: string[] = []; const sorted = items.toSorted();', + errors: [{messageId: 'preferToSorted'}] + } + ] + } +); + typedRuleTester.run('prefer-array-to-sorted (typed)', preferArrayToSorted, { valid: [ // Set spread - Set doesn't have toSorted() diff --git a/src/rules/prefer-array-to-sorted.ts b/src/rules/prefer-array-to-sorted.ts index dc29d3e..65ee0c6 100644 --- a/src/rules/prefer-array-to-sorted.ts +++ b/src/rules/prefer-array-to-sorted.ts @@ -9,6 +9,116 @@ import {isArrayType} from '../utils/typescript.js'; type MessageIds = 'preferToSorted'; +const NON_ARRAY_COPY_SOURCE_CTORS = new Set(['Set', 'Map']); +const NON_ARRAY_COPY_SOURCE_TYPES = new Set([ + 'Iterable', + 'IterableIterator', + 'Iterator', + 'Map', + 'ReadonlyMap', + 'ReadonlySet', + 'Set', + 'URLSearchParams' +]); +const ITERATOR_RETURNING_METHODS = new Set(['entries', 'keys', 'values']); + +function getTypeReferenceName(node: TSESTree.Node): string | null { + if (node.type !== 'TSTypeReference') { + return null; + } + + const {typeName} = node; + if (typeName.type === 'Identifier') { + return typeName.name; + } + if (typeName.type === 'TSQualifiedName') { + return typeName.right.name; + } + return null; +} + +function hasKnownNonArrayTypeAnnotation(node: TSESTree.BindingName): boolean { + const typeAnnotation = node.typeAnnotation; + if (typeAnnotation === undefined) { + return false; + } + + const annotation = typeAnnotation.typeAnnotation; + if (annotation.type === 'TSUnionType') { + return annotation.types.some((typeNode) => hasKnownNonArrayType(typeNode)); + } + return hasKnownNonArrayType(annotation); +} + +function hasKnownNonArrayType(node: TSESTree.Node): boolean { + const typeName = getTypeReferenceName(node); + return typeName !== null && NON_ARRAY_COPY_SOURCE_TYPES.has(typeName); +} + +function isIteratorReturningCall(node: TSESTree.Node): boolean { + return ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + ITERATOR_RETURNING_METHODS.has(node.callee.property.name) && + !( + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Object' + ) + ); +} + +function isKnownNonArrayCopySource(node: TSESTree.Node): boolean { + return ( + isIteratorReturningCall(node) || + (node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + NON_ARRAY_COPY_SOURCE_CTORS.has(node.callee.name)) + ); +} + +function resolvesToKnownNonArrayCopySource( + node: TSESTree.Node, + context: TSESLint.RuleContext +): boolean { + if (isKnownNonArrayCopySource(node)) { + return true; + } + + if (node.type !== 'Identifier') { + return false; + } + + const variable = context.sourceCode + .getScope(node) + .references.find((ref) => ref.identifier === node)?.resolved; + + if (!variable || variable.defs.length !== 1) { + return false; + } + + const def = variable.defs[0]; + if (!def || (def.type !== 'Variable' && def.type !== 'Parameter')) { + return false; + } + + if (hasKnownNonArrayTypeAnnotation(def.name)) { + return true; + } + + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') { + return false; + } + + const init = def.node.init; + + return ( + init !== null && + isKnownNonArrayCopySource(init) && + variable.references.every((ref) => !ref.isWrite() || ref.writeExpr === init) + ); +} + export const preferArrayToSorted: TSESLint.RuleModule = { meta: { type: 'suggestion', @@ -40,7 +150,15 @@ export const preferArrayToSorted: TSESLint.RuleModule = { const arrayNode = getArrayFromCopyPattern(sortCallee); if (arrayNode) { - if (isArrayType(arrayNode, context) === false) { + const arrayType = isArrayType(arrayNode, context); + if (arrayType === false) { + return; + } + + if ( + arrayType !== true && + resolvesToKnownNonArrayCopySource(arrayNode, context) + ) { return; }