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
13pub trait Rule<'a> {
18 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#[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 pub finding: Finding,
39
40 pub description: String,
42
43 pub pos: SourceRange,
45
46 pub overridden: bool,
48
49 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
59pub 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 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#[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 pub code: &'static str,
153
154 pub title: &'static str,
156
157 pub description: &'static str,
159
160 pub experimental: bool,
162}
163
164impl Finding {
165 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 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 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 let lints = prog.lint_all().unwrap();
236 assert!(lints.len() >= 4);
237
238 let (new_code, unfixed) = lint_and_fix(f).unwrap();
240 assert!(unfixed.len() < 4);
241
242 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 $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 $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 let code = discovered_finding.apply_suggestion($kcl).unwrap();
281
282 $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}