kcl_lib/lint/
rule.rs

1use anyhow::Result;
2#[cfg(feature = "pyo3")]
3use pyo3_stub_gen::{inventory, type_info::PyEnumInfo};
4use serde::Serialize;
5use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
6
7use crate::{
8    SourceRange,
9    errors::Suggestion,
10    lsp::{IntoDiagnostic, ToLspRange, to_lsp_edit},
11    parsing::ast::types::{Node as AstNode, Program},
12    walk::Node,
13};
14
15/// Check the provided AST for any found rule violations.
16///
17/// The Rule trait is automatically implemented for a few other types,
18/// but it can also be manually implemented as required.
19pub trait Rule<'a> {
20    /// Check the AST at this specific node for any Finding(s).
21    fn check(&self, node: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>>;
22}
23
24impl<'a, FnT> Rule<'a> for FnT
25where
26    FnT: Fn(Node<'a>, &AstNode<Program>) -> Result<Vec<Discovered>>,
27{
28    fn check(&self, n: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>> {
29        self(n, prog)
30    }
31}
32
33/// Specific discovered lint rule Violation of a particular Finding.
34#[derive(Clone, Debug, ts_rs::TS, Serialize)]
35#[ts(export)]
36#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
37#[serde(rename_all = "camelCase")]
38pub struct Discovered {
39    /// Zoo Lint Finding information.
40    pub finding: Finding,
41
42    /// Further information about the specific finding.
43    pub description: String,
44
45    /// Source code location.
46    pub pos: SourceRange,
47
48    /// Is this discovered issue overridden by the programmer?
49    pub overridden: bool,
50
51    /// Suggestion to fix the issue.
52    pub suggestion: Option<Suggestion>,
53}
54
55impl Discovered {
56    pub fn apply_suggestion(&self, src: &str) -> Option<String> {
57        self.suggestion.as_ref().map(|suggestion| suggestion.apply(src))
58    }
59}
60
61/// Lint, and try to apply all suggestions.
62/// Returns the new source code, and any lints without suggestions.
63/// # Implementation
64/// Currently, this runs a loop: parse the code, lint it, apply a lint with suggestions,
65/// and loop again, until there's no more lints with suggestions. This is because our auto-fix
66/// system currently replaces the whole program, not just a certain part of it.
67/// If/when we discover that this autofix loop is too slow, we'll change our lint system so that
68/// lints can be applied to a small part of the program.
69pub fn lint_and_fix_all(mut source: String) -> anyhow::Result<(String, Vec<Discovered>)> {
70    loop {
71        let (program, errors) = crate::Program::parse(&source)?;
72        if !errors.is_empty() {
73            anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
74        }
75        let Some(program) = program else {
76            anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
77        };
78        let lints = program.lint_all()?;
79        if let Some(to_fix) = lints.iter().find_map(|lint| lint.suggestion.clone()) {
80            source = to_fix.apply(&source);
81        } else {
82            return Ok((source, lints));
83        }
84    }
85}
86
87/// Lint, and try to apply all suggestions.
88/// Returns the new source code, and any lints without suggestions.
89/// # Implementation
90/// Currently, this runs a loop: parse the code, lint it, apply a lint with suggestions,
91/// and loop again, until there's no more lints with suggestions. This is because our auto-fix
92/// system currently replaces the whole program, not just a certain part of it.
93/// If/when we discover that this autofix loop is too slow, we'll change our lint system so that
94/// lints can be applied to a small part of the program.
95pub fn lint_and_fix_families(
96    mut source: String,
97    families_to_fix: &[FindingFamily],
98) -> anyhow::Result<(String, Vec<Discovered>)> {
99    loop {
100        let (program, errors) = crate::Program::parse(&source)?;
101        if !errors.is_empty() {
102            anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
103        }
104        let Some(program) = program else {
105            anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
106        };
107        let lints = program.lint_all()?;
108        if let Some(to_fix) = lints.iter().find_map(|lint| {
109            if families_to_fix.contains(&lint.finding.family) {
110                lint.suggestion.clone()
111            } else {
112                None
113            }
114        }) {
115            source = to_fix.apply(&source);
116        } else {
117            return Ok((source, lints));
118        }
119    }
120}
121
122#[cfg(feature = "pyo3")]
123#[pyo3_stub_gen::derive::gen_stub_pymethods]
124#[pyo3::pymethods]
125impl Discovered {
126    #[getter]
127    pub fn finding(&self) -> Finding {
128        self.finding.clone()
129    }
130
131    #[getter]
132    pub fn description(&self) -> String {
133        self.description.clone()
134    }
135
136    #[getter]
137    pub fn pos(&self) -> (usize, usize) {
138        (self.pos.start(), self.pos.end())
139    }
140
141    #[getter]
142    pub fn overridden(&self) -> bool {
143        self.overridden
144    }
145}
146
147impl IntoDiagnostic for Discovered {
148    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
149        (&self).to_lsp_diagnostics(code)
150    }
151
152    fn severity(&self) -> DiagnosticSeverity {
153        (&self).severity()
154    }
155}
156
157impl IntoDiagnostic for &Discovered {
158    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
159        let message = self.finding.title.to_owned();
160        let source_range = self.pos;
161        let edit = self.suggestion.as_ref().map(|s| to_lsp_edit(s, code));
162
163        vec![Diagnostic {
164            range: source_range.to_lsp_range(code),
165            severity: Some(self.severity()),
166            code: None,
167            // TODO: this is neat we can pass a URL to a help page here for this specific error.
168            code_description: None,
169            source: Some("lint".to_string()),
170            message,
171            related_information: None,
172            tags: None,
173            data: edit.map(|e| serde_json::to_value(e).unwrap()),
174        }]
175    }
176
177    fn severity(&self) -> DiagnosticSeverity {
178        DiagnosticSeverity::INFORMATION
179    }
180}
181
182/// Abstract lint problem type.
183#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
184#[ts(export)]
185#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
186#[serde(rename_all = "camelCase")]
187pub struct Finding {
188    /// Unique identifier for this particular issue.
189    pub code: &'static str,
190
191    /// Short one-line description of this issue.
192    pub title: &'static str,
193
194    /// Long human-readable description of this issue.
195    pub description: &'static str,
196
197    /// Is this discovered issue experimental?
198    pub experimental: bool,
199
200    /// Findings are sorted into families, e.g. "style" or "correctness".
201    pub family: FindingFamily,
202}
203
204/// Abstract lint problem type.
205#[derive(Clone, Copy, Debug, PartialEq, Eq, ts_rs::TS, Serialize, Hash)]
206#[ts(export)]
207#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
208#[serde(rename_all = "camelCase")]
209pub enum FindingFamily {
210    /// KCL style guidelines, e.g. identifier casing.
211    Style,
212    /// The user is probably doing something incorrect or unintended.
213    Correctness,
214    /// The user has expressed something in a complex way that
215    /// could be simplified.
216    Simplify,
217}
218
219impl std::fmt::Display for FindingFamily {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        match self {
222            FindingFamily::Style => write!(f, "style"),
223            FindingFamily::Correctness => write!(f, "correctness"),
224            FindingFamily::Simplify => write!(f, "simplify"),
225        }
226    }
227}
228
229#[cfg(feature = "pyo3")]
230impl pyo3_stub_gen::PyStubType for FindingFamily {
231    fn type_output() -> pyo3_stub_gen::TypeInfo {
232        // Expose the enum name in stubs; functions using FindingFamily will be annotated accordingly.
233        pyo3_stub_gen::TypeInfo::unqualified("FindingFamily")
234    }
235}
236
237#[cfg(feature = "pyo3")]
238fn finding_family_type_id() -> std::any::TypeId {
239    std::any::TypeId::of::<FindingFamily>()
240}
241
242#[cfg(feature = "pyo3")]
243inventory::submit! {
244    PyEnumInfo {
245        enum_id: finding_family_type_id,
246        pyclass_name: "FindingFamily",
247        module: None,
248        doc: "Lint families such as style or correctness.",
249        variants: &[
250            ("Style", "KCL style guidelines, e.g. identifier casing."),
251            ("Correctness", "The user is probably doing something incorrect or unintended."),
252            ("Simplify", "The user has expressed something in a complex way that could be simplified."),
253        ],
254    }
255}
256
257impl Finding {
258    /// Create a new Discovered finding at the specific Position.
259    pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
260        Discovered {
261            description,
262            finding: self.clone(),
263            pos,
264            overridden: false,
265            suggestion,
266        }
267    }
268}
269
270#[cfg(feature = "pyo3")]
271#[pyo3_stub_gen::derive::gen_stub_pymethods]
272#[pyo3::pymethods]
273impl Finding {
274    #[getter]
275    pub fn code(&self) -> &'static str {
276        self.code
277    }
278
279    #[getter]
280    pub fn title(&self) -> &'static str {
281        self.title
282    }
283
284    #[getter]
285    pub fn description(&self) -> &'static str {
286        self.description
287    }
288
289    #[getter]
290    pub fn experimental(&self) -> bool {
291        self.experimental
292    }
293
294    #[getter]
295    pub fn family(&self) -> String {
296        self.family.to_string()
297    }
298}
299
300macro_rules! def_finding {
301    ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path) => {
302        /// Generated Finding
303        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description, $family);
304    };
305}
306pub(crate) use def_finding;
307
308macro_rules! finding {
309    ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path ) => {
310        $crate::lint::rule::Finding {
311            code: stringify!($code),
312            title: $title,
313            description: $description,
314            experimental: false,
315            family: $family,
316        }
317    };
318}
319pub(crate) use finding;
320#[cfg(test)]
321pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
322
323#[cfg(test)]
324mod test {
325
326    #[test]
327    fn test_lint_and_fix_all() {
328        // This file has some snake_case identifiers.
329        let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
330        let f = std::fs::read_to_string(path).unwrap();
331        let prog = crate::Program::parse_no_errs(&f).unwrap();
332
333        // That should cause linter errors.
334        let lints = prog.lint_all().unwrap();
335        assert!(lints.len() >= 4);
336
337        // But the linter errors can be fixed.
338        let (new_code, unfixed) = lint_and_fix_all(f).unwrap();
339        assert!(unfixed.len() < 4);
340
341        // After the fix, no more snake_case identifiers.
342        assert!(!new_code.contains('_'));
343    }
344
345    #[test]
346    fn test_lint_and_fix_families() {
347        // This file has some snake_case identifiers.
348        let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
349        let original_code = std::fs::read_to_string(path).unwrap();
350        let prog = crate::Program::parse_no_errs(&original_code).unwrap();
351
352        // That should cause linter errors.
353        let lints = prog.lint_all().unwrap();
354        assert!(lints.len() >= 4);
355
356        // But the linter errors can be fixed.
357        let (new_code, unfixed) =
358            lint_and_fix_families(original_code, &[FindingFamily::Correctness, FindingFamily::Simplify]).unwrap();
359        assert!(unfixed.len() >= 3);
360
361        // After the fix, no more snake_case identifiers.
362        assert!(new_code.contains("box_width"));
363        assert!(new_code.contains("box_depth"));
364        assert!(new_code.contains("box_height"));
365    }
366
367    macro_rules! assert_no_finding {
368        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
369            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
370
371            // Ensure the code still works.
372            $crate::execution::parse_execute($kcl).await.unwrap();
373
374            for discovered_finding in prog.lint($check).unwrap() {
375                if discovered_finding.finding == $finding {
376                    assert!(false, "Finding {:?} was emitted", $finding.code);
377                }
378            }
379        };
380    }
381
382    macro_rules! assert_finding {
383        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
384            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
385
386            // Ensure the code still works.
387            $crate::execution::parse_execute($kcl).await.unwrap();
388
389            for discovered_finding in prog.lint($check).unwrap() {
390                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
391
392                if discovered_finding.finding == $finding {
393                    pretty_assertions::assert_eq!(
394                        discovered_finding.suggestion.clone().map(|s| s.insert),
395                        $suggestion,
396                    );
397
398                    if discovered_finding.suggestion.is_some() {
399                        // Apply the suggestion to the source code.
400                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
401
402                        // Ensure the code still works.
403                        $crate::execution::parse_execute(&code).await.unwrap();
404                    }
405                    return;
406                }
407            }
408            assert!(false, "Finding {:?} was not emitted", $finding.code);
409        };
410    }
411
412    macro_rules! test_finding {
413        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
414            #[tokio::test]
415            async fn $name() {
416                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
417            }
418        };
419    }
420
421    macro_rules! test_no_finding {
422        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
423            #[tokio::test]
424            async fn $name() {
425                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
426            }
427        };
428    }
429
430    pub(crate) use assert_finding;
431    pub(crate) use assert_no_finding;
432    pub(crate) use test_finding;
433    pub(crate) use test_no_finding;
434
435    use super::*;
436}