use std::collections::{BTreeMap, BTreeSet};
use super::context::{Context, Item};
use super::{Raw, RuleId};
use crate::identity::Key;
use crate::ids::NodeId;
use crate::kind::NodeKind;
use crate::node::Node;
use crate::tree::Component;
pub(super) fn check(context: &Context<'_>, out: &mut Vec<Raw>) {
for item in &context.items {
let key = item.id.key();
let one = |rule, message: &str| Raw::new(rule, vec![key], message);
match item.node {
Node::Feature(feature) => {
if feature
.id
.is_some_and(|id| !context.realizing.contains(&id))
{
out.push(one(RuleId::Sh001, "Feature has no `realizes` edge."));
}
if feature.requirements.is_empty() {
out.push(one(RuleId::Sh004, "Feature has no Requirement."));
}
}
Node::Component(component) => {
let realized = component
.id
.is_some_and(|id| context.realized.contains(&id));
let external = component
.id
.is_some_and(|id| context.boundaries.is_external(id));
if component.components.is_empty() && !realized && !external {
out.push(one(RuleId::Sh002, "Leaf Component has no `realizes` edge."));
}
if looks_like_a_path(&component.name) {
out.push(one(
RuleId::Sh010,
"Component name resembles a concrete file or directory.",
));
}
}
Node::Requirement(requirement) if requirement.criteria.is_empty() => {
out.push(one(RuleId::Sh003, "Requirement has no criterion."));
}
Node::Product(product) if product.feature_groups.is_empty() => {
out.push(one(RuleId::Sh005, "Product has no Feature Group."));
}
Node::FeatureGroup(group) if group.features.is_empty() => {
out.push(one(RuleId::Sh005, "Feature Group has no Feature."));
}
_ => {}
}
for tag in item.tags() {
if !context
.document
.tags
.iter()
.any(|entry| entry.name.as_str() == tag)
{
out.push(one(RuleId::Sh009, "Tag names no tag registry entry.").term(tag));
}
}
}
unused_registry_entries(context, out);
duplicate_sibling_names(context, out);
bare_component_subtrees(context, out);
}
fn unused_registry_entries(context: &Context<'_>, out: &mut Vec<Raw>) {
let applied: BTreeSet<&str> = context.items.iter().flat_map(Item::tags).collect();
for entry in &context.document.tags {
if !applied.contains(entry.name.as_str()) {
out.push(
Raw::new(
RuleId::Sh006,
vec![entry.id.key()],
"Tag registry entry is applied by no node.",
)
.term(entry.name.as_str()),
);
}
}
}
const fn rank(kind: NodeKind) -> u8 {
match kind {
NodeKind::Product | NodeKind::FeatureGroup | NodeKind::Feature | NodeKind::Requirement => 0,
NodeKind::Component => 1,
NodeKind::Constraint => 2,
NodeKind::QualityAttribute => 3,
NodeKind::Criterion => 5,
NodeKind::Section => 6,
}
}
fn duplicate_sibling_names(context: &Context<'_>, out: &mut Vec<Raw>) {
let mut groups = BTreeMap::<(Option<NodeId>, u8, String), Vec<_>>::new();
for item in &context.items {
let name = item.name().trim().to_lowercase();
let group = (item.parent, rank(item.id.kind()), name);
groups.entry(group).or_default().push(item.id.key());
}
for mut keys in groups.into_values().filter(|keys| keys.len() > 1) {
keys.sort_unstable();
out.push(Raw::new(
RuleId::Sh007,
keys,
"Siblings have the same name after trimming and lower-casing.",
));
}
}
fn bare_component_subtrees(context: &Context<'_>, out: &mut Vec<Raw>) {
let external = |component: &Component| {
component
.id
.is_some_and(|id| context.boundaries.is_external(id))
};
let mut envelopes = BTreeMap::new();
for item in &context.items {
if let Node::Component(component) = item.node {
envelope(component, &external, &mut envelopes);
}
}
for item in &context.items {
let Node::Component(component) = item.node else {
continue;
};
if external(component) {
continue;
}
let parent_clears = item.parent.is_none_or(|parent| {
context.boundaries.is_external_node(parent)
|| envelopes.get(&parent.key()).copied().unwrap_or(false)
});
if !envelope(component, &external, &mut envelopes) && parent_clears {
out.push(Raw::new(
RuleId::Sh008,
vec![item.id.key()],
"Component subtree has no Constraint or Quality Attribute.",
));
}
}
}
fn envelope(
component: &Component,
external: &impl Fn(&Component) -> bool,
memo: &mut BTreeMap<Key, bool>,
) -> bool {
if let Some(known) = component.id.and_then(|id| memo.get(&id.key())) {
return *known;
}
let attached = !component.constraints.is_empty() || !component.quality_attributes.is_empty();
let held = component
.components
.iter()
.filter(|child| !external(child))
.any(|child| envelope(child, external, memo));
let result = attached || held;
if let Some(id) = component.id {
memo.insert(id.key(), result);
}
result
}
fn looks_like_a_path(name: &str) -> bool {
let extension = name.rsplit_once('.').is_some_and(|(_, extension)| {
(1..=4).contains(&extension.len()) && extension.chars().all(|c| c.is_ascii_alphanumeric())
});
name.contains('/') || name.contains('\\') || extension
}