kcl_lib/lint/
rule.rs

1use anyhow::Result;
2use schemars::JsonSchema;
3use serde::Serialize;
4use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
5
6use crate::{
7    SourceRange,
8    errors::Suggestion,
9    lsp::IntoDiagnostic,
10    parsing::ast::types::{Node as AstNode, Program},
11    walk::Node,
12};
13
14/// Check the provided AST for any found rule violations.
15///
16/// The Rule trait is automatically implemented for a few other types,
17/// but it can also be manually implemented as required.
18pub trait Rule<'a> {
19    /// Check the AST at this specific node for any Finding(s).
20    fn check(&self, node: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>>;
21}
22
23impl<'a, FnT> Rule<'a> for FnT
24where
25    FnT: Fn(Node<'a>, &AstNode<Program>) -> Result<Vec<Discovered>>,
26{
27    fn check(&self, n: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>> {
28        self(n, prog)
29    }
30}
31
32/// Specific discovered lint rule Violation of a particular Finding.
33#[derive(Clone, Debug, ts_rs::TS, Serialize, JsonSchema)]
34#[ts(export)]
35#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
36#[serde(rename_all = "camelCase")]
37pub struct Discovered {
38    /// Zoo Lint Finding information.
39    pub finding: Finding,
40
41    /// Further information about the specific finding.
42    pub description: String,
43
44    /// Source code location.
45    pub pos: SourceRange,
46
47    /// Is this discovered issue overridden by the programmer?
48    pub overridden: bool,
49
50    /// Suggestion to fix the issue.
51    pub suggestion: Option<Suggestion>,
52}
53
54impl Discovered {
55    #[cfg(test)]
56    pub fn apply_suggestion(&self, src: &str) -> Option<String> {
57        let suggestion = self.suggestion.as_ref()?;
58        Some(format!(
59            "{}{}{}",
60            &src[0..suggestion.source_range.start()],
61            suggestion.insert,
62            &src[suggestion.source_range.end()..]
63        ))
64    }
65}
66
67#[cfg(feature = "pyo3")]
68#[pyo3_stub_gen::derive::gen_stub_pymethods]
69#[pyo3::pymethods]
70impl Discovered {
71    #[getter]
72    pub fn finding(&self) -> Finding {
73        self.finding.clone()
74    }
75
76    #[getter]
77    pub fn description(&self) -> String {
78        self.description.clone()
79    }
80
81    #[getter]
82    pub fn pos(&self) -> (usize, usize) {
83        (self.pos.start(), self.pos.end())
84    }
85
86    #[getter]
87    pub fn overridden(&self) -> bool {
88        self.overridden
89    }
90}
91
92impl IntoDiagnostic for Discovered {
93    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
94        (&self).to_lsp_diagnostics(code)
95    }
96
97    fn severity(&self) -> DiagnosticSeverity {
98        (&self).severity()
99    }
100}
101
102impl IntoDiagnostic for &Discovered {
103    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
104        let message = self.finding.title.to_owned();
105        let source_range = self.pos;
106        let edit = self.suggestion.as_ref().map(|s| s.to_lsp_edit(code));
107
108        vec![Diagnostic {
109            range: source_range.to_lsp_range(code),
110            severity: Some(self.severity()),
111            code: None,
112            // TODO: this is neat we can pass a URL to a help page here for this specific error.
113            code_description: None,
114            source: Some("lint".to_string()),
115            message,
116            related_information: None,
117            tags: None,
118            data: edit.map(|e| serde_json::to_value(e).unwrap()),
119        }]
120    }
121
122    fn severity(&self) -> DiagnosticSeverity {
123        DiagnosticSeverity::INFORMATION
124    }
125}
126
127/// Abstract lint problem type.
128#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize, JsonSchema)]
129#[ts(export)]
130#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
131#[serde(rename_all = "camelCase")]
132pub struct Finding {
133    /// Unique identifier for this particular issue.
134    pub code: &'static str,
135
136    /// Short one-line description of this issue.
137    pub title: &'static str,
138
139    /// Long human-readable description of this issue.
140    pub description: &'static str,
141
142    /// Is this discovered issue experimental?
143    pub experimental: bool,
144}
145
146impl Finding {
147    /// Create a new Discovered finding at the specific Position.
148    pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
149        Discovered {
150            description,
151            finding: self.clone(),
152            pos,
153            overridden: false,
154            suggestion,
155        }
156    }
157}
158
159#[cfg(feature = "pyo3")]
160#[pyo3_stub_gen::derive::gen_stub_pymethods]
161#[pyo3::pymethods]
162impl Finding {
163    #[getter]
164    pub fn code(&self) -> &'static str {
165        self.code
166    }
167
168    #[getter]
169    pub fn title(&self) -> &'static str {
170        self.title
171    }
172
173    #[getter]
174    pub fn description(&self) -> &'static str {
175        self.description
176    }
177
178    #[getter]
179    pub fn experimental(&self) -> bool {
180        self.experimental
181    }
182}
183
184macro_rules! def_finding {
185    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
186        /// Generated Finding
187        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description);
188    };
189}
190pub(crate) use def_finding;
191
192macro_rules! finding {
193    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
194        $crate::lint::rule::Finding {
195            code: stringify!($code),
196            title: $title,
197            description: $description,
198            experimental: false,
199        }
200    };
201}
202pub(crate) use finding;
203#[cfg(test)]
204pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
205
206#[cfg(test)]
207mod test {
208
209    macro_rules! assert_no_finding {
210        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
211            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
212
213            // Ensure the code still works.
214            $crate::execution::parse_execute($kcl).await.unwrap();
215
216            for discovered_finding in prog.lint($check).unwrap() {
217                if discovered_finding.finding == $finding {
218                    assert!(false, "Finding {:?} was emitted", $finding.code);
219                }
220            }
221        };
222    }
223
224    macro_rules! assert_finding {
225        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
226            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
227
228            // Ensure the code still works.
229            $crate::execution::parse_execute($kcl).await.unwrap();
230
231            for discovered_finding in prog.lint($check).unwrap() {
232                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
233
234                if discovered_finding.finding == $finding {
235                    pretty_assertions::assert_eq!(
236                        discovered_finding.suggestion.clone().map(|s| s.insert),
237                        $suggestion,
238                    );
239
240                    if discovered_finding.suggestion.is_some() {
241                        // Apply the suggestion to the source code.
242                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
243
244                        // Ensure the code still works.
245                        $crate::execution::parse_execute(&code).await.unwrap();
246                    }
247                    return;
248                }
249            }
250            assert!(false, "Finding {:?} was not emitted", $finding.code);
251        };
252    }
253
254    macro_rules! test_finding {
255        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
256            #[tokio::test]
257            async fn $name() {
258                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
259            }
260        };
261    }
262
263    macro_rules! test_no_finding {
264        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
265            #[tokio::test]
266            async fn $name() {
267                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
268            }
269        };
270    }
271
272    pub(crate) use assert_finding;
273    pub(crate) use assert_no_finding;
274    pub(crate) use test_finding;
275    pub(crate) use test_no_finding;
276}