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