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    #[cfg(test)]
55    pub fn apply_suggestion(&self, src: &str) -> Option<String> {
56        let suggestion = self.suggestion.as_ref()?;
57        Some(format!(
58            "{}{}{}",
59            &src[0..suggestion.source_range.start()],
60            suggestion.insert,
61            &src[suggestion.source_range.end()..]
62        ))
63    }
64}
65
66#[cfg(feature = "pyo3")]
67#[pyo3_stub_gen::derive::gen_stub_pymethods]
68#[pyo3::pymethods]
69impl Discovered {
70    #[getter]
71    pub fn finding(&self) -> Finding {
72        self.finding.clone()
73    }
74
75    #[getter]
76    pub fn description(&self) -> String {
77        self.description.clone()
78    }
79
80    #[getter]
81    pub fn pos(&self) -> (usize, usize) {
82        (self.pos.start(), self.pos.end())
83    }
84
85    #[getter]
86    pub fn overridden(&self) -> bool {
87        self.overridden
88    }
89}
90
91impl IntoDiagnostic for Discovered {
92    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
93        (&self).to_lsp_diagnostics(code)
94    }
95
96    fn severity(&self) -> DiagnosticSeverity {
97        (&self).severity()
98    }
99}
100
101impl IntoDiagnostic for &Discovered {
102    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
103        let message = self.finding.title.to_owned();
104        let source_range = self.pos;
105        let edit = self.suggestion.as_ref().map(|s| to_lsp_edit(s, code));
106
107        vec![Diagnostic {
108            range: source_range.to_lsp_range(code),
109            severity: Some(self.severity()),
110            code: None,
111            // TODO: this is neat we can pass a URL to a help page here for this specific error.
112            code_description: None,
113            source: Some("lint".to_string()),
114            message,
115            related_information: None,
116            tags: None,
117            data: edit.map(|e| serde_json::to_value(e).unwrap()),
118        }]
119    }
120
121    fn severity(&self) -> DiagnosticSeverity {
122        DiagnosticSeverity::INFORMATION
123    }
124}
125
126/// Abstract lint problem type.
127#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
128#[ts(export)]
129#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
130#[serde(rename_all = "camelCase")]
131pub struct Finding {
132    /// Unique identifier for this particular issue.
133    pub code: &'static str,
134
135    /// Short one-line description of this issue.
136    pub title: &'static str,
137
138    /// Long human-readable description of this issue.
139    pub description: &'static str,
140
141    /// Is this discovered issue experimental?
142    pub experimental: bool,
143}
144
145impl Finding {
146    /// Create a new Discovered finding at the specific Position.
147    pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
148        Discovered {
149            description,
150            finding: self.clone(),
151            pos,
152            overridden: false,
153            suggestion,
154        }
155    }
156}
157
158#[cfg(feature = "pyo3")]
159#[pyo3_stub_gen::derive::gen_stub_pymethods]
160#[pyo3::pymethods]
161impl Finding {
162    #[getter]
163    pub fn code(&self) -> &'static str {
164        self.code
165    }
166
167    #[getter]
168    pub fn title(&self) -> &'static str {
169        self.title
170    }
171
172    #[getter]
173    pub fn description(&self) -> &'static str {
174        self.description
175    }
176
177    #[getter]
178    pub fn experimental(&self) -> bool {
179        self.experimental
180    }
181}
182
183macro_rules! def_finding {
184    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
185        /// Generated Finding
186        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description);
187    };
188}
189pub(crate) use def_finding;
190
191macro_rules! finding {
192    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
193        $crate::lint::rule::Finding {
194            code: stringify!($code),
195            title: $title,
196            description: $description,
197            experimental: false,
198        }
199    };
200}
201pub(crate) use finding;
202#[cfg(test)]
203pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
204
205#[cfg(test)]
206mod test {
207
208    macro_rules! assert_no_finding {
209        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
210            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
211
212            // Ensure the code still works.
213            $crate::execution::parse_execute($kcl).await.unwrap();
214
215            for discovered_finding in prog.lint($check).unwrap() {
216                if discovered_finding.finding == $finding {
217                    assert!(false, "Finding {:?} was emitted", $finding.code);
218                }
219            }
220        };
221    }
222
223    macro_rules! assert_finding {
224        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
225            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
226
227            // Ensure the code still works.
228            $crate::execution::parse_execute($kcl).await.unwrap();
229
230            for discovered_finding in prog.lint($check).unwrap() {
231                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
232
233                if discovered_finding.finding == $finding {
234                    pretty_assertions::assert_eq!(
235                        discovered_finding.suggestion.clone().map(|s| s.insert),
236                        $suggestion,
237                    );
238
239                    if discovered_finding.suggestion.is_some() {
240                        // Apply the suggestion to the source code.
241                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
242
243                        // Ensure the code still works.
244                        $crate::execution::parse_execute(&code).await.unwrap();
245                    }
246                    return;
247                }
248            }
249            assert!(false, "Finding {:?} was not emitted", $finding.code);
250        };
251    }
252
253    macro_rules! test_finding {
254        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
255            #[tokio::test]
256            async fn $name() {
257                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
258            }
259        };
260    }
261
262    macro_rules! test_no_finding {
263        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
264            #[tokio::test]
265            async fn $name() {
266                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
267            }
268        };
269    }
270
271    pub(crate) use assert_finding;
272    pub(crate) use assert_no_finding;
273    pub(crate) use test_finding;
274    pub(crate) use test_no_finding;
275}