Skip to main content

ifc_validate/type_check/
defined.rs

1//! Values checked against the defined type declared for their slot.
2
3use ifc_model::Value;
4use ifc_schema::{Schema, TypeKind};
5
6use super::aggregate::element_type;
7use super::enumeration;
8use super::scalar::{describe_value, primitive_of, FixedWidth};
9use super::select;
10
11/// How deeply nested aggregates are descended into.
12///
13/// The deepest aggregate IFC declares is three levels (a list of lists of
14/// measures); anything deeper is left unjudged rather than walked without
15/// bound, since the nesting depth is under the file's control.
16const MAX_NESTING: usize = 8;
17
18/// Why a value does not fit its declared type.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Mismatch {
22    /// The written form is not what the primitive accepts.
23    Primitive {
24        /// What the schema expects, in words.
25        expected: &'static str,
26        /// What the file wrote, in words.
27        actual: &'static str,
28    },
29    /// An enumeration constant the schema does not declare.
30    EnumMember {
31        /// The constant as written.
32        member: String,
33        /// The members the schema declares.
34        declared: Vec<String>,
35    },
36    /// A typed value whose type is not a member of the declared SELECT.
37    SelectMember {
38        /// The wrapper type as written.
39        written: String,
40        /// The SELECT it had to belong to.
41        select: String,
42    },
43    /// A `STRING(n) FIXED` value written at the wrong width.
44    FixedWidth {
45        /// The width the schema fixes.
46        expected: usize,
47        /// The width actually written.
48        actual: usize,
49    },
50    /// A value that is not an entity reference, in a slot only an entity
51    /// reference can fill: an entity-typed slot, or a SELECT of entities.
52    ExpectedReference {
53        /// The entity or SELECT the slot declares.
54        declared: String,
55        /// What the file wrote, in words.
56        actual: &'static str,
57    },
58}
59
60/// Checks one value against one declared type name.
61///
62/// Returns `None` when the value fits, or when the schema gives no basis to
63/// judge it. "No basis" is deliberately not a finding: an unrecognized type
64/// token means this validator cannot check the slot, and reporting that per
65/// value would bury real defects under noise. The unchecked *rules* are
66/// counted once, in the where-rule registry.
67///
68/// The members of an aggregate are checked against its element type, and
69/// the first mismatching member is reported. Which *entity* a reference
70/// points at needs the model, so it is judged by
71/// [`crate::structure::wrong_kind_references`] for entity-typed slots and by
72/// [`super::attribute_types`] for SELECTs; this function judges only that a
73/// reference was written where one may be.
74#[must_use]
75pub fn check(schema: &Schema, declared: &str, value: &Value) -> Option<Mismatch> {
76    check_nested(schema, declared, value, 0)
77}
78
79fn check_nested(schema: &Schema, declared: &str, value: &Value, depth: usize) -> Option<Mismatch> {
80    match value {
81        // `$` and `*` carry no type; presence is `structure`'s concern.
82        Value::Null | Value::Derived => None,
83        Value::List(items) => {
84            if depth >= MAX_NESTING {
85                return None;
86            }
87            // Whether a list belongs here at all is
88            // `structure::cardinality`'s question; this one is whether its
89            // members fit.
90            let element = element_type(schema, declared);
91            items
92                .iter()
93                .find_map(|item| check_nested(schema, &element, item, depth + 1))
94        }
95        Value::Ref(_) => reference_in_value_slot(schema, declared, value),
96        other if requires_reference(schema, declared, other) => Some(Mismatch::ExpectedReference {
97            declared: declared.to_string(),
98            actual: describe_value(other),
99        }),
100        Value::Enum(member) => match enumeration::is_member(schema, declared, member) {
101            Some(true) | None => None,
102            Some(false) => Some(Mismatch::EnumMember {
103                member: member.to_string(),
104                declared: enumeration::members(schema, declared)
105                    .map(<[String]>::to_vec)
106                    .unwrap_or_default(),
107            }),
108        },
109        Value::Typed { type_name, value } => {
110            // A typed wrapper in a SELECT slot must name a SELECT member.
111            if let Some(false) = select::accepts(schema, declared, type_name) {
112                return Some(Mismatch::SelectMember {
113                    written: type_name.to_string(),
114                    select: declared.to_string(),
115                });
116            }
117            // Otherwise the wrapper names the real type: check the payload
118            // against it rather than against the slot's declared type.
119            check_nested(schema, type_name, value, depth)
120        }
121        Value::Text(text) => {
122            // A fixed-width string is wrong at any other length, even when
123            // it is a perfectly good string. `IfcGloballyUniqueId` is
124            // `STRING(22) FIXED`; a 21-character GUID is malformed.
125            if let Some(width) = FixedWidth::from_resolved(&schema.resolve_defined(declared)) {
126                if !width.accepts(text) {
127                    return Some(Mismatch::FixedWidth {
128                        expected: width.0,
129                        actual: text.chars().count(),
130                    });
131                }
132            }
133            primitive_mismatch(schema, declared, value)
134        }
135        other => primitive_mismatch(schema, declared, other),
136    }
137}
138
139/// Whether only an entity reference can fill a `declared` slot, so that
140/// `value` is wrong whatever it holds.
141///
142/// A typed wrapper in a SELECT of entities is left to the SELECT membership
143/// check, which names the wrapper; everywhere else a non-reference in an
144/// entity-typed slot is reported here.
145fn requires_reference(schema: &Schema, declared: &str, value: &Value) -> bool {
146    if schema.entity(declared).is_some() {
147        return true;
148    }
149    !matches!(value, Value::Typed { .. })
150        && select::admits_only_entities(schema, declared) == Some(true)
151}
152
153/// A reference written where the declared type is not an entity.
154///
155/// Entity and SELECT slots take references; a slot resolving to an EXPRESS
156/// primitive does not, and a reference there is a type error however valid
157/// its target is.
158fn reference_in_value_slot(schema: &Schema, declared: &str, reference: &Value) -> Option<Mismatch> {
159    let is_select = schema
160        .type_def(declared)
161        .is_some_and(|definition| matches!(definition.kind, TypeKind::Select(_)));
162    if schema.entity(declared).is_some() || is_select {
163        return None;
164    }
165    primitive_mismatch(schema, declared, reference)
166}
167
168/// The value against the primitive `declared` resolves to, if any.
169fn primitive_mismatch(schema: &Schema, declared: &str, value: &Value) -> Option<Mismatch> {
170    let primitive = primitive_of(schema, declared)?;
171    if primitive.accepts(value) {
172        None
173    } else {
174        Some(Mismatch::Primitive {
175            expected: primitive.describe(),
176            actual: describe_value(value),
177        })
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use ifc_model::EntityId;
185
186    #[test]
187    fn aggregate_members_are_checked_against_the_element_type() {
188        let schema = ifc_schema::ifc4();
189        let good = Value::List(vec![Value::Real(1.0), Value::Integer(2)]);
190        assert_eq!(check(schema, "IfcLengthMeasure", &good), None);
191        let bad = Value::List(vec![Value::Real(1.0), Value::Text("x".into())]);
192        assert!(matches!(
193            check(schema, "IfcLengthMeasure", &bad),
194            Some(Mismatch::Primitive { .. })
195        ));
196        // An aliased aggregate: IfcLineIndex = LIST [2:?] OF IfcPositiveInteger.
197        let line = Value::List(vec![Value::Integer(1), Value::Real(2.5)]);
198        assert!(matches!(
199            check(schema, "IfcLineIndex", &line),
200            Some(Mismatch::Primitive { .. })
201        ));
202    }
203
204    #[test]
205    fn a_non_reference_in_an_entity_slot_is_a_mismatch() {
206        let schema = ifc_schema::ifc4();
207        for value in [
208            Value::Text("task".into()),
209            Value::Integer(1),
210            Value::Enum("NOTDEFINED".into()),
211            Value::Typed {
212                type_name: "IFCLABEL".into(),
213                value: Box::new(Value::Text("task".into())),
214            },
215        ] {
216            assert!(
217                matches!(
218                    check(schema, "IfcProcess", &value),
219                    Some(Mismatch::ExpectedReference { .. })
220                ),
221                "{value:?}"
222            );
223        }
224        assert_eq!(check(schema, "IfcProcess", &Value::Ref(EntityId(1))), None);
225        // A SELECT of entities takes only references, too.
226        assert!(matches!(
227            check(schema, "IfcActorSelect", &Value::Text("me".into())),
228            Some(Mismatch::ExpectedReference { .. })
229        ));
230        // A SELECT that also takes values leaves an untyped value alone.
231        assert_eq!(check(schema, "IfcValue", &Value::Real(1.0)), None);
232    }
233
234    #[test]
235    fn a_reference_in_a_value_slot_is_a_mismatch() {
236        let schema = ifc_schema::ifc4();
237        assert!(matches!(
238            check(schema, "IfcLabel", &Value::Ref(EntityId(1))),
239            Some(Mismatch::Primitive { .. })
240        ));
241        assert_eq!(
242            check(schema, "IfcMaterialSelect", &Value::Ref(EntityId(1))),
243            None
244        );
245    }
246}