Skip to main content

eui_core/
catalog.rs

1//! The catalog as data: `core.catalog.json` (embedded by the generator), the
2//! catalog ids, token groups, and the merged VIEW of the core plus registered
3//! extensions every lookup goes through. Mirrors `src/catalog.ts`.
4
5use std::sync::{Arc, LazyLock};
6
7use indexmap::IndexMap;
8use serde_json::Value;
9
10use crate::generated::catalog as g;
11use crate::types::{CatalogSource, ComponentDef, DefSchema, ExtensionDef, MacroDef};
12
13/// `https://ui.exponential.at/catalogs/core/v1`
14pub const CORE_CATALOG_ID: &str = g::CATALOG_ID;
15/// The prompt-sized subset: a lite surface is a valid core surface.
16pub const CORE_LITE_CATALOG_ID: &str = g::LITE_CATALOG_ID;
17/// The vendored A2UI basic catalog.
18pub const A2UI_BASIC_CATALOG_ID: &str = g::BASIC_CATALOG_ID;
19/// The A2UI wire `version` the vendored schemas carry.
20pub const A2UI_VERSION: &str = g::BASIC_VERSION;
21/// The placeholder component painted for anything unmapped.
22pub const UNKNOWN_COMPONENT: &str = g::UNKNOWN_COMPONENT;
23/// What a client advertises as `supportedCatalogIds`.
24pub const SUPPORTED_CATALOG_IDS: &[&str] = &[CORE_CATALOG_ID, CORE_LITE_CATALOG_ID, A2UI_BASIC_CATALOG_ID];
25
26/// The parsed core catalog, once per process. VAPP-106: the RUNTIME catalog,
27/// without the model-facing descriptions and examples (`descriptions()`).
28pub static CORE: LazyLock<CatalogSource> =
29    LazyLock::new(|| serde_json::from_str(g::CORE_CATALOG_JSON).expect("core.catalog.json"));
30
31/// VAPP-106: the catalog's model-facing text (`generated/core.descriptions.json`
32/// of the package: per component its description, example and prop
33/// descriptions; per def; per function), keyed like the catalog, for a host
34/// that builds its own prompt. Parsed once; only with the `descriptions`
35/// feature.
36#[cfg(feature = "descriptions")]
37pub fn descriptions() -> &'static Value {
38    static DESCRIPTIONS: LazyLock<Value> =
39        LazyLock::new(|| serde_json::from_str(crate::generated::descriptions::CORE_CATALOG_DESCRIPTIONS_JSON).expect("core.descriptions.json"));
40    &DESCRIPTIONS
41}
42
43#[derive(serde::Deserialize)]
44struct MacrosFile {
45    macros: IndexMap<String, MacroDef>,
46}
47
48/// The core macro table, once per process.
49pub static CORE_MACROS: LazyLock<IndexMap<String, MacroDef>> =
50    LazyLock::new(|| serde_json::from_str::<MacrosFile>(g::MACROS_JSON).expect("macros.json").macros);
51
52/// Token groups → names, flattened (`type.size`, not nested), in source order.
53pub static TOKEN_GROUPS: LazyLock<IndexMap<String, Vec<String>>> = LazyLock::new(|| {
54    let file: IndexMap<String, Value> = serde_json::from_str(g::TOKENS_JSON).expect("tokens.json");
55    let mut out = IndexMap::new();
56    for (group, value) in file {
57        if group.starts_with('$') {
58            continue;
59        }
60        match value {
61            Value::Array(names) => {
62                out.insert(group, names.iter().filter_map(|v| v.as_str().map(str::to_string)).collect());
63            }
64            Value::Object(subs) => {
65                // serde_json's maps are sorted; the source order comes from the
66                // generated flat group list instead.
67                let order: Vec<&str> = g::TOKEN_GROUPS.iter().filter_map(|k| k.strip_prefix(&format!("{group}."))).collect();
68                let mut subs: Vec<(String, Value)> = subs.into_iter().collect();
69                subs.sort_by_key(|(sub, _)| order.iter().position(|o| *o == sub).unwrap_or(usize::MAX));
70                for (sub, names) in subs {
71                    let names = names.as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
72                    out.insert(format!("{group}.{sub}"), names);
73                }
74            }
75            _ => {}
76        }
77    }
78    out
79});
80
81/// `$spacing.md` → `("spacing", "md")`, `$type.size.sm` → `("type.size", "sm")`;
82/// `None` when the string is not a well-formed reference.
83pub fn parse_token_ref_str(value: &str) -> Option<(String, String)> {
84    let body = value.strip_prefix('$')?;
85    let (group, name) = body.rsplit_once('.')?;
86    let ident = |s: &str, first_lower: bool| {
87        let mut chars = s.chars();
88        let first = chars.next()?;
89        let ok_first = if first_lower { first.is_ascii_lowercase() } else { first.is_ascii_alphanumeric() };
90        if !ok_first || !chars.all(|c| c.is_ascii_alphanumeric()) {
91            return None;
92        }
93        Some(())
94    };
95    for segment in group.split('.') {
96        ident(segment, true)?;
97    }
98    ident(name, false)?;
99    Some((group.to_string(), name.to_string()))
100}
101
102/// [`parse_token_ref_str`] over a JSON value.
103pub fn parse_token_ref(value: &Value) -> Option<(String, String)> {
104    value.as_str().and_then(parse_token_ref_str)
105}
106
107/// True when the reference names a token group AND a name in it.
108pub fn is_known_token(value: &Value) -> bool {
109    match parse_token_ref(value) {
110        Some((group, name)) => TOKEN_GROUPS.get(&group).is_some_and(|names| names.contains(&name)),
111        None => false,
112    }
113}
114
115/// The core merged with registered extensions. Extension names never shadow
116/// a core name (`define_extension` refuses that), so a plain overlay is exact.
117#[derive(Debug, Clone)]
118pub struct CatalogView {
119    pub components: IndexMap<String, ComponentDef>,
120    pub enums: IndexMap<String, Vec<String>>,
121    pub defs: IndexMap<String, DefSchema>,
122    pub macros: IndexMap<String, MacroDef>,
123    /// The extension ids this view was built with, in registration order.
124    pub extension_ids: Vec<String>,
125    /// Extension component name → the extension id that defines it.
126    pub owner: IndexMap<String, String>,
127}
128
129static CORE_VIEW: LazyLock<Arc<CatalogView>> = LazyLock::new(|| Arc::new(CatalogView::build(&[])));
130
131impl CatalogView {
132    fn build(extensions: &[ExtensionDef]) -> CatalogView {
133        let core = &*CORE;
134        let mut view = CatalogView {
135            components: core.components.clone(),
136            enums: core.enums.clone(),
137            defs: core.defs.clone(),
138            macros: CORE_MACROS.clone(),
139            extension_ids: Vec::new(),
140            owner: IndexMap::new(),
141        };
142        for ext in extensions {
143            for (k, v) in &ext.components {
144                view.components.insert(k.clone(), v.clone());
145                view.owner.insert(k.clone(), ext.id.clone());
146            }
147            for (k, v) in ext.enums.iter().flatten() {
148                view.enums.insert(k.clone(), v.clone());
149            }
150            for (k, v) in ext.defs.iter().flatten() {
151                view.defs.insert(k.clone(), v.clone());
152            }
153            for (k, v) in ext.macros.iter().flatten() {
154                view.macros.insert(k.clone(), v.clone());
155            }
156            view.extension_ids.push(ext.id.clone());
157        }
158        view
159    }
160
161    /// The core alone (shared, built once).
162    pub fn core() -> Arc<CatalogView> {
163        CORE_VIEW.clone()
164    }
165
166    /// The core plus the given extensions (the core alone when none).
167    pub fn with(extensions: &[ExtensionDef]) -> Arc<CatalogView> {
168        if extensions.is_empty() {
169            Self::core()
170        } else {
171            Arc::new(Self::build(extensions))
172        }
173    }
174
175    pub fn component(&self, name: &str) -> Option<&ComponentDef> {
176        self.components.get(name)
177    }
178
179    /// Is `catalog_id` one this view can reduce (core, lite, basic, an extension)?
180    pub fn knows_catalog(&self, catalog_id: &str) -> bool {
181        catalog_id == CORE_CATALOG_ID
182            || catalog_id == CORE_LITE_CATALOG_ID
183            || catalog_id == A2UI_BASIC_CATALOG_ID
184            || self.extension_ids.iter().any(|id| id == catalog_id)
185    }
186
187    /// The extension that owns `component`, if it is not a core one.
188    pub fn extension_of(&self, component: &str) -> Option<&str> {
189        if CORE.components.contains_key(component) {
190            return None;
191        }
192        self.owner.get(component).map(String::as_str)
193    }
194}
195
196/// Component names in source order, optionally the lite subset, never the
197/// hidden placeholder.
198pub fn component_names(lite: bool) -> Vec<String> {
199    CORE.components
200        .iter()
201        .filter(|(_, def)| def.is_offered() && (!lite || def.lite))
202        .map(|(name, _)| name.clone())
203        .collect()
204}
205
206/// The component definition in the core (no extensions).
207pub fn component_def(name: &str) -> Option<&'static ComponentDef> {
208    CORE.components.get(name)
209}