diff --git a/README.md b/README.md index 7e961c7..4c29cb5 100644 --- a/README.md +++ b/README.md @@ -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 | āœ–ļø | āœ–ļø | āœ–ļø | diff --git a/src/main.ts b/src/main.ts index 63a156f..88850e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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: { @@ -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 } }; diff --git a/src/rules/prefer-slice-over-split-index.test.ts b/src/rules/prefer-slice-over-split-index.test.ts new file mode 100644 index 0000000..649f5f6 --- /dev/null +++ b/src/rules/prefer-slice-over-split-index.test.ts @@ -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'}] + } + ] +}); diff --git a/src/rules/prefer-slice-over-split-index.ts b/src/rules/prefer-slice-over-split-index.ts new file mode 100644 index 0000000..98ccdac --- /dev/null +++ b/src/rules/prefer-slice-over-split-index.ts @@ -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' + }); + } + }; + } +};