Skip to content
Merged
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
46 changes: 45 additions & 1 deletion src/rules/prefer-array-to-sorted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand All @@ -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: [
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -244,6 +268,26 @@ ruleTester.run(
}
);

tsSyntaxRuleTester.run(
'prefer-array-to-sorted (typescript syntax)',
preferArrayToSorted,
{
valid: [
'function sortItems<T>(items: Iterable<T>) { return [...items].sort(); }',
'function sortItems(items: Map<string, number>) { return [...items.entries()].sort(); }',
'const items: Set<string> = new Set(); const sorted = [...items].sort();',
'let items: Iterable<string>; 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()
Expand Down
120 changes: 119 additions & 1 deletion src/rules/prefer-array-to-sorted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageIds, []>
): 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<MessageIds, []> = {
meta: {
type: 'suggestion',
Expand Down Expand Up @@ -40,7 +150,15 @@ export const preferArrayToSorted: TSESLint.RuleModule<MessageIds, []> = {
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;
}

Expand Down