Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [prefer-at] create rule #6411

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-at.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
description: 'Enforce the use of `array.at(-1)` instead of `array[array.length - 1]`.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/prefer-at** for documentation.

There are two ways to get the last item of the array:
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved

- `arr[arr.length - 1]`: getting an item by index calculated relative to the length of the array.
- `arr.at(-1)`: getting an item by the `at` method. [Docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at)
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved

`arr.at(-1)` is a cleaner equivalent to `arr[arr.length - 1]`.

This rule supports the following types:

- `string` or `String`
- `Array`
- `Int8Array`
- `Uint8Array`
- `Uint8ClampedArray`
- `Int16Array`
- `Uint16Array`
- `Int32Array`
- `Float32Array`
- `Uint32Array`
- `Float64Array`
- `BigInt64Array`
- `BigUint64Array`
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved

## Options

```ts
interface Options {
ignoreFunctions?: boolean;
}
```
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved

## Examples

### `ignoreFunctions = false` (default)

<!--tabs-->

### ❌ Incorrect

```ts
let arr = [1, 2, 3];
let a = arr[arr.length - 1];

function getArr(): Array<number> {
return arr;
}
let b = getArr()[getArr().length - 1];
```

### ✅ Correct

```ts
let arr = [1, 2, 3];
let a = arr.at(-1);

function getArr(): Array<number> {
return arr;
}
let b = getArr().at(-1);
```

<!--/tabs-->

### `ignoreFunctions = true`

<!--tabs-->

### ❌ Incorrect

```ts
let arr = [1, 2, 3];
let a = arr[arr.length - 1];
```

### ✅ Correct

```ts
let arr = [1, 2, 3];
let a = arr.at(-1);

function getArr(): Array<number> {
return arr;
}
let b = getArr()[getArr().length - 1];
```

<!--/tabs-->

## When Not To Use It

