Skip to content

Commit 86a151a

Browse files
authored
feat: port rule max-nested-callbacks (#839)
1 parent 8ec8a7a commit 86a151a

7 files changed

Lines changed: 2231 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import (
135135
"github.com/web-infra-dev/rslint/internal/rules/max_depth"
136136
"github.com/web-infra-dev/rslint/internal/rules/max_lines"
137137
"github.com/web-infra-dev/rslint/internal/rules/max_lines_per_function"
138+
"github.com/web-infra-dev/rslint/internal/rules/max_nested_callbacks"
138139
"github.com/web-infra-dev/rslint/internal/rules/no_alert"
139140
"github.com/web-infra-dev/rslint/internal/rules/no_async_promise_executor"
140141
"github.com/web-infra-dev/rslint/internal/rules/no_await_in_loop"
@@ -629,6 +630,7 @@ func registerAllCoreEslintRules() {
629630
GlobalRuleRegistry.Register("max-depth", max_depth.MaxDepthRule)
630631
GlobalRuleRegistry.Register("max-lines", max_lines.MaxLinesRule)
631632
GlobalRuleRegistry.Register("max-lines-per-function", max_lines_per_function.MaxLinesPerFunctionRule)
633+
GlobalRuleRegistry.Register("max-nested-callbacks", max_nested_callbacks.MaxNestedCallbacksRule)
632634
GlobalRuleRegistry.Register("no-alert", no_alert.NoAlertRule)
633635
GlobalRuleRegistry.Register("no-async-promise-executor", no_async_promise_executor.NoAsyncPromiseExecutorRule)
634636
GlobalRuleRegistry.Register("no-await-in-loop", no_await_in_loop.NoAwaitInLoopRule)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package max_nested_callbacks
2+
3+
import (
4+
"fmt"
5+
"math"
6+
7+
"github.com/microsoft/typescript-go/shim/ast"
8+
"github.com/web-infra-dev/rslint/internal/rule"
9+
"github.com/web-infra-dev/rslint/internal/utils"
10+
)
11+
12+
// MaxNestedCallbacksRule enforces a maximum depth of nested callbacks.
13+
// https://eslint.org/docs/latest/rules/max-nested-callbacks
14+
var MaxNestedCallbacksRule = rule.Rule{
15+
Name: "max-nested-callbacks",
16+
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
17+
threshold := parseThreshold(options)
18+
19+
// `callbackStack` mirrors ESLint's stack of FunctionExpression /
20+
// ArrowFunctionExpression nodes that sit *directly* under a
21+
// CallExpression — i.e. function-likes used as call arguments or as
22+
// the callee of an immediately-invoked call. Push happens only when
23+
// the (paren-flattened) parent is a CallExpression; pop fires on
24+
// every function-like exit, even when the entry didn't push. The
25+
// asymmetry is preserved verbatim from ESLint to keep diagnostic
26+
// counts byte-for-byte identical with upstream.
27+
var callbackStack []*ast.Node
28+
29+
check := func(node *ast.Node) {
30+
// tsgo preserves ParenthesizedExpression nodes that ESTree
31+
// flattens. Walk them so `(function(){})()` and `foo((fn))` are
32+
// recognized as the function's "real" parent being a CallExpression.
33+
parent := ast.WalkUpParenthesizedExpressions(node.Parent)
34+
if parent != nil && ast.IsCallExpression(parent) {
35+
callbackStack = append(callbackStack, node)
36+
}
37+
if len(callbackStack) > threshold {
38+
ctx.ReportNode(node, buildExceedMessage(len(callbackStack), threshold))
39+
}
40+
}
41+
pop := func(node *ast.Node) {
42+
// JS Array#pop on an empty array is a no-op; bound the slice
43+
// likewise so the asymmetric exit-pop can't underflow.
44+
if len(callbackStack) > 0 {
45+
callbackStack = callbackStack[:len(callbackStack)-1]
46+
}
47+
}
48+
49+
return rule.RuleListeners{
50+
// FunctionExpression / ArrowFunction map directly to ESLint's
51+
// FunctionExpression / ArrowFunctionExpression — push when the
52+
// (paren-flattened) parent is a CallExpression, pop on exit.
53+
ast.KindFunctionExpression: check,
54+
rule.ListenerOnExit(ast.KindFunctionExpression): pop,
55+
ast.KindArrowFunction: check,
56+
rule.ListenerOnExit(ast.KindArrowFunction): pop,
57+
58+
// In ESTree, class methods / getters / setters / constructors and
59+
// object-shorthand methods are all `FunctionExpression` nodes
60+
// nested inside `MethodDefinition` / `Property`. ESLint's listener
61+
// therefore fires on each of them — performing both the threshold
62+
// check on entry (without pushing, since parent is not a
63+
// CallExpression) and the unconditional pop on exit. tsgo
64+
// represents these as distinct AST kinds with no inner FE node, so
65+
// we wire entry+exit on each kind to reproduce the diagnostic
66+
// count exactly. The reported start position is the tsgo node
67+
// itself rather than the wrapped FE — message text, messageId,
68+
// num / max values, and line all match upstream; column may shift
69+
// by the length of the method name and modifiers.
70+
ast.KindMethodDeclaration: check,
71+
rule.ListenerOnExit(ast.KindMethodDeclaration): pop,
72+
ast.KindGetAccessor: check,
73+
rule.ListenerOnExit(ast.KindGetAccessor): pop,
74+
ast.KindSetAccessor: check,
75+
rule.ListenerOnExit(ast.KindSetAccessor): pop,
76+
ast.KindConstructor: check,
77+
rule.ListenerOnExit(ast.KindConstructor): pop,
78+
}
79+
},
80+
}
81+
82+
func buildExceedMessage(num, threshold int) rule.RuleMessage {
83+
return rule.RuleMessage{
84+
Id: "exceed",
85+
Description: fmt.Sprintf("Too many nested callbacks (%d). Maximum allowed is %d.", num, threshold),
86+
}
87+
}
88+
89+
// parseThreshold resolves the configured maximum nesting depth, mirroring
90+
// ESLint's `option.maximum || option.max` coercion. The legacy `maximum` key
91+
// is honored only when truthy (matching JS coercion); otherwise `max` wins.
92+
// When neither key is present, the default is 10.
93+
func parseThreshold(options any) int {
94+
const defaultMax = 10
95+
if options == nil {
96+
return defaultMax
97+
}
98+
// Number form: `3` or `[3]`.
99+
if arr, ok := options.([]interface{}); ok {
100+
if len(arr) == 0 {
101+
return defaultMax
102+
}
103+
if n, ok := utils.CoerceInt(arr[0]); ok {
104+
return n
105+
}
106+
} else if n, ok := utils.CoerceInt(options); ok {
107+
return n
108+
}
109+
// Object form: `{ max: 3 }` or `[{ max: 3 }]`. Use the shared extractor so
110+
// both the array-wrapped (rule_tester / multi-element CLI) and bare-object
111+
// (single-option CLI) shapes are handled uniformly.
112+
m := utils.GetOptionsMap(options)
113+
if m == nil {
114+
return defaultMax
115+
}
116+
_, hasMaximum := m["maximum"]
117+
_, hasMax := m["max"]
118+
if !hasMaximum && !hasMax {
119+
// Matches ESLint: when neither key is present, the option object is
120+
// ignored and the default is used.
121+
return defaultMax
122+
}
123+
if hasMaximum {
124+
if v, ok := utils.CoerceInt(m["maximum"]); ok && v != 0 {
125+
return v
126+
}
127+
}
128+
if hasMax {
129+
if v, ok := utils.CoerceInt(m["max"]); ok {
130+
return v
131+
}
132+
}
133+
// `option.maximum` is present but coerces to 0 / non-numeric, and
134+
// `option.max` is absent or non-numeric: ESLint sets `THRESHOLD = undefined`
135+
// here, which makes every `length > THRESHOLD` comparison false and
136+
// effectively disables the check. MaxInt produces the same observable
137+
// behavior.
138+
return math.MaxInt
139+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# max-nested-callbacks
2+
3+
## Rule Details
4+
5+
This rule enforces a maximum depth that callbacks can be nested to improve
6+
code readability. A common anti-pattern is "callback hell" — deeply nested
7+
callbacks that grow rightward and become hard to follow.
8+
9+
A function expression or arrow function counts toward the nesting depth only
10+
when it is passed directly to a call (as a call argument or as the callee of
11+
an immediately-invoked call). Function-likes assigned to variables, object or
12+
class properties, array elements, JSX attributes, default parameter values,
13+
or used as `new` arguments / tagged-template arguments do not increase the
14+
counter.
15+
16+
Examples of **incorrect** code for this rule with the default `{ "max": 10 }`
17+
option:
18+
19+
```javascript
20+
foo1(function () {
21+
foo2(function () {
22+
foo3(function () {
23+
foo4(function () {
24+
foo5(function () {
25+
foo6(function () {
26+
foo7(function () {
27+
foo8(function () {
28+
foo9(function () {
29+
foo10(function () {
30+
foo11(function () {});
31+
});
32+
});
33+
});
34+
});
35+
});
36+
});
37+
});
38+
});
39+
});
40+
});
41+
```
42+
43+
Examples of **correct** code for this rule with the default `{ "max": 10 }`
44+
option:
45+
46+
```javascript
47+
foo1(handleFoo1);
48+
49+
function handleFoo1() {
50+
foo2(handleFoo2);
51+
}
52+
53+
function handleFoo2() {
54+
foo3(handleFoo3);
55+
}
56+
```
57+
58+
## Options
59+
60+
This rule accepts a number, or an object with the following properties:
61+
62+
- `max` (default `10`): the maximum nesting depth allowed.
63+
- `maximum`: deprecated alias for `max`. When both keys are present and
64+
`maximum` is truthy, `maximum` wins (matching ESLint's
65+
`option.maximum || option.max` coercion).
66+
67+
### `max`
68+
69+
Examples of **incorrect** code for this rule with `{ "max": 3 }`:
70+
71+
```json
72+
{ "max-nested-callbacks": ["error", { "max": 3 }] }
73+
```
74+
75+
```javascript
76+
foo1(function () {
77+
foo2(function () {
78+
foo3(function () {
79+
foo4(function () {});
80+
});
81+
});
82+
});
83+
```
84+
85+
Examples of **correct** code for this rule with `{ "max": 3 }`:
86+
87+
```json
88+
{ "max-nested-callbacks": ["error", { "max": 3 }] }
89+
```
90+
91+
```javascript
92+
foo1(function () {
93+
foo2(function () {
94+
foo3(function () {});
95+
});
96+
});
97+
```
98+
99+
Arrow functions are counted the same as function expressions:
100+
101+
```javascript
102+
foo1(() => {
103+
foo2(() => {
104+
foo3(() => {
105+
foo4(() => {});
106+
});
107+
});
108+
});
109+
```
110+
111+
## Original Documentation
112+
113+
- [https://eslint.org/docs/latest/rules/max-nested-callbacks](https://eslint.org/docs/latest/rules/max-nested-callbacks)

0 commit comments

Comments
 (0)