Skip to main content

ifc_validate/type_check/
entity.rs

1//! Entity-level type checking: unknown types, abstract instantiation, values.
2
3use ifc_model::{Model, Value};
4use ifc_schema::Schema;
5
6use super::defined::{check_all, Mismatch};
7use super::select::admits_entity;
8use crate::report::{Finding, Path, Report};
9use crate::structure::expected_references;
10
11/// Reports entities whose type the schema does not declare.
12///
13/// A warning, not an error: the model is deliberately schema-agnostic and an
14/// unknown type round-trips intact. It is still worth saying, because it is
15/// usually a schema-version mismatch -- an IFC4X3 file read against IFC4.
16pub fn unknown_entity_types(model: &Model, schema: &Schema, report: &mut Report) {
17    let mut ids: Vec<_> = model.iter().map(|(id, _)| id).collect();
18    ids.sort_unstable();
19    for id in ids {
20        let Some(entity) = model.get(id) else {
21            continue;
22        };
23        if schema.entity(&entity.type_name).is_none() {
24            report.push(Finding::warning(
25                "type.entity.unknown",
26                Path::Entity(id),
27                format!(
28                    "{} is not declared by schema {}",
29                    entity.type_name,
30                    schema.name()
31                ),
32            ));
33        }
34    }
35}
36
37/// Reports instances of `ABSTRACT` entities.
38///
39/// EXPRESS `ABSTRACT SUPERTYPE` means the entity may not be instantiated
40/// directly; only its concrete subtypes may appear in a file.
41pub fn abstract_instances(model: &Model, schema: &Schema, report: &mut Report) {
42    let mut ids: Vec<_> = model.iter().map(|(id, _)| id).collect();
43    ids.sort_unstable();
44    for id in ids {
45        let Some(entity) = model.get(id) else {
46            continue;
47        };
48        let Some(definition) = schema.entity(&entity.type_name) else {
49            continue;
50        };
51        if definition.abstract_ {
52            report.push(Finding::error(
53                "type.entity.abstract",
54                Path::Entity(id),
55                format!("{} is abstract and cannot be instantiated", definition.name),
56            ));
57        }
58    }
59}
60
61/// Reports values that do not match their slot's declared type.
62///
63/// Aggregate members are checked against the element type, and every
64/// reference in a SELECT position against the SELECT's entity alternatives.
65pub fn attribute_types(model: &Model, schema: &Schema, report: &mut Report) {
66    let mut ids: Vec<_> = model.iter().map(|(id, _)| id).collect();
67    ids.sort_unstable();
68    for id in ids {
69        let Some(entity) = model.get(id) else {
70            continue;
71        };
72        let declared = schema.attributes(&entity.type_name);
73        for (index, value) in entity.attributes.iter().enumerate() {
74            let Some(attribute) = declared.get(index) else {
75                continue;
76            };
77            let path = || Path::Attribute {
78                entity: id,
79                index,
80                name: Some(attribute.name.clone()),
81            };
82            select_references(model, schema, &attribute.type_name, value, &path, report);
83            for mismatch in check_all(schema, &attribute.type_name, value) {
84                report.push(finding(
85                    mismatch,
86                    path(),
87                    &attribute.type_name,
88                    &attribute.name,
89                ));
90            }
91        }
92    }
93}
94
95/// The finding one mismatch in the slot at `path` is reported as.
96fn finding(mismatch: Mismatch, path: Path, declared: &str, attribute: &str) -> Finding {
97    match mismatch {
98        Mismatch::Primitive { expected, actual } => Finding::error(
99            "type.scalar.mismatch",
100            path,
101            format!("{declared} is {expected}, the file wrote {actual}"),
102        ),
103        Mismatch::FixedWidth { expected, actual } => Finding::error(
104            "type.scalar.fixed_width",
105            path,
106            format!("{declared} is STRING({expected}) FIXED, the file wrote {actual} characters"),
107        ),
108        Mismatch::EnumMember {
109            member,
110            declared: members,
111        } => Finding::error(
112            "type.enumeration.member",
113            path,
114            format!(
115                "{member} is not a member of {declared} ({})",
116                if members.is_empty() {
117                    "no members declared".to_string()
118                } else {
119                    members.join(", ")
120                }
121            ),
122        ),
123        Mismatch::SelectMember { written, select } => Finding::error(
124            "type.select.member",
125            path,
126            format!("{written} is not a member of {select}"),
127        ),
128        Mismatch::ExpectedReference { declared, actual } => Finding::error(
129            "type.entity.expected_reference",
130            path,
131            format!("{attribute} takes a reference to {declared}, the file wrote {actual}"),
132        ),
133        Mismatch::TypedOutsideSelect { written, declared } => Finding::error(
134            "type.typed.outside_select",
135            path,
136            format!(
137                "{declared} is not a SELECT, so its value is written bare, \
138                 not as the typed parameter {written}(...)"
139            ),
140        ),
141        Mismatch::TypedWrongType { written, declared } => Finding::error(
142            "type.typed.wrong_type",
143            path,
144            format!("{written} is not {declared}, and {declared} is not a SELECT"),
145        ),
146        Mismatch::UntypedSelectValue { select, actual } => Finding::error(
147            "type.select.untyped",
148            path,
149            format!(
150                "{select} is a SELECT, so a value that is not a reference is \
151                 written as a typed parameter; the file wrote {actual}"
152            ),
153        ),
154    }
155}
156
157/// Reports references in SELECT positions whose target the SELECT does not
158/// admit.
159///
160/// A reference is admitted when its target is, or inherits from, an entity
161/// anywhere in the SELECT's closure. One finding per distinct offending
162/// target, in ascending order. A dangling target is `structure`'s finding,
163/// and a target of a type the schema does not declare is
164/// `type.entity.unknown`'s; neither is judged again here.
165fn select_references(
166    model: &Model,
167    schema: &Schema,
168    declared: &str,
169    value: &Value,
170    path: &dyn Fn() -> Path,
171    report: &mut Report,
172) {
173    let mut offenders = Vec::new();
174    for (expected, target) in expected_references(schema, declared, value) {
175        let Some(target_entity) = model.get(target) else {
176            continue;
177        };
178        if schema.entity(&target_entity.type_name).is_none() {
179            continue;
180        }
181        if admits_entity(schema, &expected, &target_entity.type_name) == Some(false) {
182            offenders.push((target, expected, &*target_entity.type_name));
183        }
184    }
185    offenders.sort_unstable();
186    offenders.dedup();
187    for (target, select, actual) in offenders {
188        report.push(Finding::error(
189            "type.select.member",
190            path(),
191            format!("{target} is {actual}, which is not a member of {select}"),
192        ));
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use ifc_model::Entity;
200
201    /// One slot with two independent violations yields two findings on the
202    /// same path (#215).
203    #[test]
204    fn a_slot_reports_each_independent_violation() {
205        let schema = ifc_schema::ifc4();
206        let mut model = Model::new();
207        let names = schema.attribute_names("IFCCARTESIANPOINTLIST3D");
208        let mut attributes = vec![Value::Null; names.len()];
209        attributes[0] = Value::List(vec![
210            Value::List(vec![
211                Value::Text("x".into()),
212                Value::Real(0.0),
213                Value::Real(0.0),
214            ]),
215            Value::List(vec![
216                Value::Real(0.0),
217                Value::Typed {
218                    type_name: "IFCLENGTHMEASURE".into(),
219                    value: Box::new(Value::Real(1.0)),
220                },
221                Value::Real(0.0),
222            ]),
223        ]);
224        let id = model.push(Entity::new("IFCCARTESIANPOINTLIST3D", attributes));
225        let mut report = Report::new();
226        attribute_types(&model, schema, &mut report);
227        let rules: Vec<&str> = report
228            .findings()
229            .iter()
230            .filter(|finding| matches!(finding.path, Path::Attribute { entity, index: 0, .. } if entity == id))
231            .map(|finding| finding.rule.as_str())
232            .collect();
233        assert_eq!(rules, ["type.scalar.mismatch", "type.typed.outside_select"]);
234    }
235}