Skip to main content

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: Some(tower_lsp::lsp_types::NumberOrString::String(
167                self.finding.code.to_string(),
168            )),
169            // TODO: this is neat we can pass a URL to a help page here for this specific error.
170            code_description: None,
171            source: Some("lint".to_string()),
172            message,
173            related_information: None,
174            tags: None,
175            data: edit.map(|e| serde_json::to_value(e).unwrap()),
176        }]
177    }
178
179    fn severity(&self) -> DiagnosticSeverity {
180        DiagnosticSeverity::INFORMATION
181    }
182}
183
184/// Abstract lint problem type.
185#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
186#[ts(export)]
187#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
188#[serde(rename_all = "camelCase")]
189pub struct Finding {
190    /// Unique identifier for this particular issue.
191    pub code: &'static str,
192
193    /// Short one-line description of this issue.
194    pub title: &'static str,
195
196    /// Long human-readable description of this issue.
197    pub description: &'static str,
198
199    /// Is this discovered issue experimental?
200    pub experimental: bool,
201
202    /// Findings are sorted into families, e.g. "style" or "correctness".
203    pub family: FindingFamily,
204}
205
206/// Abstract lint problem type.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, ts_rs::TS, Serialize, Hash)]
208#[ts(export)]
209#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
210#[serde(rename_all = "camelCase")]
211pub enum FindingFamily {
212    /// KCL style guidelines, e.g. identifier casing.
213    Style,
214    /// The user is probably doing something incorrect or unintended.
215    Correctness,
216    /// The user has expressed something in a complex way that
217    /// could be simplified.
218    Simplify,
219}
220
221impl std::fmt::Display for FindingFamily {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            FindingFamily::Style => write!(f, "style"),
225            FindingFamily::Correctness => write!(f, "correctness"),
226            FindingFamily::Simplify => write!(f, "simplify"),
227        }
228    }
229}
230
231#[cfg(feature = "pyo3")]
232impl pyo3_stub_gen::PyStubType for FindingFamily {
233    fn type_output() -> pyo3_stub_gen::TypeInfo {
234        // Expose the enum name in stubs; functions using FindingFamily will be annotated accordingly.
235        pyo3_stub_gen::TypeInfo::unqualified("FindingFamily")
236    }
237}
238
239#[cfg(feature = "pyo3")]
240fn finding_family_type_id() -> std::any::TypeId {
241    std::any::TypeId::of::<FindingFamily>()
242}
243
244#[cfg(feature = "pyo3")]
245inventory::submit! {
246    PyEnumInfo {
247        enum_id: finding_family_type_id,
248        pyclass_name: "FindingFamily",
249        module: None,
250        doc: "Lint families such as style or correctness.",
251        variants: &[
252            ("Style", "KCL style guidelines, e.g. identifier casing."),
253            ("Correctness", "The user is probably doing something incorrect or unintended."),
254            ("Simplify", "The user has expressed something in a complex way that could be simplified."),
255        ],
256    }
257}
258
259impl Finding {
260    /// Create a new Discovered finding at the specific Position.
261    pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
262        Discovered {
263            description,
264            finding: self.clone(),
265            pos,
266            overridden: false,
267            suggestion,
268        }
269    }
270}
271
272#[cfg(feature = "pyo3")]
273#[pyo3_stub_gen::derive::gen_stub_pymethods]
274#[pyo3::pymethods]
275impl Finding {
276    #[getter]
277    pub fn code(&self) -> &'static str {
278        self.code
279    }
280
281    #[getter]
282    pub fn title(&self) -> &'static str {
283        self.title
284    }
285
286    #[getter]
287    pub fn description(&self) -> &'static str {
288        self.description
289    }
290
291    #[getter]
292    pub fn experimental(&self) -> bool {
293        self.experimental
294    }
295
296    #[getter]
297    pub fn family(&self) -> String {
298        self.family.to_string()
299    }
300}
301
302macro_rules! def_finding {
303    ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path) => {
304        /// Generated Finding
305        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description, $family);
306    };
307}
308pub(crate) use def_finding;
309
310macro_rules! finding {
311    ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path ) => {
312        $crate::lint::rule::Finding {
313            code: stringify!($code),
314            title: $title,
315            description: $description,
316            experimental: false,
317            family: $family,
318        }
319    };
320}
321pub(crate) use finding;
322#[cfg(test)]
323pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
324
325#[cfg(test)]
326mod test {
327
328    #[test]
329    fn test_lint_and_fix_all() {
330        // This file has some snake_case identifiers.
331        let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
332        let f = std::fs::read_to_string(path).unwrap();
333        let prog = crate::Program::parse_no_errs(&f).unwrap();
334
335        // That should cause linter errors.
336        let lints = prog.lint_all().unwrap();
337        assert!(lints.len() >= 4);
338
339        // But the linter errors can be fixed.
340        let (new_code, unfixed) = lint_and_fix_all(f).unwrap();
341        assert!(unfixed.len() < 4);
342
343        // After the fix, no more snake_case identifiers.
344        assert!(!new_code.contains('_'));
345    }
346
347    #[test]
348    fn test_lint_and_fix_families() {
349        // This file has some snake_case identifiers.
350        let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
351        let original_code = std::fs::read_to_string(path).unwrap();
352        let prog = crate::Program::parse_no_errs(&original_code).unwrap();
353
354        // That should cause linter errors.
355        let lints = prog.lint_all().unwrap();
356        assert!(lints.len() >= 4);
357
358        // But the linter errors can be fixed.
359        let (new_code, unfixed) =
360            lint_and_fix_families(original_code, &[FindingFamily::Correctness, FindingFamily::Simplify]).unwrap();
361        assert!(unfixed.len() >= 3);
362
363        // After the fix, no more snake_case identifiers.
364        assert!(new_code.contains("box_width"));
365        assert!(new_code.contains("box_depth"));
366        assert!(new_code.contains("box_height"));
367    }
368
369    macro_rules! assert_no_finding {
370        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
371            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
372
373            // Ensure the code still works.
374            $crate::execution::parse_execute($kcl).await.unwrap();
375
376            for discovered_finding in prog.lint($check).unwrap() {
377                if discovered_finding.finding == $finding {
378                    assert!(false, "Finding {:?} was emitted", $finding.code);
379                }
380            }
381        };
382    }
383
384    macro_rules! assert_finding {
385        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
386            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
387
388            // Ensure the code still works.
389            $crate::execution::parse_execute($kcl).await.unwrap();
390
391            for discovered_finding in prog.lint($check).unwrap() {
392                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
393
394                if discovered_finding.finding == $finding {
395                    pretty_assertions::assert_eq!(
396                        discovered_finding.suggestion.clone().map(|s| s.insert),
397                        $suggestion,
398                    );
399
400                    if discovered_finding.suggestion.is_some() {
401                        // Apply the suggestion to the source code.
402                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
403
404                        // Ensure the code still works.
405                        $crate::execution::parse_execute(&code).await.unwrap();
406                    }
407                    return;
408                }
409            }
410            assert!(false, "Finding {:?} was not emitted", $finding.code);
411        };
412    }
413
414    macro_rules! test_finding {
415        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
416            #[tokio::test]
417            async fn $name() {
418                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
419            }
420        };
421    }
422
423    macro_rules! test_no_finding {
424        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
425            #[tokio::test]
426            async fn $name() {
427                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
428            }
429        };
430    }
431
432    pub(crate) use assert_finding;
433    pub(crate) use assert_no_finding;
434    pub(crate) use test_finding;
435    pub(crate) use test_no_finding;
436
437    use super::*;
438}