Skip to main content

openbim_ids/audit/
mod.rs

1//! Auditing IDS documents against the IFC schemas of their listed releases.
2//!
3//! A schema-valid IDS document can still be meaningless for the releases it
4//! names: an entity the release does not define, an attribute the entity
5//! does not have, a value that no IFC value of the attribute's type could
6//! equal. A checker finds those late, as silent non-matches. [`audit`] finds
7//! them up front, per specification and per listed `ifcVersion`.
8//!
9//! # Features
10//!
11//! - `audit-schema` runs every check against the IFC schema tables of
12//!   `ifc-schema` (AGPL-3.0-or-later).
13//! - `audit` adds the standard property and quantity set checks
14//!   ([`AuditCode::PropertyNotInStandardSet`],
15//!   [`AuditCode::PropertyDataTypeMismatch`],
16//!   [`AuditCode::StandardSetUnknown`]) against the official templates of
17//!   `ifc-template-catalog`, which embeds buildingSMART template data under
18//!   CC BY-ND 4.0 in addition to AGPL-3.0-or-later.
19//!
20//! Enabling either puts those terms on the resulting work. The reader and
21//! writer need neither.
22//!
23//! # What is checked
24//!
25//! Each [`AuditCode`] documents its rule. In short:
26//!
27//! - entity names: upper case, defined by every listed release (IFC2X3 also
28//!   accepts the IFC4 names of the implementers' occurrence/type mapping
29//!   table), and a pattern matches at least one;
30//! - a predefined type is only stated for an entity that has one, itself or
31//!   through its type entity. Its value is not checked against the
32//!   enumeration: a user-defined value matches `ObjectType`;
33//! - a requirement `entity` can be met by an object the applicability
34//!   selects;
35//! - attribute names are explicit attributes of the applicable entity, not
36//!   derived or inverse ones, and a value is only required of an attribute
37//!   holding a single simple value;
38//! - values can be cast to the IFC type they are compared with: an
39//!   attribute's declared type or a property's `dataType`. `xs:pattern` only
40//!   applies to strings;
41//! - property `dataType`s are IFC defined (or enumeration) types of the
42//!   release; with `audit`, in a standard `Pset_`/`Qto_` set the property
43//!   exists and has that type;
44//! - `partOf` names a whole the relation can have;
45//! - a prohibited specification carries no requirements, and occurrence and
46//!   restriction bounds are not contradictory.
47//!
48//! Findings for one release carry it in [`AuditFinding::ifc_version`]; a
49//! document listing `IFC2X3 IFC4` can be wrong for only one of them.
50//!
51//! # What is not checked
52//!
53//! An entity is not required to be an `IfcObject` or type: attribute facets
54//! legitimately apply to resource entities such as `IfcPerson`. Enumerated
55//! attribute values and patterns that use XSD-only constructs (`\i`, `\c`,
56//! class subtraction, `\p{Is…}`) are not evaluated; the latter are reported
57//! as [`AuditCode::PatternUnverified`] warnings.
58
59mod mapping;
60mod pattern;
61
62use core::fmt;
63use std::collections::BTreeSet;
64use std::sync::OnceLock;
65
66use ifc_schema::{Schema, TypeKind};
67#[cfg(feature = "audit")]
68use ifc_template_catalog::catalog::Catalog;
69#[cfg(feature = "audit")]
70use ifc_template_catalog::definition::{
71    CatalogEdition, PropertyKind, QuantityKind, SetTemplateKind,
72};
73
74use crate::model::{
75    Attribute, Entity, Facet, PartOf, Property, Relation, Restriction, Specification, Value,
76};
77use crate::{Ids, IfcVersion};
78
79/// How serious a finding is.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
81pub enum Severity {
82    /// The specification cannot work as written for the release.
83    Error,
84    /// The audit could not decide, or the document is suspicious.
85    Warning,
86}
87
88impl fmt::Display for Severity {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(match self {
91            Severity::Error => "error",
92            Severity::Warning => "warning",
93        })
94    }
95}
96
97/// What a finding is about. [`AuditCode::as_str`] is the stable code.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99#[non_exhaustive]
100pub enum AuditCode {
101    /// `entity-name-case`: an entity name is not upper case. IDS compares
102    /// entity names case-sensitively against the upper-case IFC name.
103    EntityNameCase,
104    /// `entity-unknown`: the release does not define the entity.
105    EntityUnknown,
106    /// `entity-pattern-matches-nothing`: no entity of the release matches.
107    EntityPatternMatchesNothing,
108    /// `predefined-type-unavailable`: a predefined type is stated for an
109    /// entity that has none, neither itself nor through its type entity.
110    PredefinedTypeUnavailable,
111    /// `entity-requirement-contradicts-applicability`: no object the
112    /// applicability selects can be the entity the requirement demands.
113    /// Subtypes do not count; IDS matches entity names exactly. In IFC2X3 a
114    /// name of the occurrence/type mapping table overlaps its occurrence
115    /// class (`IFCAIRTERMINAL` is an `IFCFLOWTERMINAL` with an
116    /// `IFCAIRTERMINALTYPE`).
117    EntityRequirementContradictsApplicability,
118    /// `attribute-unknown`: the applicable entity has no such explicit
119    /// attribute (inverse attributes are not checkable).
120    AttributeUnknown,
121    /// `attribute-derived`: the attribute is derived, so a model never
122    /// stores it.
123    AttributeDerived,
124    /// `attribute-pattern-matches-nothing`: no attribute of the applicable
125    /// entity matches the name pattern.
126    AttributePatternMatchesNothing,
127    /// `attribute-value-not-comparable`: a value is required of an attribute
128    /// holding a list, an entity or a select, which never equals a literal.
129    AttributeValueNotComparable,
130    /// `value-type-mismatch`: a value cannot be cast to the IFC type it is
131    /// compared with, e.g. `42.0` for an integer or `FALSE` for a boolean.
132    ValueTypeMismatch,
133    /// `pattern-on-non-string`: an `xs:pattern` is applied to a non-string
134    /// value, which never matches.
135    PatternOnNonString,
136    /// `pattern-unverified` (warning): the pattern uses an XSD construct this
137    /// audit cannot evaluate, so checks depending on it were skipped.
138    PatternUnverified,
139    /// `restriction-bounds-contradict`: the restriction's bounds or lengths
140    /// admit no value.
141    RestrictionBoundsContradict,
142    /// `data-type-unknown`: a property `dataType` is not an IFC defined or
143    /// enumeration type of the release.
144    DataTypeUnknown,
145    /// `property-not-in-standard-set`: a standard property or quantity set of
146    /// the release does not define the property.
147    PropertyNotInStandardSet,
148    /// `property-data-type-mismatch`: the standard set defines the property
149    /// with a different data type.
150    PropertyDataTypeMismatch,
151    /// `standard-set-unknown`: a set named with the reserved `Pset_` or
152    /// `Qto_` prefix is not a standard set of the release.
153    StandardSetUnknown,
154    /// `part-of-relation-entity`: the relation cannot have this entity as
155    /// its whole, e.g. `IFCRELASSIGNSTOGROUP` with a non-group.
156    PartOfRelationEntity,
157    /// `prohibited-with-requirements`: a prohibited specification
158    /// (`maxOccurs="0"`) has requirements, which can never be checked.
159    ProhibitedWithRequirements,
160    /// `occurrence-bounds-contradict`: `minOccurs` exceeds `maxOccurs`.
161    OccurrenceBoundsContradict,
162}
163
164impl AuditCode {
165    /// The stable code, e.g. `entity-unknown`.
166    #[must_use]
167    pub fn as_str(self) -> &'static str {
168        match self {
169            AuditCode::EntityNameCase => "entity-name-case",
170            AuditCode::EntityUnknown => "entity-unknown",
171            AuditCode::EntityPatternMatchesNothing => "entity-pattern-matches-nothing",
172            AuditCode::PredefinedTypeUnavailable => "predefined-type-unavailable",
173            AuditCode::EntityRequirementContradictsApplicability => {
174                "entity-requirement-contradicts-applicability"
175            }
176            AuditCode::AttributeUnknown => "attribute-unknown",
177            AuditCode::AttributeDerived => "attribute-derived",
178            AuditCode::AttributePatternMatchesNothing => "attribute-pattern-matches-nothing",
179            AuditCode::AttributeValueNotComparable => "attribute-value-not-comparable",
180            AuditCode::ValueTypeMismatch => "value-type-mismatch",
181            AuditCode::PatternOnNonString => "pattern-on-non-string",
182            AuditCode::PatternUnverified => "pattern-unverified",
183            AuditCode::RestrictionBoundsContradict => "restriction-bounds-contradict",
184            AuditCode::DataTypeUnknown => "data-type-unknown",
185            AuditCode::PropertyNotInStandardSet => "property-not-in-standard-set",
186            AuditCode::PropertyDataTypeMismatch => "property-data-type-mismatch",
187            AuditCode::StandardSetUnknown => "standard-set-unknown",
188            AuditCode::PartOfRelationEntity => "part-of-relation-entity",
189            AuditCode::ProhibitedWithRequirements => "prohibited-with-requirements",
190            AuditCode::OccurrenceBoundsContradict => "occurrence-bounds-contradict",
191        }
192    }
193
194    /// The severity findings with this code have.
195    #[must_use]
196    pub fn severity(self) -> Severity {
197        match self {
198            AuditCode::PatternUnverified => Severity::Warning,
199            _ => Severity::Error,
200        }
201    }
202}
203
204impl fmt::Display for AuditCode {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(self.as_str())
207    }
208}
209
210/// One problem in a document.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct AuditFinding {
213    /// What is wrong.
214    pub code: AuditCode,
215    /// The index of the specification in [`Ids::specifications`].
216    pub specification: usize,
217    /// The path to the offending part within the specification, such as
218    /// `requirements/facets[1]/name`. Empty for the specification itself.
219    pub path: String,
220    /// The release the finding applies to, `None` when it holds for all.
221    pub ifc_version: Option<IfcVersion>,
222    /// A human-readable explanation.
223    pub message: String,
224}
225
226impl AuditFinding {
227    /// The finding's severity, see [`AuditCode::severity`].
228    #[must_use]
229    pub fn severity(&self) -> Severity {
230        self.code.severity()
231    }
232}
233
234impl fmt::Display for AuditFinding {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write!(
237            f,
238            "{} [{}] specifications[{}]",
239            self.severity(),
240            self.code,
241            self.specification
242        )?;
243        if !self.path.is_empty() {
244            write!(f, "/{}", self.path)?;
245        }
246        if let Some(version) = self.ifc_version {
247            write!(f, " ({version})")?;
248        }
249        write!(f, ": {}", self.message)
250    }
251}
252
253/// Audits every specification of `ids` against its listed IFC releases.
254///
255/// Findings come in document order. An empty result means no problem was
256/// found, not that the document is fit for every purpose.
257///
258/// ```
259/// use openbim_ids::audit::{audit, AuditCode};
260/// use openbim_ids::{Entity, Ids, IfcVersion, Info, Specification};
261///
262/// let mut spec = Specification::new("Rabbits", [IfcVersion::Ifc4]);
263/// spec.applicability.facets.push(Entity::new("IFCRABBIT").into());
264/// let mut ids = Ids::new(Info::new("Zoo"));
265/// ids.specifications.push(spec);
266///
267/// let findings = audit(&ids);
268/// assert_eq!(findings[0].code, AuditCode::EntityUnknown);
269/// ```
270#[must_use]
271pub fn audit(ids: &Ids) -> Vec<AuditFinding> {
272    let mut auditor = Auditor::default();
273    for (index, specification) in ids.specifications.iter().enumerate() {
274        auditor.specification = index;
275        auditor.audit_specification(specification);
276    }
277    auditor.findings
278}
279
280/// One IFC release: its schema, its standard sets, and its entity names.
281struct Release {
282    version: IfcVersion,
283    schema: &'static Schema,
284    #[cfg(feature = "audit")]
285    templates: Option<Catalog>,
286    /// Upper-case entity names, plus for IFC2X3 the mapped IFC4 names.
287    entities: Vec<String>,
288}
289
290fn release(version: IfcVersion) -> &'static Release {
291    static RELEASES: [OnceLock<Release>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()];
292    let (slot, schema): (usize, fn() -> &'static Schema) = match version {
293        IfcVersion::Ifc2x3 => (0, ifc_schema::ifc2x3),
294        IfcVersion::Ifc4 => (1, ifc_schema::ifc4),
295        IfcVersion::Ifc4x3Add2 => (2, ifc_schema::ifc4x3),
296    };
297    RELEASES[slot].get_or_init(|| {
298        let schema = schema();
299        let mut entities: BTreeSet<String> =
300            schema.entity_names().map(str::to_ascii_uppercase).collect();
301        if version == IfcVersion::Ifc2x3 {
302            entities.extend(
303                mapping::IFC2X3_OCCURRENCE_TYPES
304                    .iter()
305                    .map(|(name, _, _)| (*name).to_owned()),
306            );
307        }
308        Release {
309            version,
310            schema,
311            #[cfg(feature = "audit")]
312            templates: templates(version),
313            entities: entities.into_iter().collect(),
314        }
315    })
316}
317
318/// The official standard set templates of a release.
319#[cfg(feature = "audit")]
320fn templates(version: IfcVersion) -> Option<Catalog> {
321    let edition = match version {
322        IfcVersion::Ifc2x3 => CatalogEdition::Ifc2x3Tc1,
323        IfcVersion::Ifc4 => CatalogEdition::Ifc4Add2Tc1,
324        IfcVersion::Ifc4x3Add2 => CatalogEdition::Ifc4x3Add2,
325    };
326    ifc_template_catalog::embedded::official_catalog(edition).ok()
327}
328
329impl Release {
330    /// Whether one object can match both entity names exactly.
331    ///
332    /// Equal names can. In IFC2X3, a name of the occurrence/type mapping
333    /// table is an occurrence class narrowed by its type entity, so it and
334    /// that occurrence class (`IFCAIRTERMINAL`, `IFCFLOWTERMINAL`) describe
335    /// overlapping objects. Two mapped names never do, even with a shared
336    /// occurrence class: their type entities differ.
337    fn can_be(&self, one: &str, other: &str) -> bool {
338        if one == other {
339            return true;
340        }
341        if self.version != IfcVersion::Ifc2x3 {
342            return false;
343        }
344        let occurrence = |name: &str| {
345            mapping::IFC2X3_OCCURRENCE_TYPES
346                .iter()
347                .find(|(mapped, _, _)| *mapped == name)
348                .map(|(_, occurrence, _)| *occurrence)
349        };
350        match (occurrence(one), occurrence(other)) {
351            (Some(occurrence), None) => occurrence == other,
352            (None, Some(occurrence)) => occurrence == one,
353            _ => false,
354        }
355    }
356
357    fn has_entity(&self, name: &str) -> bool {
358        self.entities
359            .binary_search_by(|e| e.as_str().cmp(name))
360            .is_ok()
361    }
362
363    /// The schema entity an IDS entity name stands for, and its type entity.
364    fn resolve(&self, name: &str) -> Option<(String, Option<String>)> {
365        if self.version == IfcVersion::Ifc2x3 {
366            if let Some((_, occurrence, type_entity)) = mapping::IFC2X3_OCCURRENCE_TYPES
367                .iter()
368                .find(|(mapped, _, _)| *mapped == name)
369            {
370                return Some(((*occurrence).to_owned(), Some((*type_entity).to_owned())));
371            }
372        }
373        let entity = self.schema.entity(name)?;
374        let type_entity = format!("{name}TYPE");
375        let type_entity = (!name.ends_with("TYPE") && self.schema.entity(&type_entity).is_some())
376            .then_some(type_entity);
377        Some((entity.name.to_ascii_uppercase(), type_entity))
378    }
379
380    fn has_attribute(&self, entity: &str, attribute: &str) -> bool {
381        self.schema.attribute_names(entity).contains(&attribute)
382    }
383
384    fn has_predefined_type(&self, name: &str) -> bool {
385        self.resolve(name).is_some_and(|(occurrence, type_entity)| {
386            self.has_attribute(&occurrence, "PredefinedType")
387                || type_entity.is_some_and(|t| self.has_attribute(&t, "PredefinedType"))
388        })
389    }
390
391    /// Whether `attribute` is derived in `entity` or a supertype.
392    fn is_derived(&self, entity: &str, attribute: &str) -> bool {
393        std::iter::once(entity)
394            .chain(self.schema.supertypes(entity))
395            .filter_map(|name| self.schema.entity(name))
396            .any(|def| def.derived.iter().any(|d| d == attribute))
397    }
398
399    fn is_defined_type(&self, name: &str) -> bool {
400        self.schema
401            .type_def(name)
402            .is_some_and(|def| matches!(def.kind, TypeKind::Defined(_) | TypeKind::Enumeration(_)))
403    }
404
405    /// What a value compared with the named IFC type must look like.
406    fn kind_of_type(&self, type_name: &str) -> Kind {
407        if self.schema.entity(type_name).is_some() {
408            return Kind::NotComparable("an entity");
409        }
410        match self.schema.type_def(type_name).map(|def| &def.kind) {
411            Some(TypeKind::Select(_)) => return Kind::NotComparable("a select"),
412            Some(TypeKind::Enumeration(_)) => return Kind::Unchecked,
413            _ => {}
414        }
415        let base = self.schema.resolve_defined(type_name).to_ascii_uppercase();
416        match base.as_str() {
417            "INTEGER" => Kind::Integer,
418            "REAL" | "NUMBER" => Kind::Real,
419            "BOOLEAN" => Kind::Boolean,
420            "LOGICAL" => Kind::Logical,
421            "STRING" | "BINARY" => Kind::String,
422            other
423                if other.starts_with("LIST")
424                    || other.starts_with("SET")
425                    || other.starts_with("ARRAY")
426                    || other.starts_with("BAG") =>
427            {
428                Kind::NotComparable("a list")
429            }
430            _ => match self.schema.type_def(&base).map(|def| &def.kind) {
431                Some(TypeKind::Select(_)) => Kind::NotComparable("a select"),
432                _ => Kind::Unchecked,
433            },
434        }
435    }
436}
437
438/// What literal values an IFC type can equal.
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum Kind {
441    Integer,
442    Real,
443    Boolean,
444    Logical,
445    String,
446    /// An enumeration or a type this audit does not judge.
447    Unchecked,
448    /// Lists, entities and selects never equal a literal.
449    NotComparable(&'static str),
450}
451
452impl Kind {
453    fn accepts(self, value: &str) -> bool {
454        let value = value.trim_matches(|c| matches!(c, ' ' | '\t' | '\n' | '\r'));
455        match self {
456            Kind::Integer => is_xs_integer(value),
457            Kind::Real => is_xs_double(value),
458            Kind::Boolean => matches!(value, "true" | "false"),
459            Kind::Logical => matches!(value, "true" | "false" | "unknown"),
460            Kind::String | Kind::Unchecked | Kind::NotComparable(_) => true,
461        }
462    }
463
464    fn is_string(self) -> bool {
465        matches!(self, Kind::String | Kind::Unchecked)
466    }
467
468    fn describe(self) -> &'static str {
469        match self {
470            Kind::Integer => "an integer",
471            Kind::Real => "a real number",
472            Kind::Boolean => "a boolean (true or false)",
473            Kind::Logical => "a logical (true, false or unknown)",
474            Kind::String => "a string",
475            Kind::Unchecked => "an enumerated value",
476            Kind::NotComparable(what) => what,
477        }
478    }
479}
480
481/// The names a value admits, as far as the audit can tell.
482enum Names {
483    /// A `simpleValue` or an enumeration: exactly these.
484    Listed(Vec<String>),
485    /// A pattern, filtered against a known universe: these matched.
486    Matched(Vec<String>),
487    /// Something the audit cannot enumerate.
488    Unknown,
489}
490
491fn names<'u>(value: &Value, universe: impl Iterator<Item = &'u str>) -> Names {
492    match value {
493        Value::Simple(value) => Names::Listed(vec![value.clone()]),
494        Value::Restriction(restriction) if !restriction.enumeration.is_empty() => {
495            Names::Listed(restriction.enumeration.clone())
496        }
497        Value::Restriction(restriction) if !restriction.patterns.is_empty() => {
498            let Ok(patterns) = restriction
499                .patterns
500                .iter()
501                .map(|p| pattern::compile(p))
502                .collect::<Result<Vec<_>, _>>()
503            else {
504                return Names::Unknown;
505            };
506            Names::Matched(
507                universe
508                    .filter(|name| patterns.iter().any(|p| p.is_match(name)))
509                    .map(str::to_owned)
510                    .collect(),
511            )
512        }
513        Value::Restriction(_) => Names::Unknown,
514    }
515}
516
517#[derive(Default)]
518struct Auditor {
519    findings: Vec<AuditFinding>,
520    specification: usize,
521}
522
523impl Auditor {
524    fn report(
525        &mut self,
526        code: AuditCode,
527        path: &str,
528        version: Option<IfcVersion>,
529        message: String,
530    ) {
531        self.findings.push(AuditFinding {
532            code,
533            specification: self.specification,
534            path: path.to_owned(),
535            ifc_version: version,
536            message,
537        });
538    }
539
540    fn audit_specification(&mut self, spec: &Specification) {
541        let applicability = &spec.applicability;
542        if applicability
543            .max_occurs
544            .is_some_and(|max| max < applicability.min_occurs)
545        {
546            self.report(
547                AuditCode::OccurrenceBoundsContradict,
548                "applicability",
549                None,
550                format!(
551                    "minOccurs {} exceeds maxOccurs {}",
552                    applicability.min_occurs,
553                    applicability.max_occurs.unwrap_or_default()
554                ),
555            );
556        }
557        if applicability.max_occurs == Some(0)
558            && spec
559                .requirements
560                .as_ref()
561                .is_some_and(|r| !r.facets.is_empty())
562        {
563            self.report(
564                AuditCode::ProhibitedWithRequirements,
565                "requirements",
566                None,
567                "a prohibited specification fails on any applicable object, so its \
568                 requirements are never checked"
569                    .into(),
570            );
571        }
572
573        // Release-independent checks, once per facet.
574        let facets = facets(spec);
575        for (path, facet) in &facets {
576            self.facet_independent(path, facet);
577        }
578
579        // Release-dependent checks.
580        for &version in &spec.ifc_versions {
581            let release = release(version);
582            let applicable = applicability.facets.iter().find_map(|facet| match facet {
583                Facet::Entity(entity) => Some(entity),
584                _ => None,
585            });
586            let applicable = applicable
587                .and_then(|entity| self.entity(release, "applicability/facets[0]", entity))
588                .map(|(names, _)| names);
589            for (path, facet) in &facets {
590                match facet {
591                    Facet::Entity(entity) if path.starts_with("requirements") => {
592                        let required = self.entity(release, path, entity).map(|(names, _)| names);
593                        if let (Some(required), Some(applicable)) = (&required, &applicable) {
594                            if !required.is_empty()
595                                && !applicable.is_empty()
596                                && !required
597                                    .iter()
598                                    .any(|r| applicable.iter().any(|a| release.can_be(r, a)))
599                            {
600                                self.report(
601                                    AuditCode::EntityRequirementContradictsApplicability,
602                                    path,
603                                    Some(version),
604                                    format!(
605                                        "requires {} but applies to {}; IDS does not match subtypes",
606                                        alternatives(required),
607                                        alternatives(applicable)
608                                    ),
609                                );
610                            }
611                        }
612                    }
613                    Facet::Entity(_) => {}
614                    Facet::PartOf(part_of) => self.part_of(release, path, part_of),
615                    Facet::Attribute(attribute) => {
616                        self.attribute(release, path, attribute, applicable.as_deref());
617                    }
618                    Facet::Property(property) => self.property(release, path, property),
619                    Facet::Classification(_) | Facet::Material(_) => {}
620                }
621            }
622        }
623    }
624
625    fn facet_independent(&mut self, path: &str, facet: &Facet) {
626        let entities: Vec<(&Entity, String)> = match facet {
627            Facet::Entity(entity) => vec![(entity, path.to_owned())],
628            Facet::PartOf(part_of) => vec![(&part_of.entity, format!("{path}/entity"))],
629            _ => Vec::new(),
630        };
631        for (entity, entity_path) in entities {
632            if let Names::Listed(names) = names(&entity.name, std::iter::empty()) {
633                for name in names.iter().filter(|n| **n != n.to_ascii_uppercase()) {
634                    self.report(
635                        AuditCode::EntityNameCase,
636                        &format!("{entity_path}/name"),
637                        None,
638                        format!(
639                            "{name:?} is not upper case; IFC entity names are written as {:?}",
640                            name.to_ascii_uppercase()
641                        ),
642                    );
643                }
644            }
645        }
646        for (value_path, value) in values(facet) {
647            if let Value::Restriction(restriction) = value {
648                self.restriction(&format!("{path}/{value_path}"), restriction);
649            }
650        }
651    }
652
653    /// Checks an entity facet against a release; returns the known names it
654    /// can match, or `None` when they cannot be enumerated.
655    fn entity(
656        &mut self,
657        release: &Release,
658        path: &str,
659        entity: &Entity,
660    ) -> Option<(Vec<String>, bool)> {
661        let version = Some(release.version);
662        let known = match names(&entity.name, release.entities.iter().map(String::as_str)) {
663            Names::Listed(names) => {
664                let mut known = Vec::new();
665                for name in names {
666                    if name != name.to_ascii_uppercase() {
667                        continue; // reported once as EntityNameCase
668                    }
669                    if release.has_entity(&name) {
670                        known.push(name);
671                    } else {
672                        self.report(
673                            AuditCode::EntityUnknown,
674                            &format!("{path}/name"),
675                            version,
676                            format!("{} does not define {name}", release.version),
677                        );
678                    }
679                }
680                if entity.predefined_type.is_some() {
681                    for name in &known {
682                        if !release.has_predefined_type(name) {
683                            self.report(
684                                AuditCode::PredefinedTypeUnavailable,
685                                &format!("{path}/predefinedType"),
686                                version,
687                                format!(
688                                    "{name} has no PredefinedType in {}, neither itself nor \
689                                     through a type entity",
690                                    release.version
691                                ),
692                            );
693                        }
694                    }
695                }
696                (known, true)
697            }
698            Names::Matched(names) => {
699                if names.is_empty() {
700                    self.report(
701                        AuditCode::EntityPatternMatchesNothing,
702                        &format!("{path}/name"),
703                        version,
704                        format!("the pattern matches no entity of {}", release.version),
705                    );
706                }
707                (names, false)
708            }
709            Names::Unknown => return None,
710        };
711        Some(known)
712    }
713
714    fn attribute(
715        &mut self,
716        release: &Release,
717        path: &str,
718        attribute: &Attribute,
719        applicable: Option<&[String]>,
720    ) {
721        let Some(applicable) = applicable.filter(|a| !a.is_empty()) else {
722            return;
723        };
724        let version = Some(release.version);
725        let entities: Vec<String> = applicable
726            .iter()
727            .filter_map(|name| release.resolve(name).map(|(occurrence, _)| occurrence))
728            .collect();
729        let mut universe: BTreeSet<&str> = BTreeSet::new();
730        for entity in &entities {
731            universe.extend(release.schema.attribute_names(entity));
732        }
733        let name_path = format!("{path}/name");
734        let matched: Vec<String> = match names(&attribute.name, universe.iter().copied()) {
735            Names::Listed(names) => names
736                .into_iter()
737                .filter(|name| {
738                    let explicit = entities
739                        .iter()
740                        .any(|e| release.has_attribute(e, name) && !release.is_derived(e, name));
741                    if !explicit {
742                        let derived = entities.iter().any(|e| release.is_derived(e, name));
743                        let (code, message) = if derived {
744                            (
745                                AuditCode::AttributeDerived,
746                                format!("{name} is derived on {}", alternatives(applicable)),
747                            )
748                        } else {
749                            (
750                                AuditCode::AttributeUnknown,
751                                format!(
752                                    "{} has no explicit attribute {name} in {}",
753                                    alternatives(applicable),
754                                    release.version
755                                ),
756                            )
757                        };
758                        self.report(code, &name_path, version, message);
759                    }
760                    explicit
761                })
762                .collect(),
763            Names::Matched(names) => {
764                let names: Vec<String> = names
765                    .into_iter()
766                    .filter(|n| !entities.iter().any(|e| release.is_derived(e, n)))
767                    .collect();
768                if names.is_empty() {
769                    self.report(
770                        AuditCode::AttributePatternMatchesNothing,
771                        &name_path,
772                        version,
773                        format!(
774                            "the pattern matches no explicit attribute of {}",
775                            alternatives(applicable)
776                        ),
777                    );
778                }
779                names
780            }
781            Names::Unknown => return,
782        };
783        let Some(value) = &attribute.value else {
784            return;
785        };
786        let kinds: Vec<Kind> = matched
787            .iter()
788            .flat_map(|name| {
789                entities.iter().flat_map(move |entity| {
790                    release
791                        .schema
792                        .attributes(entity)
793                        .into_iter()
794                        .filter(move |a| a.name == *name)
795                        .map(|a| {
796                            if a.aggregate {
797                                Kind::NotComparable("a list")
798                            } else {
799                                release.kind_of_type(&a.type_name)
800                            }
801                        })
802                })
803            })
804            .collect();
805        if kinds.is_empty() {
806            return;
807        }
808        if let Some(Kind::NotComparable(what)) = kinds
809            .iter()
810            .copied()
811            .find(|k| matches!(k, Kind::NotComparable(_)))
812            .filter(|_| kinds.iter().all(|k| matches!(k, Kind::NotComparable(_))))
813        {
814            self.report(
815                AuditCode::AttributeValueNotComparable,
816                &format!("{path}/value"),
817                version,
818                format!("the attribute holds {what}, which never equals a value"),
819            );
820            return;
821        }
822        let comparable: Vec<Kind> = kinds
823            .into_iter()
824            .filter(|k| !matches!(k, Kind::NotComparable(_)))
825            .collect();
826        self.value(&format!("{path}/value"), version, value, &comparable);
827    }
828
829    fn property(&mut self, release: &Release, path: &str, property: &Property) {
830        let version = Some(release.version);
831        let mut kinds = Vec::new();
832        if let Some(data_type) = &property.data_type {
833            if release.is_defined_type(data_type) {
834                kinds.push(release.kind_of_type(data_type));
835            } else {
836                self.report(
837                    AuditCode::DataTypeUnknown,
838                    &format!("{path}/@dataType"),
839                    version,
840                    format!("{data_type} is not a defined type of {}", release.version),
841                );
842            }
843        }
844        if let Some(value) = &property.value {
845            self.value(&format!("{path}/value"), version, value, &kinds);
846        }
847        #[cfg(feature = "audit")]
848        self.standard_set(release, path, property);
849    }
850
851    #[cfg(feature = "audit")]
852    fn standard_set(&mut self, release: &Release, path: &str, property: &Property) {
853        let Some(catalog) = &release.templates else {
854            return;
855        };
856        let Names::Listed(sets) = names(&property.property_set, std::iter::empty()) else {
857            return;
858        };
859        let version = Some(release.version);
860        for set in sets
861            .iter()
862            .filter(|s| s.starts_with("Pset_") || s.starts_with("Qto_"))
863        {
864            let Some(template) = catalog.get(set) else {
865                self.report(
866                    AuditCode::StandardSetUnknown,
867                    &format!("{path}/propertySet"),
868                    version,
869                    format!(
870                        "{set} uses a reserved prefix but is not a standard set of {}",
871                        release.version
872                    ),
873                );
874                continue;
875            };
876            let members: Vec<(String, Option<String>)> = match &template.kind {
877                SetTemplateKind::Property { properties, .. } => properties
878                    .iter()
879                    .map(|p| (p.name.clone(), property_data_type(&p.kind)))
880                    .collect(),
881                SetTemplateKind::Quantity { quantities, .. } => quantities
882                    .iter()
883                    .map(|q| (q.name.clone(), quantity_data_type(q.kind)))
884                    .collect(),
885                _ => continue,
886            };
887            let Names::Listed(base_names) = names(&property.base_name, std::iter::empty()) else {
888                continue;
889            };
890            for base_name in base_names {
891                match members.iter().find(|(name, _)| *name == base_name) {
892                    None => self.report(
893                        AuditCode::PropertyNotInStandardSet,
894                        &format!("{path}/baseName"),
895                        version,
896                        format!("{set} of {} has no property {base_name}", release.version),
897                    ),
898                    Some((_, Some(expected))) => {
899                        if let Some(data_type) = &property.data_type {
900                            if !data_type.eq_ignore_ascii_case(expected) {
901                                self.report(
902                                    AuditCode::PropertyDataTypeMismatch,
903                                    &format!("{path}/@dataType"),
904                                    version,
905                                    format!(
906                                        "{set}.{base_name} is {} in {}, not {data_type}",
907                                        expected.to_ascii_uppercase(),
908                                        release.version
909                                    ),
910                                );
911                            }
912                        }
913                    }
914                    Some((_, None)) => {}
915                }
916            }
917        }
918    }
919
920    fn part_of(&mut self, release: &Release, path: &str, part_of: &PartOf) {
921        let entity_path = format!("{path}/entity");
922        // Only named wholes are judged: a pattern may well match entities
923        // the relation cannot have alongside ones it can.
924        let Some((wholes, true)) = self.entity(release, &entity_path, &part_of.entity) else {
925            return;
926        };
927        let Some(relation) = part_of.relation else {
928            return;
929        };
930        let required = match (relation, release.version) {
931            (Relation::Aggregates | Relation::Nests, _) => "IfcObjectDefinition",
932            (Relation::AssignsToGroup, _) => "IfcGroup",
933            (Relation::ContainedInSpatialStructure, IfcVersion::Ifc2x3) => {
934                "IfcSpatialStructureElement"
935            }
936            (Relation::ContainedInSpatialStructure, _) => "IfcSpatialElement",
937            (Relation::VoidsElementFillsElement, _) => "IfcElement",
938        };
939        for whole in &wholes {
940            let Some((occurrence, _)) = release.resolve(whole) else {
941                continue;
942            };
943            if !release.schema.is_a(&occurrence, required) {
944                self.report(
945                    AuditCode::PartOfRelationEntity,
946                    &entity_path,
947                    Some(release.version),
948                    format!(
949                        "{relation} needs a whole that is an {required}; {whole} is not in {}",
950                        release.version
951                    ),
952                );
953            }
954        }
955    }
956
957    /// Checks `value` against the IFC kinds it is compared with; it must fit
958    /// at least one of them.
959    fn value(&mut self, path: &str, version: Option<IfcVersion>, value: &Value, kinds: &[Kind]) {
960        if kinds.is_empty() {
961            return;
962        }
963        let expected = || {
964            kinds
965                .iter()
966                .map(|k| k.describe())
967                .collect::<Vec<_>>()
968                .join(" or ")
969        };
970        match value {
971            Value::Simple(literal) => {
972                if !kinds.iter().any(|k| k.accepts(literal)) {
973                    self.report(
974                        AuditCode::ValueTypeMismatch,
975                        path,
976                        version,
977                        format!("{literal:?} is not {}", expected()),
978                    );
979                }
980            }
981            Value::Restriction(restriction) => {
982                if !restriction.patterns.is_empty() && !kinds.iter().any(|k| k.is_string()) {
983                    self.report(
984                        AuditCode::PatternOnNonString,
985                        path,
986                        version,
987                        format!("a pattern never matches {}", expected()),
988                    );
989                }
990                let literals = restriction.enumeration.iter().chain(
991                    [
992                        &restriction.min_inclusive,
993                        &restriction.max_inclusive,
994                        &restriction.min_exclusive,
995                        &restriction.max_exclusive,
996                    ]
997                    .into_iter()
998                    .flatten(),
999                );
1000                for literal in literals {
1001                    if !kinds.iter().any(|k| k.accepts(literal)) {
1002                        self.report(
1003                            AuditCode::ValueTypeMismatch,
1004                            path,
1005                            version,
1006                            format!("{literal:?} is not {}", expected()),
1007                        );
1008                    }
1009                }
1010            }
1011        }
1012    }
1013
1014    fn restriction(&mut self, path: &str, restriction: &Restriction) {
1015        for pattern in &restriction.patterns {
1016            if let Err(reason) = pattern::compile(pattern) {
1017                self.report(
1018                    AuditCode::PatternUnverified,
1019                    path,
1020                    None,
1021                    format!("the pattern {pattern:?} was not evaluated: {reason}"),
1022                );
1023            }
1024        }
1025        let number = |v: &Option<String>| v.as_deref().and_then(|v| v.trim().parse::<f64>().ok());
1026        let lower = [
1027            (number(&restriction.min_inclusive), false),
1028            (number(&restriction.min_exclusive), true),
1029        ];
1030        let upper = [
1031            (number(&restriction.max_inclusive), false),
1032            (number(&restriction.max_exclusive), true),
1033        ];
1034        let mut contradiction = None;
1035        for (low, low_exclusive) in lower {
1036            for (high, high_exclusive) in upper {
1037                if let (Some(low), Some(high)) = (low, high) {
1038                    let empty = if low_exclusive || high_exclusive {
1039                        low >= high
1040                    } else {
1041                        low > high
1042                    };
1043                    if empty {
1044                        contradiction = Some(format!("no value lies between {low} and {high}"));
1045                    }
1046                }
1047            }
1048        }
1049        let lengths = (
1050            restriction.length,
1051            restriction.min_length,
1052            restriction.max_length,
1053        );
1054        match lengths {
1055            (_, Some(min), Some(max)) if min > max => {
1056                contradiction = Some(format!("minLength {min} exceeds maxLength {max}"));
1057            }
1058            (Some(length), Some(min), _) if length < min => {
1059                contradiction = Some(format!("length {length} is below minLength {min}"));
1060            }
1061            (Some(length), _, Some(max)) if length > max => {
1062                contradiction = Some(format!("length {length} exceeds maxLength {max}"));
1063            }
1064            _ => {}
1065        }
1066        if let Some(message) = contradiction {
1067            self.report(AuditCode::RestrictionBoundsContradict, path, None, message);
1068        }
1069    }
1070}
1071
1072/// `A or B or C`, shortened when long.
1073fn alternatives(names: &[String]) -> String {
1074    const SHOWN: usize = 5;
1075    let mut text = names
1076        .iter()
1077        .take(SHOWN)
1078        .map(String::as_str)
1079        .collect::<Vec<_>>()
1080        .join(" or ");
1081    if names.len() > SHOWN {
1082        text.push_str(&format!(" (and {} more)", names.len() - SHOWN));
1083    }
1084    text
1085}
1086
1087/// Every facet of a specification with its path, in document order.
1088fn facets(spec: &Specification) -> Vec<(String, &Facet)> {
1089    let mut facets: Vec<(String, &Facet)> = spec
1090        .applicability
1091        .facets
1092        .iter()
1093        .enumerate()
1094        .map(|(i, facet)| (format!("applicability/facets[{i}]"), facet))
1095        .collect();
1096    if let Some(requirements) = &spec.requirements {
1097        facets.extend(
1098            requirements
1099                .facets
1100                .iter()
1101                .enumerate()
1102                .map(|(i, r)| (format!("requirements/facets[{i}]"), &r.facet)),
1103        );
1104    }
1105    facets
1106}
1107
1108/// Every value of a facet with its path relative to the facet.
1109fn values(facet: &Facet) -> Vec<(&'static str, &Value)> {
1110    let values: Vec<(&'static str, Option<&Value>)> = match facet {
1111        Facet::Entity(entity) => vec![
1112            ("name", Some(&entity.name)),
1113            ("predefinedType", entity.predefined_type.as_ref()),
1114        ],
1115        Facet::PartOf(part_of) => vec![
1116            ("entity/name", Some(&part_of.entity.name)),
1117            (
1118                "entity/predefinedType",
1119                part_of.entity.predefined_type.as_ref(),
1120            ),
1121        ],
1122        Facet::Classification(classification) => vec![
1123            ("value", classification.value.as_ref()),
1124            ("system", Some(&classification.system)),
1125        ],
1126        Facet::Attribute(attribute) => vec![
1127            ("name", Some(&attribute.name)),
1128            ("value", attribute.value.as_ref()),
1129        ],
1130        Facet::Property(property) => vec![
1131            ("propertySet", Some(&property.property_set)),
1132            ("baseName", Some(&property.base_name)),
1133            ("value", property.value.as_ref()),
1134        ],
1135        Facet::Material(material) => vec![("value", material.value.as_ref())],
1136    };
1137    values
1138        .into_iter()
1139        .filter_map(|(path, value)| value.map(|value| (path, value)))
1140        .collect()
1141}
1142
1143/// The IDS `dataType` a standard property template states, upper case.
1144#[cfg(feature = "audit")]
1145fn property_data_type(kind: &PropertyKind) -> Option<String> {
1146    let data_type = match kind {
1147        PropertyKind::SingleValue { data_type }
1148        | PropertyKind::BoundedValue { data_type }
1149        | PropertyKind::ListValue { data_type } => Some(data_type),
1150        PropertyKind::EnumeratedValue { data_type, .. } => data_type.as_ref(),
1151        _ => None,
1152    }?;
1153    data_type.type_name.as_ref().map(|t| t.to_ascii_uppercase())
1154}
1155
1156/// The IDS `dataType` of a standard quantity's value.
1157#[cfg(feature = "audit")]
1158fn quantity_data_type(kind: QuantityKind) -> Option<String> {
1159    let name = match kind {
1160        QuantityKind::Length => "IFCLENGTHMEASURE",
1161        QuantityKind::Area => "IFCAREAMEASURE",
1162        QuantityKind::Volume => "IFCVOLUMEMEASURE",
1163        QuantityKind::Weight => "IFCMASSMEASURE",
1164        QuantityKind::Time => "IFCTIMEMEASURE",
1165        QuantityKind::Count => "IFCCOUNTMEASURE",
1166        QuantityKind::Number => "IFCNUMERICMEASURE",
1167        _ => return None,
1168    };
1169    Some(name.to_owned())
1170}
1171
1172/// `xs:integer`'s lexical space.
1173fn is_xs_integer(value: &str) -> bool {
1174    let digits = value.strip_prefix(['+', '-']).unwrap_or(value);
1175    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
1176}
1177
1178/// `xs:double`'s lexical space, which includes `xs:decimal`'s.
1179fn is_xs_double(value: &str) -> bool {
1180    if matches!(value, "INF" | "+INF" | "-INF" | "NaN") {
1181        return true;
1182    }
1183    let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
1184    let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
1185        Some(at) => (&unsigned[..at], Some(&unsigned[at + 1..])),
1186        None => (unsigned, None),
1187    };
1188    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
1189    let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
1190    let mantissa_ok =
1191        (!whole.is_empty() || !fraction.is_empty()) && digits(whole) && digits(fraction);
1192    mantissa_ok && exponent.is_none_or(is_xs_integer)
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198
1199    #[test]
1200    fn numbers_follow_the_xsd_lexical_spaces() {
1201        for good in ["42", "+42", "-0", "007"] {
1202            assert!(is_xs_integer(good), "{good}");
1203        }
1204        for bad in ["42.0", "42.", "4 2", "", "+", "1e3"] {
1205            assert!(!is_xs_integer(bad), "{bad}");
1206        }
1207        for good in ["42", "42.", ".5", "-1.5e-3", "1E10", "INF", "-INF", "NaN"] {
1208            assert!(is_xs_double(good), "{good}");
1209        }
1210        for bad in ["42,3", "123,4.5", "", ".", "1e", "e3", "inf", "1.2.3"] {
1211            assert!(!is_xs_double(bad), "{bad}");
1212        }
1213    }
1214}