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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ Read more at the
| [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 | ✖️ | 💡 | 🔶 |
| [no-delete-property](./src/rules/no-delete-property.ts) | Disallow `delete` on properties — V8 deoptimizes the object to dictionary mode | ✖️ | 💡 | ✖️ |
| [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 | ✖️ | ✅ | ✖️ |
| [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 | ✖️ | ✖️ | ✖️ |
| [no-spread-in-reduce](./src/rules/no-spread-in-reduce.ts) | Disallow spreading the accumulator inside a `.reduce()` callback — it's O(N²) | ✖️ | ✖️ | ✖️ |
| [prefer-static-collator](./src/rules/prefer-static-collator.ts) | Prefer hoisting an `Intl.Collator` over calling `localeCompare` inside a `sort`/`toSorted` callback | ✖️ | ✖️ | ✖️ |

Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {preferFlatMapOverMapFlat} from './rules/prefer-flatmap-over-map-flat.js'
import {preferStaticCollator} from './rules/prefer-static-collator.js';
import {preferGetOrInsert} from './rules/prefer-get-or-insert.js';
import {preferCharCodeAtInLoop} from './rules/prefer-charcode-at-in-loop.js';
import {preferSliceOverSplitIndex} from './rules/prefer-slice-over-split-index.js';

const plugin: ESLint.Plugin = {
meta: {
Expand Down Expand Up @@ -67,6 +68,7 @@ const plugin: ESLint.Plugin = {
'prefer-get-or-insert': preferGetOrInsert,
'prefer-charcode-at-in-loop':
preferCharCodeAtInLoop as never as Rule.RuleModule,
'prefer-slice-over-split-index': preferSliceOverSplitIndex,
'ban-dependencies': banDependencies
}
};
Expand Down
94 changes: 94 additions & 0 deletions src/rules/prefer-slice-over-split-index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {RuleTester} from 'eslint';
import {preferSliceOverSplitIndex} from './prefer-slice-over-split-index.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module'
}
});

ruleTester.run('prefer-slice-over-split-index', preferSliceOverSplitIndex, {
valid: [
'const x = s.slice(0, s.indexOf("/"));',
'const x = s.indexOf("/") < 0 ? s : s.slice(0, s.indexOf("/"));',
's.split("/").map(x => x);',
's.split("/").pop();',
's.split("/").length;',
's.split(",")[2];',
's.split(".")[5];',
's.split(",").length;',
's.split(",")[i];',
's.split(",")[idx];',
's.split()[0];',
's.split("")[0];',
's.split(/\\?|#/)[0];',
's.split(separator)[0];',
's.split(`${separator}`)[0];',
's.split(",", limit)[0];',
's.split(",", 0)[0];',
's.split(",", 0)[1];',
's.split(",", 1)[1];',
's.split(",", 1.5)[0];',
's.split(",", -1)[0];',
's.split(",", 2, extra)[0];',
'arr[0];',
'fn()[0];',
'obj.method()[0];'
],

invalid: [
{
code: 'const head = s.split("/")[0];',
errors: [
{
messageId: 'preferSliceFirst',
line: 1,
column: 14
}
]
},
{
code: 'const head = s.split(`/`)[0];',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'const head = s.split(",", 1)[0];',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'const head = s.split(",", 2)[0];',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'const tail = s.split("=")[1];',
errors: [
{
messageId: 'preferSliceSecond',
line: 1,
column: 14
}
]
},
{
code: 'const tail = s.split("=", 2)[1];',
errors: [{messageId: 'preferSliceSecond'}]
},
{
code: 'const head = url.toLowerCase().split("?")[0];',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'arr.map(x => x?.split(":")[0]);',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'const head = msg.split("\\n", 1)[0];',
errors: [{messageId: 'preferSliceFirst'}]
},
{
code: 'doStuff(line.split(":")[0]);',
errors: [{messageId: 'preferSliceFirst'}]
}
]
});
80 changes: 80 additions & 0 deletions src/rules/prefer-slice-over-split-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type {
CallExpression,
Expression,
MemberExpression,
SpreadElement
} from 'estree';
import type {Rule} from 'eslint';

function stringValue(node: Expression | SpreadElement): string | null {
if (node.type === 'Literal') {
return typeof node.value === 'string' ? node.value : null;
}
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
const cooked = node.quasis[0]?.value.cooked;
return typeof cooked === 'string' ? cooked : null;
}
return null;
}

function isSupportedLimit(
limit: Expression | SpreadElement | undefined,
index: 0 | 1
): boolean {
if (!limit) return true;
if (limit.type !== 'Literal') return false;
const {value} = limit;
return typeof value === 'number' && Number.isInteger(value) && value > index;
}

function getSplitCall(node: MemberExpression): CallExpression | null {
const call = node.object;
if (call.type !== 'CallExpression') return null;
const callee = call.callee;
if (callee.type !== 'MemberExpression' || callee.computed) return null;
if (callee.property.type !== 'Identifier' || callee.property.name !== 'split')
return null;
if (call.arguments.length === 0 || call.arguments.length > 2) return null;
const separator = call.arguments[0];
if (!separator) return null;
const value = stringValue(separator);
if (value === null || value.length === 0) return null;
return call;
}

export const preferSliceOverSplitIndex: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description:
'Prefer `indexOf(needle)` and `slice(...)` over `split()[N]` when splitting a string into two pieces.',
recommended: false
},
schema: [],
messages: {
preferSliceFirst:
'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.',
preferSliceSecond:
'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.'
}
},
create(context) {
return {
MemberExpression(node: MemberExpression) {
if (!node.computed) return;
if (node.property.type !== 'Literal') return;
const index = node.property.value;
if (index !== 0 && index !== 1) return;

const call = getSplitCall(node);
if (!call) return;
if (!isSupportedLimit(call.arguments[1], index)) return;

context.report({
node,
messageId: index === 0 ? 'preferSliceFirst' : 'preferSliceSecond'
});
}
};
}
};