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