Skip to main content

haystack_core/xeto/
spec.rs

1// Xeto resolved Spec and Slot types.
2
3use std::collections::HashMap;
4
5use crate::kinds::Kind;
6
7/// A resolved slot within a Spec.
8#[derive(Debug, Clone)]
9pub struct Slot {
10    /// Slot name.
11    pub name: String,
12    /// Type reference (e.g. `"Str"`, `"Number"`).
13    pub type_ref: Option<String>,
14    /// Metadata tags.
15    pub meta: HashMap<String, Kind>,
16    /// Default value.
17    pub default: Option<Kind>,
18    /// Whether this slot is a marker (no type/value).
19    pub is_marker: bool,
20    /// Whether this slot is a query.
21    pub is_query: bool,
22    /// Nested child slots.
23    pub children: Vec<Slot>,
24}
25
26impl Slot {
27    /// Returns true if this slot has the "maybe" meta tag (optional).
28    pub fn is_maybe(&self) -> bool {
29        self.meta.contains_key("maybe")
30    }
31}
32
33/// A resolved Xeto spec (type definition).
34#[derive(Debug, Clone)]
35pub struct Spec {
36    /// Fully qualified name (`"lib::Name"`).
37    pub qname: String,
38    /// Short name (`"Name"`).
39    pub name: String,
40    /// Library name (`"lib"`).
41    pub lib: String,
42    /// Base spec qualified name.
43    pub base: Option<String>,
44    /// Metadata tags.
45    pub meta: HashMap<String, Kind>,
46    /// Slots (child tag definitions).
47    pub slots: Vec<Slot>,
48    /// Whether this spec is abstract.
49    pub is_abstract: bool,
50    /// Documentation string.
51    pub doc: String,
52}
53
54impl Spec {
55    /// Create a new spec with the given qualified name.
56    pub fn new(qname: impl Into<String>, lib: impl Into<String>, name: impl Into<String>) -> Self {
57        Self {
58            qname: qname.into(),
59            name: name.into(),
60            lib: lib.into(),
61            base: None,
62            meta: HashMap::new(),
63            slots: Vec::new(),
64            is_abstract: false,
65            doc: String::new(),
66        }
67    }
68
69    /// All marker slot names.
70    pub fn markers(&self) -> Vec<&str> {
71        self.slots
72            .iter()
73            .filter(|s| s.is_marker)
74            .map(|s| s.name.as_str())
75            .collect()
76    }
77
78    /// Mandatory marker slot names (those without "maybe" meta).
79    pub fn mandatory_markers(&self) -> Vec<&str> {
80        self.slots
81            .iter()
82            .filter(|s| s.is_marker && !s.is_maybe())
83            .map(|s| s.name.as_str())
84            .collect()
85    }
86
87    /// Collect all slots including inherited ones from the base chain.
88    pub fn effective_slots(&self, specs: &HashMap<String, Spec>) -> Vec<Slot> {
89        let mut visited = std::collections::HashSet::new();
90        self.collect_effective_slots(specs, &mut visited)
91    }
92
93    fn collect_effective_slots(
94        &self,
95        specs: &HashMap<String, Spec>,
96        visited: &mut std::collections::HashSet<String>,
97    ) -> Vec<Slot> {
98        if !visited.insert(self.qname.clone()) {
99            return Vec::new();
100        }
101        let mut inherited: Vec<Slot> = Vec::new();
102        if let Some(ref base_name) = self.base
103            && let Some(base_spec) = specs.get(base_name)
104        {
105            inherited = base_spec.collect_effective_slots(specs, visited);
106        }
107        let own_names: std::collections::HashSet<&str> =
108            self.slots.iter().map(|s| s.name.as_str()).collect();
109        let mut result: Vec<Slot> = inherited
110            .into_iter()
111            .filter(|s| !own_names.contains(s.name.as_str()))
112            .collect();
113        result.extend(self.slots.iter().cloned());
114        result
115    }
116
117    /// Children of the "points" slot, if present.
118    pub fn point_specs(&self) -> Vec<&Slot> {
119        self.slots
120            .iter()
121            .find(|s| s.name == "points")
122            .map(|s| s.children.iter().collect())
123            .unwrap_or_default()
124    }
125}
126
127/// Convert an AST `SlotDef` into a resolved `Slot`.
128impl From<&super::ast::SlotDef> for Slot {
129    fn from(def: &super::ast::SlotDef) -> Self {
130        Slot {
131            name: def.name.clone(),
132            type_ref: def.type_ref.clone(),
133            meta: def.meta.clone(),
134            default: def.default.clone(),
135            is_marker: def.is_marker,
136            is_query: def.is_query,
137            children: def.children.iter().map(Slot::from).collect(),
138        }
139    }
140}
141
142/// Convert an AST `SpecDef` into a resolved `Spec`.
143pub fn spec_from_def(def: &super::ast::SpecDef, lib_name: &str) -> Spec {
144    let qname = format!("{}::{}", lib_name, def.name);
145    let is_abstract = def.meta.contains_key("abstract");
146    Spec {
147        qname,
148        name: def.name.clone(),
149        lib: lib_name.to_string(),
150        base: def.base.clone(),
151        meta: def.meta.clone(),
152        slots: def.slots.iter().map(Slot::from).collect(),
153        is_abstract,
154        doc: def.doc.clone(),
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::xeto::ast::SlotDef;
162
163    fn make_marker_slot(name: &str) -> Slot {
164        Slot {
165            name: name.to_string(),
166            type_ref: None,
167            meta: HashMap::new(),
168            default: None,
169            is_marker: true,
170            is_query: false,
171            children: Vec::new(),
172        }
173    }
174
175    fn make_typed_slot(name: &str, type_ref: &str) -> Slot {
176        Slot {
177            name: name.to_string(),
178            type_ref: Some(type_ref.to_string()),
179            meta: HashMap::new(),
180            default: None,
181            is_marker: false,
182            is_query: false,
183            children: Vec::new(),
184        }
185    }
186
187    fn make_maybe_marker(name: &str) -> Slot {
188        let mut meta = HashMap::new();
189        meta.insert("maybe".to_string(), Kind::Marker);
190        Slot {
191            name: name.to_string(),
192            type_ref: None,
193            meta,
194            default: None,
195            is_marker: true,
196            is_query: false,
197            children: Vec::new(),
198        }
199    }
200
201    #[test]
202    fn spec_markers() {
203        let mut spec = Spec::new("test::Ahu", "test", "Ahu");
204        spec.slots.push(make_marker_slot("hot"));
205        spec.slots.push(make_marker_slot("cold"));
206        spec.slots.push(make_typed_slot("dis", "Str"));
207
208        let markers = spec.markers();
209        assert_eq!(markers, vec!["hot", "cold"]);
210    }
211
212    #[test]
213    fn spec_mandatory_markers() {
214        let mut spec = Spec::new("test::Ahu", "test", "Ahu");
215        spec.slots.push(make_marker_slot("hot"));
216        spec.slots.push(make_maybe_marker("cold"));
217
218        let mandatory = spec.mandatory_markers();
219        assert_eq!(mandatory, vec!["hot"]);
220    }
221
222    #[test]
223    fn spec_point_specs() {
224        let mut spec = Spec::new("test::Ahu", "test", "Ahu");
225        let points_slot = Slot {
226            name: "points".to_string(),
227            type_ref: None,
228            meta: HashMap::new(),
229            default: None,
230            is_marker: false,
231            is_query: true,
232            children: vec![
233                make_typed_slot("temp", "Point"),
234                make_typed_slot("flow", "Point"),
235            ],
236        };
237        spec.slots.push(points_slot);
238
239        let points = spec.point_specs();
240        assert_eq!(points.len(), 2);
241        assert_eq!(points[0].name, "temp");
242        assert_eq!(points[1].name, "flow");
243    }
244
245    #[test]
246    fn spec_point_specs_no_points_slot() {
247        let spec = Spec::new("test::Ahu", "test", "Ahu");
248        assert!(spec.point_specs().is_empty());
249    }
250
251    #[test]
252    fn slot_is_maybe() {
253        let mut slot = make_marker_slot("optional");
254        assert!(!slot.is_maybe());
255        slot.meta.insert("maybe".to_string(), Kind::Marker);
256        assert!(slot.is_maybe());
257    }
258
259    #[test]
260    fn slot_from_ast_slot_def() {
261        let mut def = SlotDef::new("dis");
262        def.type_ref = Some("Str".to_string());
263        def.is_marker = false;
264        let slot = Slot::from(&def);
265        assert_eq!(slot.name, "dis");
266        assert_eq!(slot.type_ref.as_deref(), Some("Str"));
267    }
268
269    #[test]
270    fn spec_from_def_conversion() {
271        let mut def = crate::xeto::ast::SpecDef::new("Ahu");
272        def.base = Some("Equip".to_string());
273        def.meta.insert("abstract".to_string(), Kind::Marker);
274        def.doc = "An AHU".to_string();
275
276        let spec = spec_from_def(&def, "phIoT");
277        assert_eq!(spec.qname, "phIoT::Ahu");
278        assert_eq!(spec.name, "Ahu");
279        assert_eq!(spec.lib, "phIoT");
280        assert_eq!(spec.base.as_deref(), Some("Equip"));
281        assert!(spec.is_abstract);
282        assert_eq!(spec.doc, "An AHU");
283    }
284
285    #[test]
286    fn spec_new() {
287        let spec = Spec::new("mylib::Foo", "mylib", "Foo");
288        assert_eq!(spec.qname, "mylib::Foo");
289        assert_eq!(spec.name, "Foo");
290        assert_eq!(spec.lib, "mylib");
291        assert!(spec.base.is_none());
292        assert!(!spec.is_abstract);
293        assert!(spec.slots.is_empty());
294    }
295
296    #[test]
297    fn effective_slots_cycle_does_not_stackoverflow() {
298        // Create two specs that refer to each other as base
299        let mut spec_a = Spec::new("test::A", "test", "A");
300        spec_a.base = Some("test::B".to_string());
301        spec_a.slots.push(make_marker_slot("tagA"));
302
303        let mut spec_b = Spec::new("test::B", "test", "B");
304        spec_b.base = Some("test::A".to_string());
305        spec_b.slots.push(make_marker_slot("tagB"));
306
307        let mut specs = HashMap::new();
308        specs.insert("test::A".to_string(), spec_a.clone());
309        specs.insert("test::B".to_string(), spec_b.clone());
310
311        // Should not stack overflow — returns own slots plus whatever it can
312        // safely collect before hitting the cycle.
313        let slots_a = spec_a.effective_slots(&specs);
314        let names_a: Vec<&str> = slots_a.iter().map(|s| s.name.as_str()).collect();
315        assert!(names_a.contains(&"tagA"), "A should have its own tagA");
316
317        let slots_b = spec_b.effective_slots(&specs);
318        let names_b: Vec<&str> = slots_b.iter().map(|s| s.name.as_str()).collect();
319        assert!(names_b.contains(&"tagB"), "B should have its own tagB");
320    }
321
322    #[test]
323    fn effective_slots_includes_inherited() {
324        let mut parent = Spec::new("test::Parent", "test", "Parent");
325        parent.slots.push(make_marker_slot("equip"));
326        parent.slots.push(make_typed_slot("dis", "Str"));
327
328        let mut child = Spec::new("test::Child", "test", "Child");
329        child.base = Some("test::Parent".to_string());
330        child.slots.push(make_marker_slot("ahu"));
331        // Override dis with own version
332        child.slots.push(make_typed_slot("dis", "Str"));
333
334        let mut specs = HashMap::new();
335        specs.insert("test::Parent".to_string(), parent);
336        specs.insert("test::Child".to_string(), child.clone());
337
338        let effective = child.effective_slots(&specs);
339        let names: Vec<&str> = effective.iter().map(|s| s.name.as_str()).collect();
340
341        // Should include inherited "equip" plus own "ahu" and "dis" (own overrides parent)
342        assert!(names.contains(&"equip"), "should inherit equip from parent");
343        assert!(names.contains(&"ahu"), "should have own ahu");
344        assert!(names.contains(&"dis"), "should have dis");
345        // No duplicates for overridden "dis"
346        let dis_count = names.iter().filter(|n| **n == "dis").count();
347        assert_eq!(dis_count, 1, "dis should not be duplicated");
348    }
349}