1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9use crate::SymbolTable;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13pub enum LintSeverity {
14 Info,
15 Warning,
16 Error,
17}
18
19impl std::fmt::Display for LintSeverity {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 LintSeverity::Info => write!(f, "INFO"),
23 LintSeverity::Warning => write!(f, "WARN"),
24 LintSeverity::Error => write!(f, "ERROR"),
25 }
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum LintCode {
32 UnusedDomain,
34 OrphanPredicate,
36 DomainNamingConvention,
38 PredicateNamingConvention,
40 EmptyDomain,
42 ZeroArityPredicate,
44}
45
46impl LintCode {
47 pub fn default_severity(&self) -> LintSeverity {
49 match self {
50 LintCode::OrphanPredicate => LintSeverity::Error,
51 LintCode::EmptyDomain | LintCode::ZeroArityPredicate => LintSeverity::Warning,
52 LintCode::UnusedDomain
53 | LintCode::DomainNamingConvention
54 | LintCode::PredicateNamingConvention => LintSeverity::Info,
55 }
56 }
57
58 pub fn name(&self) -> &'static str {
60 match self {
61 LintCode::UnusedDomain => "unused-domain",
62 LintCode::OrphanPredicate => "orphan-predicate",
63 LintCode::DomainNamingConvention => "domain-naming",
64 LintCode::PredicateNamingConvention => "predicate-naming",
65 LintCode::EmptyDomain => "empty-domain",
66 LintCode::ZeroArityPredicate => "zero-arity",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct LintIssue {
74 pub severity: LintSeverity,
75 pub code: LintCode,
76 pub message: String,
77 pub location: String,
79}
80
81impl std::fmt::Display for LintIssue {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(
84 f,
85 "[{}] {}: {} ({})",
86 self.severity,
87 self.code.name(),
88 self.message,
89 self.location
90 )
91 }
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct LintResult {
97 pub issues: Vec<LintIssue>,
98}
99
100impl LintResult {
101 pub fn error_count(&self) -> usize {
103 self.issues
104 .iter()
105 .filter(|i| i.severity == LintSeverity::Error)
106 .count()
107 }
108
109 pub fn warning_count(&self) -> usize {
111 self.issues
112 .iter()
113 .filter(|i| i.severity == LintSeverity::Warning)
114 .count()
115 }
116
117 pub fn info_count(&self) -> usize {
119 self.issues
120 .iter()
121 .filter(|i| i.severity == LintSeverity::Info)
122 .count()
123 }
124
125 pub fn total_count(&self) -> usize {
127 self.issues.len()
128 }
129
130 pub fn is_clean(&self) -> bool {
132 self.issues.is_empty()
133 }
134
135 pub fn has_errors(&self) -> bool {
137 self.error_count() > 0
138 }
139
140 pub fn filter_by_severity(&self, min_severity: LintSeverity) -> Vec<&LintIssue> {
142 self.issues
143 .iter()
144 .filter(|i| i.severity >= min_severity)
145 .collect()
146 }
147
148 pub fn summary(&self) -> String {
150 format!(
151 "{} errors, {} warnings, {} infos",
152 self.error_count(),
153 self.warning_count(),
154 self.info_count()
155 )
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct LinterConfig {
162 pub check_unused_domains: bool,
163 pub check_orphan_predicates: bool,
164 pub check_domain_naming: bool,
165 pub check_predicate_naming: bool,
166 pub check_empty_domains: bool,
167 pub check_zero_arity: bool,
168}
169
170impl Default for LinterConfig {
171 fn default() -> Self {
172 LinterConfig {
173 check_unused_domains: true,
174 check_orphan_predicates: true,
175 check_domain_naming: true,
176 check_predicate_naming: true,
177 check_empty_domains: true,
178 check_zero_arity: true,
179 }
180 }
181}
182
183impl LinterConfig {
184 pub fn all_enabled() -> Self {
186 Self::default()
187 }
188
189 pub fn all_disabled() -> Self {
191 LinterConfig {
192 check_unused_domains: false,
193 check_orphan_predicates: false,
194 check_domain_naming: false,
195 check_predicate_naming: false,
196 check_empty_domains: false,
197 check_zero_arity: false,
198 }
199 }
200}
201
202pub struct SchemaLinter {
207 config: LinterConfig,
208}
209
210impl SchemaLinter {
211 pub fn new(config: LinterConfig) -> Self {
213 SchemaLinter { config }
214 }
215
216 pub fn with_all_rules() -> Self {
218 Self::new(LinterConfig::all_enabled())
219 }
220
221 pub fn lint(&self, table: &SymbolTable) -> LintResult {
223 let mut result = LintResult::default();
224
225 if self.config.check_unused_domains {
226 self.check_unused_domains(table, &mut result);
227 }
228 if self.config.check_orphan_predicates {
229 self.check_orphan_predicates(table, &mut result);
230 }
231 if self.config.check_domain_naming {
232 self.check_domain_naming(table, &mut result);
233 }
234 if self.config.check_predicate_naming {
235 self.check_predicate_naming(table, &mut result);
236 }
237 if self.config.check_empty_domains {
238 self.check_empty_domains(table, &mut result);
239 }
240 if self.config.check_zero_arity {
241 self.check_zero_arity(table, &mut result);
242 }
243
244 result
245 }
246
247 fn check_unused_domains(&self, table: &SymbolTable, result: &mut LintResult) {
249 let mut referenced: HashSet<&str> = HashSet::new();
250 for pred in table.predicates.values() {
251 for domain_name in &pred.arg_domains {
252 referenced.insert(domain_name.as_str());
253 }
254 }
255 for domain_name in table.variables.values() {
257 referenced.insert(domain_name.as_str());
258 }
259
260 for domain_name in table.domains.keys() {
261 if !referenced.contains(domain_name.as_str()) {
262 result.issues.push(LintIssue {
263 severity: LintCode::UnusedDomain.default_severity(),
264 code: LintCode::UnusedDomain,
265 message: format!(
266 "Domain '{}' is defined but not referenced by any predicate or variable",
267 domain_name
268 ),
269 location: domain_name.clone(),
270 });
271 }
272 }
273 }
274
275 fn check_orphan_predicates(&self, table: &SymbolTable, result: &mut LintResult) {
277 for pred in table.predicates.values() {
278 for domain_name in &pred.arg_domains {
279 if !table.domains.contains_key(domain_name) {
280 result.issues.push(LintIssue {
281 severity: LintCode::OrphanPredicate.default_severity(),
282 code: LintCode::OrphanPredicate,
283 message: format!(
284 "Predicate '{}' references nonexistent domain '{}'",
285 pred.name, domain_name
286 ),
287 location: pred.name.clone(),
288 });
289 }
290 }
291 }
292 }
293
294 fn check_domain_naming(&self, table: &SymbolTable, result: &mut LintResult) {
296 for domain_name in table.domains.keys() {
297 if !is_pascal_case(domain_name) {
298 result.issues.push(LintIssue {
299 severity: LintCode::DomainNamingConvention.default_severity(),
300 code: LintCode::DomainNamingConvention,
301 message: format!(
302 "Domain '{}' does not follow PascalCase naming convention",
303 domain_name
304 ),
305 location: domain_name.clone(),
306 });
307 }
308 }
309 }
310
311 fn check_predicate_naming(&self, table: &SymbolTable, result: &mut LintResult) {
313 for pred_name in table.predicates.keys() {
314 if !is_snake_case(pred_name) {
315 result.issues.push(LintIssue {
316 severity: LintCode::PredicateNamingConvention.default_severity(),
317 code: LintCode::PredicateNamingConvention,
318 message: format!(
319 "Predicate '{}' does not follow snake_case naming convention",
320 pred_name
321 ),
322 location: pred_name.clone(),
323 });
324 }
325 }
326 }
327
328 fn check_empty_domains(&self, table: &SymbolTable, result: &mut LintResult) {
330 for (domain_name, domain_info) in &table.domains {
331 if domain_info.cardinality == 0 {
332 result.issues.push(LintIssue {
333 severity: LintCode::EmptyDomain.default_severity(),
334 code: LintCode::EmptyDomain,
335 message: format!("Domain '{}' has zero cardinality", domain_name),
336 location: domain_name.clone(),
337 });
338 }
339 }
340 }
341
342 fn check_zero_arity(&self, table: &SymbolTable, result: &mut LintResult) {
344 for (pred_name, pred_info) in &table.predicates {
345 if pred_info.arity == 0 {
346 result.issues.push(LintIssue {
347 severity: LintCode::ZeroArityPredicate.default_severity(),
348 code: LintCode::ZeroArityPredicate,
349 message: format!("Predicate '{}' has zero arity (no arguments)", pred_name),
350 location: pred_name.clone(),
351 });
352 }
353 }
354 }
355}
356
357fn is_pascal_case(s: &str) -> bool {
361 if s.is_empty() {
362 return false;
363 }
364 let mut chars = s.chars();
365 let first = match chars.next() {
366 Some(c) => c,
367 None => return false,
368 };
369 first.is_uppercase() && !s.contains('_')
370}
371
372fn is_snake_case(s: &str) -> bool {
376 if s.is_empty() {
377 return false;
378 }
379 s.chars()
380 .all(|c| c.is_lowercase() || c == '_' || c.is_ascii_digit())
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::{DomainInfo, PredicateInfo};
387
388 fn make_clean_table() -> SymbolTable {
390 let mut table = SymbolTable::new();
391 table
392 .add_domain(DomainInfo::new("Person", 100))
393 .expect("failed to add domain");
394 table
395 .add_predicate(PredicateInfo::new(
396 "knows",
397 vec!["Person".to_string(), "Person".to_string()],
398 ))
399 .expect("failed to add predicate");
400 table
401 }
402
403 #[test]
404 fn test_lint_clean_schema() {
405 let table = make_clean_table();
406 let linter = SchemaLinter::with_all_rules();
407 let result = linter.lint(&table);
408 assert!(
409 result.is_clean(),
410 "Expected clean schema, got: {:?}",
411 result.issues
412 );
413 }
414
415 #[test]
416 fn test_lint_unused_domain() {
417 let mut table = SymbolTable::new();
418 table
419 .add_domain(DomainInfo::new("Person", 100))
420 .expect("failed to add domain");
421 table
422 .add_domain(DomainInfo::new("Animal", 50))
423 .expect("failed to add domain");
424 table
426 .add_predicate(PredicateInfo::new(
427 "knows",
428 vec!["Person".to_string(), "Person".to_string()],
429 ))
430 .expect("failed to add predicate");
431
432 let linter = SchemaLinter::with_all_rules();
433 let result = linter.lint(&table);
434
435 let unused: Vec<_> = result
436 .issues
437 .iter()
438 .filter(|i| i.code == LintCode::UnusedDomain)
439 .collect();
440 assert_eq!(unused.len(), 1);
441 assert_eq!(unused[0].location, "Animal");
442 assert_eq!(unused[0].severity, LintSeverity::Info);
443 }
444
445 #[test]
446 fn test_lint_orphan_predicate() {
447 let mut table = SymbolTable::new();
448 table.predicates.insert(
450 "likes".to_string(),
451 PredicateInfo::new("likes", vec!["Ghost".to_string()]),
452 );
453
454 let linter = SchemaLinter::with_all_rules();
455 let result = linter.lint(&table);
456
457 let orphans: Vec<_> = result
458 .issues
459 .iter()
460 .filter(|i| i.code == LintCode::OrphanPredicate)
461 .collect();
462 assert_eq!(orphans.len(), 1);
463 assert_eq!(orphans[0].severity, LintSeverity::Error);
464 assert!(orphans[0].message.contains("Ghost"));
465 }
466
467 #[test]
468 fn test_lint_domain_naming_bad() {
469 let mut table = SymbolTable::new();
470 table
471 .add_domain(DomainInfo::new("person", 100))
472 .expect("failed to add domain");
473 table.predicates.insert(
475 "exists_in".to_string(),
476 PredicateInfo::new("exists_in", vec!["person".to_string()]),
477 );
478
479 let linter = SchemaLinter::with_all_rules();
480 let result = linter.lint(&table);
481
482 let naming: Vec<_> = result
483 .issues
484 .iter()
485 .filter(|i| i.code == LintCode::DomainNamingConvention)
486 .collect();
487 assert_eq!(naming.len(), 1);
488 assert_eq!(naming[0].location, "person");
489 }
490
491 #[test]
492 fn test_lint_domain_naming_good() {
493 let table = make_clean_table();
494 let linter = SchemaLinter::with_all_rules();
495 let result = linter.lint(&table);
496
497 let naming: Vec<_> = result
498 .issues
499 .iter()
500 .filter(|i| i.code == LintCode::DomainNamingConvention)
501 .collect();
502 assert!(naming.is_empty());
503 }
504
505 #[test]
506 fn test_lint_predicate_naming_bad() {
507 let mut table = SymbolTable::new();
508 table
509 .add_domain(DomainInfo::new("Person", 100))
510 .expect("failed to add domain");
511 table
512 .add_predicate(PredicateInfo::new(
513 "Knows",
514 vec!["Person".to_string(), "Person".to_string()],
515 ))
516 .expect("failed to add predicate");
517
518 let linter = SchemaLinter::with_all_rules();
519 let result = linter.lint(&table);
520
521 let naming: Vec<_> = result
522 .issues
523 .iter()
524 .filter(|i| i.code == LintCode::PredicateNamingConvention)
525 .collect();
526 assert_eq!(naming.len(), 1);
527 assert_eq!(naming[0].location, "Knows");
528 }
529
530 #[test]
531 fn test_lint_predicate_naming_good() {
532 let table = make_clean_table();
533 let linter = SchemaLinter::with_all_rules();
534 let result = linter.lint(&table);
535
536 let naming: Vec<_> = result
537 .issues
538 .iter()
539 .filter(|i| i.code == LintCode::PredicateNamingConvention)
540 .collect();
541 assert!(naming.is_empty());
542 }
543
544 #[test]
545 fn test_lint_empty_domain() {
546 let mut table = SymbolTable::new();
547 table
548 .add_domain(DomainInfo::new("Empty", 0))
549 .expect("failed to add domain");
550 table.predicates.insert(
551 "check".to_string(),
552 PredicateInfo::new("check", vec!["Empty".to_string()]),
553 );
554
555 let linter = SchemaLinter::with_all_rules();
556 let result = linter.lint(&table);
557
558 let empty: Vec<_> = result
559 .issues
560 .iter()
561 .filter(|i| i.code == LintCode::EmptyDomain)
562 .collect();
563 assert_eq!(empty.len(), 1);
564 assert_eq!(empty[0].severity, LintSeverity::Warning);
565 }
566
567 #[test]
568 fn test_lint_zero_arity() {
569 let mut table = SymbolTable::new();
570 table
571 .add_domain(DomainInfo::new("Person", 100))
572 .expect("failed to add domain");
573 table.predicates.insert(
574 "tautology".to_string(),
575 PredicateInfo::new("tautology", vec![]),
576 );
577
578 let linter = SchemaLinter::with_all_rules();
579 let result = linter.lint(&table);
580
581 let zero: Vec<_> = result
582 .issues
583 .iter()
584 .filter(|i| i.code == LintCode::ZeroArityPredicate)
585 .collect();
586 assert_eq!(zero.len(), 1);
587 assert_eq!(zero[0].severity, LintSeverity::Warning);
588 }
589
590 #[test]
591 fn test_lint_multiple_issues() {
592 let mut table = SymbolTable::new();
593 table
595 .add_domain(DomainInfo::new("Empty", 0))
596 .expect("failed to add domain");
597 table.predicates.insert(
599 "orphan".to_string(),
600 PredicateInfo::new("orphan", vec!["Ghost".to_string()]),
601 );
602 table
604 .predicates
605 .insert("nullary".to_string(), PredicateInfo::new("nullary", vec![]));
606
607 let linter = SchemaLinter::with_all_rules();
608 let result = linter.lint(&table);
609
610 assert!(
612 result.total_count() >= 3,
613 "Expected at least 3 issues, got {}",
614 result.total_count()
615 );
616 }
617
618 #[test]
619 fn test_lint_severity_filter() {
620 let mut table = SymbolTable::new();
621 table
623 .add_domain(DomainInfo::new("Unused", 10))
624 .expect("failed to add domain");
625 table
627 .add_domain(DomainInfo::new("Empty", 0))
628 .expect("failed to add domain");
629 table.predicates.insert(
631 "orphan".to_string(),
632 PredicateInfo::new("orphan", vec!["Missing".to_string()]),
633 );
634
635 let linter = SchemaLinter::with_all_rules();
636 let result = linter.lint(&table);
637
638 let warnings_and_above = result.filter_by_severity(LintSeverity::Warning);
639 for issue in &warnings_and_above {
641 assert!(issue.severity >= LintSeverity::Warning);
642 }
643 assert!(!warnings_and_above.is_empty());
644 }
645
646 #[test]
647 fn test_lint_summary() {
648 let mut table = SymbolTable::new();
649 table.predicates.insert(
650 "orphan".to_string(),
651 PredicateInfo::new("orphan", vec!["Missing".to_string()]),
652 );
653
654 let linter = SchemaLinter::with_all_rules();
655 let result = linter.lint(&table);
656
657 let summary = result.summary();
658 assert!(summary.contains("errors"));
659 assert!(summary.contains("warnings"));
660 assert!(summary.contains("infos"));
661 }
662
663 #[test]
664 fn test_lint_error_count() {
665 let mut table = SymbolTable::new();
666 table.predicates.insert(
668 "pred_a".to_string(),
669 PredicateInfo::new("pred_a", vec!["Phantom".to_string()]),
670 );
671 table.predicates.insert(
672 "pred_b".to_string(),
673 PredicateInfo::new("pred_b", vec!["Specter".to_string()]),
674 );
675
676 let linter = SchemaLinter::with_all_rules();
677 let result = linter.lint(&table);
678
679 assert_eq!(result.error_count(), 2);
680 }
681
682 #[test]
683 fn test_lint_config_disabled() {
684 let mut table = SymbolTable::new();
685 table
687 .add_domain(DomainInfo::new("Lonely", 50))
688 .expect("failed to add domain");
689
690 let mut config = LinterConfig::all_enabled();
691 config.check_unused_domains = false;
692
693 let linter = SchemaLinter::new(config);
694 let result = linter.lint(&table);
695
696 let unused: Vec<_> = result
697 .issues
698 .iter()
699 .filter(|i| i.code == LintCode::UnusedDomain)
700 .collect();
701 assert!(unused.is_empty());
702 }
703
704 #[test]
705 fn test_lint_config_all_disabled() {
706 let mut table = SymbolTable::new();
707 table
709 .add_domain(DomainInfo::new("unused", 0))
710 .expect("failed to add domain");
711 table.predicates.insert(
712 "Orphan".to_string(),
713 PredicateInfo::new("Orphan", vec!["Ghost".to_string()]),
714 );
715
716 let linter = SchemaLinter::new(LinterConfig::all_disabled());
717 let result = linter.lint(&table);
718
719 assert!(result.is_clean());
720 }
721
722 #[test]
723 fn test_is_pascal_case() {
724 assert!(is_pascal_case("Person"));
725 assert!(is_pascal_case("MyDomain"));
726 assert!(is_pascal_case("A"));
727 assert!(!is_pascal_case("person"));
728 assert!(!is_pascal_case("my_domain"));
729 assert!(!is_pascal_case(""));
730 }
731
732 #[test]
733 fn test_is_snake_case() {
734 assert!(is_snake_case("knows"));
735 assert!(is_snake_case("knows_about"));
736 assert!(is_snake_case("pred2"));
737 assert!(!is_snake_case("Knows"));
738 assert!(!is_snake_case("knowsAbout"));
739 assert!(!is_snake_case(""));
740 }
741
742 #[test]
743 fn test_lint_code_names() {
744 let codes = vec![
745 LintCode::UnusedDomain,
746 LintCode::OrphanPredicate,
747 LintCode::DomainNamingConvention,
748 LintCode::PredicateNamingConvention,
749 LintCode::EmptyDomain,
750 LintCode::ZeroArityPredicate,
751 ];
752 for code in &codes {
753 let name = code.name();
754 assert!(!name.is_empty(), "LintCode {:?} has empty name", code);
755 }
756 }
757}