Files
millionco__react-doctor/native/oxlint/rules/no-enter-submit-without-ime-composition-guard.rs
2026-08-30 00:30:27 -07:00

1007 lines
35 KiB
Rust

use oxc_ast::{
AstKind,
ast::{
BinaryExpression, CallExpression, Expression, JSXAttribute, JSXAttributeItem,
JSXAttributeName, JSXAttributeValue, Statement,
},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_semantic::NodeId;
use oxc_span::{GetSpan, Span};
use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator};
use rustc_hash::FxHashSet;
use crate::{AstNode, context::LintContext, rule::Rule};
const MESSAGE: &str = "This text-entry Enter handler commits/submits without bailing on IME composition, so it fires mid-composition for CJK users pressing Enter to confirm a candidate. Bail first with `if (e.nativeEvent.isComposing) return;` (or track `onCompositionStart`/`onCompositionEnd`) before acting on Enter.";
const NON_TEXT_INPUT_TYPES: [&str; 18] = [
"radio",
"checkbox",
"button",
"submit",
"reset",
"file",
"range",
"color",
"image",
"hidden",
"number",
"password",
"tel",
"date",
"time",
"week",
"month",
"datetime-local",
];
const NON_TEXT_ENTRY_ROLES: [&str; 14] = [
"button",
"radio",
"checkbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"tab",
"switch",
"link",
"slider",
"spinbutton",
"treeitem",
"gridcell",
];
#[derive(Debug, Default, Clone)]
pub struct NoEnterSubmitWithoutImeCompositionGuard;
declare_oxc_lint!(
/// Require IME composition protection before Enter commits text input.
NoEnterSubmitWithoutImeCompositionGuard,
react_doctor_native,
correctness,
version = "0.1.0",
short_description = "Require an IME composition guard before Enter submit.",
);
impl Rule for NoEnterSubmitWithoutImeCompositionGuard {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
let AstKind::JSXOpeningElement(opening) = node.kind() else {
return;
};
if file_is_non_react_jsx_dialect(ctx) || !ime_is_text_entry(opening, ctx) {
return;
}
let Some(handler_node) = ime_handler_node(opening, ctx) else {
return;
};
for candidate in ctx.nodes().iter() {
let AstKind::BinaryExpression(binary) = candidate.kind() else {
continue;
};
if ime_nearest_function_id(candidate, ctx) != Some(handler_node.id())
|| !ime_is_enter_test(binary)
{
continue;
}
let Some(branch) = ime_analyze_enter_branch(candidate, ctx) else {
continue;
};
if ime_composition_is_active_when_predicate(branch.test, true, ctx) == Some(false)
|| ime_has_prior_composition_early_exit(
handler_node.span(),
branch.test.span(),
ctx,
)
|| ime_expression_requires_modifier(
branch.test,
true,
ctx,
&mut FxHashSet::default(),
)
|| ime_scope_uses_space(branch.test, ctx)
|| !ime_branch_performs_commit(branch.action_span, handler_node.id(), ctx)
|| ime_scope_commits_are_composition_guarded(
branch.action_span,
handler_node.id(),
&mut FxHashSet::default(),
ctx,
)
{
continue;
}
ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(opening.name.span()));
return;
}
}
}
fn ime_attribute<'a>(
opening: &'a oxc_ast::ast::JSXOpeningElement<'a>,
name: &str,
) -> Option<&'a JSXAttribute<'a>> {
opening
.attributes
.iter()
.find_map(|item| match item {
JSXAttributeItem::Attribute(attribute)
if matches!(&attribute.name, JSXAttributeName::Identifier(identifier) if identifier.name.eq_ignore_ascii_case(name)) =>
{
Some(attribute)
}
_ => None,
})
.map(|attribute| &**attribute)
}
fn ime_static_string<'a>(attribute: &'a JSXAttribute<'a>) -> Option<&'a str> {
match &attribute.value {
Some(JSXAttributeValue::StringLiteral(value)) => Some(value.value.as_str()),
Some(JSXAttributeValue::ExpressionContainer(container)) => {
match container
.expression
.as_expression()
.map(Expression::get_inner_expression)
{
Some(Expression::StringLiteral(value)) => Some(value.value.as_str()),
_ => None,
}
}
_ => None,
}
}
fn ime_potentially_truthy(attribute: &JSXAttribute<'_>) -> bool {
match &attribute.value {
None => true,
Some(JSXAttributeValue::StringLiteral(_)) => true,
Some(JSXAttributeValue::ExpressionContainer(container)) => {
match container
.expression
.as_expression()
.map(Expression::get_inner_expression)
{
Some(Expression::BooleanLiteral(value)) => value.value,
Some(Expression::NullLiteral(_)) => false,
Some(Expression::UnaryExpression(unary)) if is_literal_void_expression(unary) => {
false
}
_ => true,
}
}
_ => true,
}
}
fn ime_is_text_entry<'a>(
opening: &'a oxc_ast::ast::JSXOpeningElement<'a>,
ctx: &LintContext<'a>,
) -> bool {
let tag_name = opening
.name
.get_identifier_name()
.map_or("", |name| name.as_str());
let role = ime_attribute(opening, "role")
.and_then(ime_static_string)
.unwrap_or("");
if NON_TEXT_ENTRY_ROLES.contains(&role)
|| ["readOnly", "disabled"]
.iter()
.any(|name| ime_attribute(opening, name).is_some_and(ime_potentially_truthy))
{
return false;
}
if ime_attribute(opening, "inputMode")
.and_then(ime_static_string)
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "numeric" | "decimal"))
|| ime_numeric_change_handler(opening, ctx)
{
return false;
}
if tag_name.eq_ignore_ascii_case("textarea") {
return true;
}
if tag_name.eq_ignore_ascii_case("input") {
if let Some(input_type) = ime_attribute(opening, "type").and_then(ime_static_string) {
return !NON_TEXT_INPUT_TYPES
.iter()
.any(|candidate| input_type.eq_ignore_ascii_case(candidate));
}
return !ime_dynamic_input_type_can_be_non_text(opening, ctx);
}
if ime_attribute(opening, "contentEditable").is_some_and(ime_content_editable_is_editable) {
return true;
}
matches!(role, "textbox" | "searchbox" | "combobox")
}
fn ime_handler_node<'a, 'b>(
opening: &'a oxc_ast::ast::JSXOpeningElement<'a>,
ctx: &'b LintContext<'a>,
) -> Option<&'b AstNode<'a>> {
for attribute_name in ["onKeyDown", "onKeyUp"] {
let Some(attribute) = ime_attribute(opening, attribute_name) else {
continue;
};
let Some(JSXAttributeValue::ExpressionContainer(container)) = attribute.value.as_ref()
else {
continue;
};
let Some(expression) = container.expression.as_expression() else {
continue;
};
if let Some(function) = ime_resolve_function_expression(expression, ctx) {
return Some(function);
}
}
None
}
fn ime_resolve_function_expression<'a, 'b>(
expression: &Expression<'a>,
ctx: &'b LintContext<'a>,
) -> Option<&'b AstNode<'a>> {
match expression.get_inner_expression() {
Expression::ArrowFunctionExpression(function) => {
Some(ctx.nodes().get_node(function.node_id.get()))
}
Expression::FunctionExpression(function) => {
Some(ctx.nodes().get_node(function.node_id.get()))
}
Expression::Identifier(identifier) => {
let symbol_id = ctx
.scoping()
.get_reference(identifier.reference_id())
.symbol_id()?;
let declaration = ctx.symbol_declaration(symbol_id);
match declaration.kind() {
AstKind::Function(_) => Some(declaration),
AstKind::VariableDeclarator(declarator) => {
ime_resolve_function_expression(declarator.init.as_ref()?, ctx)
}
_ => None,
}
}
_ => None,
}
}
fn ime_dynamic_input_type_can_be_non_text(
opening: &oxc_ast::ast::JSXOpeningElement<'_>,
ctx: &LintContext<'_>,
) -> bool {
let Some(JSXAttributeValue::ExpressionContainer(container)) =
ime_attribute(opening, "type").and_then(|attribute| attribute.value.as_ref())
else {
return false;
};
let Some(expression) = container.expression.as_expression() else {
return false;
};
ctx.nodes().iter().any(|candidate| {
expression.span().contains_inclusive(candidate.span())
&& matches!(candidate.kind(), AstKind::StringLiteral(literal)
if NON_TEXT_INPUT_TYPES.iter().any(|input_type| literal.value.eq_ignore_ascii_case(input_type)))
})
}
fn ime_content_editable_is_editable(attribute: &JSXAttribute<'_>) -> bool {
match &attribute.value {
None => true,
Some(JSXAttributeValue::StringLiteral(value)) => value.value != "false",
Some(JSXAttributeValue::ExpressionContainer(container)) => {
match container
.expression
.as_expression()
.map(Expression::get_inner_expression)
{
Some(Expression::BooleanLiteral(value)) => value.value,
Some(Expression::StringLiteral(value)) => value.value != "false",
_ => true,
}
}
_ => true,
}
}
fn ime_numeric_change_handler<'a>(
opening: &'a oxc_ast::ast::JSXOpeningElement<'a>,
ctx: &LintContext<'a>,
) -> bool {
let Some(JSXAttributeValue::ExpressionContainer(container)) =
ime_attribute(opening, "onChange").and_then(|attribute| attribute.value.as_ref())
else {
return false;
};
let Some(expression) = container.expression.as_expression() else {
return false;
};
let Some(handler) = ime_resolve_function_expression(expression, ctx) else {
return false;
};
ctx.nodes().iter().any(|candidate| {
if !handler.span().contains_inclusive(candidate.span()) {
return false;
}
match candidate.kind() {
AstKind::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryPlus => {
ime_expression_reads_value_member(&unary.argument, ctx)
}
AstKind::CallExpression(call) => ime_call_coerces_value_numerically(call, ctx),
_ => false,
}
})
}
fn ime_call_coerces_value_numerically(call: &CallExpression<'_>, ctx: &LintContext<'_>) -> bool {
let callee = call.callee.get_inner_expression();
let numeric_callee = match callee {
Expression::Identifier(identifier) => {
matches!(
identifier.name.as_str(),
"Number" | "parseInt" | "parseFloat"
)
}
Expression::StaticMemberExpression(member) => {
matches!(member.object.get_inner_expression(), Expression::Identifier(identifier) if identifier.name == "Number")
&& matches!(
member.property.name.as_str(),
"Number" | "parseInt" | "parseFloat"
)
}
_ => false,
};
if numeric_callee {
return call
.arguments
.first()
.and_then(|argument| argument.as_expression())
.is_some_and(|argument| ime_expression_reads_value_member(argument, ctx));
}
let Some(member) = callee.as_member_expression() else {
return false;
};
if member.static_property_name().as_deref() != Some("replace")
|| !ime_expression_reads_value_member(member.object(), ctx)
{
return false;
}
let Some(source) = ctx
.source_text()
.get(call.span.start as usize..call.span.end as usize)
else {
return false;
};
source.contains(r"/\D") || source.contains("/[^0-9]") || source.contains(r"/[^\d]")
}
fn ime_expression_reads_value_member(expression: &Expression<'_>, ctx: &LintContext<'_>) -> bool {
ctx.nodes().iter().any(|candidate| {
expression.span().contains_inclusive(candidate.span())
&& matches!(candidate.kind(), AstKind::StaticMemberExpression(member) if member.property.name == "value")
|| expression.span().contains_inclusive(candidate.span())
&& matches!(candidate.kind(), AstKind::ComputedMemberExpression(member) if member.static_property_name().as_deref() == Some("value"))
})
}
struct ImeEnterBranch<'a, 'b> {
test: &'b Expression<'a>,
action_span: Span,
}
fn ime_is_enter_test(binary: &BinaryExpression<'_>) -> bool {
if !matches!(
binary.operator,
BinaryOperator::Equality | BinaryOperator::StrictEquality
) {
return false;
}
ime_member_matches_enter_value(&binary.left, &binary.right)
|| ime_member_matches_enter_value(&binary.right, &binary.left)
}
fn ime_member_matches_enter_value(member: &Expression<'_>, value: &Expression<'_>) -> bool {
let Some(property_name) = member
.get_inner_expression()
.as_member_expression()
.and_then(|member| member.static_property_name())
else {
return false;
};
match (property_name.as_ref(), value.get_inner_expression()) {
("key", Expression::StringLiteral(value)) => value.value == "Enter",
("keyCode" | "which", Expression::NumericLiteral(value)) => value.value == 13.0,
_ => false,
}
}
fn ime_analyze_enter_branch<'a, 'b>(
enter_test: &'b AstNode<'a>,
ctx: &'b LintContext<'a>,
) -> Option<ImeEnterBranch<'a, 'b>> {
let mut previous_span = enter_test.span();
for ancestor in ctx.nodes().ancestors(enter_test.id()) {
match ancestor.kind() {
AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) => return None,
AstKind::IfStatement(statement) => {
return (statement.test.span() == previous_span).then_some(ImeEnterBranch {
test: &statement.test,
action_span: statement.consequent.span(),
});
}
AstKind::ConditionalExpression(expression) => {
return (expression.test.span() == previous_span).then_some(ImeEnterBranch {
test: &expression.test,
action_span: expression.consequent.span(),
});
}
AstKind::ExpressionStatement(statement) => {
let expression = statement.expression.get_inner_expression();
return matches!(expression, Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And)
.then_some(ImeEnterBranch {
test: &statement.expression,
action_span: statement.expression.span(),
});
}
_ => previous_span = ancestor.span(),
}
}
None
}
fn ime_nearest_function_id(node: &AstNode<'_>, ctx: &LintContext<'_>) -> Option<NodeId> {
ctx.nodes().ancestors(node.id()).find_map(|ancestor| {
matches!(
ancestor.kind(),
AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
)
.then_some(ancestor.id())
})
}
fn ime_expression_requires_modifier<'a>(
expression: &Expression<'a>,
predicate_result: bool,
ctx: &LintContext<'a>,
visited_functions: &mut FxHashSet<NodeId>,
) -> bool {
match expression.get_inner_expression() {
Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
ime_expression_requires_modifier(
&unary.argument,
!predicate_result,
ctx,
visited_functions,
)
}
Expression::LogicalExpression(logical) => match (logical.operator, predicate_result) {
(LogicalOperator::And, true) | (LogicalOperator::Or, false) => {
ime_expression_requires_modifier(
&logical.left,
predicate_result,
ctx,
visited_functions,
) || ime_expression_requires_modifier(
&logical.right,
predicate_result,
ctx,
visited_functions,
)
}
(LogicalOperator::Or, true) => {
ime_expression_requires_modifier(&logical.left, true, ctx, visited_functions)
&& ime_expression_requires_modifier(
&logical.right,
true,
ctx,
visited_functions,
)
}
_ => false,
},
expression if expression.as_member_expression().is_some() => expression
.as_member_expression()
.and_then(|member| member.static_property_name())
.is_some_and(|property| {
predicate_result
&& matches!(
property.as_ref(),
"metaKey" | "ctrlKey" | "shiftKey" | "altKey"
)
}),
Expression::CallExpression(call) => {
let Some(function) = ime_resolve_called_function(call, ctx) else {
return false;
};
if !visited_functions.insert(function.id()) {
return false;
}
let result = ime_single_return_expression(function).is_some_and(|returned| {
ime_expression_requires_modifier(returned, predicate_result, ctx, visited_functions)
});
visited_functions.remove(&function.id());
result
}
_ => false,
}
}
fn ime_scope_uses_space(expression: &Expression<'_>, ctx: &LintContext<'_>) -> bool {
ctx.nodes().iter().any(|candidate| {
let AstKind::BinaryExpression(binary) = candidate.kind() else {
return false;
};
if !expression.span().contains_inclusive(candidate.span())
|| ctx
.nodes()
.ancestors(candidate.id())
.take_while(|ancestor| expression.span().contains_inclusive(ancestor.span()))
.any(|ancestor| matches!(ancestor.kind(), AstKind::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot))
{
return false;
}
matches!(binary.operator, BinaryOperator::Equality | BinaryOperator::StrictEquality)
&& (ime_member_matches_space_value(&binary.left, &binary.right)
|| ime_member_matches_space_value(&binary.right, &binary.left))
})
}
fn ime_member_matches_space_value(member: &Expression<'_>, value: &Expression<'_>) -> bool {
let Some(property_name) = member
.get_inner_expression()
.as_member_expression()
.and_then(|member| member.static_property_name())
else {
return false;
};
match (property_name.as_ref(), value.get_inner_expression()) {
("key", Expression::StringLiteral(value)) => {
matches!(value.value.as_str(), " " | "Spacebar")
}
("keyCode" | "which", Expression::NumericLiteral(value)) => value.value == 32.0,
_ => false,
}
}
fn ime_branch_performs_commit(
action_span: Span,
owner_function_id: NodeId,
ctx: &LintContext<'_>,
) -> bool {
ctx.nodes().iter().any(|candidate| {
let AstKind::CallExpression(call) = candidate.kind() else {
return false;
};
action_span.contains_inclusive(candidate.span())
&& ime_nearest_function_id(candidate, ctx) == Some(owner_function_id)
&& !ime_is_non_commit_call(call, ctx)
})
}
fn ime_is_non_commit_call(call: &CallExpression<'_>, ctx: &LintContext<'_>) -> bool {
let callee = call.callee.get_inner_expression();
let Some(member) = callee.as_member_expression() else {
return false;
};
let Some(property_name) = member.static_property_name() else {
return false;
};
if matches!(
property_name.as_ref(),
"preventDefault" | "stopPropagation" | "stopImmediatePropagation"
) {
return true;
}
matches!(
property_name.as_ref(),
"debug" | "error" | "info" | "log" | "trace" | "warn"
) && matches!(member.object().get_inner_expression(), Expression::Identifier(identifier)
if identifier.name == "console"
&& ctx.scoping().get_reference(identifier.reference_id()).symbol_id().is_none())
}
fn ime_identifier_has_ime_word(name: &str) -> bool {
let mut word = String::new();
let characters: Vec<char> = name.chars().collect();
for (index, character) in characters.iter().copied().enumerate() {
if matches!(character, '_' | '-' | '$') {
if word.eq_ignore_ascii_case("ime") {
return true;
}
word.clear();
continue;
}
let starts_new_word = !word.is_empty()
&& character.is_ascii_uppercase()
&& (characters[index - 1].is_ascii_lowercase()
|| characters[index - 1].is_ascii_digit()
|| characters
.get(index + 1)
.is_some_and(|next| next.is_ascii_lowercase()));
if starts_new_word {
if word.eq_ignore_ascii_case("ime") {
return true;
}
word.clear();
}
word.push(character);
}
word.eq_ignore_ascii_case("ime")
}
fn ime_subtree_has_composition_signal(expression: &Expression<'_>, ctx: &LintContext<'_>) -> bool {
ctx.nodes().iter().any(|candidate| {
if !expression.span().contains_inclusive(candidate.span()) {
return false;
}
match candidate.kind() {
AstKind::IdentifierReference(identifier) => {
identifier.name.to_ascii_lowercase().contains("composi")
|| ime_identifier_has_ime_word(identifier.name.as_str())
}
AstKind::StaticMemberExpression(member) => {
member
.property
.name
.to_ascii_lowercase()
.contains("composi")
|| ime_identifier_has_ime_word(member.property.name.as_str())
}
AstKind::ComputedMemberExpression(member) => {
member.static_property_name().is_some_and(|property| {
property.to_ascii_lowercase().contains("composi")
|| ime_identifier_has_ime_word(property.as_ref())
})
}
AstKind::NumericLiteral(literal) => literal.value == 229.0,
_ => false,
}
})
}
fn ime_merge_composition_state(left: Option<bool>, right: Option<bool>) -> Option<bool> {
match (left, right) {
(None, right) => right,
(left, None) => left,
(Some(left), Some(right)) if left == right => Some(left),
_ => None,
}
}
fn ime_composition_is_active_when_predicate(
expression: &Expression<'_>,
predicate_result: bool,
ctx: &LintContext<'_>,
) -> Option<bool> {
match expression.get_inner_expression() {
Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
ime_composition_is_active_when_predicate(&unary.argument, !predicate_result, ctx)
}
Expression::LogicalExpression(logical)
if (logical.operator == LogicalOperator::And && predicate_result)
|| (logical.operator == LogicalOperator::Or && !predicate_result) =>
{
Some(ime_merge_composition_state(
ime_composition_is_active_when_predicate(&logical.left, predicate_result, ctx),
ime_composition_is_active_when_predicate(&logical.right, predicate_result, ctx),
)?)
}
expression
if matches!(
expression,
Expression::Identifier(_) | Expression::CallExpression(_)
) || expression.as_member_expression().is_some() =>
{
ime_subtree_has_composition_signal(expression, ctx).then_some(predicate_result)
}
Expression::BinaryExpression(binary) => {
ime_composition_binary_state(binary, predicate_result, ctx)
}
_ => None,
}
}
fn ime_composition_binary_state(
binary: &BinaryExpression<'_>,
predicate_result: bool,
ctx: &LintContext<'_>,
) -> Option<bool> {
let is_equality = matches!(
binary.operator,
BinaryOperator::Equality | BinaryOperator::StrictEquality
);
if !is_equality
&& !matches!(
binary.operator,
BinaryOperator::Inequality | BinaryOperator::StrictInequality
)
{
return None;
}
let keycode_member = |expression: &Expression<'_>| {
expression
.get_inner_expression()
.as_member_expression()
.and_then(|member| member.static_property_name())
.is_some_and(|property| matches!(property.as_ref(), "keyCode" | "which"))
};
let ime_keycode_value = |expression: &Expression<'_>| match expression.get_inner_expression() {
Expression::NumericLiteral(value) => value.value == 229.0,
Expression::Identifier(identifier) => ime_identifier_has_ime_word(identifier.name.as_str()),
_ => false,
};
if (keycode_member(&binary.left) && ime_keycode_value(&binary.right))
|| (keycode_member(&binary.right) && ime_keycode_value(&binary.left))
{
return Some(predicate_result == is_equality);
}
let (composition_side, value_side) = if ime_subtree_has_composition_signal(&binary.left, ctx) {
(&binary.left, &binary.right)
} else if ime_subtree_has_composition_signal(&binary.right, ctx) {
(&binary.right, &binary.left)
} else {
return None;
};
let _ = composition_side;
match value_side.get_inner_expression() {
Expression::BooleanLiteral(value) => {
let active_result = if is_equality {
value.value
} else {
!value.value
};
Some(predicate_result == active_result)
}
Expression::NumericLiteral(value) if value.value == 229.0 => {
Some(predicate_result == is_equality)
}
_ => None,
}
}
fn ime_has_prior_composition_early_exit(
boundary_span: Span,
target_span: Span,
ctx: &LintContext<'_>,
) -> bool {
let Some(mut current) = ctx
.nodes()
.iter()
.find(|candidate| candidate.span() == target_span)
else {
return false;
};
while current.span() != boundary_span {
let parent = ctx.nodes().parent_node(current.id());
if let AstKind::BlockStatement(block) = parent.kind()
&& let Some(current_index) = block
.body
.iter()
.position(|statement| statement.span() == current.span())
{
for statement in block.body.iter().take(current_index) {
let Statement::IfStatement(statement) = statement else {
continue;
};
if ime_composition_is_active_when_predicate(&statement.test, true, ctx)
== Some(true)
&& ime_statement_terminates_flow(&statement.consequent)
{
return true;
}
}
}
if !boundary_span.contains_inclusive(parent.span()) {
return false;
}
current = parent;
}
false
}
fn ime_statement_terminates_flow(statement: &Statement<'_>) -> bool {
match statement {
Statement::ReturnStatement(_) | Statement::ThrowStatement(_) => true,
Statement::BlockStatement(block) => {
block.body.last().is_some_and(ime_statement_terminates_flow)
}
_ => false,
}
}
fn ime_scope_commits_are_composition_guarded(
scope_span: Span,
owner_function_id: NodeId,
visited_functions: &mut FxHashSet<NodeId>,
ctx: &LintContext<'_>,
) -> bool {
let mut found_commit = false;
for candidate in ctx.nodes().iter() {
let AstKind::CallExpression(call) = candidate.kind() else {
continue;
};
if !scope_span.contains_inclusive(candidate.span())
|| ime_nearest_function_id(candidate, ctx) != Some(owner_function_id)
|| ime_is_propagation_call(call)
{
continue;
}
found_commit = true;
if ime_is_inside_composition_safe_condition(candidate, scope_span, ctx)
|| ime_has_prior_composition_early_exit(scope_span, candidate.span(), ctx)
{
continue;
}
if let Some(function) = ime_resolve_called_function(call, ctx)
&& visited_functions.insert(function.id())
{
let guarded = ime_scope_commits_are_composition_guarded(
function.span(),
function.id(),
visited_functions,
ctx,
);
visited_functions.remove(&function.id());
if guarded {
continue;
}
}
return false;
}
found_commit
}
fn ime_is_propagation_call(call: &CallExpression<'_>) -> bool {
call.callee
.get_inner_expression()
.as_member_expression()
.and_then(|member| member.static_property_name())
.is_some_and(|property| {
matches!(
property.as_ref(),
"preventDefault" | "stopPropagation" | "stopImmediatePropagation"
)
})
}
fn ime_is_inside_composition_safe_condition(
node: &AstNode<'_>,
boundary_span: Span,
ctx: &LintContext<'_>,
) -> bool {
let mut child_span = node.span();
for ancestor in ctx.nodes().ancestors(node.id()) {
if ancestor.span() == boundary_span {
break;
}
match ancestor.kind() {
AstKind::IfStatement(statement) => {
let predicate = if statement.consequent.span() == child_span {
Some(true)
} else if statement
.alternate
.as_ref()
.is_some_and(|alternate| alternate.span() == child_span)
{
Some(false)
} else {
None
};
if predicate.is_some_and(|predicate| {
ime_composition_is_active_when_predicate(&statement.test, predicate, ctx)
== Some(false)
}) {
return true;
}
}
AstKind::ConditionalExpression(expression) => {
let predicate = if expression.consequent.span() == child_span {
Some(true)
} else if expression.alternate.span() == child_span {
Some(false)
} else {
None
};
if predicate.is_some_and(|predicate| {
ime_composition_is_active_when_predicate(&expression.test, predicate, ctx)
== Some(false)
}) {
return true;
}
}
AstKind::LogicalExpression(expression) if expression.right.span() == child_span => {
let predicate = match expression.operator {
LogicalOperator::And => Some(true),
LogicalOperator::Or => Some(false),
_ => None,
};
if predicate.is_some_and(|predicate| {
ime_composition_is_active_when_predicate(&expression.left, predicate, ctx)
== Some(false)
}) {
return true;
}
}
AstKind::WhileStatement(statement) if statement.body.span() == child_span => {
if ime_composition_is_active_when_predicate(&statement.test, true, ctx)
== Some(false)
{
return true;
}
}
AstKind::DoWhileStatement(statement) if statement.body.span() == child_span => {
if ime_composition_is_active_when_predicate(&statement.test, true, ctx)
== Some(false)
{
return true;
}
}
_ => {}
}
child_span = ancestor.span();
}
false
}
fn ime_resolve_called_function<'a, 'b>(
call: &CallExpression<'a>,
ctx: &'b LintContext<'a>,
) -> Option<&'b AstNode<'a>> {
let callee = call.callee.get_inner_expression();
if let Expression::Identifier(_) = callee {
return ime_resolve_function_expression(callee, ctx);
}
let Expression::StaticMemberExpression(member) = callee else {
return None;
};
if !matches!(
member.object.get_inner_expression(),
Expression::ThisExpression(_)
) {
return None;
}
let member_name = member.property.name.as_str();
let class_body_span = ctx
.nodes()
.iter()
.filter(|candidate| matches!(candidate.kind(), AstKind::ClassBody(_)))
.find(|candidate| candidate.span().contains_inclusive(call.span))?
.span();
for candidate in ctx.nodes().iter() {
if !class_body_span.contains_inclusive(candidate.span()) {
continue;
}
match candidate.kind() {
AstKind::MethodDefinition(method)
if method.key.static_name().as_deref() == Some(member_name) =>
{
return Some(ctx.nodes().get_node(method.value.node_id.get()));
}
AstKind::PropertyDefinition(property)
if property.key.static_name().as_deref() == Some(member_name) =>
{
return ime_resolve_function_expression(property.value.as_ref()?, ctx);
}
_ => {}
}
}
None
}
fn ime_single_return_expression<'a, 'b>(function: &'b AstNode<'a>) -> Option<&'b Expression<'a>> {
let statements = match function.kind() {
AstKind::ArrowFunctionExpression(function) => {
if let Some(expression) = function.get_expression() {
return Some(expression);
}
function.get_function_body()?.statements.as_slice()
}
AstKind::Function(function) => function.body.as_ref()?.statements.as_slice(),
_ => return None,
};
let [Statement::ReturnStatement(statement)] = statements else {
return None;
};
statement.argument.as_ref()
}