|
| 1 | +import {findVariable, getPropertyName} from '@eslint-community/eslint-utils'; |
| 2 | +import {isValueNotUsable} from './utils/index.js'; |
| 3 | + |
| 4 | +const MESSAGE_ID = 'no-unused-array-method-return'; |
| 5 | +const messages = { |
| 6 | + [MESSAGE_ID]: 'Do not ignore the return value of `.{{method}}(…)`.', |
| 7 | +}; |
| 8 | + |
| 9 | +const methods = new Set([ |
| 10 | + 'at', |
| 11 | + 'concat', |
| 12 | + 'entries', |
| 13 | + 'every', |
| 14 | + 'filter', |
| 15 | + 'find', |
| 16 | + 'findIndex', |
| 17 | + 'findLast', |
| 18 | + 'findLastIndex', |
| 19 | + 'flat', |
| 20 | + 'flatMap', |
| 21 | + 'includes', |
| 22 | + 'indexOf', |
| 23 | + 'join', |
| 24 | + 'keys', |
| 25 | + 'lastIndexOf', |
| 26 | + 'map', |
| 27 | + // Using these as short-circuiting `forEach()` alternatives is an anti-pattern. |
| 28 | + 'some', |
| 29 | + 'slice', |
| 30 | + // This list is the implementation contract. We intentionally exclude |
| 31 | + // `toString()` and `toLocaleString()` because they exist on almost every |
| 32 | + // object, and tracking them in this syntax-only rule creates too many |
| 33 | + // non-array false positives. |
| 34 | + 'toReversed', |
| 35 | + 'toSorted', |
| 36 | + 'toSpliced', |
| 37 | + 'values', |
| 38 | + 'with', |
| 39 | +]); |
| 40 | + |
| 41 | +const pascalCaseNamePattern = /^\p{Lu}/v; |
| 42 | +const uncertainValue = Symbol('uncertainValue'); |
| 43 | +const nonArrayFactoryFunctions = new Set([ |
| 44 | + 'BigInt', |
| 45 | + 'Boolean', |
| 46 | + 'Number', |
| 47 | + 'RegExp', |
| 48 | + 'String', |
| 49 | +]); |
| 50 | + |
| 51 | +const isPascalCaseIdentifier = node => |
| 52 | + node.type === 'Identifier' |
| 53 | + && pascalCaseNamePattern.test(node.name); |
| 54 | + |
| 55 | +const isGlobalIdentifier = (node, name, context) => |
| 56 | + node.type === 'Identifier' |
| 57 | + && node.name === name |
| 58 | + && context.sourceCode.isGlobalReference(node); |
| 59 | + |
| 60 | +const isUndefined = (node, context) => |
| 61 | + isGlobalIdentifier(node, 'undefined', context); |
| 62 | + |
| 63 | +// Treat `new Foo()` as non-array unless it is the global `Array`. Local `Array` |
| 64 | +// subclasses are intentionally out of scope for this best-effort inference. |
| 65 | +const isKnownNonArrayConstruction = (node, context) => |
| 66 | + node.type === 'NewExpression' |
| 67 | + && node.callee.type === 'Identifier' |
| 68 | + && !isGlobalIdentifier(node.callee, 'Array', context); |
| 69 | + |
| 70 | +const isKnownNonArrayFactoryCall = (node, context) => |
| 71 | + node.type === 'CallExpression' |
| 72 | + && node.callee.type === 'Identifier' |
| 73 | + && nonArrayFactoryFunctions.has(node.callee.name) |
| 74 | + && context.sourceCode.isGlobalReference(node.callee); |
| 75 | + |
| 76 | +const isDefinitelyArrayExpression = (node, context) => |
| 77 | + node.type === 'ArrayExpression' |
| 78 | + || ( |
| 79 | + (node.type === 'CallExpression' || node.type === 'NewExpression') |
| 80 | + && isGlobalIdentifier(node.callee, 'Array', context) |
| 81 | + ); |
| 82 | + |
| 83 | +const isDefinitelyNonArrayExpression = (node, context) => |
| 84 | + isUndefined(node, context) |
| 85 | + || node.type === 'ObjectExpression' |
| 86 | + || node.type === 'Literal' |
| 87 | + || node.type === 'TemplateLiteral' |
| 88 | + || node.type === 'ArrowFunctionExpression' |
| 89 | + || node.type === 'FunctionExpression' |
| 90 | + || node.type === 'ClassExpression' |
| 91 | + || isKnownNonArrayConstruction(node, context) |
| 92 | + || isKnownNonArrayFactoryCall(node, context); |
| 93 | + |
| 94 | +function getVariable(node, context) { |
| 95 | + if (node.type !== 'Identifier') { |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + return findVariable(context.sourceCode.getScope(node), node); |
| 100 | +} |
| 101 | + |
| 102 | +function hasEarlierWrite(variable, node, context) { |
| 103 | + if (!variable) { |
| 104 | + return false; |
| 105 | + } |
| 106 | + |
| 107 | + const [nodeStart] = context.sourceCode.getRange(node); |
| 108 | + |
| 109 | + return variable.references.some(reference => !reference.init && reference.isWrite() && context.sourceCode.getRange(reference.identifier)[0] < nodeStart); |
| 110 | +} |
| 111 | + |
| 112 | +function getVariableValue(node, context) { |
| 113 | + const variable = getVariable(node, context); |
| 114 | + if (!variable) { |
| 115 | + return; |
| 116 | + } |
| 117 | + |
| 118 | + if (variable.defs.length !== 1) { |
| 119 | + return uncertainValue; |
| 120 | + } |
| 121 | + |
| 122 | + // Supported variable inference boundary: |
| 123 | + // - exactly one binding definition |
| 124 | + // - a `VariableDeclarator` whose id is the same identifier we are resolving |
| 125 | + // - the original declarator initializer only |
| 126 | + // |
| 127 | + // Unsupported on purpose: |
| 128 | + // - any destructuring, including defaults |
| 129 | + // - any write before the call site |
| 130 | + // - aliasing through another identifier |
| 131 | + // - parameter defaults, `for…of`, catch bindings, and any non-declarator binding |
| 132 | + // - control-flow-sensitive value tracking |
| 133 | + // |
| 134 | + // This is intentionally extremely small. The rule only trusts the initializer |
| 135 | + // syntax of `const value = ...` or `let value = ...` when the binding has not |
| 136 | + // been written again. Everything else stays unresolved on purpose. |
| 137 | + if (hasEarlierWrite(variable, node, context)) { |
| 138 | + return uncertainValue; |
| 139 | + } |
| 140 | + |
| 141 | + const [definition] = variable.defs; |
| 142 | + if ( |
| 143 | + definition.type === 'Variable' |
| 144 | + && definition.node.type === 'VariableDeclarator' |
| 145 | + && definition.node.id.type === 'Identifier' |
| 146 | + && definition.node.id.name === node.name |
| 147 | + && definition.node.init |
| 148 | + && definition.parent.type === 'VariableDeclaration' |
| 149 | + ) { |
| 150 | + return definition.node.init; |
| 151 | + } |
| 152 | + |
| 153 | + return uncertainValue; |
| 154 | +} |
| 155 | + |
| 156 | +function getStaticPropertyName(node, context) { |
| 157 | + return getPropertyName(node, context.sourceCode.getScope(node)); |
| 158 | +} |
| 159 | + |
| 160 | +function resolveReceiver(node, context, visitedNodes = new Set()) { |
| 161 | + if (!node || node === uncertainValue) { |
| 162 | + return node; |
| 163 | + } |
| 164 | + |
| 165 | + if (visitedNodes.has(node)) { |
| 166 | + return node; |
| 167 | + } |
| 168 | + |
| 169 | + visitedNodes.add(node); |
| 170 | + |
| 171 | + if (node.type === 'Identifier') { |
| 172 | + const value = getVariableValue(node, context); |
| 173 | + if (value === uncertainValue) { |
| 174 | + return value; |
| 175 | + } |
| 176 | + |
| 177 | + return value === undefined ? node : resolveReceiver(value, context, visitedNodes); |
| 178 | + } |
| 179 | + |
| 180 | + if (node.type === 'ChainExpression') { |
| 181 | + return resolveReceiver(node.expression, context, visitedNodes); |
| 182 | + } |
| 183 | + |
| 184 | + if (node.type === 'MemberExpression') { |
| 185 | + return uncertainValue; |
| 186 | + } |
| 187 | + |
| 188 | + // Supported receiver inference boundary: |
| 189 | + // - the receiver expression itself, if it is direct array syntax like `[]` |
| 190 | + // - trivial identifier aliases to that same initializer, like `const alias = values` |
| 191 | + // |
| 192 | + // Unsupported on purpose: |
| 193 | + // - any destructuring, including defaults |
| 194 | + // - any member/property receiver, including `wrapper.items`, `alias.items`, and `this.items` |
| 195 | + // - any object, class-field, or `this`-based inference |
| 196 | + // - any class field or constructor reasoning, even when `this.items = []` looks obvious |
| 197 | + // - any write before the call site |
| 198 | + // - any "latest value" reconstruction after assignments |
| 199 | + // |
| 200 | + // This comment is intentionally blunt because this boundary is the feature: |
| 201 | + // the rule is not a general value tracker anymore. If a case requires |
| 202 | + // following properties, destructuring, or writes, we leave it unresolved. |
| 203 | + return node; |
| 204 | +} |
| 205 | + |
| 206 | +const isObviouslyNonArrayReceiver = (node, context) => { |
| 207 | + node = resolveReceiver(node, context); |
| 208 | + |
| 209 | + return node === uncertainValue |
| 210 | + || isDefinitelyNonArrayExpression(node, context) |
| 211 | + || ( |
| 212 | + isPascalCaseIdentifier(node) |
| 213 | + && !isDefinitelyArrayExpression(node, context) |
| 214 | + ); |
| 215 | +}; |
| 216 | + |
| 217 | +const getTrackedMethodName = (node, context) => |
| 218 | + node.callee.type === 'MemberExpression' |
| 219 | + ? getStaticPropertyName(node.callee, context) |
| 220 | + : undefined; |
| 221 | + |
| 222 | +// Supported discarded-value boundary: |
| 223 | +// - direct unused expressions handled by `isValueNotUsable()` |
| 224 | +// - `await foo.map()` when the awaited expression is itself directly discarded |
| 225 | +// - TypeScript assertion wrappers around that same direct discard site |
| 226 | +// - direct `for` init/update expressions like `for (foo.map(); ; )` and `for (; ; foo.map())` |
| 227 | +// |
| 228 | +// Unsupported on purpose: |
| 229 | +// - comma-expression wrappers |
| 230 | +// - logical wrappers like `condition && foo.map()` |
| 231 | +// - conditional wrappers like `condition ? foo.map() : other()` |
| 232 | +// - any other parent-expression pattern not listed above |
| 233 | +// |
| 234 | +// The rule stops after this short fixed wrapper list. We intentionally do not |
| 235 | +// keep climbing through arbitrary parent expressions just to catch one more |
| 236 | +// nested discard shape. |
| 237 | +const isDiscardedExpression = node => { |
| 238 | + while (true) { |
| 239 | + if (isValueNotUsable(node)) { |
| 240 | + return true; |
| 241 | + } |
| 242 | + |
| 243 | + const {parent} = node; |
| 244 | + if ( |
| 245 | + parent.type === 'ForStatement' |
| 246 | + && (parent.init === node || parent.update === node) |
| 247 | + ) { |
| 248 | + return true; |
| 249 | + } |
| 250 | + |
| 251 | + if ( |
| 252 | + parent.type !== 'ChainExpression' |
| 253 | + && parent.type !== 'AwaitExpression' |
| 254 | + && parent.type !== 'TSAsExpression' |
| 255 | + && parent.type !== 'TSTypeAssertion' |
| 256 | + && parent.type !== 'TSNonNullExpression' |
| 257 | + && parent.type !== 'TSSatisfiesExpression' |
| 258 | + ) { |
| 259 | + return false; |
| 260 | + } |
| 261 | + |
| 262 | + node = parent; |
| 263 | + } |
| 264 | +}; |
| 265 | + |
| 266 | +/** @param {import('eslint').Rule.RuleContext} context */ |
| 267 | +const create = context => { |
| 268 | + context.on('CallExpression', node => { |
| 269 | + const method = getTrackedMethodName(node, context); |
| 270 | + if ( |
| 271 | + !methods.has(method) |
| 272 | + || !isDiscardedExpression(node) |
| 273 | + || isObviouslyNonArrayReceiver(node.callee.object, context) |
| 274 | + ) { |
| 275 | + return; |
| 276 | + } |
| 277 | + |
| 278 | + return { |
| 279 | + node: node.callee.property, |
| 280 | + messageId: MESSAGE_ID, |
| 281 | + data: { |
| 282 | + method, |
| 283 | + }, |
| 284 | + }; |
| 285 | + }); |
| 286 | +}; |
| 287 | + |
| 288 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 289 | +const config = { |
| 290 | + create, |
| 291 | + meta: { |
| 292 | + type: 'suggestion', |
| 293 | + docs: { |
| 294 | + description: 'Disallow ignoring the return value of selected array methods.', |
| 295 | + recommended: 'unopinionated', |
| 296 | + }, |
| 297 | + messages, |
| 298 | + }, |
| 299 | +}; |
| 300 | + |
| 301 | +export default config; |
0 commit comments