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