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_types::IntoDiagnostic;
13use crate::lsp_types::ToLspRange;
14use crate::lsp_types::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(
41 feature = "pyo3",
42 pyo3::pyclass(from_py_object),
43 pyo3_stub_gen::derive::gen_stub_pyclass
44)]
45#[serde(rename_all = "camelCase")]
46pub struct Discovered {
47 pub finding: Finding,
49
50 pub description: String,
52
53 pub pos: SourceRange,
55
56 pub overridden: bool,
58
59 pub suggestion: Option<Suggestion>,
61}
62
63impl Discovered {
64 pub fn apply_suggestion(&self, src: &str) -> Option<String> {
65 self.suggestion.as_ref().map(|suggestion| suggestion.apply(src))
66 }
67}
68
69const MAX_FIX_ITERATIONS: usize = 10_000;
76
77pub fn lint_and_fix_all(mut source: String) -> anyhow::Result<(String, Vec<Discovered>)> {
86 for _ in 0..MAX_FIX_ITERATIONS {
87 let (program, errors) = crate::Program::parse(&source)?;
88 if !errors.is_empty() {
89 anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
90 }
91 let Some(program) = program else {
92 anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
93 };
94 let lints = program.lint_all()?;
95 if let Some(to_fix) = lints.iter().find_map(|lint| lint.suggestion.clone()) {
96 source = to_fix.apply(&source);
97 } else {
98 return Ok((source, lints));
99 }
100 }
101 anyhow::bail!(
102 "Lint auto-fix did not finish after {MAX_FIX_ITERATIONS} iterations; a lint suggestion is not making progress"
103 )
104}
105
106pub fn lint_and_fix_families(
115 mut source: String,
116 families_to_fix: &[FindingFamily],
117) -> anyhow::Result<(String, Vec<Discovered>)> {
118 for _ in 0..MAX_FIX_ITERATIONS {
119 let (program, errors) = crate::Program::parse(&source)?;
120 if !errors.is_empty() {
121 anyhow::bail!("Found errors while parsing, please run the parser and fix them before linting.");
122 }
123 let Some(program) = program else {
124 anyhow::bail!("Could not parse, please run parser and ensure the program is valid before linting");
125 };
126 let lints = program.lint_all()?;
127 if let Some(to_fix) = lints.iter().find_map(|lint| {
128 if families_to_fix.contains(&lint.finding.family) {
129 lint.suggestion.clone()
130 } else {
131 None
132 }
133 }) {
134 source = to_fix.apply(&source);
135 } else {
136 return Ok((source, lints));
137 }
138 }
139 anyhow::bail!(
140 "Lint auto-fix did not finish after {MAX_FIX_ITERATIONS} iterations; a lint suggestion is not making progress"
141 )
142}
143
144#[cfg(feature = "pyo3")]
145#[pyo3_stub_gen::derive::gen_stub_pymethods]
146#[pyo3::pymethods]
147impl Discovered {
148 #[getter]
149 pub fn finding(&self) -> Finding {
150 self.finding.clone()
151 }
152
153 #[getter]
154 pub fn description(&self) -> String {
155 self.description.clone()
156 }
157
158 #[getter]
159 pub fn pos(&self) -> (usize, usize) {
160 (self.pos.start(), self.pos.end())
161 }
162
163 #[getter]
164 pub fn overridden(&self) -> bool {
165 self.overridden
166 }
167}
168
169impl IntoDiagnostic for Discovered {
170 fn to_lsp_diagnostics(&self, code: &str, uri: &tower_lsp::lsp_types::Url) -> Vec<Diagnostic> {
171 (&self).to_lsp_diagnostics(code, uri)
172 }
173
174 fn severity(&self) -> DiagnosticSeverity {
175 (&self).severity()
176 }
177}
178
179impl IntoDiagnostic for &Discovered {
180 fn to_lsp_diagnostics(&self, code: &str, _uri: &tower_lsp::lsp_types::Url) -> Vec<Diagnostic> {
181 let message = self.finding.title.to_owned();
182 let source_range = self.pos;
183 let edit = self.suggestion.as_ref().map(|s| to_lsp_edit(s, code));
184
185 vec![Diagnostic {
186 range: source_range.to_lsp_range(code),
187 severity: Some(self.severity()),
188 code: Some(tower_lsp::lsp_types::NumberOrString::String(
189 self.finding.code.to_string(),
190 )),
191 code_description: None,
193 source: Some("lint".to_string()),
194 message,
195 related_information: None,
196 tags: None,
197 data: edit.map(|e| serde_json::to_value(e).unwrap()),
198 }]
199 }
200
201 fn severity(&self) -> DiagnosticSeverity {
202 DiagnosticSeverity::INFORMATION
203 }
204}
205
206#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
208#[ts(export)]
209#[cfg_attr(
210 feature = "pyo3",
211 pyo3::pyclass(from_py_object),
212 pyo3_stub_gen::derive::gen_stub_pyclass
213)]
214#[serde(rename_all = "camelCase")]
215pub struct Finding {
216 pub code: &'static str,
218
219 pub title: &'static str,
221
222 pub description: &'static str,
224
225 pub experimental: bool,
227
228 pub family: FindingFamily,
230}
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq, ts_rs::TS, Serialize, Hash)]
234#[ts(export)]
235#[cfg_attr(feature = "pyo3", pyo3::pyclass(from_py_object))]
236#[serde(rename_all = "camelCase")]
237pub enum FindingFamily {
238 Style,
240 Correctness,
242 Simplify,
245}
246
247impl std::fmt::Display for FindingFamily {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 match self {
250 FindingFamily::Style => write!(f, "style"),
251 FindingFamily::Correctness => write!(f, "correctness"),
252 FindingFamily::Simplify => write!(f, "simplify"),
253 }
254 }
255}
256
257#[cfg(feature = "pyo3")]
258impl pyo3_stub_gen::PyStubType for FindingFamily {
259 fn type_output() -> pyo3_stub_gen::TypeInfo {
260 pyo3_stub_gen::TypeInfo::unqualified("FindingFamily")
262 }
263}
264
265#[cfg(feature = "pyo3")]
266fn finding_family_type_id() -> std::any::TypeId {
267 std::any::TypeId::of::<FindingFamily>()
268}
269
270#[cfg(feature = "pyo3")]
271inventory::submit! {
272 PyEnumInfo {
273 enum_id: finding_family_type_id,
274 pyclass_name: "FindingFamily",
275 module: None,
276 doc: "Lint families such as style or correctness.",
277 variants: &[
278 ("Style", "KCL style guidelines, e.g. identifier casing."),
279 ("Correctness", "The user is probably doing something incorrect or unintended."),
280 ("Simplify", "The user has expressed something in a complex way that could be simplified."),
281 ],
282 }
283}
284
285impl Finding {
286 pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
288 Discovered {
289 description,
290 finding: self.clone(),
291 pos,
292 overridden: false,
293 suggestion,
294 }
295 }
296}
297
298#[cfg(feature = "pyo3")]
299#[pyo3_stub_gen::derive::gen_stub_pymethods]
300#[pyo3::pymethods]
301impl Finding {
302 #[getter]
303 pub fn code(&self) -> &'static str {
304 self.code
305 }
306
307 #[getter]
308 pub fn title(&self) -> &'static str {
309 self.title
310 }
311
312 #[getter]
313 pub fn description(&self) -> &'static str {
314 self.description
315 }
316
317 #[getter]
318 pub fn experimental(&self) -> bool {
319 self.experimental
320 }
321
322 #[getter]
323 pub fn family(&self) -> String {
324 self.family.to_string()
325 }
326}
327
328macro_rules! def_finding {
329 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path) => {
330 pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description, $family);
332 };
333}
334pub(crate) use def_finding;
335
336macro_rules! finding {
337 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path ) => {
338 $crate::lint::rule::Finding {
339 code: stringify!($code),
340 title: $title,
341 description: $description,
342 experimental: false,
343 family: $family,
344 }
345 };
346}
347pub(crate) use finding;
348#[cfg(test)]
349pub(crate) use test::assert_finding;
350#[cfg(test)]
351pub(crate) use test::assert_no_finding;
352#[cfg(test)]
353pub(crate) use test::test_finding;
354#[cfg(test)]
355pub(crate) use test::test_no_finding;
356
357#[cfg(test)]
358mod test {
359
360 #[test]
361 fn test_lint_and_fix_all() {
362 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
364 let f = std::fs::read_to_string(path).unwrap();
365 let prog = crate::Program::parse_no_errs(&f).unwrap();
366
367 let lints = prog.lint_all().unwrap();
369 assert!(lints.len() >= 4);
370
371 let (new_code, unfixed) = lint_and_fix_all(f).unwrap();
373 assert!(unfixed.len() < 4);
374
375 assert!(!new_code.contains('_'));
377 }
378
379 #[test]
380 fn test_lint_and_fix_all_terminates_on_unfixable_rename() {
381 let source = "\
386x = if true {
387 local_value = 1
388 local_value
389} else {
390 0
391}
392";
393 let (new_code, unfixed) = lint_and_fix_all(source.to_string()).unwrap();
394 assert_eq!(new_code, source);
395 assert_eq!(unfixed.len(), 1, "unfixed: {unfixed:?}");
396 assert_eq!(unfixed[0].description, "found 'local_value'");
397 assert!(unfixed[0].suggestion.is_none());
398 }
399
400 #[test]
401 fn test_lint_and_fix_families() {
402 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
404 let original_code = std::fs::read_to_string(path).unwrap();
405 let prog = crate::Program::parse_no_errs(&original_code).unwrap();
406
407 let lints = prog.lint_all().unwrap();
409 assert!(lints.len() >= 4);
410
411 let (new_code, unfixed) =
413 lint_and_fix_families(original_code, &[FindingFamily::Correctness, FindingFamily::Simplify]).unwrap();
414 assert!(unfixed.len() >= 3);
415
416 assert!(new_code.contains("box_width"));
418 assert!(new_code.contains("box_depth"));
419 assert!(new_code.contains("box_height"));
420 }
421
422 macro_rules! assert_no_finding {
423 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
424 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
425
426 $crate::execution::parse_execute($kcl).await.unwrap();
428
429 for discovered_finding in prog.lint($check).unwrap() {
430 if discovered_finding.finding == $finding {
431 assert!(false, "Finding {:?} was emitted", $finding.code);
432 }
433 }
434 };
435 }
436
437 macro_rules! assert_finding {
438 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
439 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
440
441 $crate::execution::parse_execute($kcl).await.unwrap();
443
444 for discovered_finding in prog.lint($check).unwrap() {
445 pretty_assertions::assert_eq!(discovered_finding.description, $output,);
446
447 if discovered_finding.finding == $finding {
448 pretty_assertions::assert_eq!(
449 discovered_finding.suggestion.clone().map(|s| s.insert),
450 $suggestion,
451 );
452
453 if discovered_finding.suggestion.is_some() {
454 let code = discovered_finding.apply_suggestion($kcl).unwrap();
456
457 $crate::execution::parse_execute(&code).await.unwrap();
459 }
460 return;
461 }
462 }
463 assert!(false, "Finding {:?} was not emitted", $finding.code);
464 };
465 }
466
467 macro_rules! test_finding {
468 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
469 #[tokio::test]
470 async fn $name() {
471 $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
472 }
473 };
474 }
475
476 macro_rules! test_no_finding {
477 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
478 #[tokio::test]
479 async fn $name() {
480 $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
481 }
482 };
483 }
484
485 pub(crate) use assert_finding;
486 pub(crate) use assert_no_finding;
487 pub(crate) use test_finding;
488 pub(crate) use test_no_finding;
489
490 use super::*;
491}