kcl_lib/lint/
rule.rs

1use anyhow::Result;
2use serde::Serialize;
3use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
4
5use crate::{
6    SourceRange,
7    errors::Suggestion,
8    lsp::{IntoDiagnostic, ToLspRange, to_lsp_edit},
9    parsing::ast::types::{Node as AstNode, Program},
10    walk::Node,
11};
12
13/// Check the provided AST for any found rule violations.
14///
15/// The Rule trait is automatically implemented for a few other types,
16/// but it can also be manually implemented as required.
17pub trait Rule<'a> {
18    /// Check the AST at this specific node for any Finding(s).
19    fn check(&self, node: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>>;
20}
21
22impl<'a, FnT> Rule<'a> for FnT
23where
24    FnT: Fn(Node<'a>, &AstNode<Program>) -> Result<Vec<Discovered>>,
25{
26    fn check(&self, n: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>> {
27        self(n, prog)
28    }
29}
30
31/// Specific discovered lint rule Violation of a particular Finding.
32#[derive(Clone, Debug, ts_rs::TS, Serialize)]
33#[ts(export)]
34#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
35#[serde(rename_all = "camelCase")]
36pub struct Discovered {
37    /// Zoo Lint Finding information.
38    pub finding: Finding,
39
40    /// Further information about the specific finding.
41    pub description: String,
42
43    /// Source code location.
44    pub pos: SourceRange,
45
46    /// Is this discovered issue overridden by the programmer?
47    pub overridden: bool,
48
49    /// Suggestion to fix the issue.
50    pub suggestion: Option<Suggestion>,
51}
52
53impl Discovered {
54    pub fn apply_suggestion(&self, src: &str) -> Option<String> {
55        self.suggestion.as_ref().map(|suggestion| suggestion.apply(src))
56    }
57}
58
59/// Lint, and try to apply all suggestions.
60/// Returns the new source code, and any lints without suggestions.
61/// # Implementation
62/// Currently, this runs a loop: parse the code, lint it, apply a lint with suggestions,
63/// and loop again, until there's no more lints with suggestions. This is because our auto-fix
64/// system currently replaces the whole program, not just a certain part of it.
65/// If/when we discover that this autofix loop is too slow, we'll change our lint system so that
66/// lints can be applied to a small part of the program.
67pub fn lint_and_fix(mut source: String) -> anyhow::Result<(String, Vec<Discovered>)> {
68    loop {
69        let (program, errors) = crate::Program::parse(&source)?;
70        if !errors.is_empty() {
71            anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
72        }
73        let Some(program) = program else {
74            anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
75        };
76        let lints = program.lint_all()?;
77        if let Some(to_fix) = lints.iter().find_map(|lint| lint.suggestion.clone()) {
78            source = to_fix.apply(&source);
79        } else {
80            return Ok((source, lints));
81        }
82    }
83}
84
85#[cfg(feature = "pyo3")]
86#[pyo3_stub_gen::derive::gen_stub_pymethods]
87#[pyo3::pymethods]
88impl Discovered {
89    #[getter]
90    pub fn finding(&self) -> Finding {
91        self.finding.clone()
92    }
93
94    #[getter]
95    pub fn description(&self) -> String {
96        self.description.clone()
97    }
98
99    #[getter]
100    pub fn pos(&self) -> (usize, usize) {
101        (self.pos.start(), self.pos.end())
102    }
103
104    #[getter]
105    pub fn overridden(&self) -> bool {
106        self.overridden
107    }
108}
109
110impl IntoDiagnostic for Discovered {
111    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
112        (&self).to_lsp_diagnostics(code)
113    }
114
115    fn severity(&self) -> DiagnosticSeverity {
116        (&self).severity()
117    }
118}
119
120impl IntoDiagnostic for &Discovered {
121    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
122        let message = self.finding.title.to_owned();
123        let source_range = self.pos;
124        let edit = self.suggestion.as_ref().map(|s| to_lsp_edit(s, code));
125
126        vec![Diagnostic {
127            range: source_range.to_lsp_range(code),
128            severity: Some(self.severity()),
129            code: None,
130            // TODO: this is neat we can pass a URL to a help page here for this specific error.
131            code_description: None,
132            source: Some("lint".to_string()),
133            message,
134            related_information: None,
135            tags: None,
136            data: edit.map(|e| serde_json::to_value(e).unwrap()),
137        }]
138    }
139
140    fn severity(&self) -> DiagnosticSeverity {
141        DiagnosticSeverity::INFORMATION
142    }
143}
144
145/// Abstract lint problem type.
146#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
147#[ts(export)]
148#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
149#[serde(rename_all = "camelCase")]
150pub struct Finding {
151    /// Unique identifier for this particular issue.
152    pub code: &'static str,
153
154    /// Short one-line description of this issue.
155    pub title: &'static str,
156
157    /// Long human-readable description of this issue.
158    pub description: &'static str,
159
160    /// Is this discovered issue experimental?
161    pub experimental: bool,
162}
163
164impl Finding {
165    /// Create a new Discovered finding at the specific Position.
166    pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
167        Discovered {
168            description,
169            finding: self.clone(),
170            pos,
171            overridden: false,
172            suggestion,
173        }
174    }
175}
176
177#[cfg(feature = "pyo3")]
178#[pyo3_stub_gen::derive::gen_stub_pymethods]
179#[pyo3::pymethods]
180impl Finding {
181    #[getter]
182    pub fn code(&self) -> &'static str {
183        self.code
184    }
185
186    #[getter]
187    pub fn title(&self) -> &'static str {
188        self.title
189    }
190
191    #[getter]
192    pub fn description(&self) -> &'static str {
193        self.description
194    }
195
196    #[getter]
197    pub fn experimental(&self) -> bool {
198        self.experimental
199    }
200}
201
202macro_rules! def_finding {
203    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
204        /// Generated Finding
205        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description);
206    };
207}
208pub(crate) use def_finding;
209
210macro_rules! finding {
211    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
212        $crate::lint::rule::Finding {
213            code: stringify!($code),
214            title: $title,
215            description: $description,
216            experimental: false,
217        }
218    };
219}
220pub(crate) use finding;
221#[cfg(test)]
222pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
223
224#[cfg(test)]
225mod test {
226
227    #[test]
228    fn test_lint_and_fix() {
229        // This file has some snake_case identifiers.
230        let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
231        let f = std::fs::read_to_string(path).unwrap();
232        let prog = crate::Program::parse_no_errs(&f).unwrap();
233
234        // That should cause linter errors.
235        let lints = prog.lint_all().unwrap();
236        assert!(lints.len() >= 4);
237
238        // But the linter errors can be fixed.
239        let (new_code, unfixed) = lint_and_fix(f).unwrap();
240        assert!(unfixed.len() < 4);
241
242        // After the fix, no more snake_case identifiers.
243        eprintln!("{new_code}");
244        assert!(!new_code.contains('_'));
245    }
246
247    macro_rules! assert_no_finding {
248        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
249            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
250
251            // Ensure the code still works.
252            $crate::execution::parse_execute($kcl).await.unwrap();
253
254            for discovered_finding in prog.lint($check).unwrap() {
255                if discovered_finding.finding == $finding {
256                    assert!(false, "Finding {:?} was emitted", $finding.code);
257                }
258            }
259        };
260    }
261
262    macro_rules! assert_finding {
263        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
264            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
265
266            // Ensure the code still works.
267            $crate::execution::parse_execute($kcl).await.unwrap();
268
269            for discovered_finding in prog.lint($check).unwrap() {
270                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
271
272                if discovered_finding.finding == $finding {
273                    pretty_assertions::assert_eq!(
274                        discovered_finding.suggestion.clone().map(|s| s.insert),
275                        $suggestion,
276                    );
277
278                    if discovered_finding.suggestion.is_some() {
279                        // Apply the suggestion to the source code.
280                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
281
282                        // Ensure the code still works.
283                        $crate::execution::parse_execute(&code).await.unwrap();
284                    }
285                    return;
286                }
287            }
288            assert!(false, "Finding {:?} was not emitted", $finding.code);
289        };
290    }
291
292    macro_rules! test_finding {
293        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
294            #[tokio::test]
295            async fn $name() {
296                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
297            }
298        };
299    }
300
301    macro_rules! test_no_finding {
302        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
303            #[tokio::test]
304            async fn $name() {
305                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
306            }
307        };
308    }
309
310    pub(crate) use assert_finding;
311    pub(crate) use assert_no_finding;
312    pub(crate) use test_finding;
313    pub(crate) use test_no_finding;
314
315    use super::*;
316}