Skip to main content

ktrs_compose/rules/
view_model_forwarding.rs

1//! Port of `rules/ViewModelForwarding.kt`.
2
3use 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 &parameters {
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
70/// The state of upstream's local `scanCallExpressions`. Its sequences are lazy: `filterNot { it in
71/// alreadyProcessedCallExpressions }` is checked per element as each pass reaches it.
72struct 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.";