Skip to content

Commit 6aa3906

Browse files
webpro43081j
andauthored
feat(prefer-slice-over-split-index): add rule (#137)
* feat(prefer-slice-over-split-index): add rule * chore: pr feedback --------- Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
1 parent 8ad942c commit 6aa3906

4 files changed

Lines changed: 177 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Read more at the
174174
| [prefer-charcode-at-in-loop](./src/rules/prefer-charcode-at-in-loop.ts) | Prefer `charCodeAt()` over indexed string access for single-character comparisons inside loops | ✖️ | 💡 | 🔶 |
175175
| [no-delete-property](./src/rules/no-delete-property.ts) | Disallow `delete` on properties — V8 deoptimizes the object to dictionary mode | ✖️ | 💡 | ✖️ |
176176
| [prefer-flatmap-over-map-flat](./src/rules/prefer-flatmap-over-map-flat.ts) | Prefer `Array.prototype.flatMap()` over `.map(fn).flat()` to skip the intermediate array | ✖️ || ✖️ |
177+
| [prefer-slice-over-split-index](./src/rules/prefer-slice-over-split-index.ts) | Prefer `indexOf(needle)` and `slice(...)` over `split()[N]` when splitting a string into two pieces | ✖️ | ✖️ | ✖️ |
177178
| [no-spread-in-reduce](./src/rules/no-spread-in-reduce.ts) | Disallow spreading the accumulator inside a `.reduce()` callback — it's O(N²) | ✖️ | ✖️ | ✖️ |
178179
| [prefer-static-collator](./src/rules/prefer-static-collator.ts) | Prefer hoisting an `Intl.Collator` over calling `localeCompare` inside a `sort`/`toSorted` callback | ✖️ | ✖️ | ✖️ |
179180

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {preferFlatMapOverMapFlat} from './rules/prefer-flatmap-over-map-flat.js'
3131
import {preferStaticCollator} from './rules/prefer-static-collator.js';
3232
import {preferGetOrInsert} from './rules/prefer-get-or-insert.js';
3333
import {preferCharCodeAtInLoop} from './rules/prefer-charcode-at-in-loop.js';
34+
import {preferSliceOverSplitIndex} from './rules/prefer-slice-over-split-index.js';
3435

3536
const plugin: ESLint.Plugin = {
3637
meta: {
@@ -67,6 +68,7 @@ const plugin: ESLint.Plugin = {
6768
'prefer-get-or-insert': preferGetOrInsert,
6869
'prefer-charcode-at-in-loop':
6970
preferCharCodeAtInLoop as never as Rule.RuleModule,
71+
'prefer-slice-over-split-index': preferSliceOverSplitIndex,
7072
'ban-dependencies': banDependencies
7173
}
7274
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {RuleTester} from 'eslint';
2+
import {preferSliceOverSplitIndex} from './prefer-slice-over-split-index.js';
3+
4+
const ruleTester = new RuleTester({
5+
languageOptions: {
6+
ecmaVersion: 2022,
7+
sourceType: 'module'
8+
}
9+
});
10+
11+
ruleTester.run('prefer-slice-over-split-index', preferSliceOverSplitIndex, {
12+
valid: [
13+
'const x = s.slice(0, s.indexOf("/"));',
14+
'const x = s.indexOf("/") < 0 ? s : s.slice(0, s.indexOf("/"));',
15+
's.split("/").map(x => x);',
16+
's.split("/").pop();',
17+
's.split("/").length;',
18+
's.split(",")[2];',
19+
's.split(".")[5];',
20+
's.split(",").length;',
21+
's.split(",")[i];',
22+
's.split(",")[idx];',
23+
's.split()[0];',
24+
's.split("")[0];',
25+
's.split(/\\?|#/)[0];',
26+
's.split(separator)[0];',
27+
's.split(`${separator}`)[0];',
28+
's.split(",", limit)[0];',
29+
's.split(",", 0)[0];',
30+
's.split(",", 0)[1];',
31+
's.split(",", 1)[1];',
32+
's.split(",", 1.5)[0];',
33+
's.split(",", -1)[0];',
34+
's.split(",", 2, extra)[0];',
35+
'arr[0];',
36+
'fn()[0];',
37+
'obj.method()[0];'
38+
],
39+
40+
invalid: [
41+
{
42+
code: 'const head = s.split("/")[0];',
43+
errors: [
44+
{
45+
messageId: 'preferSliceFirst',
46+
line: 1,
47+
column: 14
48+
}
49+
]
50+
},
51+
{
52+
code: 'const head = s.split(`/`)[0];',
53+
errors: [{messageId: 'preferSliceFirst'}]
54+
},
55+
{
56+
code: 'const head = s.split(",", 1)[0];',
57+
errors: [{messageId: 'preferSliceFirst'}]
58+
},
59+
{
60+
code: 'const head = s.split(",", 2)[0];',
61+
errors: [{messageId: 'preferSliceFirst'}]
62+
},
63+
{
64+
code: 'const tail = s.split("=")[1];',
65+
errors: [
66+
{
67+
messageId: 'preferSliceSecond',
68+
line: 1,
69+
column: 14
70+
}
71+
]
72+
},
73+
{
74+
code: 'const tail = s.split("=", 2)[1];',
75+
errors: [{messageId: 'preferSliceSecond'}]
76+
},
77+
{
78+
code: 'const head = url.toLowerCase().split("?")[0];',
79+
errors: [{messageId: 'preferSliceFirst'}]
80+
},
81+
{
82+
code: 'arr.map(x => x?.split(":")[0]);',
83+
errors: [{messageId: 'preferSliceFirst'}]
84+
},
85+
{
86+
code: 'const head = msg.split("\\n", 1)[0];',
87+
errors: [{messageId: 'preferSliceFirst'}]
88+
},
89+
{
90+
code: 'doStuff(line.split(":")[0]);',
91+
errors: [{messageId: 'preferSliceFirst'}]
92+
}
93+
]
94+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type {
2+
CallExpression,
3+
Expression,
4+
MemberExpression,
5+
SpreadElement
6+
} from 'estree';
7+
import type {Rule} from 'eslint';
8+
9+
function stringValue(node: Expression | SpreadElement): string | null {
10+
if (node.type === 'Literal') {
11+
return typeof node.value === 'string' ? node.value : null;
12+
}
13+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
14+
const cooked = node.quasis[0]?.value.cooked;
15+
return typeof cooked === 'string' ? cooked : null;
16+
}
17+
return null;
18+
}
19+
20+
function isSupportedLimit(
21+
limit: Expression | SpreadElement | undefined,
22+
index: 0 | 1
23+
): boolean {
24+
if (!limit) return true;
25+
if (limit.type !== 'Literal') return false;
26+
const {value} = limit;
27+
return typeof value === 'number' && Number.isInteger(value) && value > index;
28+
}
29+
30+
function getSplitCall(node: MemberExpression): CallExpression | null {
31+
const call = node.object;
32+
if (call.type !== 'CallExpression') return null;
33+
const callee = call.callee;
34+
if (callee.type !== 'MemberExpression' || callee.computed) return null;
35+
if (callee.property.type !== 'Identifier' || callee.property.name !== 'split')
36+
return null;
37+
if (call.arguments.length === 0 || call.arguments.length > 2) return null;
38+
const separator = call.arguments[0];
39+
if (!separator) return null;
40+
const value = stringValue(separator);
41+
if (value === null || value.length === 0) return null;
42+
return call;
43+
}
44+
45+
export const preferSliceOverSplitIndex: Rule.RuleModule = {
46+
meta: {
47+
type: 'suggestion',
48+
docs: {
49+
description:
50+
'Prefer `indexOf(needle)` and `slice(...)` over `split()[N]` when splitting a string into two pieces.',
51+
recommended: false
52+
},
53+
schema: [],
54+
messages: {
55+
preferSliceFirst:
56+
'Prefer `indexOf(needle)` and `slice(0, index)` over `split(needle)[0]` for string separators. `split` will allocate an intermediate array, resulting in poorer performance. Preserve the missing-separator fallback explicitly.',
57+
preferSliceSecond:
58+
'Prefer `indexOf(needle)` and `slice(index)` over `split(needle)[1]` for string separators. `split` will allocate an intermediate array, resulting in poorer performance. Preserve the missing-separator fallback explicitly.'
59+
}
60+
},
61+
create(context) {
62+
return {
63+
MemberExpression(node: MemberExpression) {
64+
if (!node.computed) return;
65+
if (node.property.type !== 'Literal') return;
66+
const index = node.property.value;
67+
if (index !== 0 && index !== 1) return;
68+
69+
const call = getSplitCall(node);
70+
if (!call) return;
71+
if (!isSupportedLimit(call.arguments[1], index)) return;
72+
73+
context.report({
74+
node,
75+
messageId: index === 0 ? 'preferSliceFirst' : 'preferSliceSecond'
76+
});
77+
}
78+
};
79+
}
80+
};

0 commit comments

Comments
 (0)