ktrs_compose/rules/
view_model_forwarding.rs1use std::collections::HashSet;
4
5use ktrs_ast::Ast;
6use ktrs_ast::psi::{
7 KtCallExpression, KtDotQualifiedExpression, KtFunction, KtNameReferenceExpression, KtReferenceExpression, KtThisExpression,
8};
9use ktrs_lint::rules::internal::KotlinRegex;
10
11use crate::core::compose_kt_config::ComposeKtConfig;
12use crate::core::compose_kt_visitor::ComposeKtVisitor;
13use crate::core::emitter::Emitter;
14use crate::core::util::composables::is_restartable_effect;
15use crate::core::util::kotlin_utils::{KOTLIN_IT_OBJECT_SCOPE_FUNCTIONS, KOTLIN_SCOPE_FUNCTIONS, join_to_regex, join_to_regex_or_null};
16use crate::core::util::kt_functions::{defined_in_interface, is_actual, is_override};
17use crate::core::util::psi_elements::{find_all_children, find_direct_children_by_class};
18
19pub struct ViewModelForwarding;
20
21impl ComposeKtVisitor for ViewModelForwarding {
22 fn visit_composable(&self, ast: &mut Ast, function: KtFunction, emitter: &mut dyn Emitter, config: &dyn ComposeKtConfig) {
23 if is_override(ast, function) || defined_in_interface(ast, function) || is_actual(ast, function) {
24 return;
25 }
26 let Some(body_block) = function.body_block_expression(ast) else { return };
27 let parameters = function.value_parameter_list(ast).map(|l| l.parameters(ast)).unwrap_or_default();
28 if parameters.is_empty() {
29 return;
30 }
31 let mut state_holder_names = config.get_set("allowedStateHolderNames", &[]);
32 for name in DEFAULT_STATE_HOLDER_NAMES {
33 if !state_holder_names.iter().any(|it| it == name) {
34 state_holder_names.push((*name).to_owned());
35 }
36 }
37 let state_holder_valid_names = join_to_regex(&state_holder_names);
38 let allowed_forwarding_target_names = join_to_regex_or_null(&config.get_set("allowedForwarding", &[]));
39 let allowed_forwarding_of_types = join_to_regex_or_null(&config.get_set("allowedForwardingOfTypes", &[]));
40
41 let type_text_matches = |parameter: &ktrs_ast::psi::KtParameter, regex: &KotlinRegex| {
42 parameter.type_reference(ast).is_some_and(|t| regex.matches(&t.text(ast)))
43 };
44 let mut view_model_parameter_names: Vec<String> = Vec::new();
45 for parameter in ¶meters {
46 if !type_text_matches(parameter, &state_holder_valid_names) {
47 continue;
48 }
49 if allowed_forwarding_of_types.as_ref().is_some_and(|regex| type_text_matches(parameter, regex)) {
50 continue;
51 }
52 if let Some(name) = parameter.name(ast)
53 && !view_model_parameter_names.contains(&name)
54 {
55 view_model_parameter_names.push(name);
56 }
57 }
58
59 let mut scan = Scan {
60 view_model_parameter_names,
61 allowed_forwarding_target_names,
62 already_processed_call_expressions: HashSet::new(),
63 emitter,
64 };
65 let call_expressions = find_all_children::<KtCallExpression>(ast, body_block.node());
66 scan.scan_call_expressions(ast, &call_expressions, None, false);
67 }
68}
69
70struct Scan<'a> {
73 view_model_parameter_names: Vec<String>,
74 allowed_forwarding_target_names: Option<KotlinRegex>,
75 already_processed_call_expressions: HashSet<KtCallExpression>,
76 emitter: &'a mut dyn Emitter,
77}
78
79impl Scan<'_> {
80 fn scan_call_expressions(
81 &mut self,
82 ast: &Ast,
83 call_expressions: &[KtCallExpression],
84 scoped_parameter: Option<String>,
85 uses_it_object_ref: bool,
86 ) {
87 for &call_expression in call_expressions {
88 if self.already_processed_call_expressions.contains(&call_expression) || !is_scope_function(ast, call_expression) {
89 continue;
90 }
91 let lambda_bodies: Vec<_> = call_expression
92 .lambda_arguments(ast)
93 .into_iter()
94 .filter_map(|it| it.lambda_expression(ast)?.body_expression(ast))
95 .collect();
96 for lambda_body_expression in lambda_bodies {
97 let expressions = find_direct_children_by_class::<KtCallExpression>(ast, lambda_body_expression.node());
98 self.scan_call_expressions(
99 ast,
100 &expressions,
101 get_scoped_parameter_value(ast, call_expression),
102 has_it_object_reference(ast, call_expression),
103 );
104 }
105 }
106
107 for &call_expression in call_expressions {
108 if self.already_processed_call_expressions.contains(&call_expression) {
109 continue;
110 }
111 let Some(callee) = call_expression.callee_expression(ast).map(|c| ast.text(c)) else { continue };
112 let first = callee.chars().next().expect("NoSuchElementException: Char sequence is empty.");
113 if !(first.len_utf16() == 1 && first.is_uppercase()) {
114 continue;
115 }
116 if is_restartable_effect(ast, call_expression) {
117 continue;
118 }
119 if self.allowed_forwarding_target_names.as_ref().is_some_and(|regex| regex.matches(&callee)) {
120 continue;
121 }
122 self.already_processed_call_expressions.insert(call_expression);
123 let scoped_in_vm_params =
124 scoped_parameter.as_ref().is_some_and(|p| self.view_model_parameter_names.contains(p));
125 let matches = call_expression
126 .value_arguments(ast)
127 .into_iter()
128 .filter_map(|it| it.argument_expression(ast))
129 .filter(|&it| KtReferenceExpression::is(ast, it) || KtThisExpression::is(ast, it))
130 .filter(|&argument_expression| {
131 let text = ast.text(argument_expression);
132 let is_it_ref_and_scoped_in_vm_params = uses_it_object_ref && text == "it" && scoped_in_vm_params;
133 let is_this_ref_and_scoped_in_vm_params = !uses_it_object_ref && text == "this" && scoped_in_vm_params;
134 self.view_model_parameter_names.contains(&text)
135 || is_it_ref_and_scoped_in_vm_params
136 || is_this_ref_and_scoped_in_vm_params
137 })
138 .count();
139 for _ in 0..matches {
140 self.emitter.report(ast, call_expression.node(), AVOID_VIEW_MODEL_FORWARDING, false);
141 }
142 }
143 }
144}
145
146fn referenced_name(ast: &Ast, call: KtCallExpression) -> Option<String> {
147 let callee = call.callee_expression(ast)?;
148 Some(KtNameReferenceExpression::cast(ast, callee)?.referenced_name(ast))
149}
150
151fn is_scope_function(ast: &Ast, call: KtCallExpression) -> bool {
152 referenced_name(ast, call).is_some_and(|n| KOTLIN_SCOPE_FUNCTIONS.contains(&n.as_str()))
153}
154
155fn is_with_scope(ast: &Ast, call: KtCallExpression) -> bool {
156 referenced_name(ast, call).as_deref() == Some("with")
157}
158
159fn has_it_object_reference(ast: &Ast, call: KtCallExpression) -> bool {
160 referenced_name(ast, call).is_some_and(|n| KOTLIN_IT_OBJECT_SCOPE_FUNCTIONS.contains(&n.as_str()))
161}
162
163fn get_scoped_parameter_value(ast: &Ast, call: KtCallExpression) -> Option<String> {
164 if is_with_scope(ast, call) {
165 call.value_arguments(ast).first()?.argument_expression(ast).map(|e| ast.text(e))
166 } else {
167 let parent = KtDotQualifiedExpression::cast(ast, ast.tree_parent(call.node())?)?;
168 parent.receiver_expression(ast).map(|e| ast.text(e))
169 }
170}
171
172const DEFAULT_STATE_HOLDER_NAMES: &[&str] = &[".*ViewModel", ".*Presenter"];
173
174pub const AVOID_VIEW_MODEL_FORWARDING: &str = "\
175Forwarding a ViewModel/Presenter through multiple @Composable functions should be avoided. Consider using state hoisting.
176See https://mrmans0n.github.io/compose-rules/rules/#hoist-all-the-things for more information.";