If you support a browser that does not match
[this table](https://caniuse.com/mdn-javascript_builtins_array_at)
or use `node.js < 16.6.0` and don't include the polyfill
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export = {
'@typescript-eslint/padding-line-between-statements': 'error',
'@typescript-eslint/parameter-properties': 'error',
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/prefer-at': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import objectCurlySpacing from './object-curly-spacing';
import paddingLineBetweenStatements from './padding-line-between-statements';
import parameterProperties from './parameter-properties';
import preferAsConst from './prefer-as-const';
import preferAt from './prefer-at';
import preferEnumInitializers from './prefer-enum-initializers';
import preferForOf from './prefer-for-of';
import preferFunctionType from './prefer-function-type';
Expand Down Expand Up @@ -231,6 +232,7 @@ export default {
'padding-line-between-statements': paddingLineBetweenStatements,
'parameter-properties': parameterProperties,
'prefer-as-const': preferAsConst,
'prefer-at': preferAt,
'prefer-enum-initializers': preferEnumInitializers,
'prefer-for-of': preferForOf,
'prefer-function-type': preferFunctionType,
Expand Down
219 changes: 219 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import * as path from 'path';
import * as ts from 'typescript';

import * as util from '../util';

export default util.createRule({
name: 'prefer-at',
meta: {
type: 'suggestion',
fixable: 'code',
docs: {
description:
'Enforce the use of `array.at(-1)` instead of `array[array.length - 1]`',
recommended: false,
requiresTypeChecking: true,
},
messages: {
preferAt:
'Expected a `{{name}}.at(-1)` instead of `{{name}}[{{name}}.length - 1]`.',
},
schema: [
{
oneOf: [
{
type: 'object',
properties: {
ignoreFunctions: {
type: 'boolean',
},
},
additionalProperties: false,
required: ['ignoreFunctions'],
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
},
],
},
],
},
defaultOptions: [
{
ignoreFunctions: false,
},
],
create(context, [options]) {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();

class UnknownNodeError extends Error {
public constructor(node: TSESTree.Node) {
super(`UnknownNode ${node.type}`);

Check warning on line 51 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L51

Added line #L51 was not covered by tests
}
}

function getName(node: TSESTree.Node): string {
switch (node.type) {
case AST_NODE_TYPES.Identifier:
return node.name;
case AST_NODE_TYPES.MemberExpression:
return getName(node.property);
default:
throw new UnknownNodeError(node);

Check warning on line 62 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L62

Added line #L62 was not covered by tests
}
}

function getFullName(node: TSESTree.Node): string {
switch (node.type) {
case AST_NODE_TYPES.PrivateIdentifier:
return `#${node.name}`;
case AST_NODE_TYPES.Identifier:
return node.name;
case AST_NODE_TYPES.Literal:
return node.raw;
case AST_NODE_TYPES.ThisExpression:
return 'this';
case AST_NODE_TYPES.CallExpression:
return `${getFullName(node.callee)}()`;
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
case AST_NODE_TYPES.MemberExpression:
if (node.property.type === AST_NODE_TYPES.Literal) {
return `${getFullName(node.object)}[${node.property.raw}]`;
}
return `${getFullName(node.object)}.${getFullName(node.property)}`;
default:
throw new UnknownNodeError(node);

Check warning on line 84 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L84

Added line #L84 was not covered by tests
}
}

function hasCallExpression(node: TSESTree.MemberExpression): boolean {
return (
node.object.type === AST_NODE_TYPES.CallExpression ||
(node.object.type === AST_NODE_TYPES.MemberExpression &&
hasCallExpression(node.object))
);
}

function getTypeAtLocation(node: TSESTree.Node): ts.Type | undefined {
const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
if (!originalNode) {
return undefined;

Check warning on line 99 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L99

Added line #L99 was not covered by tests
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
}
return checker.getTypeAtLocation(originalNode);
}

type SupportedObject = (type: ts.Type) => boolean;

function checkObjectName(name: string): SupportedObject {
return type => type.getSymbol()?.name === name;
}

function checkObjectType(flags: ts.TypeFlags): SupportedObject {
return type => type.getFlags() === flags;
}

const supporterObjects: Array<SupportedObject> = [
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
checkObjectName('Array'),
checkObjectName('Int8Array'),
checkObjectName('Uint8Array'),
checkObjectName('Uint8ClampedArray'),
checkObjectName('Int16Array'),
checkObjectName('Uint16Array'),
checkObjectName('Int32Array'),
checkObjectName('Float32Array'),
checkObjectName('Uint32Array'),
checkObjectName('Float64Array'),
checkObjectName('BigInt64Array'),
checkObjectName('BigUint64Array'),
// eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum
checkObjectName('String'),
checkObjectType(ts.TypeFlags.String),
];

function isSupportedObject(type: ts.Type): boolean {
return supporterObjects.some(check => check(type));
}
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved

function isExpectedObject(
node: TSESTree.Node,
): node is TSESTree.MemberExpression {
if (
node.type !== AST_NODE_TYPES.MemberExpression ||
(options.ignoreFunctions && hasCallExpression(node))
) {
return false;
}
const type = getTypeAtLocation(node.object);
if (type && !isSupportedObject(type)) {
return false;
}
const atMember = type?.getProperty('at');
return (
atMember?.declarations?.every(declaration => {
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
const sourceFile = declaration.getSourceFile();
const directory = path.join(sourceFile.fileName, '../');
return directory.endsWith(path.join('node_modules/typescript/lib/'));
}) ?? false
);
}

function isExpectedExpressionLeft(
node: TSESTree.BinaryExpression,
): boolean {
if (!isExpectedObject(node.left) || getName(node.left) !== 'length') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] Similar lint rules to this one use the getStaticValue from eslint-utils. Have you tried using it in this file? I'm wondering if it'll help do some of the logical checks that are implemented manually right now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give me an example where this utility will help me?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump for this unresolved thread @sviat9440 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump: Can you give me an example where this utility will help me??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviorally, it should help catch cases like this:

  • str[str['length'] - 1]
  • const key = 'length'; str[str[key] - 1]

For code, it would eliminate the need to manually write a function getName. Which is something a lot of our rules end up wanting. So we'd really rather than write explicit "what is the string name of this property?" logic repeatedly without a real need.

https://eslint-community.github.io/eslint-utils/api/ast-utils.html#getstaticvalue

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also, for anybody seeing just this thread and wondering why we're bumping at each other 😂 the comments were pending)

return false;
}
const type = getTypeAtLocation(node.left);
return type?.getFlags() === ts.TypeFlags.Number;
}

function isExpectedExpressionRight(
node: TSESTree.BinaryExpression,
): boolean {
const type = getTypeAtLocation(node.right);
return (
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
type?.isNumberLiteral() || type?.getFlags() === ts.TypeFlags.Number
);
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
}

function isExpectedExpression<T extends TSESTree.BinaryExpression>(
node: T,
): node is T & { left: TSESTree.MemberExpression } {
return isExpectedExpressionRight(node) && isExpectedExpressionLeft(node);
}

return {
'MemberExpression[property.type="BinaryExpression"][property.operator="-"]'(
node: TSESTree.MemberExpressionComputedName & {
property: TSESTree.BinaryExpression & { operator: '-' };
},
): void {
try {
if (!isExpectedExpression(node.property)) {
return;
}
const objectName = getFullName(node.object);
const memberName = getFullName(node.property.left.object);
if (objectName !== memberName) {
return;
}
const rightName = getFullName(node.property.right);
context.report({
messageId: 'preferAt',
data: {
name: objectName,
},
node,
fix: fixer =>
fixer.replaceText(node, `${objectName}.at(-${rightName})`),
});
} catch (error) {
if (error instanceof UnknownNodeError) {
return;

Check warning on line 212 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L212

Added line #L212 was not covered by tests
}
throw error;

Check warning on line 214 in packages/eslint-plugin/src/rules/prefer-at.ts

View check run for this annotation

Codecov / codecov/patch

packages/eslint-plugin/src/rules/prefer-at.ts#L214

Added line #L214 was not covered by tests
}
sviat9440 marked this conversation as resolved.
Show resolved Hide resolved
},
};
},
});