1use 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
45pub(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.";