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