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#[non_exhaustive]
32#[derive(Debug)]
33pub enum KalRunError {
34 Violations(String),
36 Configuration(String),
38 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
124thread_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(¤t_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}