use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use sealmap_model::{Codebase, Fingerprint, SourcePath, Span, Symbol, SymbolId};
use serde::Serialize;
use super::lock::{Lock, LockError, SymbolSeal, TopicSeal};
use super::topic::{citations, pointers, topic_hash, topic_id};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Holds,
Behaviour,
Contract,
Absent,
Unparsable,
UnsealedCitation,
Orphan,
ProseEdited,
LockFault,
}
impl Class {
pub fn passes(self) -> bool {
self == Self::Holds
}
pub fn name(self) -> &'static str {
match self {
Self::Holds => "holds",
Self::Behaviour => "behaviour",
Self::Contract => "contract",
Self::Absent => "absent",
Self::Unparsable => "unparsable",
Self::UnsealedCitation => "unsealed-citation",
Self::Orphan => "orphan",
Self::ProseEdited => "prose-edited",
Self::LockFault => "lock-fault",
}
}
}
impl fmt::Display for Class {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Finding {
pub topic: Option<String>,
pub file: Option<SourcePath>,
pub symbol: Option<String>,
pub class: Class,
pub detail: String,
pub candidates: Vec<SymbolId>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SealReport {
pub findings: Vec<Finding>,
pub unsealed_topics: Vec<SourcePath>,
}
impl SealReport {
pub fn passes(&self) -> bool {
self.findings.iter().all(|f| f.class.passes())
}
pub fn failures(&self) -> impl Iterator<Item = &Finding> {
self.findings.iter().filter(|f| !f.class.passes())
}
pub fn count(&self, class: Class) -> usize {
self.findings.iter().filter(|f| f.class == class).count()
}
}
pub type Topics = BTreeMap<SourcePath, String>;
#[derive(Debug, Clone, PartialEq, Eq)]
struct SymbolState {
class: Class,
detail: String,
candidates: Vec<SymbolId>,
}
fn classify(
codebase: &Codebase,
unparsable: &BTreeMap<SymbolId, SourcePath>,
id: &SymbolId,
seal: &SymbolSeal,
) -> SymbolState {
let state = |class, detail: String| SymbolState { class, detail, candidates: Vec::new() };
match codebase.symbol(id) {
Some(s) if s.is_unparsable() => state(Class::Unparsable, format!("{} does not parse", s.file)),
Some(s) if s.sig_hash != seal.sig => {
state(Class::Contract, format!("sig {} -> {} ({})", seal.sig, s.sig_hash, location(s)))
}
Some(s) if s.body_hash != seal.body => {
state(Class::Behaviour, format!("body {} -> {} ({})", seal.body, s.body_hash, location(s)))
}
Some(s) => state(Class::Holds, location(s)),
None => {
if let Some(file) = unparsable_ancestor(unparsable, id) {
return state(Class::Unparsable, format!("{file} may hold it and does not parse"));
}
let candidates = rename_candidates(codebase, id, seal.body);
let detail = if candidates.is_empty() {
"id not found; no symbol of the same kind has the sealed body".to_owned()
} else {
format!("id not found; rename suspected ({} candidate(s) with the sealed body)", candidates.len())
};
SymbolState { class: Class::Absent, detail, candidates }
}
}
}
fn location(s: &Symbol) -> String {
format!("{}:{}-{}", s.file, s.span.start_line, s.span.end_line)
}
fn unparsable_modules(codebase: &Codebase) -> BTreeMap<SymbolId, SourcePath> {
codebase.symbols.values().filter(|s| s.is_unparsable()).map(|s| (s.id.clone(), s.file.clone())).collect()
}
fn unparsable_ancestor<'a>(unparsable: &'a BTreeMap<SymbolId, SourcePath>, id: &SymbolId) -> Option<&'a SourcePath> {
let mut cur = id.parent();
while let Some(p) = cur {
if let Some(file) = unparsable.get(&p) {
return Some(file);
}
cur = p.parent();
}
None
}
fn rename_candidates(codebase: &Codebase, id: &SymbolId, body: Fingerprint) -> Vec<SymbolId> {
if body.is_unset() {
return Vec::new();
}
let suffix = id.last().map(|d| d.suffix().clone());
codebase
.symbols
.values()
.filter(|s| s.body_hash == body && &s.id != id && s.id.last().map(|d| d.suffix().clone()) == suffix)
.map(|s| s.id.clone())
.collect()
}
pub fn seal_check(
lock: &Lock,
lock_name: &str,
topics: &Topics,
codebase: &Codebase,
only: Option<&BTreeSet<String>>,
) -> SealReport {
let unparsable = unparsable_modules(codebase);
let mut findings = Vec::new();
if let Some(only) = only {
for id in only.iter().filter(|id| !lock.topics.contains_key(*id)) {
findings.push(fault(Some(id.clone()), None, format!("no lock entry for topic `{id}`")));
}
}
for entry in lock.topics.values().filter(|t| only.is_none_or(|o| o.contains(&t.id))) {
check_entry(entry, lock_name, topics, codebase, &unparsable, &mut findings);
}
findings.sort();
SealReport { findings, unsealed_topics: Vec::new() }
}
fn check_entry(
entry: &TopicSeal,
lock_name: &str,
topics: &Topics,
codebase: &Codebase,
unparsable: &BTreeMap<SymbolId, SourcePath>,
out: &mut Vec<Finding>,
) {
let finding = |symbol: Option<String>, class, detail: String| Finding {
topic: Some(entry.id.clone()),
file: Some(entry.file.clone()),
symbol,
class,
detail,
candidates: Vec::new(),
};
let Some(text) = topics.get(&entry.file) else {
out.push(finding(None, Class::Orphan, format!("sealed topic file {} does not exist", entry.file)));
return;
};
match topic_id(text) {
Some(id) if id == entry.id => {}
Some(id) => out.push(finding(None, Class::LockFault, format!("the file's front matter says `id: {id}`"))),
None => out.push(finding(None, Class::LockFault, "the file has no front-matter `id:`".into())),
}
if let Some(problem) = pointer_problem(&pointers(text), lock_name) {
out.push(finding(None, Class::LockFault, problem));
}
let hash = topic_hash(text);
if hash == entry.topic_hash {
out.push(finding(None, Class::Holds, "prose".into()));
} else {
out.push(finding(None, Class::ProseEdited, format!("topic_hash {} -> {hash}", entry.topic_hash)));
}
let cited = citations(text);
let cited_ids: BTreeSet<&SymbolId> = cited.iter().filter_map(|c| c.id.as_ref().ok()).collect();
for (id, seal) in &entry.symbols {
let s = classify(codebase, unparsable, id, seal);
out.push(Finding { candidates: s.candidates, ..finding(Some(id.to_string()), s.class, s.detail) });
if !cited_ids.contains(id) {
out.push(finding(Some(id.to_string()), Class::Orphan, "sealed but no longer cited by the topic".into()));
}
}
unsealed_citations(&entry.id, &entry.file, &cited, |id| entry.symbols.contains_key(id), out);
}
fn unsealed_citations(
topic: &str,
file: &SourcePath,
cited: &[super::topic::Citation],
sealed: impl Fn(&SymbolId) -> bool,
out: &mut Vec<Finding>,
) {
let mut seen = BTreeSet::new();
for c in cited {
let detail = match &c.id {
Err(e) => format!("line {}: not a canonical sym: id ({e})", c.line),
Ok(id) if sealed(id) => continue,
Ok(_) => format!("line {}: cited but not sealed", c.line),
};
if seen.insert(c.text.clone()) {
out.push(Finding {
topic: Some(topic.to_owned()),
file: Some(file.clone()),
symbol: Some(c.text.clone()),
class: Class::UnsealedCitation,
detail,
candidates: Vec::new(),
});
}
}
}
fn pointer_problem(pointers: &[String], lock_name: &str) -> Option<String> {
match pointers {
[] => Some(format!("sealed in the lock but the topic has no `sealed: {lock_name}` line")),
[p] if p == lock_name => None,
[p] => Some(format!("the topic's pointer names `{p}`, not `{lock_name}`")),
_ => Some(format!("the topic has {} `sealed:` lines", pointers.len())),
}
}
fn fault(topic: Option<String>, file: Option<SourcePath>, detail: String) -> Finding {
Finding { topic, file, symbol: None, class: Class::LockFault, detail, candidates: Vec::new() }
}
pub fn verify(lock_text: Option<&str>, lock_name: &str, topics: &Topics, codebase: &Codebase) -> SealReport {
let lock = match lock_text.map(Lock::parse) {
None => Lock::new(),
Some(Ok(lock)) => lock,
Some(Err(e)) => {
return SealReport {
findings: vec![fault(None, None, format!("{lock_name}: {e}"))],
unsealed_topics: Vec::new(),
};
}
};
let mut report = seal_check(&lock, lock_name, topics, codebase, None);
if let Some(text) = lock_text {
if lock.to_toml() != text {
report.findings.push(fault(None, None, format!("{lock_name}: {}", LockError::NonCanonical)));
}
}
for (path, text) in topics {
let Some(id) = topic_id(text) else { continue };
if lock.by_file(path).is_some() {
continue;
}
let cited = citations(text);
let has_pointer = !pointers(text).is_empty();
if has_pointer {
report.findings.push(fault(
Some(id.clone()),
Some(path.clone()),
format!("the topic has a `sealed:` pointer but {lock_name} has no entry for {path}"),
));
}
unsealed_citations(&id, path, &cited, |_| false, &mut report.findings);
if cited.is_empty() && !has_pointer {
report.unsealed_topics.push(path.clone());
}
}
report.findings.sort();
report
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Resolution {
Found {
file: SourcePath,
span: Span,
sig: Fingerprint,
body: Fingerprint,
},
Unparsable {
file: SourcePath,
},
Absent {
candidates: Vec<SymbolId>,
},
}
pub fn resolve(codebase: &Codebase, id: &SymbolId, sealed_body: Option<Fingerprint>) -> Resolution {
match codebase.symbol(id) {
Some(s) if s.is_unparsable() => Resolution::Unparsable { file: s.file.clone() },
Some(s) => Resolution::Found { file: s.file.clone(), span: s.span, sig: s.sig_hash, body: s.body_hash },
None => match unparsable_ancestor(&unparsable_modules(codebase), id) {
Some(file) => Resolution::Unparsable { file: file.clone() },
None => Resolution::Absent {
candidates: sealed_body.map(|b| rename_candidates(codebase, id, b)).unwrap_or_default(),
},
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct StaleSymbol {
pub topic: String,
pub file: SourcePath,
pub symbol: SymbolId,
pub class: Class,
pub detail: String,
pub candidates: Vec<SymbolId>,
}
pub fn stale(lock: &Lock, before: Option<&Codebase>, after: &Codebase) -> Vec<StaleSymbol> {
let unparsable = unparsable_modules(after);
let mut out = Vec::new();
for entry in lock.topics.values() {
for (id, sealed) in &entry.symbols {
let baseline = before
.and_then(|b| b.symbol(id))
.filter(|s| !s.is_unparsable())
.map_or(*sealed, |s| SymbolSeal { sig: s.sig_hash, body: s.body_hash });
let s = classify(after, &unparsable, id, &baseline);
if !s.class.passes() {
out.push(StaleSymbol {
topic: entry.id.clone(),
file: entry.file.clone(),
symbol: id.clone(),
class: s.class,
detail: s.detail,
candidates: s.candidates,
});
}
}
}
out.sort();
out
}