Skip to main content

katana_ast_lint/
lib.rs

1#![deny(warnings, clippy::all)]
2#![allow(
3    missing_docs,
4    clippy::missing_errors_doc,
5    clippy::too_many_lines,
6    clippy::expect_used,
7    clippy::indexing_slicing,
8    clippy::unwrap_used,
9    clippy::panic,
10    clippy::todo,
11    clippy::unimplemented,
12    clippy::unwrap_or_default,
13    clippy::wildcard_imports,
14    clippy::match_wild_err_arm,
15    clippy::let_and_return,
16    clippy::manual_ok_err,
17    clippy::cognitive_complexity
18)]
19
20pub mod config;
21pub mod rules;
22pub mod utils;
23
24use crate::config::Severity;
25use crate::rules::{RULE_CATALOG, RuleImplementation};
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use std::path::{Path, PathBuf};
29
30/* WHY: Domain entities for linter violation reporting and JSON AST traversal. */
31#[non_exhaustive]
32#[derive(Debug)]
33pub enum KalRunError {
34    /// rule violations were detected and reported
35    Violations(String),
36    /// kal.json or workspace configuration is invalid / unreadable
37    Configuration(String),
38    /// non-violation runtime failure (IO, internal invariants)
39    System(String),
40}
41
42impl std::fmt::Display for KalRunError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Violations(msg) | Self::Configuration(msg) | Self::System(msg) => {
46                write!(f, "{msg}")
47            }
48        }
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct Violation {
55    pub file: PathBuf,
56    pub line: usize,
57    pub column: usize,
58    pub message: String,
59    pub severity: Severity,
60}
61
62impl Violation {
63    pub fn err(file: PathBuf, line: usize, column: usize, message: impl Into<String>) -> Self {
64        Self {
65            file,
66            line,
67            column,
68            message: message.into(),
69            severity: Severity::Error,
70        }
71    }
72
73    pub fn warn(file: PathBuf, line: usize, column: usize, message: impl Into<String>) -> Self {
74        Self {
75            file,
76            line,
77            column,
78            message: message.into(),
79            severity: Severity::Warning,
80        }
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum JsonNodeKind {
86    Object,
87    Array,
88    String,
89    Number,
90    Bool,
91    Null,
92}
93
94impl JsonNodeKind {
95    pub fn from_value(value: &Value) -> Self {
96        match value {
97            Value::Object(_) => Self::Object,
98            Value::Array(_) => Self::Array,
99            Value::String(_) => Self::String,
100            Value::Number(_) => Self::Number,
101            Value::Bool(_) => Self::Bool,
102            Value::Null => Self::Null,
103        }
104    }
105}
106
107impl std::fmt::Display for JsonNodeKind {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::Object => write!(f, "object"),
111            Self::Array => write!(f, "array"),
112            Self::String => write!(f, "string"),
113            Self::Number => write!(f, "number"),
114            Self::Bool => write!(f, "bool"),
115            Self::Null => write!(f, "null"),
116        }
117    }
118}
119
120use std::cell::RefCell;
121use std::collections::HashMap;
122use std::rc::Rc;
123
124/* WHY: Cache parsed ASTs to avoid redundant parsing across multiple linter tests.
125We use thread_local to avoid requiring Sync on the syn::File type while still
126achieving significant speedups since tests run in long-lived threads. */
127thread_local! {
128    static AST_CACHE: RefCell<HashMap<PathBuf, Rc<syn::File>>> = RefCell::new(HashMap::new());
129}
130
131pub struct KatanaAstLint {
132    config: config::KalConfig,
133    target_dirs: Vec<PathBuf>,
134}
135
136impl KatanaAstLint {
137    pub fn from_workspace() -> Self {
138        Self::try_from_workspace().unwrap_or_else(|e| panic!("{}", e))
139    }
140
141    pub fn try_from_workspace() -> Result<Self, KalRunError> {
142        let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
143        let root = Self::find_workspace_root(&current_dir).unwrap_or_else(|| current_dir.clone());
144        let config = AstLinterOps::try_load_config(std::slice::from_ref(&root))?;
145        Ok(Self {
146            config,
147            target_dirs: vec![root],
148        })
149    }
150
151    fn find_workspace_root(start: &Path) -> Option<PathBuf> {
152        let mut curr = start.to_path_buf();
153        loop {
154            let manifest = curr.join("Cargo.toml");
155            if manifest.exists()
156                && std::fs::read_to_string(&manifest)
157                    .ok()
158                    .is_some_and(|s| s.contains("[workspace]"))
159            {
160                return Some(curr);
161            }
162            if !curr.pop() {
163                break;
164            }
165        }
166        None
167    }
168
169    pub fn with_config(config: config::KalConfig) -> Self {
170        let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
171        Self {
172            config,
173            target_dirs: vec![current_dir],
174        }
175    }
176
177    pub fn with_reporter_mode(mut self, mode: config::ReporterMode) -> Self {
178        self.config.reporter.mode = Some(mode);
179        self
180    }
181
182    pub fn violations(&self) -> Vec<Violation> {
183        let mut results = self.lint_all();
184
185        let mut sorted_rule_ids: Vec<_> = results.keys().copied().collect();
186        sorted_rule_ids.sort();
187
188        let mut all = Vec::new();
189        for rule_id in sorted_rule_ids {
190            if let Some(violations) = results.remove(rule_id) {
191                all.extend(violations);
192            }
193        }
194        all
195    }
196
197    pub fn assert_clean(&self) {
198        self.try_assert_clean().unwrap_or_else(|e| panic!("{}", e))
199    }
200
201    pub fn try_assert_clean(&self) -> Result<(), KalRunError> {
202        let results = self.lint_all();
203        if results.is_empty() {
204            return Ok(());
205        }
206
207        let mut errors = Vec::new();
208        let sorted_keys: Vec<_> = {
209            let mut keys: Vec<_> = results.keys().collect();
210            keys.sort();
211            keys
212        };
213
214        for rule_id in sorted_keys {
215            let rule_violations = results.get(rule_id).unwrap();
216            let hint = RULE_CATALOG
217                .iter()
218                .find(|r| r.id == *rule_id)
219                .map(|r| r.hint)
220                .unwrap_or("Check the documentation for remediation guidance.");
221
222            if let Err(e) = utils::ViolationReporterOps::try_report(
223                rule_id,
224                hint,
225                rule_violations,
226                &self.config.reporter,
227            ) {
228                errors.push(e);
229            }
230        }
231
232        if errors.is_empty() {
233            Ok(())
234        } else {
235            let mut msgs = Vec::new();
236            let mut is_violation = false;
237            let mut is_config = false;
238
239            for e in errors {
240                match e {
241                    KalRunError::Violations(m) => {
242                        msgs.push(m);
243                        is_violation = true;
244                    }
245                    KalRunError::Configuration(m) => {
246                        msgs.push(m);
247                        is_config = true;
248                    }
249                    KalRunError::System(m) => {
250                        msgs.push(m);
251                    }
252                }
253            }
254
255            let combined = msgs.join("\n");
256            if is_config {
257                Err(KalRunError::Configuration(combined))
258            } else if is_violation {
259                Err(KalRunError::Violations(combined))
260            } else {
261                Err(KalRunError::System(combined))
262            }
263        }
264    }
265
266    fn lint_all(&self) -> HashMap<&'static str, Vec<Violation>> {
267        let mut results = HashMap::new();
268
269        for rule_def in RULE_CATALOG.iter() {
270            let mut rule_config = self
271                .config
272                .rules
273                .get(rule_def.id)
274                .cloned()
275                .unwrap_or_else(|| config::RuleConfig::default_for_rule(rule_def.id));
276
277            if !rule_config.enabled.unwrap_or(true) {
278                continue;
279            }
280
281            if rule_config.threshold.is_none() {
282                rule_config.threshold = rule_def.default_threshold;
283            }
284
285            let mut rule_violations = Vec::new();
286
287            let source_roots = if !rule_config.inputs.is_empty() {
288                &rule_config.inputs
289            } else if !self.config.source_roots.is_empty() {
290                &self.config.source_roots
291            } else {
292                &self.target_dirs
293            };
294
295            for source_root in source_roots {
296                match &rule_def.implementation {
297                    RuleImplementation::RustFile(lint_fn) => {
298                        AstLinterOps::lint_directory(
299                            source_root,
300                            &rule_config,
301                            *lint_fn,
302                            &mut rule_violations,
303                        );
304                    }
305                    RuleImplementation::Directory(lint_dir_fn) => {
306                        let violations = lint_dir_fn(source_root);
307                        let severity = rule_config.severity.unwrap_or(rule_def.default_severity);
308                        for mut v in violations {
309                            v.severity = severity;
310                            rule_violations.push(v);
311                        }
312                    }
313                }
314            }
315
316            if !rule_violations.is_empty() {
317                results.insert(rule_def.id, rule_violations);
318            }
319        }
320
321        results
322    }
323}
324
325pub struct AstLinterOps;
326
327impl AstLinterOps {
328    fn find_config_file(target_dirs: &[PathBuf]) -> Option<PathBuf> {
329        if let Ok(env_path) = std::env::var("KAL_CONFIG_PATH") {
330            let path = PathBuf::from(env_path);
331            return path.exists().then_some(path);
332        }
333
334        for target_dir in target_dirs {
335            if let Some(config_path) = Self::find_config_from(target_dir) {
336                return Some(config_path);
337            }
338        }
339
340        Self::find_config_from(&std::env::current_dir().ok()?)
341    }
342
343    fn find_config_from(start: &Path) -> Option<PathBuf> {
344        let mut curr = if start.is_file() {
345            start.parent()?.to_path_buf()
346        } else {
347            start.to_path_buf()
348        };
349
350        loop {
351            let config = curr.join("kal.json");
352            if config.exists() {
353                return Some(config);
354            }
355            if !curr.pop() {
356                break;
357            }
358        }
359        None
360    }
361
362    pub(crate) fn load_config(target_dirs: &[PathBuf]) -> config::KalConfig {
363        Self::try_load_config(target_dirs).unwrap_or_else(|e| panic!("{}", e))
364    }
365
366    pub(crate) fn try_load_config(
367        target_dirs: &[PathBuf],
368    ) -> Result<config::KalConfig, KalRunError> {
369        match Self::find_config_file(target_dirs) {
370            Some(config_path) => config::KalConfig::load_from_path(&config_path).map_err(|e| {
371                KalRunError::Configuration(format!(
372                    "Invalid KAL configuration at {}: {}",
373                    config_path.display(),
374                    e
375                ))
376            }),
377            None => Ok(config::KalConfig::load_default()),
378        }
379    }
380
381    pub fn run(
382        rule_name: &str,
383        hint: &str,
384        target_dirs: &[PathBuf],
385        lint_fn: fn(&Path, &syn::File) -> Vec<Violation>,
386    ) {
387        let config = Self::load_config(target_dirs);
388        Self::run_inner(rule_name, hint, target_dirs, &config, |path, syntax, _| {
389            lint_fn(path, syntax)
390        });
391    }
392
393    pub fn run_with_configured_rule(
394        rule_name: &str,
395        hint: &str,
396        target_dirs: &[PathBuf],
397        lint_fn: fn(&Path, &syn::File, &config::RuleConfig) -> Vec<Violation>,
398    ) {
399        let config = Self::load_config(target_dirs);
400        Self::run_inner(rule_name, hint, target_dirs, &config, lint_fn);
401    }
402
403    pub fn run_with_config(
404        rule_name: &str,
405        hint: &str,
406        target_dirs: &[PathBuf],
407        config: &config::KalConfig,
408        lint_fn: fn(&Path, &syn::File, &config::RuleConfig) -> Vec<Violation>,
409    ) {
410        Self::run_inner(rule_name, hint, target_dirs, config, lint_fn);
411    }
412
413    fn run_inner(
414        rule_name: &str,
415        hint: &str,
416        target_dirs: &[PathBuf],
417        config: &config::KalConfig,
418        lint_fn: impl Fn(&Path, &syn::File, &config::RuleConfig) -> Vec<Violation> + Copy,
419    ) {
420        let rule_config = config
421            .rules
422            .get(rule_name)
423            .cloned()
424            .unwrap_or_else(|| config::RuleConfig::default_for_rule(rule_name));
425
426        if !rule_config.enabled.unwrap_or(true) {
427            return;
428        }
429
430        let mut all_violations: Vec<Violation> = Vec::new();
431
432        let source_roots = if !rule_config.inputs.is_empty() {
433            &rule_config.inputs
434        } else if !config.source_roots.is_empty() {
435            &config.source_roots
436        } else {
437            target_dirs
438        };
439
440        for target_dir in source_roots {
441            Self::lint_directory(target_dir, &rule_config, lint_fn, &mut all_violations);
442        }
443
444        utils::ViolationReporterOps::report(rule_name, hint, &all_violations, &config.reporter);
445    }
446
447    pub(crate) fn lint_directory(
448        target_dir: &Path,
449        rule_config: &config::RuleConfig,
450        lint_fn: impl Fn(&Path, &syn::File, &config::RuleConfig) -> Vec<Violation>,
451        all_violations: &mut Vec<Violation>,
452    ) {
453        let now = chrono::Utc::now().naive_utc().date();
454
455        for file in &utils::LinterFileOps::collect_rs_files(target_dir) {
456            if rule_config.allow.iter().any(|a| {
457                if !file.ends_with(&a.path) {
458                    return false;
459                }
460                if let Some(expiry) = &a.expires_at {
461                    match chrono::NaiveDate::parse_from_str(expiry, "%Y-%m-%d") {
462                        Ok(expiry_date) => return now < expiry_date,
463                        Err(e) => {
464                            eprintln!("Warning: Failed to parse expiry date '{}': {}", expiry, e);
465                            return false;
466                        }
467                    }
468                }
469                true
470            }) {
471                continue;
472            }
473
474            let syntax = match Self::get_cached_ast(file) {
475                Ok(s) => s,
476                Err(errors) => {
477                    all_violations.extend(errors);
478                    continue;
479                }
480            };
481
482            let mut violations = lint_fn(file, &syntax, rule_config);
483            let severity = rule_config.severity.unwrap_or(Severity::Error);
484            for v in &mut violations {
485                v.severity = severity;
486            }
487            all_violations.extend(violations);
488        }
489    }
490
491    fn get_cached_ast(file: &Path) -> Result<Rc<syn::File>, Vec<Violation>> {
492        AST_CACHE.with(|cache| {
493            let mut cache = cache.borrow_mut();
494            if let Some(cached) = cache.get(file) {
495                return Ok(Rc::clone(cached));
496            }
497
498            match utils::LinterParserOps::parse_file(file) {
499                Ok(syntax) => {
500                    let syntax_rc = Rc::new(syntax);
501                    cache.insert(file.to_path_buf(), Rc::clone(&syntax_rc));
502                    Ok(syntax_rc)
503                }
504                Err(errors) => Err(errors),
505            }
506        })
507    }
508}