Skip to main content

kcl_lib/lint/
rule.rs

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