-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(linter): add promise/no-nesting
#9345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
camc314
merged 51 commits into
oxc-project:main
from
therewillbecode:promise/no-nesting
Feb 27, 2025
Merged
Changes from 47 commits
Commits
Show all changes
51 commits
Select commit
Hold shift + click to select a range
eed522c
Init no-nesting rule
therewillbecode 58a3f95
wip
therewillbecode 236c4e6
wip
therewillbecode 73c48c3
wip
therewillbecode a276991
wip
therewillbecode 2ed7b44
wip
therewillbecode 26229b3
wip
therewillbecode 26e7542
wip
therewillbecode 374dc16
wip
therewillbecode b1790b4
wip
therewillbecode f508186
wip
therewillbecode dc03943
wip
therewillbecode d8f2b11
wip
therewillbecode 203031d
wip
therewillbecode d0b94a6
wip
therewillbecode 3d42766
wip
therewillbecode a175548
wip
therewillbecode 458b908
wip
therewillbecode 9de20e3
wip
therewillbecode f2703eb
wip
therewillbecode 1c3cea7
wip
therewillbecode 4a81897
wip
therewillbecode 8126d51
wip
therewillbecode 4cb1daa
wip
therewillbecode 718ee6c
wip
therewillbecode 3a928af
Tests passing for no-nesting
therewillbecode c84647d
Correct indentation in tests
therewillbecode ad5e6dc
wip refactor
therewillbecode cf91f3d
wip refactor
therewillbecode 272222f
wip refactor
therewillbecode ad4ca8d
wip refactor
therewillbecode ef6f3b4
wip refactor
therewillbecode 3d48f94
Add promise/no-nesting insta snapshot
therewillbecode ad86633
Add test cases to promise/no-nesting
therewillbecode 6316573
wip refactor
therewillbecode 8aeff06
[autofix.ci] apply automated fixes
autofix-ci[bot] 7acbbf9
clippy fixes
therewillbecode 4b2172b
Remove unneeded file
therewillbecode d232a25
Better naming
therewillbecode a8a2ad2
Remove unneeded var bindings
therewillbecode 5f58040
Remove redundant filter on iterator
therewillbecode 51b8929
Better docs for function
therewillbecode 3ab9855
Better wording in comment
therewillbecode f22637c
Revert "Remove redundant filter on iterator"
therewillbecode c1b719a
Remove use of allocator
therewillbecode 7506ab2
Use iter
therewillbecode cdb8933
Small refactor
therewillbecode e8867f7
Fix comment wording
therewillbecode c3d40e5
Better naming
therewillbecode 9e41efb
Dont do unnecessary work
therewillbecode 4f4cc7a
Change return type to bool as option unused
therewillbecode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,306 @@ | ||
| use oxc_ast::{ | ||
| AstKind, | ||
| ast::{CallExpression, Expression, MemberExpression}, | ||
| }; | ||
| use oxc_diagnostics::OxcDiagnostic; | ||
| use oxc_macros::declare_oxc_lint; | ||
| use oxc_semantic::ScopeId; | ||
| use oxc_span::{GetSpan, Span}; | ||
|
|
||
| use crate::{AstNode, context::LintContext, rule::Rule}; | ||
|
|
||
| fn no_nesting_diagnostic(span: Span) -> OxcDiagnostic { | ||
| OxcDiagnostic::warn("Avoid nesting promises.") | ||
| .with_help("Refactor so that promises are chained in a flat manner.") | ||
| .with_label(span) | ||
| } | ||
|
|
||
| #[derive(Debug, Default, Clone)] | ||
| pub struct NoNesting; | ||
|
|
||
| declare_oxc_lint!( | ||
| /// ### What it does | ||
| /// | ||
| /// Disallow nested then() or catch() statements. | ||
| /// | ||
| /// ### Why is this bad? | ||
| /// | ||
| /// Nesting promises makes code harder to read and understand. | ||
| /// | ||
| /// ### Examples | ||
| /// | ||
| /// Examples of **incorrect** code for this rule: | ||
| /// ```javascript | ||
| /// doThing().then(() => a.then()) | ||
| /// ``` | ||
| /// | ||
| /// ```javascript | ||
| /// doThing().then(function() { a.then() }) | ||
| /// ``` | ||
| /// | ||
| /// ```javascript | ||
| /// doThing().then(() => { b.catch() }) | ||
| /// ``` | ||
| /// | ||
| /// Examples of **correct** code for this rule: | ||
| /// ```javascript | ||
| /// doThing().then(() => 4) | ||
| /// ``` | ||
| /// | ||
| /// ```javascript | ||
| /// doThing().then(function() { return 4 }) | ||
| /// ``` | ||
| /// | ||
| /// ```javascript | ||
| /// doThing().catch(() => 4) | ||
| /// ``` | ||
| /// | ||
| /// ```javascript | ||
| /// doThing() | ||
| /// .then(() => Promise.resolve(1)) | ||
| /// .then(() => Promise.resolve(2)) | ||
| /// ``` | ||
| /// | ||
| /// This example is not a rule violation as unnesting here would | ||
| /// result in `a` being undefined in the expression `getC(a, b)`. | ||
| /// ```javascript | ||
| /// doThing() | ||
| /// .then(a => getB(a) | ||
| /// .then(b => getC(a, b)) | ||
| /// ) | ||
| /// ``` | ||
| NoNesting, | ||
| promise, | ||
| style, | ||
| pending | ||
| ); | ||
|
|
||
| fn is_inside_promise(node: &AstNode, ctx: &LintContext) -> bool { | ||
| if !matches!(node.kind(), AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)) | ||
| || !matches!(ctx.nodes().parent_kind(node.id()), Some(AstKind::Argument(_))) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| ctx.nodes() | ||
| .ancestors(node.id()) | ||
| .nth(2) | ||
| .is_some_and(|node| node.kind().as_call_expression().is_some_and(has_promise_callback)) | ||
| } | ||
|
|
||
| /// Gets the closest promise callback function of the nested promise. | ||
| fn closest_promise_cb<'a, 'b>( | ||
| node: &'a AstNode<'b>, | ||
| ctx: &'a LintContext<'b>, | ||
| ) -> Option<&'a CallExpression<'b>> { | ||
| ctx.nodes() | ||
| .ancestors(node.id()) | ||
| .filter_map(|node| node.kind().as_call_expression()) | ||
| .filter(|a| has_promise_callback(a)) | ||
| .nth(1) | ||
| } | ||
|
|
||
| fn has_promise_callback(call_expr: &CallExpression) -> bool { | ||
| matches!( | ||
| call_expr.callee.as_member_expression().and_then(MemberExpression::static_property_name), | ||
| Some("then" | "catch") | ||
| ) | ||
| } | ||
|
|
||
| fn is_promise_then_or_catch(call_expr: &CallExpression) -> Option<String> { | ||
| let member_expr = call_expr.callee.get_member_expr()?; | ||
| let prop_name = member_expr.static_property_name()?; | ||
|
|
||
| // For example: hello.then(), hello.catch() | ||
| if matches!(prop_name, "then" | "catch") { | ||
| return Some(prop_name.into()); | ||
| } | ||
|
|
||
| None | ||
| } | ||
|
|
||
| /// Checks if we can safely unnest the promise callback. | ||
| /// | ||
| /// 1. This function gets variable bindings defined in closest parent promise callback function | ||
| /// scope. | ||
| /// | ||
| /// 2. Checks if the argument callback of the nested promise call uses any of these variables | ||
| /// and if so returns `false` to denote that the promises cannot be safely unnested. | ||
| /// | ||
| /// Here is an example of a nested promise which isn't safe to nest without further refactoring. | ||
| /// ```javascript | ||
| /// doThing() | ||
| /// .then(a => getB(a) <---- 1. Get this scopes bound variables | ||
| /// .then(b => getC(a, b)) <--- 2. Check for references to the bound variables from 1. | ||
| /// ) | ||
| /// ``` | ||
| /// | ||
| /// In this case unnesting is not safe as doing so would result in `a` being undefined in the | ||
| /// expression `getC(a, b)`, as seen below in the unnested version of the example: | ||
| /// ```javascript | ||
| /// doThing() | ||
| /// .then(a => getB(a)) | ||
| /// .then(b => getC(a, b)) | ||
| /// ``` | ||
| fn can_safely_unnest( | ||
| call_expr: &CallExpression, | ||
| closest: &CallExpression, | ||
| ctx: &LintContext, | ||
| ) -> bool { | ||
| let mut safe_to_unnest: bool = true; | ||
|
|
||
| closest.arguments.iter().for_each(|new_expr| { | ||
| if let Some(arg_expr) = new_expr.as_expression() { | ||
| match arg_expr { | ||
| Expression::ArrowFunctionExpression(arrow_expr) => { | ||
| let scope = arrow_expr.scope_id(); | ||
| if usage_of_closest_cb_vars(scope, call_expr, ctx) { | ||
| safe_to_unnest = false; | ||
| } | ||
| } | ||
| Expression::FunctionExpression(func_expr) => { | ||
| let scope = func_expr.scope_id(); | ||
| if usage_of_closest_cb_vars(scope, call_expr, ctx) { | ||
| safe_to_unnest = false; | ||
| } | ||
| } | ||
| _ => {} | ||
| } | ||
| }; | ||
| }); | ||
|
|
||
| safe_to_unnest | ||
| } | ||
|
|
||
| /// Check check for references in cb_span to variables defined in the closest parent cb scope. | ||
| /// In the given example we would loop through all bindings in the closest | ||
| /// parent scope a,b,c,d. | ||
| /// | ||
| /// .then((a,b,c) => { // closest_cb_scope_id | ||
| /// const d = 5; | ||
| /// getB(a).then(d => getC(a, b)) }); | ||
| /// // ^^^^^^^^^^^^^^ <- `cb_span` | ||
| fn usage_of_closest_cb_vars( | ||
| closest_cb_scope_id: ScopeId, | ||
| cb_call_expr: &CallExpression, | ||
| ctx: &LintContext, | ||
| ) -> bool { | ||
| if let Some(cb_span) = cb_call_expr.arguments.first().map(GetSpan::span) { | ||
|
camc314 marked this conversation as resolved.
Outdated
|
||
| for (_, binding_symbol_id) in ctx.scopes().get_bindings(closest_cb_scope_id).iter() { | ||
| for usage in ctx.semantic().symbol_references(*binding_symbol_id) { | ||
| let usage_span: Span = ctx.reference_span(usage); | ||
| if cb_span.contains_inclusive(usage_span) { | ||
| // Cannot unnest this nested promise as the nested cb refers to a variable | ||
| // defined in the parent promise callback scope. Unnesting would result in | ||
| // reference to an undefined variable. | ||
| return true; | ||
| }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| false | ||
| } | ||
|
|
||
| impl Rule for NoNesting { | ||
| fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { | ||
| let AstKind::CallExpression(call_expr) = node.kind() else { | ||
| return; | ||
| }; | ||
|
|
||
| if is_promise_then_or_catch(call_expr).is_none() { | ||
| return; | ||
| }; | ||
|
|
||
| let mut ancestors = ctx.nodes().ancestors(node.id()); | ||
| if ancestors.any(|node| is_inside_promise(node, ctx)) { | ||
| match closest_promise_cb(node, ctx) { | ||
| Some(closest) => { | ||
| if can_safely_unnest(call_expr, closest, ctx) { | ||
| ctx.diagnostic(no_nesting_diagnostic(call_expr.callee.span())); | ||
| } | ||
| } | ||
| None => ctx.diagnostic(no_nesting_diagnostic(call_expr.callee.span())), | ||
| } | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test() { | ||
| use crate::tester::Tester; | ||
|
|
||
| let pass = vec![ | ||
| "Promise.resolve(4).then(function(x) { return x })", | ||
| "Promise.reject(4).then(function(x) { return x })", | ||
| "Promise.resolve(4).then(function() {})", | ||
| "Promise.reject(4).then(function() {})", | ||
| "doThing().then(function() { return 4 })", | ||
| "doThing().then(function() { throw 4 })", | ||
| "doThing().then(null, function() { return 4 })", | ||
| "doThing().then(null, function() { throw 4 })", | ||
| "doThing().catch(null, function() { return 4 })", | ||
| "doThing().catch(null, function() { throw 4 })", | ||
| "doThing().then(() => 4)", | ||
| "doThing().then(() => { throw 4 })", | ||
| "doThing().then(()=>{}, () => 4)", | ||
| "doThing().then(()=>{}, () => { throw 4 })", | ||
| "doThing().catch(() => 4)", | ||
| "doThing().catch(() => { throw 4 })", | ||
| "var x = function() { return Promise.resolve(4) }", | ||
| "function y() { return Promise.resolve(4) }", | ||
| "function then() { return Promise.reject() }", | ||
| "doThing(function(x) { return Promise.reject(x) })", | ||
| "doThing().then(function() { return Promise.all([a,b,c]) })", | ||
| "doThing().then(function() { return Promise.resolve(4) })", | ||
| "doThing().then(() => Promise.resolve(4))", | ||
| "doThing() | ||
| .then(() => Promise.resolve(1)) | ||
| .then(() => Promise.resolve(2))", | ||
| "doThing().then(() => Promise.all([a]))", | ||
| "doThing() | ||
| .then(a => getB(a) | ||
| .then(b => getC(a, b)) | ||
| )", | ||
| "doThing() | ||
| .then(a => getB(a) | ||
| .then(function(b) { getC(a, b) }) | ||
| )", | ||
| "doThing() | ||
| .then(a => { | ||
| const c = a * 2; | ||
| return getB(c).then(b => getC(c, b)) | ||
| })", | ||
| "doThing() | ||
| .then(function (a) { | ||
| const c = a * 2; | ||
| return getB(c).then(function () { getC(c, b) } ) | ||
| })", | ||
| ]; | ||
|
|
||
| let fail = vec![ | ||
| "doThing().then(function() { a.then() })", | ||
| "doThing().then(function() { b.catch() })", | ||
| "doThing().then(function() { return a.then() })", | ||
| "doThing().then(function() { return b.catch() })", | ||
| "doThing().then(() => { a.then() })", | ||
| "doThing().then(() => { b.catch() })", | ||
| "doThing().then(() => a.then())", | ||
| "doThing().then(() => b.catch())", | ||
| "doThing() | ||
| .then(() => | ||
| a.then(() => Promise.resolve(1)))", | ||
| "doThing() | ||
| .then(a => getB(a) | ||
| .then(b => getC(b)) | ||
| )", | ||
| "doThing() | ||
| .then(a => getB(a) | ||
| .then(b => getC(a, b) | ||
| .then(c => getD(a, c)) | ||
| ) | ||
| )", | ||
| ]; | ||
|
|
||
| Tester::new(NoNesting::NAME, NoNesting::PLUGIN, pass, fail).test_and_snapshot(); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.