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)]
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::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| s.to_lsp_edit(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, JsonSchema)]
128#[ts(export)]
129#[cfg_attr(feature = "pyo3", pyo3::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::pymethods]
160impl Finding {
161    #[getter]
162    pub fn code(&self) -> &'static str {
163        self.code
164    }
165
166    #[getter]
167    pub fn title(&self) -> &'static str {
168        self.title
169    }
170
171    #[getter]
172    pub fn description(&self) -> &'static str {
173        self.description
174    }
175
176    #[getter]
177    pub fn experimental(&self) -> bool {
178        self.experimental
179    }
180}
181
182macro_rules! def_finding {
183    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
184        /// Generated Finding
185        pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description);
186    };
187}
188pub(crate) use def_finding;
189
190macro_rules! finding {
191    ( $code:ident, $title:expr_2021, $description:expr_2021 ) => {
192        $crate::lint::rule::Finding {
193            code: stringify!($code),
194            title: $title,
195            description: $description,
196            experimental: false,
197        }
198    };
199}
200pub(crate) use finding;
201#[cfg(test)]
202pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
203
204#[cfg(test)]
205mod test {
206
207    macro_rules! assert_no_finding {
208        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
209            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
210
211            // Ensure the code still works.
212            $crate::execution::parse_execute($kcl).await.unwrap();
213
214            for discovered_finding in prog.lint($check).unwrap() {
215                if discovered_finding.finding == $finding {
216                    assert!(false, "Finding {:?} was emitted", $finding.code);
217                }
218            }
219        };
220    }
221
222    macro_rules! assert_finding {
223        ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
224            let prog = $crate::Program::parse_no_errs($kcl).unwrap();
225
226            // Ensure the code still works.
227            $crate::execution::parse_execute($kcl).await.unwrap();
228
229            for discovered_finding in prog.lint($check).unwrap() {
230                pretty_assertions::assert_eq!(discovered_finding.description, $output,);
231
232                if discovered_finding.finding == $finding {
233                    pretty_assertions::assert_eq!(
234                        discovered_finding.suggestion.clone().map(|s| s.insert),
235                        $suggestion,
236                    );
237
238                    if discovered_finding.suggestion.is_some() {
239                        // Apply the suggestion to the source code.
240                        let code = discovered_finding.apply_suggestion($kcl).unwrap();
241
242                        // Ensure the code still works.
243                        $crate::execution::parse_execute(&code).await.unwrap();
244                    }
245                    return;
246                }
247            }
248            assert!(false, "Finding {:?} was not emitted", $finding.code);
249        };
250    }
251
252    macro_rules! test_finding {
253        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
254            #[tokio::test]
255            async fn $name() {
256                $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
257            }
258        };
259    }
260
261    macro_rules! test_no_finding {
262        ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
263            #[tokio::test]
264            async fn $name() {
265                $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
266            }
267        };
268    }
269
270    pub(crate) use assert_finding;
271    pub(crate) use assert_no_finding;
272    pub(crate) use test_finding;
273    pub(crate) use test_no_finding;
274}