Skip to main content

nu_lint/
rule.rs

1#[cfg(test)]
2use std::borrow::Cow;
3use std::{
4    any::TypeId,
5    fmt::{Debug, Formatter, Result as FmtResult},
6    hash::{Hash, Hasher},
7};
8
9use lsp_types::DiagnosticTag;
10
11use crate::{
12    Fix, LintLevel,
13    context::LintContext,
14    violation::{Detection, Violation},
15};
16
17/// Trait for implementing lint rules with typed fix data.
18pub trait DetectFix: Send + Sync + 'static {
19    /// Data used to construct a fix (optional)
20    type FixInput<'a>: Send + Sync;
21
22    /// Should only contain lower-case letters and underscores. Imperative,
23    /// descriptive and around 2-4 keywords.
24    fn id(&self) -> &'static str;
25
26    /// Default lint level of rule
27    fn level(&self) -> LintLevel;
28
29    /// Create a vector of detections of violations of the rule
30    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)>;
31
32    /// Description shown next to rule ID in table
33    fn short_description(&self) -> &'static str;
34
35    /// Optional long description formatted as additional help next to
36    /// violations. Remove if too short and use `short_description` instead.
37    fn long_description(&self) -> Option<&'static str> {
38        None
39    }
40
41    /// Optional hyperlink to (semi-)official Nu shell documentation or style
42    /// guide
43    fn source_link(&self) -> Option<&'static str> {
44        None
45    }
46
47    /// Rules that conflict with this rule. When both rules are enabled,
48    /// the linter will error at startup.
49    fn conflicts_with(&self) -> &'static [&'static dyn Rule] {
50        &[]
51    }
52
53    fn fix(&self, _context: &LintContext, _fix_data: &Self::FixInput<'_>) -> Option<Fix> {
54        None
55    }
56
57    /// Diagnostic tags to apply to violations from this rule.
58    /// Returns an empty slice by default.
59    fn diagnostic_tags(&self) -> &'static [DiagnosticTag] {
60        &[]
61    }
62
63    /// Pairs violations with default fix input (for rules with `FixInput =
64    /// ()`).
65    fn no_fix<'a>(detections: Vec<Detection>) -> Vec<(Detection, Self::FixInput<'a>)>
66    where
67        Self::FixInput<'a>: Default,
68    {
69        detections
70            .into_iter()
71            .map(|v| (v, Self::FixInput::default()))
72            .collect()
73    }
74}
75
76/// Type-erased interface for storing and executing rules.
77///
78/// All `DetectFix` implementations automatically implement this via a blanket
79/// impl.
80pub trait Rule: Send + Sync {
81    fn id(&self) -> &'static str;
82    fn short_description(&self) -> &'static str;
83    fn source_link(&self) -> Option<&'static str>;
84    fn level(&self) -> LintLevel;
85    fn has_auto_fix(&self) -> bool;
86    fn conflicts_with(&self) -> &'static [&'static dyn Rule];
87    fn diagnostic_tags(&self) -> &'static [DiagnosticTag];
88    fn check(&self, context: &LintContext) -> Vec<Violation>;
89}
90
91impl<T: DetectFix> Rule for T {
92    fn id(&self) -> &'static str {
93        DetectFix::id(self)
94    }
95
96    fn short_description(&self) -> &'static str {
97        DetectFix::short_description(self)
98    }
99
100    fn source_link(&self) -> Option<&'static str> {
101        DetectFix::source_link(self)
102    }
103
104    fn level(&self) -> LintLevel {
105        DetectFix::level(self)
106    }
107
108    fn has_auto_fix(&self) -> bool {
109        TypeId::of::<T::FixInput<'static>>() != TypeId::of::<()>()
110    }
111
112    fn conflicts_with(&self) -> &'static [&'static dyn Rule] {
113        DetectFix::conflicts_with(self)
114    }
115
116    fn diagnostic_tags(&self) -> &'static [DiagnosticTag] {
117        DetectFix::diagnostic_tags(self)
118    }
119
120    fn check(&self, context: &LintContext) -> Vec<Violation> {
121        self.detect(context)
122            .into_iter()
123            .map(|(detected, fix_data)| {
124                let long_description = self.long_description();
125                let fix = self.fix(context, &fix_data);
126                Violation::from_detected(detected, fix, long_description)
127            })
128            .collect()
129    }
130}
131
132impl Debug for dyn Rule {
133    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
134        f.debug_struct("Rule")
135            .field("id", &self.id())
136            .field("level", &self.level())
137            .field("has_auto_fix", &self.has_auto_fix())
138            .finish()
139    }
140}
141
142impl Hash for dyn Rule {
143    fn hash<H: Hasher>(&self, state: &mut H) {
144        self.id().hash(state);
145    }
146}
147
148impl PartialEq for dyn Rule {
149    fn eq(&self, other: &Self) -> bool {
150        self.id() == other.id()
151    }
152}
153
154impl Eq for dyn Rule {}
155
156#[cfg(test)]
157impl dyn Rule {
158    fn run_check(&self, code: &str) -> Vec<Violation> {
159        LintContext::test_get_violations(code, |context| self.check(context))
160    }
161
162    #[track_caller]
163    fn first_violation(&self, code: &str) -> Violation {
164        let violations = self.run_check(code);
165        assert!(
166            !violations.is_empty(),
167            "Expected rule '{}' to detect violations, but found none",
168            self.id()
169        );
170        violations.into_iter().next().unwrap()
171    }
172
173    pub fn first_replacement_text(&self, code: &str) -> Cow<'static, str> {
174        let fix = self
175            .first_violation(code)
176            .fix
177            .expect("Expected violation to have a fix");
178        assert!(
179            !fix.replacements.is_empty(),
180            "Expected fix to have replacements"
181        );
182        fix.replacements
183            .into_iter()
184            .next()
185            .unwrap()
186            .replacement_text
187    }
188
189    /// Assumes there is only one violation and fix in the code (with zero or
190    /// more replacements)
191    pub fn apply_first_fix(&self, code: &str) -> String {
192        use std::cmp::Reverse;
193
194        let violation = self.first_violation(code);
195        let fix = violation.fix.expect("Expected violation to have a fix");
196        assert!(
197            !fix.replacements.is_empty(),
198            "Expected fix to have replacements"
199        );
200
201        let mut replacements = fix.replacements;
202        replacements.sort_by_key(|b| Reverse(b.file_span().start));
203
204        let mut result = code.to_string();
205        for replacement in replacements {
206            let start = replacement.file_span().start;
207            let end = replacement.file_span().end;
208            result.replace_range(start..end, &replacement.replacement_text);
209        }
210        result
211    }
212
213    #[track_caller]
214    pub fn assert_detects(&self, code: &str) {
215        let violations = self.run_check(code);
216        assert!(
217            !violations.is_empty(),
218            "Expected rule '{}' to detect violations in code, but found none",
219            self.id()
220        );
221    }
222
223    #[track_caller]
224    pub fn assert_ignores(&self, code: &str) {
225        let violations = self.run_check(code);
226        assert!(
227            violations.is_empty(),
228            "Expected rule '{}' to ignore code, but found {} violations",
229            self.id(),
230            violations.len()
231        );
232    }
233
234    #[track_caller]
235    pub fn assert_count(&self, code: &str, expected: usize) {
236        let violations = self.run_check(code);
237        assert_eq!(
238            violations.len(),
239            expected,
240            "Expected rule '{}' to find exactly {} violation(s), but found {}",
241            self.id(),
242            expected,
243            violations.len()
244        );
245    }
246
247    #[track_caller]
248    pub fn assert_fixed_contains(&self, code: &str, expected_text: &str) {
249        let fixed = self.apply_first_fix(code);
250        assert!(
251            fixed.contains(expected_text),
252            "Expected fixed code to contain `{expected_text}`, but it didn't, it was `{fixed}`"
253        );
254    }
255
256    #[track_caller]
257    pub fn assert_fixed_not_contains(&self, code: &str, unexpected_text: &str) {
258        let fixed = self.apply_first_fix(code);
259        assert!(
260            !fixed.contains(unexpected_text),
261            "Expected fixed code NOT to contain `{unexpected_text}`, but it did: `{fixed}`"
262        );
263    }
264
265    #[track_caller]
266    pub fn assert_fixed_is(&self, bad_code: &str, expected_code: &str) {
267        let fixed = self.apply_first_fix(bad_code);
268        assert!(
269            fixed == expected_code,
270            "Expected fix to be `{fixed}` but received `{expected_code}`"
271        );
272    }
273
274    #[track_caller]
275    pub fn assert_labels_contain(&self, code: &str, expected_text: &str) {
276        let violation = self.first_violation(code);
277        let label_texts: Vec<&str> = violation
278            .extra_labels
279            .iter()
280            .filter_map(|(_, label)| label.as_deref())
281            .collect();
282
283        assert!(
284            label_texts.iter().any(|t| t.contains(expected_text)),
285            "Expected a label to contain '{expected_text}', but got labels: {label_texts:?}"
286        );
287    }
288
289    #[track_caller]
290    pub fn assert_fix_erases(&self, code: &str, erased_text: &str) {
291        let fixed = self.apply_first_fix(code);
292        assert!(
293            code.contains(erased_text),
294            "Original code should contain '{erased_text}', but it doesn't"
295        );
296        assert!(
297            !fixed.contains(erased_text),
298            "Expected fixed code to not contain '{erased_text}', but it still appears in: {fixed}"
299        );
300    }
301}