selene_lib/lints/
unused_variable.rs

1use crate::{
2    ast_util::scopes::AssignedValue,
3    standard_library::{Field, FieldKind, Observes},
4};
5
6use super::*;
7
8use full_moon::ast::Ast;
9use regex::Regex;
10use serde::Deserialize;
11
12#[derive(Clone, Deserialize)]
13#[serde(default)]
14pub struct UnusedVariableConfig {
15    allow_unused_self: bool,
16    ignore_pattern: String,
17}
18
19impl Default for UnusedVariableConfig {
20    fn default() -> Self {
21        Self {
22            allow_unused_self: true,
23            ignore_pattern: "^_".to_owned(),
24        }
25    }
26}
27
28pub struct UnusedVariableLint {
29    allow_unused_self: bool,
30    ignore_pattern: Regex,
31}
32
33#[derive(Debug, PartialEq, Eq)]
34pub enum AnalyzedReference {
35    Read,
36    PlainWrite,
37    ObservedWrite(Label),
38}
39
40impl Lint for UnusedVariableLint {
41    type Config = UnusedVariableConfig;
42    type Error = regex::Error;
43
44    const SEVERITY: Severity = Severity::Warning;
45    const LINT_TYPE: LintType = LintType::Style;
46
47    fn new(config: Self::Config) -> Result<Self, Self::Error> {
48        Ok(Self {
49            allow_unused_self: config.allow_unused_self,
50            ignore_pattern: Regex::new(&config.ignore_pattern)?,
51        })
52    }
53
54    fn pass(&self, _: &Ast, context: &Context, ast_context: &AstContext) -> Vec<Diagnostic> {
55        let mut diagnostics = Vec::new();
56
57        for (_, variable) in ast_context
58            .scope_manager
59            .variables
60            .iter()
61            .filter(|(_, variable)| !self.ignore_pattern.is_match(&variable.name))
62        {
63            if context.standard_library.global_has_fields(&variable.name) {
64                continue;
65            }
66
67            let references = variable
68                .references
69                .iter()
70                .copied()
71                .map(|id| &ast_context.scope_manager.references[id]);
72
73            // We need to make sure that references that are marked as "read" aren't only being read in an "observes: write" context.
74            let analyzed_references = references
75                .map(|reference| {
76                    let is_static_table =
77                        matches!(variable.value, Some(AssignedValue::StaticTable { .. }));
78
79                    if reference.write.is_some() {
80                        if let Some(indexing) = &reference.indexing {
81                            if is_static_table
82                                && indexing.len() == 1 // This restriction can be lifted someday, but only once we can verify that the value has no side effects/is its own static table
83                                && indexing.iter().any(|index| index.static_name.is_some())
84                            {
85                                return AnalyzedReference::ObservedWrite(Label::new_with_message(
86                                    reference.identifier,
87                                    format!("`{}` is only getting written to", variable.name),
88                                ));
89                            }
90                        }
91
92                        if !reference.read {
93                            return AnalyzedReference::PlainWrite;
94                        }
95                    }
96
97                    if !is_static_table {
98                        return AnalyzedReference::Read;
99                    }
100
101                    let within_function_stmt = match &reference.within_function_stmt {
102                        Some(within_function_stmt) => within_function_stmt,
103                        None => return AnalyzedReference::Read,
104                    };
105
106                    let function_call_stmt = &ast_context.scope_manager.function_calls
107                        [within_function_stmt.function_call_stmt_id];
108
109                    // The function call it's within is script defined, we can't assume anything
110                    if ast_context.scope_manager.references[function_call_stmt.initial_reference]
111                        .resolved
112                        .is_some()
113                    {
114                        return AnalyzedReference::Read;
115                    }
116
117                    let function_behavior = match context
118                        .standard_library
119                        .find_global(&function_call_stmt.call_name_path)
120                    {
121                        Some(Field {
122                            field_kind: FieldKind::Function(function_behavior),
123                            ..
124                        }) => function_behavior,
125                        _ => return AnalyzedReference::Read,
126                    };
127
128                    let argument = match function_behavior
129                        .arguments
130                        .get(within_function_stmt.argument_index)
131                    {
132                        Some(argument) => argument,
133                        None => return AnalyzedReference::Read,
134                    };
135
136                    let write_only = argument.observes == Observes::Write;
137
138                    if !write_only {
139                        return AnalyzedReference::Read;
140                    }
141
142                    AnalyzedReference::ObservedWrite(Label::new_with_message(
143                        reference.identifier,
144                        format!(
145                            "`{}` only writes to `{}`",
146                            // TODO: This is a typo if this is a method call
147                            function_call_stmt.call_name_path.join("."),
148                            variable.name
149                        ),
150                    ))
151                })
152                .collect::<Vec<_>>();
153
154            if !analyzed_references
155                .iter()
156                .any(|reference| reference == &AnalyzedReference::Read)
157            {
158                let mut notes = Vec::new();
159
160                if variable.is_self {
161                    if self.allow_unused_self {
162                        continue;
163                    }
164
165                    notes.push("`self` is implicitly defined when defining a method".to_owned());
166                    notes
167                        .push("if you don't need it, consider using `.` instead of `:`".to_owned());
168                }
169
170                let write_only = !analyzed_references.is_empty();
171
172                diagnostics.push(Diagnostic::new_complete(
173                    "unused_variable",
174                    if write_only {
175                        format!("{} is assigned a value, but never used", variable.name)
176                    } else {
177                        format!("{} is defined, but never used", variable.name)
178                    },
179                    Label::new(variable.identifiers[0]),
180                    notes,
181                    analyzed_references
182                        .into_iter()
183                        .filter_map(|reference| {
184                            if let AnalyzedReference::ObservedWrite(label) = reference {
185                                Some(label)
186                            } else {
187                                None
188                            }
189                        })
190                        .collect(),
191                ));
192            };
193        }
194
195        diagnostics
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{super::test_util::test_lint, *};
202
203    #[test]
204    fn test_blocks() {
205        test_lint(
206            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
207            "unused_variable",
208            "blocks",
209        );
210    }
211
212    #[test]
213    fn test_locals() {
214        test_lint(
215            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
216            "unused_variable",
217            "locals",
218        );
219    }
220
221    #[test]
222    fn test_edge_cases() {
223        test_lint(
224            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
225            "unused_variable",
226            "edge_cases",
227        );
228    }
229
230    #[test]
231    fn test_explicit_self() {
232        test_lint(
233            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
234            "unused_variable",
235            "explicit_self",
236        );
237    }
238
239    #[test]
240    fn test_function_overriding() {
241        test_lint(
242            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
243            "unused_variable",
244            "function_overriding",
245        );
246    }
247
248    #[test]
249    fn test_generic_for_shadowing() {
250        test_lint(
251            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
252            "unused_variable",
253            "generic_for_shadowing",
254        );
255    }
256
257    #[test]
258    fn test_if() {
259        test_lint(
260            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
261            "unused_variable",
262            "if",
263        );
264    }
265
266    #[test]
267    fn test_ignore() {
268        test_lint(
269            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
270            "unused_variable",
271            "ignore",
272        );
273    }
274
275    #[test]
276    fn test_invalid_regex() {
277        assert!(UnusedVariableLint::new(UnusedVariableConfig {
278            ignore_pattern: "(".to_owned(),
279            ..UnusedVariableConfig::default()
280        })
281        .is_err());
282    }
283
284    #[test]
285    fn test_mutating_functions() {
286        test_lint(
287            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
288            "unused_variable",
289            "mutating_functions",
290        );
291    }
292
293    #[test]
294    fn test_observes() {
295        test_lint(
296            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
297            "unused_variable",
298            "observes",
299        );
300    }
301
302    #[test]
303    fn test_overriding() {
304        test_lint(
305            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
306            "unused_variable",
307            "overriding",
308        );
309    }
310
311    #[test]
312    fn test_self() {
313        test_lint(
314            UnusedVariableLint::new(UnusedVariableConfig {
315                allow_unused_self: false,
316                ..UnusedVariableConfig::default()
317            })
318            .unwrap(),
319            "unused_variable",
320            "self",
321        );
322    }
323
324    #[test]
325    fn test_self_ignored() {
326        test_lint(
327            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
328            "unused_variable",
329            "self_ignored",
330        );
331    }
332
333    #[test]
334    fn test_shadowing() {
335        test_lint(
336            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
337            "unused_variable",
338            "shadowing",
339        );
340    }
341
342    #[test]
343    fn test_varargs() {
344        test_lint(
345            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
346            "unused_variable",
347            "varargs",
348        );
349    }
350
351    #[cfg(feature = "roblox")]
352    #[test]
353    fn test_types() {
354        test_lint(
355            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
356            "unused_variable",
357            "types",
358        );
359    }
360
361    #[test]
362    fn test_write_only() {
363        test_lint(
364            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
365            "unused_variable",
366            "write_only",
367        );
368    }
369}