use indexmap::IndexSet;
use crate::schema::{FieldType, Schema};
use super::minimize::equivalence_classes;
use super::prune::satisfiable_set;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintFinding {
pub code: &'static str,
pub severity: &'static str,
pub location: String,
pub message: String,
}
fn reachable(s: &Schema) -> IndexSet<String> {
let mut seen: IndexSet<String> = IndexSet::new();
let mut stack = vec![s.root().name.clone()];
while let Some(name) = stack.pop() {
if seen.contains(&name) {
continue;
}
let rec = s
.env()
.get(&name)
.expect("Schema's own invariant: every Ref target resolves within its env");
seen.insert(name.clone());
for f in rec.fields() {
if let FieldType::Ref(r) = &f.ty {
stack.push(r.name.clone());
}
}
}
seen
}
pub fn lint(s: &Schema) -> Vec<LintFinding> {
let mut findings: Vec<LintFinding> = Vec::new();
let reach = reachable(s);
let sat = satisfiable_set(s);
for name in &reach {
if !sat.contains(name) {
findings.push(LintFinding {
code: "unsatisfiable-record",
severity: "warning",
location: name.clone(),
message: format!(
"record {name:?} is reachable but unsatisfiable -- no finite document \
can match it (e.g. a mandatory ref cycle)"
),
});
}
}
for name in s.env().keys() {
if !reach.contains(name) {
findings.push(LintFinding {
code: "unreachable-record",
severity: "warning",
location: name.clone(),
message: format!(
"record {name:?} is defined but never reachable from the root; drop it \
with `schema prune`"
),
});
}
}
for block in equivalence_classes(s) {
if block.len() > 1 {
let mut group = block.clone();
group.sort();
let location = group.join(", ");
let keep = group[0].clone();
let others: Vec<String> = group[1..].iter().map(|n| format!("{n:?}")).collect();
findings.push(LintFinding {
code: "duplicate-record",
severity: "warning",
location,
message: format!(
"records {} are structurally identical to {keep:?}; merge them with \
`schema normalize`",
others.join(", ")
),
});
}
}
for name in s.env().keys() {
let rec = s.env().get(name).expect("name comes from s.env's own keys");
for f in rec.fields() {
if matches!(f.ty, FieldType::Any) {
findings.push(LintFinding {
code: "any-field",
severity: "info",
location: format!("{name}.{}", f.label),
message: format!(
"field {:?} of record {name:?} is typed `any` (accepts any value \
unchecked)",
f.label
),
});
}
}
}
findings.sort_by(|a, b| (a.code, &a.location).cmp(&(b.code, &b.location)));
findings
}