ifc_validate/structure/cardinality.rs
1//! Aggregates where the schema expects a scalar, and the reverse.
2//!
3//! # What this can and cannot check
4//!
5//! The EXPRESS parser records *whether* an attribute is an aggregate
6//! (`LIST`/`SET`/`ARRAY`/`BAG`), not its bounds. So `LIST [1:?] OF X` and
7//! `LIST [3:3] OF X` are indistinguishable here: a three-element list where
8//! the schema demands exactly two is **not** caught, and this module does not
9//! pretend otherwise.
10//!
11//! What is caught is the shape mismatch that actually occurs in the wild: a
12//! scalar written where an aggregate belongs, or an aggregate written where a
13//! scalar belongs. Both are usually a writer that guessed the slot layout.
14//!
15//! Bounds checking needs the parser to retain them. Until it does, a bounds
16//! violation is unchecked rather than passed, and
17//! [`crate::where_rule`] counts it.
18
19use ifc_model::{Model, Value};
20use ifc_schema::{Schema, TypeKind};
21
22use crate::report::{Finding, Path, Report};
23
24/// Reports scalar/aggregate shape mismatches against the schema.
25pub fn aggregate_shape(model: &Model, schema: &Schema, report: &mut Report) {
26 let mut ids: Vec<_> = model.iter().map(|(id, _)| id).collect();
27 ids.sort_unstable();
28 for id in ids {
29 let Some(entity) = model.get(id) else {
30 continue;
31 };
32 let declared = schema.attributes(&entity.type_name);
33 for (index, value) in entity.attributes.iter().enumerate() {
34 let Some(attribute) = declared.get(index) else {
35 continue;
36 };
37 // `$` and `*` carry no shape; optionality and derivation are
38 // checked in `required`.
39 if matches!(value, Value::Null | Value::Derived) {
40 continue;
41 }
42 let is_list = matches!(value.unwrap_typed(), Value::List(_));
43 // A defined type can itself be an aggregate: IFC4 declares
44 // `IfcComplexNumber = ARRAY [1:2] OF REAL` and four others. Such
45 // an attribute is declared scalar yet legitimately holds a list,
46 // so the declared type has to be resolved before judging shape.
47 // A typed wrapper names its own type, which overrides the
48 // declared slot type: `NominalValue : IfcValue` is a SELECT, and
49 // only `IFCCOMPLEXNUMBER((0.,0.))` says which member was chosen.
50 let effective_type = match value {
51 Value::Typed { type_name, .. } => type_name.as_ref(),
52 _ => attribute.type_name.as_str(),
53 };
54 let declared_is_aggregate =
55 attribute.aggregate || type_is_aggregate(schema, effective_type);
56 let path = || Path::Attribute {
57 entity: id,
58 index,
59 name: Some(attribute.name.clone()),
60 };
61 if declared_is_aggregate && !is_list {
62 report.push(Finding::error(
63 "structure.cardinality.expected_aggregate",
64 path(),
65 format!("{} is an aggregate, a scalar was written", attribute.name),
66 ));
67 } else if !declared_is_aggregate && is_list {
68 report.push(Finding::error(
69 "structure.cardinality.unexpected_aggregate",
70 path(),
71 format!("{} is a scalar, an aggregate was written", attribute.name),
72 ));
73 }
74 }
75 }
76}
77
78/// Whether a named type resolves to an aggregate declaration.
79///
80/// EXPRESS lets a `TYPE` alias an aggregate directly. The parser keeps such a
81/// right-hand side as text, so this is a textual test against the resolved
82/// alias chain rather than a structural one -- which is why it is confined to
83/// this one question and not exposed.
84fn type_is_aggregate(schema: &Schema, type_name: &str) -> bool {
85 let mut current = type_name.to_string();
86 for _ in 0..16 {
87 let Some(definition) = schema.type_def(¤t) else {
88 return false;
89 };
90 let TypeKind::Defined(target) = &definition.kind else {
91 return false;
92 };
93 let head = target.trim_start().to_ascii_uppercase();
94 if head.starts_with("LIST")
95 || head.starts_with("ARRAY")
96 || head.starts_with("SET")
97 || head.starts_with("BAG")
98 {
99 return true;
100 }
101 current = target.trim().to_string();
102 }
103 false
104}