Skip to main content

ktrs_compose/rules/
modifier_clickable_order.rs

1//! Port of `rules/ModifierClickableOrder.kt`.
2
3use ktrs_ast::psi::{KtCallExpression, KtDotQualifiedExpression, KtFunction, KtIfExpression, KtReferenceExpression, KtValueArgument};
4use ktrs_ast::{Ast, NodeId};
5
6use crate::core::compose_kt_config::ComposeKtConfig;
7use crate::core::compose_kt_visitor::ComposeKtVisitor;
8use crate::core::emitter::Emitter;
9use crate::core::util::modifiers::{MODIFIER_NAMES, arguments_using_modifiers, modifier_parameters, obtain_all_modifier_names};
10use crate::core::util::psi_elements::find_all_children;
11
12pub struct ModifierClickableOrder;
13
14impl ComposeKtVisitor for ModifierClickableOrder {
15    fn visit_composable(&self, ast: &mut Ast, function: KtFunction, emitter: &mut dyn Emitter, config: &dyn ComposeKtConfig) {
16        let Some(code) = function.body_block_expression(ast) else { return };
17        let initial_modifier_names: Vec<String> =
18            modifier_parameters(ast, function, config).into_iter().filter_map(|it| it.name(ast)).collect();
19        let mut modifiers: Vec<String> = Vec::new();
20        for name in initial_modifier_names.iter().flat_map(|it| obtain_all_modifier_names(ast, code, it)) {
21            insert(&mut modifiers, name);
22        }
23        insert(&mut modifiers, "Modifier".to_owned());
24        let type_names: Vec<String> = MODIFIER_NAMES.iter().map(|s| s.to_string()).collect();
25        let suspicious_order_modifiers: Vec<KtCallExpression> = find_all_children::<KtCallExpression>(ast, code.node())
26            .into_iter()
27            .filter(|it| callee_starts_upper_case(ast, *it))
28            .flat_map(|call| arguments_using_modifiers(ast, call, &modifiers, &type_names))
29            .filter_map(|argument| argument.argument_expression(ast))
30            .filter_map(|it| KtDotQualifiedExpression::cast(ast, it))
31            .filter_map(|chain| find_call_expression_suspicious_order(ast, chain))
32            .collect();
33        for method_invocation in suspicious_order_modifiers {
34            emitter.report(ast, method_invocation.node(), MODIFIER_CHAIN_WITH_SUSPICIOUS_ORDER, false);
35        }
36    }
37}
38
39fn insert(set: &mut Vec<String>, name: String) {
40    if !set.contains(&name) {
41        set.push(name);
42    }
43}
44
45/// `calleeExpression?.text?.first()?.isUpperCase() == true`.
46pub(crate) fn callee_starts_upper_case(ast: &Ast, call: KtCallExpression) -> bool {
47    callee_text(ast, call).and_then(|t| t.chars().next()).is_some_and(char::is_uppercase)
48}
49
50fn callee_text(ast: &Ast, call: KtCallExpression) -> Option<String> {
51    call.callee_expression(ast).map(|c| ast.text(c))
52}
53
54fn find_call_expression_suspicious_order(ast: &Ast, chain: KtDotQualifiedExpression) -> Option<KtCallExpression> {
55    let mut current_receiver: NodeId = chain.receiver_expression(ast).expect("NullPointerException: receiverExpression");
56    let mut current_selector: Option<NodeId> = chain.selector_expression(ast);
57    let mut shape_altering_candidate = false;
58    while let Some(selector) = current_selector {
59        if let Some(selector) = KtCallExpression::cast(ast, selector) {
60            if shape_altering_candidate && is_clickable_interaction(ast, selector) {
61                return Some(selector);
62            } else if is_clip_with_shape(ast, selector)
63                || is_border_with_shape(ast, selector)
64                || is_background_with_shape(ast, selector)
65                || is_shadow_with_shape(ast, selector)
66            {
67                shape_altering_candidate = true;
68            } else if is_then(ast, selector)
69                && let Some(param) = selector.value_arguments(ast).first().copied()
70                && let Some(argument_expression) = param.argument_expression(ast).and_then(|e| KtIfExpression::cast(ast, e))
71            {
72                let suspicious = [argument_expression.then(ast), argument_expression.r#else(ast)]
73                    .into_iter()
74                    .flatten()
75                    .flat_map(|it| modifier_chain_calls(ast, it))
76                    .any(|it| {
77                        is_clip_with_shape(ast, it)
78                            || is_background_with_shape(ast, it)
79                            || is_border_with_shape(ast, it)
80                            || is_shadow_with_shape(ast, it)
81                    });
82                if suspicious {
83                    shape_altering_candidate = true;
84                }
85            }
86        }
87        if let Some(receiver) = KtDotQualifiedExpression::cast(ast, current_receiver) {
88            current_selector = receiver.selector_expression(ast);
89            current_receiver = receiver.receiver_expression(ast).expect("NullPointerException: receiverExpression");
90        } else {
91            current_selector = None;
92        }
93    }
94    None
95}
96
97fn is_clickable_interaction(ast: &Ast, call: KtCallExpression) -> bool {
98    callee_text(ast, call).is_some_and(|t| INTERACTION_MODIFIERS.contains(&t.as_str()))
99}
100
101fn is_then(ast: &Ast, call: KtCallExpression) -> bool {
102    callee_text(ast, call).as_deref() == Some("then")
103}
104
105fn is_clip_with_shape(ast: &Ast, call: KtCallExpression) -> bool {
106    callee_text(ast, call).as_deref() == Some("clip")
107}
108
109fn is_background_with_shape(ast: &Ast, call: KtCallExpression) -> bool {
110    callee_text(ast, call).as_deref() == Some("background")
111        && call.value_arguments(ast).into_iter().any(|it| is_named_shape(ast, it) || references_shape(ast, it))
112}
113
114fn is_border_with_shape(ast: &Ast, call: KtCallExpression) -> bool {
115    callee_text(ast, call).as_deref() == Some("border")
116        && call.value_arguments(ast).into_iter().any(|it| is_named_shape(ast, it) || references_shape(ast, it))
117}
118
119fn is_shadow_with_shape(ast: &Ast, call: KtCallExpression) -> bool {
120    callee_text(ast, call).as_deref() == Some("shadow") && has_shadow_shape(ast, call) && !is_shadow_clip_disabled(ast, call)
121}
122
123fn has_shadow_shape(ast: &Ast, call: KtCallExpression) -> bool {
124    let arguments = call.value_arguments(ast);
125    arguments.iter().any(|&it| is_named_shape(ast, it) || references_shape(ast, it))
126        || arguments.get(1).is_some_and(|it| !it.is_named(ast))
127}
128
129fn argument_named(ast: &Ast, arguments: &[KtValueArgument], name: &str) -> Option<KtValueArgument> {
130    arguments.iter().copied().find(|it| it.argument_name(ast).and_then(|n| n.as_name(ast)).as_deref() == Some(name))
131}
132
133fn argument_expression_text(ast: &Ast, argument: KtValueArgument) -> Option<String> {
134    argument.argument_expression(ast).map(|e| ast.text(e))
135}
136
137fn is_shadow_clip_disabled(ast: &Ast, call: KtCallExpression) -> bool {
138    let arguments = call.value_arguments(ast);
139    if let Some(named_clip) = argument_named(ast, &arguments, "clip") {
140        return argument_expression_text(ast, named_clip).as_deref() == Some("false");
141    }
142    if let Some(positional_clip) = arguments.get(2).copied().filter(|it| !it.is_named(ast)) {
143        return argument_expression_text(ast, positional_clip).as_deref() == Some("false");
144    }
145    is_zero_elevation(ast, &arguments)
146}
147
148fn is_zero_elevation(ast: &Ast, arguments: &[KtValueArgument]) -> bool {
149    let elevation = argument_named(ast, arguments, "elevation")
150        .or_else(|| arguments.first().copied().filter(|it| !it.is_named(ast)));
151    elevation.and_then(|it| argument_expression_text(ast, it)).as_deref() == Some("0.dp")
152}
153
154fn modifier_chain_calls(ast: &Ast, expression: NodeId) -> Vec<KtCallExpression> {
155    if let Some(call) = KtCallExpression::cast(ast, expression) {
156        return vec![call];
157    }
158    let Some(dot) = KtDotQualifiedExpression::cast(ast, expression) else { return Vec::new() };
159    let mut calls = modifier_chain_calls(ast, dot.receiver_expression(ast).expect("NullPointerException: receiverExpression"));
160    calls.extend(dot.selector_expression(ast).and_then(|s| KtCallExpression::cast(ast, s)));
161    calls
162}
163
164fn is_named_shape(ast: &Ast, argument: KtValueArgument) -> bool {
165    argument.is_named(ast) && argument.name(ast).as_deref() == Some("shape")
166}
167
168fn references_shape(ast: &Ast, argument: KtValueArgument) -> bool {
169    let Some(expression) = argument.argument_expression(ast) else { return false };
170    if let Some(call) = KtCallExpression::cast(ast, expression) {
171        callee_text(ast, call).is_some_and(|t| t.ends_with("Shape"))
172    } else if KtReferenceExpression::is(ast, expression) {
173        ast.text(expression).ends_with("Shape")
174    } else if let Some(if_expression) = KtIfExpression::cast(ast, expression) {
175        if_expression.then(ast).is_some_and(|t| ast.text(t).ends_with("Shape"))
176            || if_expression.r#else(ast).is_some_and(|e| ast.text(e).ends_with("Shape"))
177    } else if KtDotQualifiedExpression::is(ast, expression) {
178        let text = ast.text(expression);
179        text.starts_with("MaterialTheme.shapes") || text.contains("Shape")
180    } else {
181        false
182    }
183}
184
185const INTERACTION_MODIFIERS: &[&str] = &["clickable", "selectable", "toggleable", "triStateToggleable", "combinedClickable"];
186
187pub const MODIFIER_CHAIN_WITH_SUSPICIOUS_ORDER: &str = "\
188This order of modifiers is likely to cause visual issues. You should have your clickable modifiers after modifiers that use shapes, so that the clickable selected area takes into account the change in shape as well.
189See https://mrmans0n.github.io/compose-rules/rules/#modifier-order-matters for more information.";