Skip to main content

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::{
202        super::test_util::{test_lint, test_lint_config, TestUtilConfig},
203        *,
204    };
205
206    #[cfg(feature = "roblox")]
207    #[test]
208    fn test_attributes() {
209        test_lint_config(
210            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
211            "unused_variable",
212            "attributes",
213            TestUtilConfig::luau(),
214        );
215    }
216
217    #[test]
218    fn test_blocks() {
219        test_lint(
220            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
221            "unused_variable",
222            "blocks",
223        );
224    }
225
226    #[test]
227    fn test_locals() {
228        test_lint(
229            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
230            "unused_variable",
231            "locals",
232        );
233    }
234
235    #[test]
236    fn test_edge_cases() {
237        test_lint(
238            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
239            "unused_variable",
240            "edge_cases",
241        );
242    }
243
244    #[test]
245    fn test_explicit_self() {
246        test_lint(
247            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
248            "unused_variable",
249            "explicit_self",
250        );
251    }
252
253    #[test]
254    fn test_function_overriding() {
255        test_lint(
256            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
257            "unused_variable",
258            "function_overriding",
259        );
260    }
261
262    #[test]
263    fn test_generic_for_shadowing() {
264        test_lint(
265            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
266            "unused_variable",
267            "generic_for_shadowing",
268        );
269    }
270
271    #[test]
272    fn test_if() {
273        test_lint(
274            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
275            "unused_variable",
276            "if",
277        );
278    }
279
280    #[test]
281    fn test_ignore() {
282        test_lint(
283            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
284            "unused_variable",
285            "ignore",
286        );
287    }
288
289    #[test]
290    fn test_invalid_regex() {
291        assert!(UnusedVariableLint::new(UnusedVariableConfig {
292            ignore_pattern: "(".to_owned(),
293            ..UnusedVariableConfig::default()
294        })
295        .is_err());
296    }
297
298    #[test]
299    fn test_mutating_functions() {
300        test_lint(
301            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
302            "unused_variable",
303            "mutating_functions",
304        );
305    }
306
307    #[test]
308    fn test_observes() {
309        test_lint(
310            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
311            "unused_variable",
312            "observes",
313        );
314    }
315
316    #[test]
317    fn test_overriding() {
318        test_lint(
319            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
320            "unused_variable",
321            "overriding",
322        );
323    }
324
325    #[test]
326    fn test_self() {
327        test_lint(
328            UnusedVariableLint::new(UnusedVariableConfig {
329                allow_unused_self: false,
330                ..UnusedVariableConfig::default()
331            })
332            .unwrap(),
333            "unused_variable",
334            "self",
335        );
336    }
337
338    #[test]
339    fn test_self_ignored() {
340        test_lint(
341            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
342            "unused_variable",
343            "self_ignored",
344        );
345    }
346
347    #[test]
348    fn test_shadowing() {
349        test_lint(
350            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
351            "unused_variable",
352            "shadowing",
353        );
354    }
355
356    #[test]
357    fn test_varargs() {
358        test_lint(
359            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
360            "unused_variable",
361            "varargs",
362        );
363    }
364
365    #[cfg(feature = "roblox")]
366    #[test]
367    fn test_types() {
368        test_lint_config(
369            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
370            "unused_variable",
371            "types",
372            TestUtilConfig::luau(),
373        );
374    }
375
376    #[cfg(feature = "roblox")]
377    #[test]
378    fn test_types_generic_instantiation() {
379        test_lint_config(
380            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
381            "unused_variable",
382            "types_generic_instantiation",
383            TestUtilConfig::luau(),
384        );
385    }
386
387    #[test]
388    fn test_write_only() {
389        test_lint(
390            UnusedVariableLint::new(UnusedVariableConfig::default()).unwrap(),
391            "unused_variable",
392            "write_only",
393        );
394    }
395}