Skip to main content

cimvalidation/
shape_source.rs

1//! Where a family's shape table comes from.
2//!
3//! By default it is the table `cimgen` generated. With the `dynamic-shapes`
4//! feature, a directory of SHACL TTL files can supply it instead, so a new
5//! profile version is a data load rather than a recompile.
6//!
7//! Resolution order, highest first:
8//!
9//! 1. [`load_from`] — an explicit call, for libraries and tests
10//! 2. `CIMOXIDE_SHACL_DIR` — for the CLI and the Python bindings. It may list
11//!    several directories, separated as in `PATH`; each serves the family
12//!    whose files it holds (see [`family_of_dir`]), so `…/NCP/SHACL` alone
13//!    still loads only NC, as it always did
14//! 3. the generated table
15//!
16//! CGMES classes are compiled structs, so a CGMES table loaded from another
17//! release can name classes this build cannot decode; shapes on those simply
18//! match nothing.
19//!
20//! A directory that is missing or fails to parse falls back to the generated
21//! table **with a warning**. Silently validating against a stale profile
22//! because a path had a typo is the worst outcome available here.
23//!
24//! This mirrors [`cimmodel::schema_source`] closely enough that the two
25//! should be read side by side. The one difference that matters: there, the
26//! generator and the loader build the table by separate code paths and a test
27//! compares them. Here both call `cimschema::shacl::resolve`, so there is no
28//! second implementation to drift — this module only turns the resolved model
29//! into the `&'static` form the interpreter needs.
30
31use crate::shapes::ShapeDef;
32
33/// Resolve the shape table for a family.
34///
35/// `generated` is the table `cimgen` emitted, used unless something overrides
36/// it.
37pub fn resolve(family: &'static str, generated: &'static [ShapeDef]) -> &'static [ShapeDef] {
38    #[cfg(feature = "dynamic-shapes")]
39    {
40        dynamic::resolve(family, generated)
41    }
42    #[cfg(not(feature = "dynamic-shapes"))]
43    {
44        let _ = family;
45        generated
46    }
47}
48
49/// Resolve the profile index for a bag family.
50///
51/// Loaded from the same directory as the shapes, because a shape table and a
52/// profile index that disagree would run the wrong rules — the index decides
53/// which profile a dataset is, the table decides what that profile checks.
54pub fn resolve_profiles(
55    family: &'static str,
56    generated_iris: &'static [(&'static str, &'static str)],
57    generated_codes: &'static [&'static str],
58) -> (&'static [(&'static str, &'static str)], &'static [&'static str]) {
59    #[cfg(feature = "dynamic-shapes")]
60    {
61        dynamic::resolve_profiles(family, generated_iris, generated_codes)
62    }
63    #[cfg(not(feature = "dynamic-shapes"))]
64    {
65        let _ = family;
66        (generated_iris, generated_codes)
67    }
68}
69
70#[cfg(feature = "dynamic-shapes")]
71pub use dynamic::{family_of_dir, intern_shapes, load_from, load_table, ShapeError, SHACL_DIR_ENV};
72
73#[cfg(feature = "dynamic-shapes")]
74mod dynamic {
75    use std::collections::HashMap;
76    use std::path::{Path, PathBuf};
77    use std::sync::atomic::{AtomicBool, Ordering};
78    use std::sync::{Mutex, OnceLock};
79
80    use cimschema::family::{self, Family};
81    use cimschema::shacl::resolve as r;
82    use cimschema::shacl::skip::SkipCollector;
83
84    use crate::shapes::{
85        AltBranch, Branch, Check, ClosedShape, Constraint, Logic, LogicOp, NodeKind, Path as SPath,
86        PropShape, ShapeDef, Step, Target,
87    };
88
89    /// Environment variable naming SHACL constraint directories — one, or
90    /// several separated as in `PATH`, each serving the family whose files it
91    /// holds.
92    pub const SHACL_DIR_ENV: &str = "CIMOXIDE_SHACL_DIR";
93
94    #[derive(Debug)]
95    pub enum ShapeError {
96        /// The table was already resolved, so this load would have been ignored.
97        TooLate,
98        /// A table was already loaded for this family.
99        AlreadyLoaded,
100        /// No family with this id.
101        UnknownFamily(String),
102        /// The RDFS the shapes resolve against could not be read.
103        Schema(String),
104        Parse(String),
105    }
106
107    impl std::fmt::Display for ShapeError {
108        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109            match self {
110                Self::TooLate => write!(
111                    f,
112                    "the shape table is already resolved; load the shapes before validating"
113                ),
114                Self::AlreadyLoaded => write!(f, "shapes are already loaded for this family"),
115                Self::UnknownFamily(id) => write!(f, "unknown family: {id}"),
116                Self::Schema(e) => write!(f, "cannot read the schema the shapes resolve against: {e}"),
117                Self::Parse(e) => write!(f, "{e}"),
118            }
119        }
120    }
121
122    impl std::error::Error for ShapeError {}
123
124    /// A loaded table: shapes and the profile index that selects them.
125    type Loaded = (
126        &'static [ShapeDef],
127        &'static [(&'static str, &'static str)],
128        &'static [&'static str],
129    );
130
131    fn explicit() -> &'static Mutex<HashMap<&'static str, Loaded>> {
132        static E: OnceLock<Mutex<HashMap<&'static str, Loaded>>> = OnceLock::new();
133        E.get_or_init(|| Mutex::new(HashMap::new()))
134    }
135
136    /// Memoizes what the environment variable produced, so shapes and profiles
137    /// come from one load rather than two.
138    fn from_env() -> &'static Mutex<HashMap<&'static str, Option<Loaded>>> {
139        static E: OnceLock<Mutex<HashMap<&'static str, Option<Loaded>>>> = OnceLock::new();
140        E.get_or_init(|| Mutex::new(HashMap::new()))
141    }
142
143    /// Set once `resolve` has run, so a late [`load_from`] can report that it
144    /// would have had no effect instead of silently doing nothing.
145    static RESOLVED: AtomicBool = AtomicBool::new(false);
146
147    /// Load a bag family's shapes from a directory of SHACL TTL files.
148    ///
149    /// Takes precedence over `CIMOXIDE_SHACL_DIR`. Must be called before the
150    /// first validation: the table is memoized, so afterwards this returns
151    /// [`ShapeError::TooLate`] rather than being quietly ignored.
152    ///
153    /// As in `cimmodel::schema_source`, the guard is best-effort — there is a
154    /// narrow race between the check and the insert — and is documented rather
155    /// than locked, because the contract is "call before validating".
156    pub fn load_from(family_id: &str, dir: &Path) -> Result<(), ShapeError> {
157        let family = bag_family(family_id)?;
158        if RESOLVED.load(Ordering::SeqCst) {
159            return Err(ShapeError::TooLate);
160        }
161        let loaded = load(family, dir)?;
162        let mut map = explicit().lock().unwrap();
163        if map.contains_key(family.id) {
164            return Err(ShapeError::AlreadyLoaded);
165        }
166        map.insert(family.id, loaded);
167        Ok(())
168    }
169
170    /// Build a family's shape table from SHACL without installing it.
171    pub fn load_table(family_id: &str, dir: &Path) -> Result<&'static [ShapeDef], ShapeError> {
172        let family = bag_family(family_id)?;
173        Ok(load(family, dir)?.0)
174    }
175
176    fn bag_family(id: &str) -> Result<&'static Family, ShapeError> {
177        family::by_id(id).ok_or_else(|| ShapeError::UnknownFamily(id.to_string()))
178    }
179
180    /// Which family's constraint files a SHACL directory holds: NC ships
181    /// per-profile manifests in `Validation/`, CGMES files named in its
182    /// written-out manifest. `None` when it is neither.
183    pub fn family_of_dir(dir: &Path) -> Option<&'static Family> {
184        if dir.join("Validation").is_dir() {
185            return family::by_id("nc");
186        }
187        family::FAMILIES.iter().copied().find(|f| {
188            f.shacl_manifest.is_some_and(|m| {
189                m.iter().any(|(stem, _)| dir.join(format!("{stem}.ttl")).is_file())
190            })
191        })
192    }
193
194    pub(super) fn resolve(
195        family_id: &'static str,
196        generated: &'static [ShapeDef],
197    ) -> &'static [ShapeDef] {
198        env_loaded(family_id).map_or(generated, |l| l.0)
199    }
200
201    pub(super) fn resolve_profiles(
202        family_id: &'static str,
203        generated_iris: &'static [(&'static str, &'static str)],
204        generated_codes: &'static [&'static str],
205    ) -> (&'static [(&'static str, &'static str)], &'static [&'static str]) {
206        env_loaded(family_id).map_or((generated_iris, generated_codes), |l| (l.1, l.2))
207    }
208
209    /// The loaded table for this family, from an explicit call or the
210    /// environment, or `None` to use the generated one.
211    fn env_loaded(family_id: &'static str) -> Option<Loaded> {
212        RESOLVED.store(true, Ordering::SeqCst);
213
214        if let Some(l) = explicit().lock().unwrap().get(family_id) {
215            return Some(*l);
216        }
217
218        let mut cache = from_env().lock().unwrap();
219        if let Some(cached) = cache.get(family_id) {
220            return *cached;
221        }
222
223        // The directory, if any, that holds this family's files. A directory
224        // holding neither family's files is reported once, by the first family
225        // to look, rather than silently ignored.
226        let dir: Option<PathBuf> = std::env::var_os(SHACL_DIR_ENV).and_then(|dirs| {
227            let dirs: Vec<PathBuf> = std::env::split_paths(&dirs).collect();
228            static REPORTED: AtomicBool = AtomicBool::new(false);
229            if !REPORTED.swap(true, Ordering::SeqCst) {
230                for d in dirs.iter().filter(|d| family_of_dir(d).is_none()) {
231                    eprintln!(
232                        "warning: {SHACL_DIR_ENV} names {}, which holds no NC or CGMES \
233                         constraint files; ignoring it",
234                        d.display()
235                    );
236                }
237            }
238            dirs.into_iter().find(|d| family_of_dir(d).is_some_and(|f| f.id == family_id))
239        });
240        let loaded = match (dir, bag_family(family_id)) {
241            (Some(dir), Ok(family)) => match load(family, &dir) {
242                Ok(l) => Some(l),
243                Err(e) => {
244                    eprintln!(
245                        "warning: {SHACL_DIR_ENV} names {} but the {family_id} shapes could not \
246                         be loaded ({e}); falling back to the generated shape table",
247                        dir.display()
248                    );
249                    None
250                }
251            },
252            _ => None,
253        };
254        cache.insert(family_id, loaded);
255        loaded
256    }
257
258    /// Read and intern one family's shapes and profile index.
259    ///
260    /// The shapes resolve against the family's RDFS, which
261    /// `cimmodel::schema_source` already knows how to find: a shape naming a
262    /// class this workspace has no schema for cannot be checked, so the two
263    /// have to agree on what the family contains.
264    fn load(family: &'static Family, dir: &Path) -> Result<Loaded, ShapeError> {
265        // Fail on the SHACL directory before the schema one, so a bad path is
266        // reported as what it is rather than as a missing vocabulary.
267        if !dir.is_dir() {
268            return Err(ShapeError::Parse(format!(
269                "not a directory: {}",
270                dir.display()
271            )));
272        }
273        let (spec, others) = specs(family, dir)?;
274        let other_refs: Vec<&cimschema::model::CimSpecification> = others.iter().collect();
275
276        let mut collector = SkipCollector::new();
277        let table = r::load_shape_table(family, &spec, &other_refs, dir, &mut collector)
278            .map_err(|e| ShapeError::Parse(e.to_string()))?;
279
280        let mut interner = Interner::default();
281        let shapes: Vec<ShapeDef> = table
282            .shapes
283            .iter()
284            .map(|s| shape(s, &mut interner))
285            .collect();
286
287        let iris: Vec<(&'static str, &'static str)> = table
288            .profile_iris
289            .iter()
290            .map(|(iri, code)| (interner.intern(iri), interner.intern(code)))
291            .collect();
292        let codes: Vec<&'static str> =
293            table.profiles.iter().map(|c| interner.intern(c)).collect();
294
295        Ok((Vec::leak(shapes), Vec::leak(iris), Vec::leak(codes)))
296    }
297
298    /// Import the RDFS the shapes resolve against.
299    ///
300    /// Found the way `PROF` is: beside the SHACL directory, which is how the
301    /// ENTSO-E library is laid out. That keeps a load self-contained — one
302    /// directory decides both what the classes are and what the rules are —
303    /// and avoids depending on the process's working directory, which the
304    /// family's compiled-in glob does.
305    ///
306    /// `CIMOXIDE_RDFS_DIR` still wins, so a caller already pointing the class
307    /// table somewhere is not silently overridden.
308    fn specs(
309        family: &'static Family,
310        shacl_dir: &Path,
311    ) -> Result<
312        (cimschema::model::CimSpecification, Vec<cimschema::model::CimSpecification>),
313        ShapeError,
314    > {
315        let import = |f: &'static Family, pattern: &str| {
316            cimschema::import::import_schema_files(pattern, f, false)
317                .map_err(|e| ShapeError::Schema(e.to_string()))
318        };
319
320        // `.../NCP/SHACL` → `.../`, the library root every family sits under.
321        let root = shacl_dir.parent().and_then(Path::parent);
322
323        let spec = import(family, &schema_glob(family, shacl_dir, root))?;
324
325        // Every other family, for the cross-family class lists the value-type
326        // rules use: NCP's association value-type shapes name CGMES classes
327        // beside NC ones. A family whose schema cannot be read is skipped so
328        // one missing tree does not fail the load — but it **warns**, because
329        // the failure mode is silent: those classes simply drop out of the
330        // allowed lists, and a reference that should have passed starts
331        // reporting as the wrong class.
332        let mut others = Vec::new();
333        for f in family::FAMILIES.iter().filter(|f| f.id != family.id) {
334            match import(f, &schema_glob(f, shacl_dir, root)) {
335                Ok(s) => others.push(s),
336                Err(e) => eprintln!(
337                    "warning: the {} schema could not be read ({e}); its classes will be \
338                     missing from {}'s cross-family value-type rules",
339                    f.id, family.id
340                ),
341            }
342        }
343        Ok((spec, others))
344    }
345
346    /// Where to look for a family's RDFS.
347    ///
348    /// `CIMOXIDE_RDFS_DIR` wins, so a caller already pointing the class table
349    /// somewhere is not silently overridden — but it names one family's
350    /// directory, so it applies only to the family being loaded.
351    ///
352    /// Otherwise the family's compiled-in glob is re-rooted at the library the
353    /// SHACL directory came from, which keeps a load self-contained and
354    /// independent of the process's working directory.
355    fn schema_glob(f: &'static Family, shacl_dir: &Path, root: Option<&Path>) -> String {
356        let file_name = |p: &str| {
357            Path::new(p)
358                .file_name()
359                .map(|n| n.to_string_lossy().into_owned())
360                .unwrap_or_else(|| "*.rdf".to_string())
361        };
362
363        // Only for the family whose SHACL directory this is.
364        if shacl_dir
365            .parent()
366            .and_then(|p| p.file_name())
367            .zip(Path::new(f.default_schema).parent().and_then(Path::parent).and_then(|p| p.file_name()))
368            .is_some_and(|(a, b)| a == b)
369            && let Some(dir) = std::env::var_os(cimmodel::schema_source::RDFS_DIR_ENV) {
370                return PathBuf::from(dir)
371                    .join(file_name(f.default_schema))
372                    .to_string_lossy()
373                    .into_owned();
374            }
375
376        // `application-profiles-library/CGMES/RDFS/<glob>` → `<root>/CGMES/RDFS/<glob>`.
377        let tail: Option<PathBuf> = Path::new(f.default_schema)
378            .components()
379            .skip(1)
380            .fold(None::<PathBuf>, |acc, c| {
381                Some(acc.map_or_else(|| PathBuf::from(c.as_os_str()), |p| p.join(c.as_os_str())))
382            });
383        match (root, tail) {
384            (Some(root), Some(tail)) => {
385                let candidate = root.join(tail);
386                if candidate
387                    .parent()
388                    .is_some_and(|p| p.is_dir())
389                {
390                    return candidate.to_string_lossy().into_owned();
391                }
392                f.default_schema.to_string()
393            }
394            _ => f.default_schema.to_string(),
395        }
396    }
397
398    /// Convert an already-resolved table into the `&'static` IR, for a caller
399    /// that resolved the shapes itself (e.g. with its own profile mapping).
400    /// Leaked once per call.
401    pub fn intern_shapes(shapes: &[r::ShapeDef]) -> &'static [ShapeDef] {
402        let mut interner = Interner::default();
403        Vec::leak(shapes.iter().map(|s| shape(s, &mut interner)).collect())
404    }
405
406    /// Interns resolved strings so they satisfy the `&'static` signatures the
407    /// IR and the violation types require. Bounded by schema size and leaked
408    /// once per process.
409    #[derive(Default)]
410    struct Interner {
411        seen: HashMap<String, &'static str>,
412    }
413
414    impl Interner {
415        fn intern(&mut self, s: &str) -> &'static str {
416            if let Some(v) = self.seen.get(s) {
417                return v;
418            }
419            let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
420            self.seen.insert(s.to_string(), leaked);
421            leaked
422        }
423
424        fn intern_all(&mut self, items: &[String]) -> &'static [&'static str] {
425            Vec::leak(items.iter().map(|s| self.intern(s)).collect::<Vec<_>>())
426        }
427    }
428
429    // The conversions below are mechanical and total: the resolved model and
430    // the IR have the same shape, and a variant added to one without the other
431    // is a compile error rather than silent drift.
432
433    fn shape(s: &r::ShapeDef, i: &mut Interner) -> ShapeDef {
434        ShapeDef {
435            targets: Vec::leak(s.targets.iter().map(|t| target(t, i)).collect::<Vec<_>>()),
436            props: Vec::leak(
437                s.props
438                    .iter()
439                    .map(|p| &*Box::leak(Box::new(prop(p, i))))
440                    .collect::<Vec<_>>(),
441            ),
442            closed: s.closed.as_ref().map(|c| &*Box::leak(Box::new(closed(c, i)))),
443            logic: Vec::leak(s.logic.iter().map(|l| logic(l, i)).collect::<Vec<_>>()),
444            profiles: i.intern_all(&s.profiles),
445            file: i.intern(&s.file),
446        }
447    }
448
449    fn target(t: &r::Target, i: &mut Interner) -> Target {
450        match t {
451            r::Target::Class(classes) => Target::Class(i.intern_all(classes)),
452            r::Target::SubjectsOf(f) => Target::SubjectsOf(i.intern(f)),
453        }
454    }
455
456    fn prop(p: &r::PropShape, i: &mut Interner) -> PropShape {
457        PropShape {
458            path: path(&p.path, i),
459            checks: Vec::leak(p.checks.iter().map(|c| check(c, i)).collect::<Vec<_>>()),
460        }
461    }
462
463    fn path(p: &r::Path, i: &mut Interner) -> SPath {
464        match p {
465            r::Path::Forward(f) => SPath::Forward(i.intern(f)),
466            r::Path::Inverse(f) => SPath::Inverse(i.intern(f)),
467            r::Path::Chain(steps) => SPath::Chain(Vec::leak(
468                steps
469                    .iter()
470                    .map(|s| match s {
471                        r::Step::Forward(f) => Step::Forward(i.intern(f)),
472                        r::Step::Inverse(f) => Step::Inverse(i.intern(f)),
473                        r::Step::Type => Step::Type,
474                    })
475                    .collect::<Vec<_>>(),
476            )),
477            r::Path::Alternative(branches) => SPath::Alternative(Vec::leak(
478                branches
479                    .iter()
480                    .map(|b| match b {
481                        r::AltBranch::Forward(f) => AltBranch::Forward(i.intern(f)),
482                        r::AltBranch::Inverse(f) => AltBranch::Inverse(i.intern(f)),
483                    })
484                    .collect::<Vec<_>>(),
485            )),
486        }
487    }
488
489    fn check(c: &r::Check, i: &mut Interner) -> Check {
490        Check {
491            constraint: constraint(&c.constraint, i),
492            rule_id: i.intern(&c.rule_id),
493            name: i.intern(&c.name),
494            message: i.intern(&c.message),
495            description: i.intern(&c.description),
496            severity: i.intern(&c.severity),
497        }
498    }
499
500    fn constraint(c: &r::Constraint, i: &mut Interner) -> Constraint {
501        match c {
502            r::Constraint::MinCount(n) => Constraint::MinCount(*n),
503            r::Constraint::MaxCount(n) => Constraint::MaxCount(*n),
504            r::Constraint::MaxLength(n) => Constraint::MaxLength(*n),
505            r::Constraint::MinLength(n) => Constraint::MinLength(*n),
506            r::Constraint::Datatype(d) => Constraint::Datatype(i.intern(d)),
507            r::Constraint::HasValue(v) => Constraint::HasValue(i.intern(v)),
508            r::Constraint::NodeKind(k) => Constraint::NodeKind(match k {
509                r::NodeKind::Iri => NodeKind::Iri,
510                r::NodeKind::Literal => NodeKind::Literal,
511                r::NodeKind::BlankNode => NodeKind::BlankNode,
512            }),
513            r::Constraint::Class(v) => Constraint::Class(i.intern_all(v)),
514            r::Constraint::RefClass(v) => Constraint::RefClass(i.intern_all(v)),
515            r::Constraint::In(v) => Constraint::In(i.intern_all(v)),
516            r::Constraint::MinInclusive(v) => Constraint::MinInclusive(*v),
517            r::Constraint::MaxInclusive(v) => Constraint::MaxInclusive(*v),
518            r::Constraint::MinExclusive(v) => Constraint::MinExclusive(*v),
519            r::Constraint::MaxExclusive(v) => Constraint::MaxExclusive(*v),
520            r::Constraint::LessThan(f) => Constraint::LessThan(i.intern(f)),
521            r::Constraint::LessThanOrEquals(f) => Constraint::LessThanOrEquals(i.intern(f)),
522            r::Constraint::NotClass(v) => Constraint::NotClass(i.intern_all(v)),
523            r::Constraint::Length(n) => Constraint::Length(*n),
524            r::Constraint::QualifiedIn { allowed, min } => {
525                Constraint::QualifiedIn { allowed: i.intern_all(allowed), min: *min }
526            }
527        }
528    }
529
530    fn logic(l: &r::Logic, i: &mut Interner) -> Logic {
531        Logic {
532            op: match l.op {
533                r::LogicOp::And => LogicOp::And,
534                r::LogicOp::Or => LogicOp::Or,
535                r::LogicOp::Xone => LogicOp::Xone,
536            },
537            branches: Vec::leak(
538                l.branches
539                    .iter()
540                    .map(|b| Branch {
541                        props: Vec::leak(b.props.iter().map(|p| prop(p, i)).collect::<Vec<_>>()),
542                        negate: b.negate,
543                    })
544                    .collect::<Vec<_>>(),
545            ),
546            rule_id: i.intern(&l.rule_id),
547            name: i.intern(&l.name),
548            message: i.intern(&l.message),
549            description: i.intern(&l.description),
550            severity: i.intern(&l.severity),
551        }
552    }
553
554    fn closed(c: &r::ClosedShape, i: &mut Interner) -> ClosedShape {
555        ClosedShape {
556            allowed: i.intern_all(&c.allowed),
557            rule_id: i.intern(&c.rule_id),
558            name: i.intern(&c.name),
559            message: i.intern(&c.message),
560            description: i.intern(&c.description),
561            severity: i.intern(&c.severity),
562        }
563    }
564}