ifc_validate/type_check/defined.rs
1//! Values checked against the defined type declared for their slot.
2//!
3//! Both what a value is and the form it is written in are judged: Part 21
4//! writes a typed parameter exactly where the declared type is a SELECT, and
5//! a bare value everywhere else (see `typed`).
6
7use ifc_model::Value;
8use ifc_schema::Schema;
9
10use super::aggregate::element_type;
11use super::enumeration;
12use super::scalar::{describe_value, primitive_of, FixedWidth};
13use super::select;
14use super::typed::wrapper_fits;
15
16/// How deeply nested aggregates are descended into.
17///
18/// The deepest aggregate IFC declares is three levels (a list of lists of
19/// measures); anything deeper is left unjudged rather than walked without
20/// bound, since the nesting depth is under the file's control.
21const MAX_NESTING: usize = 8;
22
23/// Why a value does not fit its declared type.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum Mismatch {
27 /// The written form is not what the primitive accepts.
28 Primitive {
29 /// What the schema expects, in words.
30 expected: &'static str,
31 /// What the file wrote, in words.
32 actual: &'static str,
33 },
34 /// An enumeration constant the schema does not declare.
35 EnumMember {
36 /// The constant as written.
37 member: String,
38 /// The members the schema declares.
39 declared: Vec<String>,
40 },
41 /// A typed value whose type is not a member of the declared SELECT.
42 SelectMember {
43 /// The wrapper type as written.
44 written: String,
45 /// The SELECT it had to belong to.
46 select: String,
47 },
48 /// A `STRING(n) FIXED` value written at the wrong width.
49 FixedWidth {
50 /// The width the schema fixes.
51 expected: usize,
52 /// The width actually written.
53 actual: usize,
54 },
55 /// A value that is not an entity reference, in a slot only an entity
56 /// reference can fill: an entity-typed slot, or a SELECT of entities.
57 ExpectedReference {
58 /// The entity or SELECT the slot declares.
59 declared: String,
60 /// What the file wrote, in words.
61 actual: &'static str,
62 },
63 /// A typed parameter of the declared type, or of a specialisation of
64 /// it, where the declared type is not a SELECT. ISO 10303-21:2016
65 /// §12.1.6 writes such a value bare.
66 TypedOutsideSelect {
67 /// The wrapper type as written.
68 written: String,
69 /// The non-SELECT type the slot declares.
70 declared: String,
71 },
72 /// A typed parameter whose type is not the declared non-SELECT type:
73 /// the wrong form and the wrong type.
74 TypedWrongType {
75 /// The wrapper type as written.
76 written: String,
77 /// The non-SELECT type the slot declares.
78 declared: String,
79 },
80 /// A bare value where the declared type is a SELECT. ISO 10303-21:2016
81 /// §12.1.8 writes every value of a SELECT that is not an entity instance
82 /// as a typed parameter naming its type.
83 UntypedSelectValue {
84 /// The SELECT the slot declares.
85 select: String,
86 /// What the file wrote, in words.
87 actual: &'static str,
88 },
89}
90
91/// Checks one value against one declared type name.
92///
93/// Returns `None` when the value fits, or when the schema gives no basis to
94/// judge it. "No basis" is deliberately not a finding: an unrecognized type
95/// token means this validator cannot check the slot, and reporting that per
96/// value would bury real defects under noise. The unchecked *rules* are
97/// counted once, in the where-rule registry.
98///
99/// Returns the first of [`check_all`]'s mismatches; use that function to
100/// see every independent violation in the value.
101#[must_use]
102pub fn check(schema: &Schema, declared: &str, value: &Value) -> Option<Mismatch> {
103 check_all(schema, declared, value).into_iter().next()
104}
105
106/// Every independent way one value fails one declared type name, in the
107/// order the value is written.
108///
109/// The members of an aggregate are each checked against its element type,
110/// at every nesting level. The form is judged too: a typed parameter outside
111/// a SELECT, and a bare value inside one. A wrapper's form and its
112/// parameter are independent: `IFCLABEL(12)` in an `IfcLabel` slot is both
113/// the wrong form and a number where a string is declared, and both are
114/// returned. Identical mismatches are returned once, since a finding cannot
115/// tell them apart. Which *entity* a reference points at needs the model,
116/// so it is judged by [`crate::structure::wrong_kind_references`] for
117/// entity-typed slots and by [`super::attribute_types`] for SELECTs; this
118/// function judges only that a reference was written where one may be.
119#[must_use]
120pub fn check_all(schema: &Schema, declared: &str, value: &Value) -> Vec<Mismatch> {
121 let mut found = Vec::new();
122 check_nested(schema, declared, value, 0, &mut found);
123 found
124}
125
126/// Records `mismatch` unless an identical one already is.
127fn record(found: &mut Vec<Mismatch>, mismatch: Mismatch) {
128 if !found.contains(&mismatch) {
129 found.push(mismatch);
130 }
131}
132
133fn check_nested(
134 schema: &Schema,
135 declared: &str,
136 value: &Value,
137 depth: usize,
138 found: &mut Vec<Mismatch>,
139) {
140 let mismatch = match value {
141 // `$` and `*` carry no type; presence is `structure`'s concern.
142 Value::Null | Value::Derived => None,
143 Value::List(items) => {
144 if depth >= MAX_NESTING {
145 return;
146 }
147 // Whether a list belongs here at all is
148 // `structure::cardinality`'s question; this one is whether its
149 // members fit.
150 let element = element_type(schema, declared);
151 for item in items {
152 check_nested(schema, &element, item, depth + 1, found);
153 }
154 None
155 }
156 Value::Ref(_) => reference_in_value_slot(schema, declared, value),
157 other if requires_reference(schema, declared, other) => Some(Mismatch::ExpectedReference {
158 declared: declared.to_string(),
159 actual: describe_value(other),
160 }),
161 other
162 if !matches!(other, Value::Typed { .. })
163 && select::resolve_select(schema, declared).is_some() =>
164 {
165 // Every non-entity value of a SELECT names its type (§12.1.8).
166 // A SELECT of entities alone was answered above.
167 Some(Mismatch::UntypedSelectValue {
168 select: declared.to_string(),
169 actual: describe_value(other),
170 })
171 }
172 Value::Enum(member) => match enumeration::is_member(schema, declared, member) {
173 Some(true) | None => None,
174 Some(false) => Some(Mismatch::EnumMember {
175 member: member.to_string(),
176 declared: enumeration::members(schema, declared)
177 .map(<[String]>::to_vec)
178 .unwrap_or_default(),
179 }),
180 },
181 Value::Typed { type_name, value } => {
182 typed(schema, declared, type_name, value, depth, found);
183 None
184 }
185 Value::Text(text) => {
186 // A fixed-width string is wrong at any other length, even when
187 // it is a perfectly good string. `IfcGloballyUniqueId` is
188 // `STRING(22) FIXED`; a 21-character GUID is malformed.
189 match FixedWidth::from_resolved(&schema.resolve_defined(declared)) {
190 Some(width) if !width.accepts(text) => Some(Mismatch::FixedWidth {
191 expected: width.0,
192 actual: text.chars().count(),
193 }),
194 _ => primitive_mismatch(schema, declared, value),
195 }
196 }
197 other => primitive_mismatch(schema, declared, other),
198 };
199 if let Some(mismatch) = mismatch {
200 record(found, mismatch);
201 }
202}
203
204/// A typed parameter `type_name(inner)` written in a `declared` slot.
205///
206/// In a SELECT slot the wrapper must name a member of the select-list. In
207/// any other slot a wrapper is the wrong form (§12.1.6), and of the wrong
208/// type too when it does not name the declared type. Either way the
209/// parameter is judged as well, against the wrapper's type, which is what
210/// it claims to be: a bad parameter is a separate violation, and neither
211/// finding masks the other.
212fn typed(
213 schema: &Schema,
214 declared: &str,
215 type_name: &str,
216 inner: &Value,
217 depth: usize,
218 found: &mut Vec<Mismatch>,
219) {
220 if select::resolve_select(schema, declared).is_some() {
221 if select::accepts(schema, declared, type_name) == Some(false) {
222 record(
223 found,
224 Mismatch::SelectMember {
225 written: type_name.to_string(),
226 select: declared.to_string(),
227 },
228 );
229 }
230 check_nested(schema, type_name, inner, depth, found);
231 return;
232 }
233 match wrapper_fits(schema, type_name, declared) {
234 Some(false) => record(
235 found,
236 Mismatch::TypedWrongType {
237 written: type_name.to_string(),
238 declared: declared.to_string(),
239 },
240 ),
241 Some(true) => record(
242 found,
243 Mismatch::TypedOutsideSelect {
244 written: type_name.to_string(),
245 declared: declared.to_string(),
246 },
247 ),
248 None => {}
249 }
250 check_nested(schema, type_name, inner, depth, found);
251}
252
253/// Whether only an entity reference can fill a `declared` slot, so that
254/// `value` is wrong whatever it holds.
255///
256/// A typed wrapper in a SELECT of entities is left to the SELECT membership
257/// check, which names the wrapper; everywhere else a non-reference in an
258/// entity-typed slot is reported here.
259fn requires_reference(schema: &Schema, declared: &str, value: &Value) -> bool {
260 if schema.entity(declared).is_some() {
261 return true;
262 }
263 !matches!(value, Value::Typed { .. })
264 && select::admits_only_entities(schema, declared) == Some(true)
265}
266
267/// A reference written where the declared type is not an entity.
268///
269/// Entity and SELECT slots take references; a slot resolving to an EXPRESS
270/// primitive does not, and a reference there is a type error however valid
271/// its target is.
272fn reference_in_value_slot(schema: &Schema, declared: &str, reference: &Value) -> Option<Mismatch> {
273 let is_select = select::resolve_select(schema, declared).is_some();
274 if schema.entity(declared).is_some() || is_select {
275 return None;
276 }
277 primitive_mismatch(schema, declared, reference)
278}
279
280/// The value against the primitive `declared` resolves to, if any.
281fn primitive_mismatch(schema: &Schema, declared: &str, value: &Value) -> Option<Mismatch> {
282 let primitive = primitive_of(schema, declared)?;
283 if primitive.accepts(value) {
284 None
285 } else {
286 Some(Mismatch::Primitive {
287 expected: primitive.describe(),
288 actual: describe_value(value),
289 })
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use ifc_model::EntityId;
297
298 #[test]
299 fn aggregate_members_are_checked_against_the_element_type() {
300 let schema = ifc_schema::ifc4();
301 let good = Value::List(vec![Value::Real(1.0), Value::Integer(2)]);
302 assert_eq!(check(schema, "IfcLengthMeasure", &good), None);
303 let bad = Value::List(vec![Value::Real(1.0), Value::Text("x".into())]);
304 assert!(matches!(
305 check(schema, "IfcLengthMeasure", &bad),
306 Some(Mismatch::Primitive { .. })
307 ));
308 // An aliased aggregate: IfcLineIndex = LIST [2:?] OF IfcPositiveInteger.
309 let line = Value::List(vec![Value::Integer(1), Value::Real(2.5)]);
310 assert!(matches!(
311 check(schema, "IfcLineIndex", &line),
312 Some(Mismatch::Primitive { .. })
313 ));
314 }
315
316 #[test]
317 fn a_non_reference_in_an_entity_slot_is_a_mismatch() {
318 let schema = ifc_schema::ifc4();
319 for value in [
320 Value::Text("task".into()),
321 Value::Integer(1),
322 Value::Enum("NOTDEFINED".into()),
323 Value::Typed {
324 type_name: "IFCLABEL".into(),
325 value: Box::new(Value::Text("task".into())),
326 },
327 ] {
328 assert!(
329 matches!(
330 check(schema, "IfcProcess", &value),
331 Some(Mismatch::ExpectedReference { .. })
332 ),
333 "{value:?}"
334 );
335 }
336 assert_eq!(check(schema, "IfcProcess", &Value::Ref(EntityId(1))), None);
337 // A SELECT of entities takes only references, too.
338 assert!(matches!(
339 check(schema, "IfcActorSelect", &Value::Text("me".into())),
340 Some(Mismatch::ExpectedReference { .. })
341 ));
342 // A SELECT that also takes values needs the typed form instead
343 // (ISO 10303-21:2016 §12.1.8), which is a different finding.
344 assert!(matches!(
345 check(schema, "IfcValue", &Value::Real(1.0)),
346 Some(Mismatch::UntypedSelectValue { .. })
347 ));
348 }
349
350 #[test]
351 fn a_reference_in_a_value_slot_is_a_mismatch() {
352 let schema = ifc_schema::ifc4();
353 assert!(matches!(
354 check(schema, "IfcLabel", &Value::Ref(EntityId(1))),
355 Some(Mismatch::Primitive { .. })
356 ));
357 assert_eq!(
358 check(schema, "IfcMaterialSelect", &Value::Ref(EntityId(1))),
359 None
360 );
361 }
362
363 /// Each bad member of an aggregate is its own violation, at every
364 /// nesting level (#215).
365 #[test]
366 fn every_bad_member_of_an_aggregate_is_reported() {
367 let schema = ifc_schema::ifc4();
368 let bad = Value::List(vec![
369 Value::Text("x".into()),
370 Value::Real(1.0),
371 Value::Bool(true),
372 ]);
373 let found = check_all(schema, "IfcLengthMeasure", &bad);
374 assert_eq!(found.len(), 2, "{found:?}");
375 // `IfcCartesianPointList3D.CoordList` is `LIST OF LIST OF
376 // IfcLengthMeasure`: two rows, each with its own defect.
377 let rows = Value::List(vec![
378 Value::List(vec![Value::Text("x".into()), Value::Real(0.0)]),
379 Value::List(vec![Value::Real(0.0), Value::Bool(false)]),
380 ]);
381 assert_eq!(check_all(schema, "IfcLengthMeasure", &rows).len(), 2);
382 // The same defect twice cannot be told apart in a finding.
383 let twice = Value::List(vec![Value::Text("x".into()), Value::Text("y".into())]);
384 assert_eq!(check_all(schema, "IfcLengthMeasure", &twice).len(), 1);
385 // `check` still answers the first.
386 assert!(matches!(
387 check(schema, "IfcLengthMeasure", &bad),
388 Some(Mismatch::Primitive {
389 actual: "a string",
390 ..
391 })
392 ));
393 }
394
395 /// A wrapper's form and its parameter are judged independently, so a
396 /// bad payload inside a wrapper does not hide the wrong form (#215).
397 #[test]
398 fn a_wrong_form_and_a_bad_payload_are_both_reported() {
399 let schema = ifc_schema::ifc4();
400 let wrapped = Value::Typed {
401 type_name: "IFCLABEL".into(),
402 value: Box::new(Value::Integer(12)),
403 };
404 let found = check_all(schema, "IfcLabel", &wrapped);
405 assert!(
406 found
407 .iter()
408 .any(|m| matches!(m, Mismatch::TypedOutsideSelect { .. })),
409 "{found:?}"
410 );
411 assert!(
412 found
413 .iter()
414 .any(|m| matches!(m, Mismatch::Primitive { .. })),
415 "{found:?}"
416 );
417 // A wrapper outside its SELECT, around a payload its own type
418 // refuses: both the membership and the payload.
419 let foreign = Value::Typed {
420 type_name: "IFCLABEL".into(),
421 value: Box::new(Value::Real(1.0)),
422 };
423 let found = check_all(schema, "IfcMeasureValue", &foreign);
424 assert!(
425 found
426 .iter()
427 .any(|m| matches!(m, Mismatch::SelectMember { .. })),
428 "{found:?}"
429 );
430 assert!(
431 found
432 .iter()
433 .any(|m| matches!(m, Mismatch::Primitive { .. })),
434 "{found:?}"
435 );
436 }
437}