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: None,
167 code_description: None,
169 source: Some("lint".to_string()),
170 message,
171 related_information: None,
172 tags: None,
173 data: edit.map(|e| serde_json::to_value(e).unwrap()),
174 }]
175 }
176
177 fn severity(&self) -> DiagnosticSeverity {
178 DiagnosticSeverity::INFORMATION
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, ts_rs::TS, Serialize)]
184#[ts(export)]
185#[cfg_attr(feature = "pyo3", pyo3::pyclass, pyo3_stub_gen::derive::gen_stub_pyclass)]
186#[serde(rename_all = "camelCase")]
187pub struct Finding {
188 pub code: &'static str,
190
191 pub title: &'static str,
193
194 pub description: &'static str,
196
197 pub experimental: bool,
199
200 pub family: FindingFamily,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq, ts_rs::TS, Serialize, Hash)]
206#[ts(export)]
207#[cfg_attr(feature = "pyo3", pyo3::pyclass)]
208#[serde(rename_all = "camelCase")]
209pub enum FindingFamily {
210 Style,
212 Correctness,
214 Simplify,
217}
218
219impl std::fmt::Display for FindingFamily {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 match self {
222 FindingFamily::Style => write!(f, "style"),
223 FindingFamily::Correctness => write!(f, "correctness"),
224 FindingFamily::Simplify => write!(f, "simplify"),
225 }
226 }
227}
228
229#[cfg(feature = "pyo3")]
230impl pyo3_stub_gen::PyStubType for FindingFamily {
231 fn type_output() -> pyo3_stub_gen::TypeInfo {
232 pyo3_stub_gen::TypeInfo::unqualified("FindingFamily")
234 }
235}
236
237#[cfg(feature = "pyo3")]
238fn finding_family_type_id() -> std::any::TypeId {
239 std::any::TypeId::of::<FindingFamily>()
240}
241
242#[cfg(feature = "pyo3")]
243inventory::submit! {
244 PyEnumInfo {
245 enum_id: finding_family_type_id,
246 pyclass_name: "FindingFamily",
247 module: None,
248 doc: "Lint families such as style or correctness.",
249 variants: &[
250 ("Style", "KCL style guidelines, e.g. identifier casing."),
251 ("Correctness", "The user is probably doing something incorrect or unintended."),
252 ("Simplify", "The user has expressed something in a complex way that could be simplified."),
253 ],
254 }
255}
256
257impl Finding {
258 pub fn at(&self, description: String, pos: SourceRange, suggestion: Option<Suggestion>) -> Discovered {
260 Discovered {
261 description,
262 finding: self.clone(),
263 pos,
264 overridden: false,
265 suggestion,
266 }
267 }
268}
269
270#[cfg(feature = "pyo3")]
271#[pyo3_stub_gen::derive::gen_stub_pymethods]
272#[pyo3::pymethods]
273impl Finding {
274 #[getter]
275 pub fn code(&self) -> &'static str {
276 self.code
277 }
278
279 #[getter]
280 pub fn title(&self) -> &'static str {
281 self.title
282 }
283
284 #[getter]
285 pub fn description(&self) -> &'static str {
286 self.description
287 }
288
289 #[getter]
290 pub fn experimental(&self) -> bool {
291 self.experimental
292 }
293
294 #[getter]
295 pub fn family(&self) -> String {
296 self.family.to_string()
297 }
298}
299
300macro_rules! def_finding {
301 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path) => {
302 pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description, $family);
304 };
305}
306pub(crate) use def_finding;
307
308macro_rules! finding {
309 ( $code:ident, $title:expr_2021, $description:expr_2021, $family:path ) => {
310 $crate::lint::rule::Finding {
311 code: stringify!($code),
312 title: $title,
313 description: $description,
314 experimental: false,
315 family: $family,
316 }
317 };
318}
319pub(crate) use finding;
320#[cfg(test)]
321pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
322
323#[cfg(test)]
324mod test {
325
326 #[test]
327 fn test_lint_and_fix_all() {
328 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
330 let f = std::fs::read_to_string(path).unwrap();
331 let prog = crate::Program::parse_no_errs(&f).unwrap();
332
333 let lints = prog.lint_all().unwrap();
335 assert!(lints.len() >= 4);
336
337 let (new_code, unfixed) = lint_and_fix_all(f).unwrap();
339 assert!(unfixed.len() < 4);
340
341 assert!(!new_code.contains('_'));
343 }
344
345 #[test]
346 fn test_lint_and_fix_families() {
347 let path = "../kcl-python-bindings/files/box_with_linter_errors.kcl";
349 let original_code = std::fs::read_to_string(path).unwrap();
350 let prog = crate::Program::parse_no_errs(&original_code).unwrap();
351
352 let lints = prog.lint_all().unwrap();
354 assert!(lints.len() >= 4);
355
356 let (new_code, unfixed) =
358 lint_and_fix_families(original_code, &[FindingFamily::Correctness, FindingFamily::Simplify]).unwrap();
359 assert!(unfixed.len() >= 3);
360
361 assert!(new_code.contains("box_width"));
363 assert!(new_code.contains("box_depth"));
364 assert!(new_code.contains("box_height"));
365 }
366
367 macro_rules! assert_no_finding {
368 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
369 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
370
371 $crate::execution::parse_execute($kcl).await.unwrap();
373
374 for discovered_finding in prog.lint($check).unwrap() {
375 if discovered_finding.finding == $finding {
376 assert!(false, "Finding {:?} was emitted", $finding.code);
377 }
378 }
379 };
380 }
381
382 macro_rules! assert_finding {
383 ( $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
384 let prog = $crate::Program::parse_no_errs($kcl).unwrap();
385
386 $crate::execution::parse_execute($kcl).await.unwrap();
388
389 for discovered_finding in prog.lint($check).unwrap() {
390 pretty_assertions::assert_eq!(discovered_finding.description, $output,);
391
392 if discovered_finding.finding == $finding {
393 pretty_assertions::assert_eq!(
394 discovered_finding.suggestion.clone().map(|s| s.insert),
395 $suggestion,
396 );
397
398 if discovered_finding.suggestion.is_some() {
399 let code = discovered_finding.apply_suggestion($kcl).unwrap();
401
402 $crate::execution::parse_execute(&code).await.unwrap();
404 }
405 return;
406 }
407 }
408 assert!(false, "Finding {:?} was not emitted", $finding.code);
409 };
410 }
411
412 macro_rules! test_finding {
413 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021, $output:expr_2021, $suggestion:expr_2021 ) => {
414 #[tokio::test]
415 async fn $name() {
416 $crate::lint::rule::assert_finding!($check, $finding, $kcl, $output, $suggestion);
417 }
418 };
419 }
420
421 macro_rules! test_no_finding {
422 ( $name:ident, $check:expr_2021, $finding:expr_2021, $kcl:expr_2021 ) => {
423 #[tokio::test]
424 async fn $name() {
425 $crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
426 }
427 };
428 }
429
430 pub(crate) use assert_finding;
431 pub(crate) use assert_no_finding;
432 pub(crate) use test_finding;
433 pub(crate) use test_no_finding;
434
435 use super::*;
436}