selene_lib/lints/
roblox_incorrect_roact_usage.rs

1use super::*;
2use crate::{
3    ast_util::{range, strip_parentheses},
4    standard_library::RobloxClass,
5};
6use std::{
7    collections::{BTreeMap, HashMap},
8    convert::Infallible,
9};
10
11use full_moon::{
12    ast::{self, Ast},
13    tokenizer::{TokenReference, TokenType},
14    visitors::Visitor,
15};
16use if_chain::if_chain;
17
18pub struct IncorrectRoactUsageLint;
19
20// Assumes string includes quotes at start and end
21fn is_lua_valid_table_key_identifier(string: &str) -> bool {
22    // Valid identifier cannot start with numbers
23    let first_char = string.chars().nth(1).unwrap();
24    if !first_char.is_alphabetic() && first_char != '_' {
25        return false;
26    }
27
28    string
29        .chars()
30        .skip(1)
31        .take(string.len() - 2)
32        .all(|c| c.is_alphanumeric() || c == '_')
33}
34
35fn get_lua_table_key_format(expression: &ast::Expression) -> String {
36    match expression {
37        ast::Expression::String(token) => {
38            let string = token.to_string();
39            if is_lua_valid_table_key_identifier(&string) {
40                string[1..string.len() - 1].to_string()
41            } else {
42                format!("[{string}]")
43            }
44        }
45        _ => format!("[{expression}]"),
46    }
47}
48
49impl Lint for IncorrectRoactUsageLint {
50    type Config = ();
51    type Error = Infallible;
52
53    const SEVERITY: Severity = Severity::Error;
54    const LINT_TYPE: LintType = LintType::Correctness;
55
56    fn new(_: Self::Config) -> Result<Self, Self::Error> {
57        Ok(IncorrectRoactUsageLint)
58    }
59
60    fn pass(&self, ast: &Ast, context: &Context, _: &AstContext) -> Vec<Diagnostic> {
61        if !context.is_roblox() {
62            return Vec::new();
63        }
64
65        let roblox_classes = &context.standard_library.roblox_classes;
66
67        // Old roblox standard library
68        if roblox_classes.is_empty() {
69            return Vec::new();
70        }
71
72        let mut visitor = IncorrectRoactUsageVisitor {
73            definitions_of_create_element: HashMap::new(),
74            invalid_events: Vec::new(),
75            invalid_properties: Vec::new(),
76            unknown_class: Vec::new(),
77
78            roblox_classes,
79        };
80
81        visitor.visit_ast(ast);
82
83        let mut diagnostics = Vec::new();
84
85        for invalid_event in visitor.invalid_events {
86            diagnostics.push(Diagnostic::new(
87                "roblox_incorrect_roact_usage",
88                format!(
89                    "`{}` is not a valid event for `{}`",
90                    invalid_event.event_name, invalid_event.class_name
91                ),
92                Label::new(invalid_event.range),
93            ));
94        }
95
96        for invalid_property in visitor.invalid_properties {
97            match invalid_property.property_name.as_str() {
98                "Name" => {
99                    diagnostics.push(Diagnostic::new_complete(
100                        "roblox_incorrect_roact_usage",
101                        format!(
102                            "`{}` is assigned through the element's key for Roblox instances",
103                            invalid_property.property_name
104                        ),
105                        Label::new(invalid_property.range),
106                        vec![format!(
107                            "try: {} = {}(...)",
108                            get_lua_table_key_format(&invalid_property.property_value),
109                            invalid_property.create_element_expression,
110                        )],
111                        Vec::new(),
112                    ));
113                }
114                _ => {
115                    diagnostics.push(Diagnostic::new(
116                        "roblox_incorrect_roact_usage",
117                        format!(
118                            "`{}` is not a property of `{}`",
119                            invalid_property.property_name, invalid_property.class_name
120                        ),
121                        Label::new(invalid_property.range),
122                    ));
123                }
124            }
125        }
126
127        for unknown_class in visitor.unknown_class {
128            diagnostics.push(Diagnostic::new(
129                "roblox_incorrect_roact_usage",
130                format!("`{}` is not a valid class", unknown_class.name),
131                Label::new(unknown_class.range),
132            ));
133        }
134
135        diagnostics
136    }
137}
138
139fn is_roact_or_react_create_element(
140    prefix: &ast::Prefix,
141    suffixes: &[&ast::Suffix],
142) -> Option<LibraryName> {
143    if_chain! {
144        if let ast::Prefix::Name(prefix_token) = prefix;
145        if let Some(library_name) = match prefix_token.token().to_string().as_str() {
146            "Roact" => Some(LibraryName::Roact),
147            "React" => Some(LibraryName::React),
148            _ => None,
149        };
150        if suffixes.len() == 1;
151        if let ast::Suffix::Index(ast::Index::Dot { name, .. }) = suffixes[0];
152        if name.token().to_string() == "createElement";
153        then {
154            Some(library_name)
155        } else {
156            None
157        }
158    }
159}
160
161#[derive(Debug, PartialEq, Clone, Copy)]
162enum LibraryName {
163    Roact,
164    React,
165}
166
167#[derive(Debug)]
168struct IncorrectRoactUsageVisitor<'a> {
169    definitions_of_create_element: HashMap<String, LibraryName>,
170    invalid_events: Vec<InvalidEvent>,
171    invalid_properties: Vec<InvalidProperty>,
172    unknown_class: Vec<UnknownClass>,
173
174    roblox_classes: &'a BTreeMap<String, RobloxClass>,
175}
176
177#[derive(Debug)]
178struct InvalidEvent {
179    class_name: String,
180    event_name: String,
181    range: (usize, usize),
182}
183
184#[derive(Debug)]
185struct InvalidProperty {
186    class_name: String,
187    property_name: String,
188    property_value: ast::Expression,
189    create_element_expression: String,
190    range: (usize, usize),
191}
192
193#[derive(Debug)]
194struct UnknownClass {
195    name: String,
196    range: (usize, usize),
197}
198
199impl<'a> IncorrectRoactUsageVisitor<'a> {
200    fn check_class_name(&mut self, token: &TokenReference) -> Option<(String, &'a RobloxClass)> {
201        let name = if let TokenType::StringLiteral { literal, .. } = token.token_type() {
202            literal.to_string()
203        } else {
204            return None;
205        };
206
207        match self.roblox_classes.get(&name) {
208            Some(roblox_class) => Some((name, roblox_class)),
209
210            None => {
211                self.unknown_class.push(UnknownClass {
212                    name,
213                    range: range(token),
214                });
215
216                None
217            }
218        }
219    }
220}
221
222impl Visitor for IncorrectRoactUsageVisitor<'_> {
223    fn visit_function_call(&mut self, call: &ast::FunctionCall) {
224        // Check if caller is Roact.createElement or a variable defined to it
225        let mut suffixes = call.suffixes().collect::<Vec<_>>();
226        let call_suffix = suffixes.pop();
227
228        let mut library_name = None;
229        let mut create_element_expression = String::new();
230
231        if suffixes.is_empty() {
232            // Call is foo(), not foo.bar()
233            // Check if foo is a variable for Roact.createElement
234            if let ast::Prefix::Name(name) = call.prefix() {
235                if let Some(react_name) = self
236                    .definitions_of_create_element
237                    .get(&name.token().to_string())
238                {
239                    library_name = Some(*react_name);
240                    create_element_expression = name.token().to_string();
241                }
242            }
243        } else if suffixes.len() == 1 {
244            // Call is foo.bar()
245            // Check if foo.bar is Roact.createElement
246            library_name = is_roact_or_react_create_element(call.prefix(), &suffixes);
247
248            if let ast::Prefix::Name(name) = call.prefix() {
249                create_element_expression = format!("{}{}", name.token(), suffixes[0]);
250            }
251        }
252
253        let react_name = match library_name {
254            Some(name) => name,
255            None => return,
256        };
257
258        let ((name, class), arguments) = if_chain! {
259            if let Some(ast::Suffix::Call(ast::Call::AnonymousCall(
260                ast::FunctionArgs::Parentheses { arguments, .. }
261            ))) = call_suffix;
262            if !arguments.is_empty();
263            let mut iter = arguments.iter();
264
265            // Get first argument, check if it is a Roblox class
266            let name_arg = iter.next().unwrap();
267            if let ast::Expression::String(token) = name_arg;
268            if let Some((name, class)) = self.check_class_name(token);
269
270            // Get second argument, check if it is a table
271            if let Some(ast::Expression::TableConstructor(table)) = iter.next();
272
273            then {
274                ((name, class), table)
275            } else {
276                return;
277            }
278        };
279
280        for field in arguments.fields() {
281            match field {
282                ast::Field::NameKey { key, value, .. } => {
283                    let property_name = key.token().to_string();
284
285                    if react_name == LibraryName::React
286                        && ["ref", "key", "children"].contains(&property_name.as_str())
287                    {
288                        continue;
289                    }
290
291                    if !class.has_property(self.roblox_classes, &property_name)
292                        || property_name == "Name"
293                    {
294                        self.invalid_properties.push(InvalidProperty {
295                            class_name: name.clone(),
296                            property_name,
297                            property_value: value.clone(),
298                            create_element_expression: create_element_expression.clone().to_owned(),
299                            range: range(key),
300                        });
301                    }
302                }
303
304                ast::Field::ExpressionKey { brackets, key, .. } => {
305                    let key = strip_parentheses(key);
306
307                    if_chain::if_chain! {
308                        if let ast::Expression::Var(ast::Var::Expression(var_expression)) = key;
309
310                        if let ast::Prefix::Name(constant_roact_name) = var_expression.prefix();
311                        if ["Roact", "React"].contains(&constant_roact_name.token().to_string().as_str());
312
313                        let mut suffixes = var_expression.suffixes();
314                        if let Some(ast::Suffix::Index(ast::Index::Dot { name: constant_event_name, .. })) = suffixes.next();
315                        if constant_event_name.token().to_string() == "Event";
316
317                        if let Some(ast::Suffix::Index(ast::Index::Dot { name: event_name, .. })) = suffixes.next();
318                        then {
319                            let event_name = event_name.token().to_string();
320                            if !class.has_event(self.roblox_classes, &event_name) {
321                                self.invalid_events.push(InvalidEvent {
322                                    class_name: name.clone(),
323                                    event_name,
324                                    range: range(brackets),
325                                });
326                            }
327                        }
328                    }
329                }
330
331                _ => {}
332            }
333        }
334    }
335
336    fn visit_local_assignment(&mut self, node: &ast::LocalAssignment) {
337        for (name, expr) in node.names().iter().zip(node.expressions().iter()) {
338            if_chain! {
339                if let ast::Expression::Var(ast::Var::Expression(var_expr)) = expr;
340                if let Some(roact_or_react) = is_roact_or_react_create_element(var_expr.prefix(), &var_expr.suffixes().collect::<Vec<_>>());
341                then {
342                    self.definitions_of_create_element.insert(name.token().to_string(), roact_or_react);
343                }
344            };
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::{super::test_util::test_lint, *};
352
353    #[test]
354    fn test_mixed_roact_react_usage() {
355        test_lint(
356            IncorrectRoactUsageLint::new(()).unwrap(),
357            "roblox_incorrect_roact_usage",
358            "mixed_roact_react_usage",
359        );
360    }
361
362    #[test]
363    fn test_old_roblox_std() {
364        test_lint(
365            IncorrectRoactUsageLint::new(()).unwrap(),
366            "roblox_incorrect_roact_usage",
367            "old_roblox_std",
368        );
369    }
370
371    #[test]
372    fn test_roblox_incorrect_react_usage() {
373        test_lint(
374            IncorrectRoactUsageLint::new(()).unwrap(),
375            "roblox_incorrect_roact_usage",
376            "roblox_incorrect_react_usage",
377        );
378    }
379
380    #[test]
381    fn test_roblox_incorrect_roact_usage() {
382        test_lint(
383            IncorrectRoactUsageLint::new(()).unwrap(),
384            "roblox_incorrect_roact_usage",
385            "roblox_incorrect_roact_usage",
386        );
387    }
388}