1use anyhow::Result;
2#[cfg(feature = "pyo3")]
3use pyo3_stub_gen::{inventory, type_info::PyEnumInfo};
4use serde::Serialize;
5use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
6
7use crate::{
8 SourceRange,
9 errors::Suggestion,
10 lsp::{IntoDiagnostic, ToLspRange, to_lsp_edit},
11 parsing::ast::types::{Node as AstNode, Program},
12 walk::Node,
13};
14
15pub trait Rule<'a> {
20 fn check(&self, node: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>>;
22}
23
24impl<'a, FnT> Rule<'a> for FnT
25where
26 FnT: Fn(Node<'a>, &AstNode<Program>) -> Result<Vec<Discovered>>,
27{
28 fn check(&self, n: Node<'a>, prog: &AstNode<Program>) -> Result<Vec<Discovered>> {
29 self(n, prog)
30 }
31}
32
33#[derive(Clone, Debug, ts_rs::TS, Serialize)]
35#[ts(export)]
36#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
37#[serde(rename_all = "camelCase")]
38pub struct Discovered {
39 pub finding: Finding,
41
42 pub description: String,
44
45 pub pos: SourceRange,
47
48 pub overridden: bool,
50
51 pub suggestion: Option<Suggestion>,
53}
54
55impl Discovered {
56 pub fn apply_suggestion(&self, src: &str) -> Option<String> {
57 self.suggestion.as_ref().map(|suggestion| suggestion.apply(src))
58 }
59}
60
61pub fn lint_and_fix_all(mut source: String) -> anyhow::Result<(String, Vec<Discovered>)> {
70 loop {
71 let (program, errors) = crate::Program::parse(&source)?;
72 if !errors.is_empty() {
73 anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
74 }
75 let Some(program) = program else {
76 anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
77 };
78 let lints = program.lint_all()?;
79 if let Some(to_fix) = lints.iter().find_map(|lint| lint.suggestion.clone()) {
80 source = to_fix.apply(&source);
81 } else {
82 return Ok((source, lints));
83 }
84 }
85}
86
87pub fn lint_and_fix_families(
96 mut source: String,
97 families_to_fix: &[FindingFamily],
98) -> anyhow::Result<(String, Vec<Discovered>)> {
99 loop {
100 let (program, errors) = crate::Program::parse(&source)?;
101 if !errors.is_empty() {
102 anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
103 }
104 let Some(program) = program else {
105 anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
106 };
107 let lints = program.lint_all()?;
108 if let Some(to_fix) = lints.iter().find_map(|lint| {
109 if families_to_fix.contains(&lint.finding.family) {
110 lint.suggestion.clone()
111 } else {
112 None
113 }
114 }) {
115 source = to_fix.apply(&source);
116 } else {
117 return Ok((source, lints));
118 }
119 }
120}
121
122#[cfg(feature = "pyo3")]
123#[pyo3_stub_gen::derive::gen_stub_pymethods]
124#[pyo3::pymethods]
125impl Discovered {
126 #[getter]
127 pub fn finding(&self) -> Finding {
128 self.finding.clone()
129 }
130
131 #[getter]
132 pub fn description(&self) -> String {
133 self.description.clone()
134 }
135
136 #[getter]
137 pub fn pos(&self) -> (usize, usize) {
138 (self.pos.start(), self.pos.end())
139 }
140
141 #[getter]
142 pub fn overridden(&self) -> bool {
143 self.overridden
144 }
145}
146
147impl IntoDiagnostic for Discovered {
148 fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
149 (&self).to_lsp_diagnostics(code)
150 }
151
152 fn severity(&self) -> DiagnosticSeverity {
153 (&self).severity()
154 }
155}
156
157impl IntoDiagnostic for &Discovered {
158 fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
159 let message = self.finding.title.to_owned();
160 let source_range = self.pos;
161 let edit = self.suggestion.as_ref().map(|s| to_lsp_edit(s, code));
162
163 vec![Diagnostic {
164 range: source_range.to_lsp_range(code),
165 severity: Some(self.severity()),
166 code: Some(tower_lsp::lsp_types::NumberOrString::String(
167 self.finding.code.to_string(),
168 )),
169 code_description: None,
171 source: Some("lint".to_string()),
172 message,
173 related_information: None,
174 tags: None,
175 data: edit.map(|e| serde_json::to_value(e).unwrap()),
176 }]
177 }
178
179 fn severity(&self) -> DiagnosticSeverity {
180 DiagnosticSeverity::INFORMATION
181 }
182}
183
184#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
186#[ts(export)]
187#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
188#[serde(rename_all = "camelCase")]
189pub struct Finding {
190 pub code: &'static str,
192
193 pub title: &'static str,
195
196 pub description: &'static str,
198
199 pub experimental: bool,
201
202 pub family: FindingFamily,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, ts_rs::TS, Serialize, Hash)]
208#[ts(export)]
209#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
210#[serde(rename_all = "camelCase")]
211pub enum FindingFamily {
212 Style,
214 Correctness,
216 Simplify,
219}
220
221impl std::fmt::Display for FindingFamily {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 FindingFamily::Style => write!(f, "style"),
225 FindingFamily::Correctness => write!(f, "correctness"),
226 FindingFamily::Simplify => write!(f, "simplify"),
227 }
228 }
229}
230
231#[cfg(feature = "pyo3")]
232impl pyo3_stub_gen::PyStubType for FindingFamily {
233 fn type_output() -> pyo3_stub_gen::TypeInfo {
234 pyo3_stub_gen::TypeInfo::unqualified("FindingFamily")
236 }
237}
238
239#[cfg(feature = "pyo3")]
240fn finding_family_type_id() -> std::any::TypeId {
241 std::any::TypeId::of::<FindingFamily>()
242}
243
244#[cfg(feature = "pyo3")]
245inventory::submit! {
246 PyEnumInfo {
247 enum_id: finding_family_type_id,
248 pyclass_name: "FindingFamily",
249 module: None,
250 doc: "Lint families such as style or correctness.",
251 variants: &[
252 ("Style", "KCL style guidelines, e.g. identifier casing."),
253 ("Correctness", "The user is probably doing something incorrect or unintended."),
254 ("Simplify", "The user has expressed something in a complex way that could be simplified."),
255 ],
256 }
257}
258
259impl Finding {
260 pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
262 Discovered {
263 description,
264 finding: self.clone(),
265 pos,
266 overridden: false,
267 suggestion,
268 }
269 }
270}
271
272#[cfg(feature = "pyo3")]
273#[pyo3_stub_gen::derive::gen_stub_pymethods]
274#[pyo3::pymethods]
275impl Finding {
276 #[getter]
277 pub fn code(&self) -> &'static str {
278 self.code
279 }
280
281 #[getter]
282 pub fn title(&self) -> &'static str {
283 self.title
284 }
285
286 #[getter]
287 pub fn description(&self) -> &'static str {
288 self.description
289 }
290
291 #[getter]
292 pub fn experimental(&self) -> bool {
293 self.experimental
294 }
295
296 #[getter]
297 pub fn family(&self) -> String {
298 self.family.to_string()
299 }
300}
301
302macro_rules! def_finding {
303 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path) => {
304 pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description, $family);
306 };
307}
308pub(crate) use def_finding;
309
310macro_rules! finding {
311 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path ) => {
312 $crate::lint::rule::Finding {
313 code: stringify!($code),
314 title: $title,
315 description: $description,
316 experimental: false,
317 family: $family,
318 }
319 };
320}
321pub(crate) use finding;
322#[cfg(test)]
323pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
324
325#[cfg(test)]
326mod test {
327
328 #[test]
329 fn test_lint_and_fix_all() {
330 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
332 let f = std::fs::read_to_string(path).unwrap();
333 let prog = crate::Program::parse_no_errs(&f).unwrap();
334
335 let lints = prog.lint_all().unwrap();
337 assert!(lints.len() >= 4);
338
339 let (new_code, unfixed) = lint_and_fix_all(f).unwrap();
341 assert!(unfixed.len() < 4);
342
343 assert!(!new_code.contains('_'));
345 }
346
347 #[test]
348 fn test_lint_and_fix_families() {
349 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
351 let original_code = std::fs::read_to_string(path).unwrap();
352 let prog = crate::Program::parse_no_errs(&original_code).unwrap();
353
354 let lints = prog.lint_all().unwrap();
356 assert!(lints.len() >= 4);
357
358 let (new_code, unfixed) =
360 lint_and_fix_families(original_code, &[FindingFamily::Correctness, FindingFamily::Simplify]).unwrap();
361 assert!(unfixed.len() >= 3);
362
363 assert!(new_code.contains("box_width"));
365 assert!(new_code.contains("box_depth"));
366 assert!(new_code.contains("box_height"));
367 }
368
369 macro_rules! assert_no_finding {
370 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
371 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
372
373 $crate::execution::parse_execute($kcl).await.unwrap();
375
376 for discovered_finding in prog.lint($check).unwrap() {
377 if discovered_finding.finding == $finding {
378 assert!(false, "Finding {:?} was emitted", $finding.code);
379 }
380 }
381 };
382 }
383
384 macro_rules! assert_finding {
385 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
386 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
387
388 $crate::execution::parse_execute($kcl).await.unwrap();
390
391 for discovered_finding in prog.lint($check).unwrap() {
392 pretty_assertions::assert_eq!(discovered_finding.description, $output,);
393
394 if discovered_finding.finding == $finding {
395 pretty_assertions::assert_eq!(
396 discovered_finding.suggestion.clone().map(|s| s.insert),
397 $suggestion,
398 );
399
400 if discovered_finding.suggestion.is_some() {
401 let code = discovered_finding.apply_suggestion($kcl).unwrap();
403
404 $crate::execution::parse_execute(&code).await.unwrap();
406 }
407 return;
408 }
409 }
410 assert!(false, "Finding {:?} was not emitted", $finding.code);
411 };
412 }
413
414 macro_rules! test_finding {
415 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
416 #[tokio::test]
417 async fn $name() {
418 $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
419 }
420 };
421 }
422
423 macro_rules! test_no_finding {
424 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
425 #[tokio::test]
426 async fn $name() {
427 $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
428 }
429 };
430 }
431
432 pub(crate) use assert_finding;
433 pub(crate) use assert_no_finding;
434 pub(crate) use test_finding;
435 pub(crate) use test_no_finding;
436
437 use super::*;
438}