Skip to main content

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    /// only the trailing primitive token is examined, and anything
78    /// unrecognized yields `None` rather than a guess.
79    #[must_use]
80    pub fn from_resolved(resolved: &str) -> Option<Self> {
81        let token = resolved
82            .rsplit(|c: char| c.is_whitespace() || c == '(')
83            .find(|part| !part.is_empty())?
84            .trim_end_matches([')', ';'])
85            .to_ascii_uppercase();
86        match token.as_str() {
87            "REAL" | "NUMBER" => Some(Self::Real),
88            "INTEGER" => Some(Self::Integer),
89            "STRING" => Some(Self::Text),
90            "BOOLEAN" => Some(Self::Boolean),
91            "LOGICAL" => Some(Self::Logical),
92            "BINARY" => Some(Self::Binary),
93            _ => None,
94        }
95    }
96
97    /// Whether `value` is written in a form this primitive accepts.
98    ///
99    /// An `INTEGER` is accepted where a `REAL` is declared: Part 21 writes
100    /// `1.` and `1` for the same quantity depending on the writer, and
101    /// rejecting the integer spelling would fail almost every real file.
102    /// The reverse is not accepted -- `1.5` is not an `INTEGER`.
103    #[must_use]
104    pub fn accepts(self, value: &Value) -> bool {
105        match self {
106            Self::Real => matches!(value, Value::Real(_) | Value::Integer(_)),
107            Self::Integer => matches!(value, Value::Integer(_)),
108            Self::Text => matches!(value, Value::Text(_)),
109            Self::Boolean => matches!(value, Value::Bool(_)),
110            Self::Logical => matches!(value, Value::Bool(_) | Value::LogicalUnknown),
111            Self::Binary => matches!(value, Value::Binary(_)),
112        }
113    }
114
115    /// How this primitive is named in a diagnostic.
116    #[must_use]
117    pub const fn describe(self) -> &'static str {
118        match self {
119            Self::Real => "a number",
120            Self::Integer => "an integer",
121            Self::Text => "a string",
122            Self::Boolean => "a boolean",
123            Self::Logical => "a logical",
124            Self::Binary => "binary data",
125        }
126    }
127}
128
129/// How a value was actually written, for a diagnostic.
130#[must_use]
131pub fn describe_value(value: &Value) -> &'static str {
132    match value {
133        Value::Null => "unset",
134        Value::Derived => "derived",
135        Value::Bool(_) => "a boolean",
136        Value::LogicalUnknown => "unknown",
137        Value::Integer(_) => "an integer",
138        Value::Real(_) => "a number",
139        Value::Text(_) => "a string",
140        Value::Binary(_) => "binary data",
141        Value::Enum(_) => "an enumeration constant",
142        Value::Ref(_) => "a reference",
143        Value::List(_) => "an aggregate",
144        Value::Typed { .. } => "a typed value",
145    }
146}
147
148/// The primitive a declared type resolves to, if any.
149#[must_use]
150pub fn primitive_of(schema: &Schema, type_name: &str) -> Option<Primitive> {
151    Primitive::from_resolved(&schema.resolve_defined(type_name))
152}