Skip to main content

tensorlogic_adapters/
schema_lint.rs

1//! Schema validation and linting for SymbolTable definitions.
2//!
3//! Detects common issues in schema definitions: unused domains, orphan predicates,
4//! naming convention violations, empty domains, and arity inconsistencies.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8
9use crate::SymbolTable;
10
11/// Severity level for lint issues.
12#[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/// A lint rule code identifying the check that triggered it.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum LintCode {
32    /// Domain is defined but not referenced by any predicate
33    UnusedDomain,
34    /// Predicate references a domain that doesn't exist
35    OrphanPredicate,
36    /// Domain name doesn't follow PascalCase convention
37    DomainNamingConvention,
38    /// Predicate name doesn't follow snake_case convention
39    PredicateNamingConvention,
40    /// Domain has zero cardinality
41    EmptyDomain,
42    /// Predicate has zero arity (no arguments)
43    ZeroArityPredicate,
44}
45
46impl LintCode {
47    /// Returns the default severity for this lint code.
48    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    /// Returns the short name for this lint code.
59    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/// A single lint issue found in the schema.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct LintIssue {
74    pub severity: LintSeverity,
75    pub code: LintCode,
76    pub message: String,
77    /// The domain or predicate name this issue relates to.
78    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/// Result of linting a schema.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct LintResult {
97    pub issues: Vec<LintIssue>,
98}
99
100impl LintResult {
101    /// Count of issues with Error severity.
102    pub fn error_count(&self) -> usize {
103        self.issues
104            .iter()
105            .filter(|i| i.severity == LintSeverity::Error)
106            .count()
107    }
108
109    /// Count of issues with Warning severity.
110    pub fn warning_count(&self) -> usize {
111        self.issues
112            .iter()
113            .filter(|i| i.severity == LintSeverity::Warning)
114            .count()
115    }
116
117    /// Count of issues with Info severity.
118    pub fn info_count(&self) -> usize {
119        self.issues
120            .iter()
121            .filter(|i| i.severity == LintSeverity::Info)
122            .count()
123    }
124
125    /// Total number of issues.
126    pub fn total_count(&self) -> usize {
127        self.issues.len()
128    }
129
130    /// Returns true if no issues were found.
131    pub fn is_clean(&self) -> bool {
132        self.issues.is_empty()
133    }
134
135    /// Returns true if any error-level issues were found.
136    pub fn has_errors(&self) -> bool {
137        self.error_count() > 0
138    }
139
140    /// Filter issues by minimum severity.
141    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    /// Summary string describing issue counts.
149    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/// Configuration for the schema linter.
160#[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    /// Returns a config with all rules enabled.
185    pub fn all_enabled() -> Self {
186        Self::default()
187    }
188
189    /// Returns a config with all rules disabled.
190    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
202/// Schema linter for SymbolTable definitions.
203///
204/// Runs configurable checks against a SymbolTable and collects
205/// all lint issues into a `LintResult`.
206pub struct SchemaLinter {
207    config: LinterConfig,
208}
209
210impl SchemaLinter {
211    /// Create a linter with the given configuration.
212    pub fn new(config: LinterConfig) -> Self {
213        SchemaLinter { config }
214    }
215
216    /// Create a linter with all rules enabled.
217    pub fn with_all_rules() -> Self {
218        Self::new(LinterConfig::all_enabled())
219    }
220
221    /// Lint a SymbolTable and return all issues found.
222    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    /// Check for domains that are not referenced by any predicate.
248    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        // Also count variable bindings as references
256        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    /// Check for predicates referencing domains that do not exist.
276    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    /// Check that domain names follow PascalCase convention.
295    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    /// Check that predicate names follow snake_case convention.
312    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    /// Check for domains with zero cardinality.
329    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    /// Check for predicates with zero arity (no arguments).
343    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
357/// Check if a string follows PascalCase convention.
358///
359/// PascalCase requires the first character to be uppercase and no underscores.
360fn 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
372/// Check if a string follows snake_case convention.
373///
374/// snake_case requires all characters to be lowercase, digits, or underscores.
375fn 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    /// Helper to build a clean, well-formed SymbolTable for testing.
389    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        // Only Person is used by the predicate
425        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        // Manually insert predicate without domain validation
449        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        // Add a predicate to avoid unused-domain noise
474        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        // 1. Empty domain (Warning)
594        table
595            .add_domain(DomainInfo::new("Empty", 0))
596            .expect("failed to add domain");
597        // 2. Orphan predicate referencing nonexistent domain (Error)
598        table.predicates.insert(
599            "orphan".to_string(),
600            PredicateInfo::new("orphan", vec!["Ghost".to_string()]),
601        );
602        // 3. Zero arity predicate (Warning)
603        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        // At least 3 issues: unused Empty, orphan predicate, zero-arity
611        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        // Info: unused domain
622        table
623            .add_domain(DomainInfo::new("Unused", 10))
624            .expect("failed to add domain");
625        // Warning: empty domain
626        table
627            .add_domain(DomainInfo::new("Empty", 0))
628            .expect("failed to add domain");
629        // Error: orphan predicate
630        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        // Should not include Info-level issues
640        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        // Two orphan predicates referencing nonexistent domains
667        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        // This would normally trigger UnusedDomain
686        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        // Add problematic entries that would normally trigger issues
708        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}