selene_lib/lints/
manual_table_clone.rs

1use full_moon::{
2    ast::{self, punctuated::Punctuated, Expression},
3    visitors::Visitor,
4};
5
6use crate::ast_util::{expression_to_ident, range, scopes::AssignedValue, strip_parentheses};
7
8use super::*;
9use std::{collections::HashSet, convert::Infallible};
10
11pub struct ManualTableCloneLint;
12
13impl Lint for ManualTableCloneLint {
14    type Config = ();
15    type Error = Infallible;
16
17    const SEVERITY: Severity = Severity::Warning;
18    const LINT_TYPE: LintType = LintType::Complexity;
19
20    fn new(_: Self::Config) -> Result<Self, Self::Error> {
21        Ok(ManualTableCloneLint)
22    }
23
24    fn pass(&self, ast: &Ast, context: &Context, ast_context: &AstContext) -> Vec<Diagnostic> {
25        if context
26            .standard_library
27            .find_global(&["table", "clone"])
28            .is_none()
29        {
30            return Vec::new();
31        }
32
33        let mut visitor = ManualTableCloneVisitor {
34            matches: Vec::new(),
35            scope_manager: &ast_context.scope_manager,
36            completed_stmt_begins: Vec::new(),
37            inside_stmt_begins: HashSet::new(),
38        };
39
40        visitor.visit_ast(ast);
41
42        visitor
43            .matches
44            .into_iter()
45            .map(ManualTableCloneMatch::into_diagnostic)
46            .collect()
47    }
48}
49
50#[derive(Debug)]
51struct ManualTableCloneMatch {
52    range: (usize, usize),
53    assigning_into: String,
54    looping_over: String,
55    loop_type: LoopType,
56    replaces_definition_range: Option<(usize, usize)>,
57}
58
59impl ManualTableCloneMatch {
60    fn into_diagnostic(self) -> Diagnostic {
61        Diagnostic::new_complete(
62            "manual_table_clone",
63            "manual implementation of table.clone".to_owned(),
64            Label::new(self.range),
65            {
66                let mut notes = vec![format!(
67                    "try `local {} = table.clone({})`",
68                    self.assigning_into.trim(),
69                    self.looping_over.trim()
70                )];
71
72                if matches!(self.loop_type, LoopType::Ipairs) {
73                    notes.push("if this is a mixed table, then table.clone is not equivalent, as ipairs only goes over the array portion.\n\
74                        ignore this lint with `-- selene: allow(manual_table_clone)` if this is the case.".to_owned());
75                }
76
77                notes
78            },
79            if let Some(definition_range) = self.replaces_definition_range {
80                vec![Label::new_with_message(
81                    definition_range,
82                    "remove this definition".to_owned(),
83                )]
84            } else {
85                Vec::new()
86            },
87        )
88    }
89}
90
91struct ManualTableCloneVisitor<'ast> {
92    matches: Vec<ManualTableCloneMatch>,
93    scope_manager: &'ast ScopeManager,
94    completed_stmt_begins: Vec<usize>,
95    inside_stmt_begins: HashSet<usize>,
96}
97
98#[derive(Debug)]
99enum LoopType {
100    Ipairs,
101    Other,
102}
103
104impl ManualTableCloneVisitor<'_> {
105    // Not technically true, but we can worry about that later.
106    // Can't just check standard library because name "roblox" will override "luau".
107    fn is_luau(&self) -> bool {
108        true
109    }
110
111    fn loop_expression<'ast>(
112        &self,
113        expressions: &'ast Punctuated<Expression>,
114    ) -> Option<(LoopType, &'ast Expression)> {
115        match expressions.len() {
116            1 => {
117                let loop_expression = expressions.iter().next().unwrap();
118
119                let function_call = match strip_parentheses(loop_expression) {
120                    Expression::FunctionCall(function_call) => function_call,
121                    _ if self.is_luau() => return Some((LoopType::Other, loop_expression)),
122                    _ => return None,
123                };
124
125                #[cfg_attr(
126                    feature = "force_exhaustive_checks",
127                    deny(non_exhaustive_omitted_patterns)
128                )]
129                let function_token = match function_call.prefix() {
130                    ast::Prefix::Name(name) => name,
131                    ast::Prefix::Expression(expression) => expression_to_ident(expression)?,
132                    _ => return None,
133                };
134
135                let function_name = function_token.token().to_string();
136                let looping_expression;
137
138                if function_name == "ipairs" || function_name == "pairs" {
139                    let suffixes = function_call.suffixes().collect::<Vec<_>>();
140                    if suffixes.len() != 1 {
141                        return None;
142                    }
143
144                    let suffix = suffixes[0];
145
146                    let inner_expression = match suffix {
147                        ast::Suffix::Call(ast::Call::AnonymousCall(
148                            ast::FunctionArgs::Parentheses { arguments, .. },
149                        )) => {
150                            if arguments.len() != 1 {
151                                return None;
152                            }
153
154                            arguments.iter().next().unwrap()
155                        }
156
157                        _ => return None,
158                    };
159
160                    looping_expression = inner_expression;
161                } else if self.is_luau() {
162                    // `for i, v in what(x) do` is valid in Luau, but shouldn't be treated as a pairs equivalent.
163                    looping_expression = loop_expression;
164                } else {
165                    return None;
166                }
167
168                Some((
169                    if function_name == "ipairs" {
170                        LoopType::Ipairs
171                    } else {
172                        LoopType::Other
173                    },
174                    looping_expression,
175                ))
176            }
177
178            2 => {
179                let mut expressions = expressions.iter();
180                let (first, second) = (expressions.next().unwrap(), expressions.next().unwrap());
181
182                match expression_to_ident(first) {
183                    Some(ident) => {
184                        if ident.token().to_string() != "next" {
185                            return None;
186                        }
187                    }
188
189                    _ => return None,
190                }
191
192                Some((LoopType::Other, second))
193            }
194
195            _ => None,
196        }
197    }
198
199    fn statement_in_way_of_definition(
200        &self,
201        definition_end: usize,
202        assigment_start: usize,
203    ) -> bool {
204        debug_assert!(assigment_start > definition_end);
205
206        for &stmt_begin in self.completed_stmt_begins.iter() {
207            if stmt_begin > definition_end {
208                return true;
209            } else if stmt_begin > assigment_start {
210                return false;
211            }
212        }
213
214        false
215    }
216
217    fn get_depth_at_byte(&self, byte: usize) -> usize {
218        self.inside_stmt_begins
219            .iter()
220            .filter(|&&start| start < byte)
221            .count()
222    }
223}
224
225fn has_filter_comment(for_loop: &ast::GenericFor) -> bool {
226    let (leading_trivia, ..) = for_loop.surrounding_trivia();
227
228    for trivia in leading_trivia {
229        let comment = match trivia.token_type() {
230            full_moon::tokenizer::TokenType::SingleLineComment { comment } => comment,
231            full_moon::tokenizer::TokenType::MultiLineComment { comment, .. } => comment,
232            _ => continue,
233        };
234
235        let filters = match crate::lint_filtering::parse_comment(comment.trim()) {
236            Some(filters) => filters,
237            None => continue,
238        };
239
240        if filters
241            .into_iter()
242            .any(|filter| filter.lint == "manual_table_clone")
243        {
244            return true;
245        }
246    }
247
248    false
249}
250
251impl Visitor for ManualTableCloneVisitor<'_> {
252    fn visit_generic_for(&mut self, node: &ast::GenericFor) {
253        let (loop_type, looping_over) = match self.loop_expression(node.expressions()) {
254            Some(loop_expression) => loop_expression,
255            None => return,
256        };
257
258        let names = node.names().iter().collect::<Vec<_>>();
259        if names.len() != 2 {
260            return;
261        }
262
263        let (key, value) = (names[0].token().to_string(), names[1].token().to_string());
264
265        let statements = node.block().stmts().collect::<Vec<_>>();
266
267        if statements.len() != 1 {
268            return;
269        }
270
271        let assignment = match statements[0] {
272            ast::Stmt::Assignment(assignment) => assignment,
273            _ => return,
274        };
275
276        let variables = assignment.variables();
277        if variables.len() != 1 {
278            return;
279        }
280
281        let variable = variables.iter().next().unwrap();
282
283        let assigning_into = match variable {
284            ast::Var::Expression(var_expression) => {
285                let name = match var_expression.prefix() {
286                    ast::Prefix::Expression(expression) => match expression_to_ident(expression) {
287                        Some(name) => name,
288                        None => return,
289                    },
290                    ast::Prefix::Name(name) => name,
291                    _ => return,
292                };
293
294                let suffixes = var_expression.suffixes().collect::<Vec<_>>();
295                if suffixes.len() != 1 {
296                    return;
297                }
298
299                let index = match &suffixes[0] {
300                    ast::Suffix::Index(ast::Index::Brackets { expression, .. }) => expression,
301                    _ => return,
302                };
303
304                if expression_to_ident(index).map(|ident| ident.token().to_string()) != Some(key) {
305                    return;
306                }
307
308                match assignment
309                    .expressions()
310                    .iter()
311                    .next()
312                    .and_then(expression_to_ident)
313                {
314                    Some(name) => {
315                        if name.token().to_string() != value {
316                            return;
317                        }
318                    }
319
320                    _ => return,
321                };
322
323                name
324            }
325
326            _ => return,
327        };
328
329        let (definition_start, definition_end) = match self
330            .scope_manager
331            .reference_at_byte(assigning_into.token().start_position().bytes())
332        {
333            Some(reference) => {
334                if reference.resolved.is_none() {
335                    return;
336                }
337
338                let variable = &self.scope_manager.variables[reference.resolved.unwrap()];
339
340                if !matches!(
341                    variable.value,
342                    Some(AssignedValue::StaticTable { has_fields: false })
343                ) {
344                    return;
345                }
346
347                let first_definition_range = match variable.definitions.first() {
348                    Some(first_definition_range) => first_definition_range,
349                    None => {
350                        return;
351                    }
352                };
353
354                // Make sure we haven't potentially tainted this variable before
355                for reference_id in &variable.references {
356                    let reference = &self.scope_manager.references[*reference_id];
357
358                    if reference.identifier.1 > first_definition_range.1
359                        && reference.identifier.0 < assigning_into.token().start_position().bytes()
360                    {
361                        return;
362                    }
363                }
364
365                first_definition_range
366            }
367
368            _ => return,
369        };
370
371        let (position_start, position_end) = range(node);
372
373        if self.get_depth_at_byte(*definition_start) != self.get_depth_at_byte(position_start) {
374            return;
375        }
376
377        let only_use_loop_range = self
378            .statement_in_way_of_definition(*definition_end, position_start)
379            || has_filter_comment(node);
380
381        self.matches.push(ManualTableCloneMatch {
382            range: if only_use_loop_range {
383                (position_start, position_end)
384            } else {
385                (*definition_start, position_end)
386            },
387            assigning_into: assigning_into.token().to_string(),
388            looping_over: looping_over.to_string(),
389            replaces_definition_range: if only_use_loop_range {
390                Some((*definition_start, *definition_end))
391            } else {
392                None
393            },
394            loop_type,
395        });
396    }
397
398    fn visit_stmt(&mut self, stmt: &ast::Stmt) {
399        self.inside_stmt_begins.insert(range(stmt).0);
400    }
401
402    fn visit_stmt_end(&mut self, stmt: &ast::Stmt) {
403        self.completed_stmt_begins.push(range(stmt).0);
404        self.inside_stmt_begins.remove(&range(stmt).0);
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use crate::lints::test_util::{test_lint_config_with_output, TestUtilConfig};
411
412    use super::{super::test_util::test_lint_config, *};
413
414    #[test]
415    fn test_manual_table_clone() {
416        test_lint_config(
417            ManualTableCloneLint::new(()).unwrap(),
418            "manual_table_clone",
419            "manual_table_clone",
420            TestUtilConfig {
421                standard_library: StandardLibrary::from_name("luau").unwrap(),
422                ..Default::default()
423            },
424        );
425    }
426
427    #[test]
428    fn test_no_table_clone() {
429        test_lint_config_with_output(
430            ManualTableCloneLint::new(()).unwrap(),
431            "manual_table_clone",
432            "manual_table_clone",
433            TestUtilConfig::default(),
434            "no_table_clone.stderr",
435        );
436    }
437
438    #[test]
439    fn test_false_positive() {
440        test_lint_config(
441            ManualTableCloneLint::new(()).unwrap(),
442            "manual_table_clone",
443            "false_positive",
444            TestUtilConfig {
445                standard_library: StandardLibrary::from_name("luau").unwrap(),
446                ..Default::default()
447            },
448        )
449    }
450}