ifc_validate/type_check/scalar.rs
1//! Does a written value match the primitive its declared type resolves to?
2//!
3//! # The defect this exists to catch
4//!
5//! ```text
6//! #1=IFCPROPERTYSINGLEVALUE('x',$,IFCPOSITIVELENGTHMEASURE('1'),$);
7//! ^^^
8//! ```
9//!
10//! `IfcPositiveLengthMeasure` resolves to `REAL`, and `'1'` is a string. The
11//! file parses -- Part 21 has no idea what the token means -- and every
12//! consumer that reads a number from that slot gets nothing. This is a real
13//! IfcOpenShell regression fixture, not a hypothetical.
14
15use ifc_model::Value;
16use ifc_schema::Schema;
17
18/// A `STRING(n) FIXED` width declared by a type.
19///
20/// EXPRESS lets a string type fix its own width: `IfcGloballyUniqueId` is
21/// `STRING(22) FIXED`, and a 21-character GUID is malformed regardless of
22/// whether it is unique. The width is part of the type expression the parser
23/// keeps as text, so it is read back out here rather than special-cased per
24/// type -- any `FIXED` width in any schema is checked the same way.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct FixedWidth(pub usize);
27
28impl FixedWidth {
29 /// Reads a `STRING(n) FIXED` width from a resolved type expression.
30 ///
31 /// Returns `None` for a plain `STRING`, a bounded-but-not-fixed
32 /// `STRING(n)`, or anything that is not a string: only `FIXED` makes the
33 /// width a conformance requirement rather than a maximum.
34 #[must_use]
35 pub fn from_resolved(resolved: &str) -> Option<Self> {
36 let upper = resolved.to_ascii_uppercase();
37 if !upper.contains("FIXED") {
38 return None;
39 }
40 let start = upper.find("STRING")?;
41 let open = upper[start..].find('(')? + start;
42 let close = upper[open..].find(')')? + open;
43 upper[open + 1..close].trim().parse().ok().map(Self)
44 }
45
46 /// Whether `text` has exactly the declared width.
47 ///
48 /// Counted in characters, not bytes: a Latin-1 accented character is one
49 /// character in EXPRESS terms.
50 #[must_use]
51 pub fn accepts(self, text: &str) -> bool {
52 text.chars().count() == self.0
53 }
54}
55
56/// The EXPRESS primitive a defined type bottoms out in.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Primitive {
59 /// `REAL` or `NUMBER`.
60 Real,
61 /// `INTEGER`.
62 Integer,
63 /// `STRING`.
64 Text,
65 /// `BOOLEAN`.
66 Boolean,
67 /// `LOGICAL` -- true, false, *or* unknown.
68 Logical,
69 /// `BINARY`.
70 Binary,
71}
72
73impl Primitive {
74 /// Recognizes an EXPRESS primitive from a resolved type expression.
75 ///
76 /// The resolved text may carry aggregate syntax (`LIST [1:?] OF REAL`)
77 /// and width specifications (`STRING(255)`, `STRING(22) FIXED`); the
78 /// last primitive keyword is the one the value must satisfy, and text
79 /// naming no primitive yields `None` rather than a guess.
80 ///
81 /// Width and `FIXED` are skipped rather than read as the trailing token:
82 /// IFC4 declares `IfcLabel` and `IfcIdentifier` as `STRING(255)`, so a
83 /// trailing-token reading left every `Name` slot unchecked.
84 #[must_use]
85 pub fn from_resolved(resolved: &str) -> Option<Self> {
86 resolved
87 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
88 .filter_map(|token| match token.to_ascii_uppercase().as_str() {
89 "REAL" | "NUMBER" => Some(Self::Real),
90 "INTEGER" => Some(Self::Integer),
91 "STRING" => Some(Self::Text),
92 "BOOLEAN" => Some(Self::Boolean),
93 "LOGICAL" => Some(Self::Logical),
94 "BINARY" => Some(Self::Binary),
95 _ => None,
96 })
97 .next_back()
98 }
99
100 /// Whether `value` is written in a form this primitive accepts.
101 ///
102 /// An `INTEGER` is accepted where a `REAL` is declared: Part 21 writes
103 /// `1.` and `1` for the same quantity depending on the writer, and
104 /// rejecting the integer spelling would fail almost every real file.
105 /// The reverse is not accepted -- `1.5` is not an `INTEGER`.
106 #[must_use]
107 pub fn accepts(self, value: &Value) -> bool {
108 match self {
109 Self::Real => matches!(value, Value::Real(_) | Value::Integer(_)),
110 Self::Integer => matches!(value, Value::Integer(_)),
111 Self::Text => matches!(value, Value::Text(_)),
112 Self::Boolean => matches!(value, Value::Bool(_)),
113 Self::Logical => matches!(value, Value::Bool(_) | Value::LogicalUnknown),
114 Self::Binary => matches!(value, Value::Binary(_)),
115 }
116 }
117
118 /// How this primitive is named in a diagnostic.
119 #[must_use]
120 pub const fn describe(self) -> &'static str {
121 match self {
122 Self::Real => "a number",
123 Self::Integer => "an integer",
124 Self::Text => "a string",
125 Self::Boolean => "a boolean",
126 Self::Logical => "a logical",
127 Self::Binary => "binary data",
128 }
129 }
130}
131
132/// How a value was actually written, for a diagnostic.
133#[must_use]
134pub fn describe_value(value: &Value) -> &'static str {
135 match value {
136 Value::Null => "unset",
137 Value::Derived => "derived",
138 Value::Bool(_) => "a boolean",
139 Value::LogicalUnknown => "unknown",
140 Value::Integer(_) => "an integer",
141 Value::Real(_) => "a number",
142 Value::Text(_) => "a string",
143 Value::Binary(_) => "binary data",
144 Value::Enum(_) => "an enumeration constant",
145 Value::Ref(_) => "a reference",
146 Value::List(_) => "an aggregate",
147 Value::Typed { .. } => "a typed value",
148 }
149}
150
151/// The primitive a declared type resolves to, if any.
152#[must_use]
153pub fn primitive_of(schema: &Schema, type_name: &str) -> Option<Primitive> {
154 Primitive::from_resolved(&schema.resolve_defined(type_name))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 /// Width specifications do not hide the primitive.
162 ///
163 /// IFC4 declares `IfcLabel = STRING(255)`. Reading the trailing token
164 /// found `255`, recognised nothing, and left every label slot unchecked.
165 #[test]
166 fn a_bounded_or_fixed_string_is_still_a_string() {
167 assert_eq!(
168 Primitive::from_resolved("STRING(255)"),
169 Some(Primitive::Text)
170 );
171 assert_eq!(
172 Primitive::from_resolved("STRING(22) FIXED"),
173 Some(Primitive::Text)
174 );
175 assert_eq!(
176 Primitive::from_resolved("LIST [1:?] OF REAL"),
177 Some(Primitive::Real)
178 );
179 }
180
181 /// Text naming no primitive stays unjudged.
182 #[test]
183 fn a_non_primitive_is_not_guessed() {
184 assert_eq!(Primitive::from_resolved("IfcLabel"), None);
185 assert_eq!(Primitive::from_resolved("LIST [1:?] OF IfcReal"), None);
186 }
187
188 /// The bundled IFC4 label types resolve to a checked primitive.
189 #[test]
190 fn ifc4_labels_are_checked_as_strings() {
191 let schema = ifc_schema::ifc4();
192 assert_eq!(primitive_of(schema, "IfcLabel"), Some(Primitive::Text));
193 assert_eq!(primitive_of(schema, "IfcIdentifier"), Some(Primitive::Text));
194 }
195}