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