Skip to main content

cimmodel/
schema_source.rs

1//! Where a family's class table comes from.
2//!
3//! By default it is the table `cimgen` generated. With the `dynamic-schema`
4//! feature, an ENTSO-E RDFS directory can supply it instead, so a new profile
5//! 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_RDFS_DIR` — for the CLI and the Python bindings. It may list
11//!    several directories, separated as in `PATH`; each serves the family
12//!    whose vocabularies it holds (see [`family_of_dir`]), so NC's directory
13//!    alone leaves CGMES on its generated table
14//! 3. the generated table
15//!
16//! A directory that is missing or fails to parse falls back to the generated
17//! table **with a warning**. Silently serving a stale schema because a path had
18//! a typo is the worst outcome available here.
19
20use crate::base::Schema;
21
22/// Resolve the schema for a family.
23///
24/// `generated` is the table `cimgen` emitted, used unless something overrides it.
25pub fn resolve(family: &'static str, generated: &'static Schema) -> &'static Schema {
26    #[cfg(feature = "dynamic-schema")]
27    {
28        dynamic::resolve(family, generated)
29    }
30    #[cfg(not(feature = "dynamic-schema"))]
31    {
32        let _ = family;
33        generated
34    }
35}
36
37#[cfg(feature = "dynamic-schema")]
38pub use dynamic::{family_of_dir, load_from, load_table, SchemaError, RDFS_DIR_ENV};
39
40#[cfg(feature = "dynamic-schema")]
41mod dynamic {
42    use std::collections::HashMap;
43    use std::path::{Path, PathBuf};
44    use std::sync::atomic::{AtomicBool, Ordering};
45    use std::sync::{Mutex, OnceLock};
46
47    use cimschema::family::{self, Family};
48    use cimschema::model::{CimAttribute, CimSpecification};
49
50    use crate::base::{AttrDef, AttrKind, ClassDef, EnumDef, EnumValueDef, Schema};
51
52    /// Environment variable naming RDFS directories — one, or several separated
53    /// as in `PATH`, each serving the family whose vocabularies it holds.
54    pub const RDFS_DIR_ENV: &str = "CIMOXIDE_RDFS_DIR";
55
56    #[derive(Debug)]
57    pub enum SchemaError {
58        /// The registry was already built, so this load would have been ignored.
59        TooLate,
60        /// A table was already loaded for this family.
61        AlreadyLoaded,
62        /// No family with this id.
63        UnknownFamily(String),
64        Parse(String),
65    }
66
67    impl std::fmt::Display for SchemaError {
68        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69            match self {
70                Self::TooLate => write!(
71                    f,
72                    "the type registry is already built; load the schema before decoding"
73                ),
74                Self::AlreadyLoaded => write!(f, "a schema is already loaded for this family"),
75                Self::UnknownFamily(id) => write!(f, "unknown family: {id}"),
76                Self::Parse(e) => write!(f, "{e}"),
77            }
78        }
79    }
80
81    impl std::error::Error for SchemaError {}
82
83    fn explicit() -> &'static Mutex<HashMap<&'static str, &'static Schema>> {
84        static E: OnceLock<Mutex<HashMap<&'static str, &'static Schema>>> = OnceLock::new();
85        E.get_or_init(|| Mutex::new(HashMap::new()))
86    }
87
88    /// Set once `resolve` has run, so a late [`load_from`] can report that it
89    /// would have had no effect instead of silently doing nothing.
90    static RESOLVED: AtomicBool = AtomicBool::new(false);
91
92    /// Load a family's class table from a directory of RDFS vocabularies.
93    ///
94    /// Takes precedence over `CIMOXIDE_RDFS_DIR`. Must be called before the
95    /// first decode: the registry is memoized, so afterwards this returns
96    /// [`SchemaError::TooLate`] rather than being quietly ignored.
97    pub fn load_from(family_id: &str, dir: &Path) -> Result<(), SchemaError> {
98        let family = family::by_id(family_id)
99            
100            .ok_or_else(|| SchemaError::UnknownFamily(family_id.to_string()))?;
101        if RESOLVED.load(Ordering::SeqCst) {
102            return Err(SchemaError::TooLate);
103        }
104        let classes = load_table(family.id, dir)?;
105        let mut map = explicit().lock().unwrap();
106        if map.contains_key(family.id) {
107            return Err(SchemaError::AlreadyLoaded);
108        }
109        map.insert(family.id, classes);
110        Ok(())
111    }
112
113    pub(super) fn resolve(
114        family_id: &'static str,
115        generated: &'static Schema,
116    ) -> &'static Schema {
117        RESOLVED.store(true, Ordering::SeqCst);
118
119        if let Some(classes) = explicit().lock().unwrap().get(family_id) {
120            return classes;
121        }
122
123        let Some(dirs) = env_dirs() else {
124            return generated;
125        };
126        let Some(family) = family::by_id(family_id) else {
127            return generated;
128        };
129        let Some(dir) = dirs.iter().find(|d| family_of_dir(d).is_some_and(|f| f.id == family.id)) else {
130            return generated;
131        };
132        match load_family(family, dir) {
133            Ok(classes) => classes,
134            Err(e) => {
135                eprintln!(
136                    "warning: {RDFS_DIR_ENV} set but the {family_id} schema could not be \
137                     loaded ({e}); falling back to the generated class table"
138                );
139                generated
140            }
141        }
142    }
143
144    /// The directories `CIMOXIDE_RDFS_DIR` lists, or `None` when it is unset. A
145    /// directory holding neither family's vocabularies is reported once.
146    fn env_dirs() -> Option<&'static [PathBuf]> {
147        static DIRS: OnceLock<Option<Vec<PathBuf>>> = OnceLock::new();
148        DIRS.get_or_init(|| {
149            let value = std::env::var_os(RDFS_DIR_ENV)?;
150            let dirs: Vec<PathBuf> = std::env::split_paths(&value).collect();
151            for d in &dirs {
152                if family_of_dir(d).is_none() {
153                    eprintln!(
154                        "warning: {RDFS_DIR_ENV} lists {}, which holds no CGMES or NC RDFS \
155                         vocabularies; ignoring it",
156                        d.display()
157                    );
158                }
159            }
160            Some(dirs)
161        })
162        .as_deref()
163    }
164
165    /// Which family's vocabularies an RDFS directory holds, by the families'
166    /// file-name patterns. NC's pattern (`*-AP-Voc-RDFS2020.rdf`) also matches
167    /// CGMES's files, so CGMES's narrower one is tried first.
168    pub fn family_of_dir(dir: &Path) -> Option<&'static Family> {
169        let names: Vec<String> = std::fs::read_dir(dir)
170            .ok()?
171            .filter_map(|e| e.ok())
172            .map(|e| e.file_name().to_string_lossy().into_owned())
173            .collect();
174        let matches = |f: &Family| {
175            let pattern = Path::new(f.default_schema)
176                .file_name()
177                .map(|n| n.to_string_lossy().into_owned())
178                .unwrap_or_default();
179            let (prefix, suffix) = pattern.split_once('*').unwrap_or((&pattern, ""));
180            names.iter().any(|n| n.starts_with(prefix) && n.ends_with(suffix))
181        };
182        [&family::CGMES, &family::NC].into_iter().find(|f| matches(f))
183    }
184
185    /// Build a family's class table from RDFS without installing it.
186    ///
187    /// Useful for inspecting what a schema directory would produce, and for
188    /// checking it against the generated table.
189    pub fn load_table(family_id: &str, dir: &Path) -> Result<&'static Schema, SchemaError> {
190        let family = family::by_id(family_id)
191            
192            .ok_or_else(|| SchemaError::UnknownFamily(family_id.to_string()))?;
193        load_family(family, dir)
194    }
195
196    fn load_family(
197        family: &'static Family,
198        dir: &Path,
199    ) -> Result<&'static Schema, SchemaError> {
200        // Reuse the family's own filename pattern so a directory of mixed
201        // artefacts selects the same files cimgen would.
202        let file_pattern = Path::new(family.default_schema)
203            .file_name()
204            .map(|n| n.to_string_lossy().into_owned())
205            .unwrap_or_else(|| "*.rdf".to_string());
206        let pattern = dir.join(file_pattern);
207
208        let spec = cimschema::import::import_schema_files(&pattern.to_string_lossy(), family, false)
209            .map_err(|e| SchemaError::Parse(e.to_string()))?;
210        Ok(build(&spec))
211    }
212
213    /// Interns schema strings so they satisfy the `&'static` signatures the
214    /// element trait and registry require. Bounded by schema size and leaked
215    /// once per process.
216    #[derive(Default)]
217    struct Interner {
218        seen: HashMap<String, &'static str>,
219    }
220
221    impl Interner {
222        fn intern(&mut self, s: &str) -> &'static str {
223            if let Some(v) = self.seen.get(s) {
224                return v;
225            }
226            let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
227            self.seen.insert(s.to_string(), leaked);
228            leaked
229        }
230
231        fn intern_all(&mut self, items: &[String]) -> &'static [&'static str] {
232            let v: Vec<&'static str> = items.iter().map(|s| self.intern(s)).collect();
233            Vec::leak(v)
234        }
235    }
236
237    // Classification, ranges, datatypes and namespaces come from
238    // `cimschema::table`, which `cimgen::generator::classes_gen` calls too; the
239    // `runtime_table_matches_generated` tests check the rendering on top.
240    fn attr_kind(a: &CimAttribute) -> AttrKind {
241        match cimschema::table::attr_kind(a) {
242            cimschema::table::Kind::Literal => AttrKind::Literal,
243            cimschema::table::Kind::Association => AttrKind::Association,
244            cimschema::table::Kind::Enum => AttrKind::Enum,
245        }
246    }
247
248    fn build(spec: &CimSpecification) -> &'static Schema {
249        // Sorted key order, because the index in this vector is the class id
250        // that `super_class` refers to.
251        let mut ids: Vec<&String> = spec.types.keys().collect();
252        ids.sort();
253        let index_of: HashMap<&str, usize> = ids
254            .iter()
255            .enumerate()
256            .map(|(i, id)| (id.as_str(), i))
257            .collect();
258
259        let mut interner = Interner::default();
260        let mut out: Vec<ClassDef> = Vec::with_capacity(ids.len());
261
262        for id in &ids {
263            let t = &spec.types[*id];
264            let attrs: Vec<AttrDef> = t
265                .attributes
266                .iter()
267                .map(|a| AttrDef {
268                    id: interner.intern(&a.id),
269                    ns: interner.intern(&a.namespace),
270                    kind: attr_kind(a),
271                    range: interner.intern(cimschema::table::attr_range(a)),
272                    is_list: a.is_list,
273                    used: a.is_association_used,
274                    xsd: cimschema::table::xsd_type(a),
275                    value_ns: interner.intern(cimschema::table::value_namespace(spec, a)),
276                    origins: interner.intern_all(&a.origins),
277                    comment: interner.intern(&a.comment),
278                })
279                .collect();
280
281            let qualified = format!("{}{}", spec.family.type_prefix, t.id);
282            out.push(ClassDef {
283                ns: interner.intern(&t.namespace),
284                local: interner.intern(&t.id),
285                qualified: interner.intern(&qualified),
286                super_class: index_of.get(t.super_type.as_str()).copied(),
287                concrete: !t.concrete_in.is_empty(),
288                attrs: Vec::leak(attrs),
289                origins: interner.intern_all(&t.origins),
290                comment: interner.intern(&t.comment),
291            });
292        }
293
294        let pairs = |interner: &mut Interner, v: Vec<(&str, &str)>| -> &'static [(&'static str, &'static str)] {
295            Vec::leak(v.into_iter().map(|(a, b)| (interner.intern(a), interner.intern(b))).collect())
296        };
297        let profiles = pairs(&mut interner, cimschema::table::profile_uris(spec));
298        let namespaces = pairs(&mut interner, cimschema::table::namespaces(spec));
299        let enums: Vec<EnumDef> = cimschema::table::enums(spec)
300            .into_iter()
301            .map(|e| EnumDef {
302                ns: interner.intern(&e.namespace),
303                local: interner.intern(&e.id),
304                comment: interner.intern(&e.comment),
305                values: Vec::leak(
306                    e.values
307                        .iter()
308                        .map(|v| EnumValueDef { id: interner.intern(&v.id), comment: interner.intern(&v.comment) })
309                        .collect(),
310                ),
311            })
312            .collect();
313        Box::leak(Box::new(Schema { classes: Vec::leak(out), profiles, namespaces, enums: Vec::leak(enums) }))
314    }
315}