Skip to main content

haystack_core/xeto/
export.rs

1//! Xeto export — serialize Specs back to .xeto text format.
2
3use crate::kinds::Kind;
4use crate::xeto::spec::{Slot, Spec};
5
6/// Export a single Spec to Xeto source text.
7pub fn export_spec(spec: &Spec) -> String {
8    let mut out = String::new();
9
10    // Doc comment
11    if !spec.doc.is_empty() {
12        for line in spec.doc.lines() {
13            out.push_str(&format!("// {}\n", line));
14        }
15    }
16
17    // Spec declaration: Name: Base <meta> {
18    out.push_str(&spec.name);
19    if let Some(ref base) = spec.base {
20        out.push_str(": ");
21        // Use short name if qualified
22        let base_short = base.split("::").last().unwrap_or(base);
23        out.push_str(base_short);
24    } else {
25        out.push_str(": Obj");
26    }
27
28    // Meta tags (excluding "abstract" which is handled separately)
29    let meta_tags: Vec<String> = spec
30        .meta
31        .iter()
32        .filter(|(k, _)| k.as_str() != "abstract")
33        .map(|(k, v)| format_meta_tag(k, v))
34        .collect();
35    if spec.is_abstract || !meta_tags.is_empty() {
36        out.push_str(" <");
37        let mut parts = Vec::new();
38        if spec.is_abstract {
39            parts.push("abstract".to_string());
40        }
41        parts.extend(meta_tags);
42        out.push_str(&parts.join(", "));
43        out.push('>');
44    }
45
46    // Slots
47    if spec.slots.is_empty() {
48        out.push('\n');
49    } else {
50        out.push_str(" {\n");
51        for slot in &spec.slots {
52            export_slot(&mut out, slot, 2);
53        }
54        out.push_str("}\n");
55    }
56
57    out
58}
59
60/// Export a library pragma + all its specs to Xeto source text.
61pub fn export_lib(
62    _lib_name: &str,
63    version: &str,
64    doc: &str,
65    depends: &[String],
66    specs: &[&Spec],
67) -> String {
68    let mut out = String::new();
69
70    // Pragma
71    out.push_str("pragma: Lib <\n");
72    if !doc.is_empty() {
73        out.push_str(&format!("  doc: \"{}\"\n", escape_xeto_str(doc)));
74    }
75    out.push_str(&format!("  version: \"{}\"\n", escape_xeto_str(version)));
76    if !depends.is_empty() {
77        out.push_str("  depends: {\n");
78        for dep in depends {
79            out.push_str(&format!("    {{ lib: \"{}\" }}\n", escape_xeto_str(dep)));
80        }
81        out.push_str("  }\n");
82    }
83    out.push_str(">\n\n");
84
85    // Specs
86    for spec in specs {
87        out.push_str(&export_spec(spec));
88        out.push('\n');
89    }
90
91    out
92}
93
94fn export_slot(out: &mut String, slot: &Slot, indent: usize) {
95    let pad = " ".repeat(indent);
96    out.push_str(&pad);
97
98    if slot.is_marker {
99        // Marker slot: just the name, with optional ?
100        out.push_str(&slot.name);
101        if slot.is_maybe() {
102            out.push('?');
103        }
104    } else if slot.is_query {
105        // Query slot: name: Query<of:Type, via:"path">
106        out.push_str(&slot.name);
107        out.push_str(": Query");
108        let mut query_parts = Vec::new();
109        if let Some(Kind::Str(of)) = slot.meta.get("of") {
110            query_parts.push(format!("of:{}", of));
111        }
112        if let Some(Kind::Str(via)) = slot.meta.get("via") {
113            query_parts.push(format!("via:\"{}\"", via));
114        }
115        if let Some(Kind::Str(inv)) = slot.meta.get("inverse") {
116            query_parts.push(format!("inverse:\"{}\"", inv));
117        }
118        if !query_parts.is_empty() {
119            out.push('<');
120            out.push_str(&query_parts.join(", "));
121            out.push('>');
122        }
123    } else {
124        // Typed slot: name: Type <meta>
125        out.push_str(&slot.name);
126        out.push_str(": ");
127        if let Some(ref t) = slot.type_ref {
128            let short = t.split("::").last().unwrap_or(t);
129            out.push_str(short);
130        } else {
131            out.push_str("Obj");
132        }
133        if slot.is_maybe() {
134            out.push('?');
135        }
136
137        // Slot meta (excluding "maybe", "of", "via", "inverse" which are rendered differently)
138        let slot_meta: Vec<String> = slot
139            .meta
140            .iter()
141            .filter(|(k, _)| {
142                let k = k.as_str();
143                k != "maybe" && k != "of" && k != "via" && k != "inverse"
144            })
145            .map(|(k, v)| format_meta_tag(k, v))
146            .collect();
147        if !slot_meta.is_empty() {
148            out.push_str(" <");
149            out.push_str(&slot_meta.join(", "));
150            out.push('>');
151        }
152    }
153
154    // Default value
155    if let Some(ref val) = slot.default {
156        match val {
157            Kind::Str(s) => out.push_str(&format!(" \"{}\"", escape_xeto_str(s))),
158            Kind::Number(n) => out.push_str(&format!(" {}", n)),
159            _ => out.push_str(&format!(" {}", val)),
160        }
161    }
162
163    out.push('\n');
164
165    // Nested children
166    if !slot.children.is_empty() {
167        out.push_str(&format!("{}  {{\n", pad));
168        for child in &slot.children {
169            export_slot(out, child, indent + 4);
170        }
171        out.push_str(&format!("{}  }}\n", pad));
172    }
173}
174
175fn escape_xeto_str(s: &str) -> String {
176    s.replace('\\', "\\\\")
177        .replace('"', "\\\"")
178        .replace('\n', "\\n")
179        .replace('\t', "\\t")
180}
181
182fn format_meta_tag(key: &str, val: &Kind) -> String {
183    match val {
184        Kind::Marker => key.to_string(),
185        Kind::Str(s) => format!("{}: \"{}\"", key, escape_xeto_str(s)),
186        Kind::Number(n) => format!("{}: {}", key, n),
187        Kind::Bool(b) => format!("{}: {}", key, b),
188        _ => format!("{}: {}", key, val),
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::collections::HashMap;
196
197    #[test]
198    fn export_simple_spec() {
199        let mut spec = Spec::new("test::Foo", "test", "Foo");
200        spec.slots.push(Slot {
201            name: "active".into(),
202            type_ref: None,
203            meta: HashMap::new(),
204            default: None,
205            is_marker: true,
206            is_query: false,
207            children: vec![],
208        });
209        let output = export_spec(&spec);
210        assert!(output.contains("Foo: Obj"));
211        assert!(output.contains("active"));
212    }
213
214    #[test]
215    fn export_abstract_spec() {
216        let mut spec = Spec::new("test::Base", "test", "Base");
217        spec.is_abstract = true;
218        let output = export_spec(&spec);
219        assert!(output.contains("<abstract>"));
220    }
221
222    #[test]
223    fn export_spec_with_typed_slots() {
224        let mut spec = Spec::new("test::Site", "test", "Site");
225        spec.slots.push(Slot {
226            name: "dis".into(),
227            type_ref: Some("Str".into()),
228            meta: HashMap::new(),
229            default: None,
230            is_marker: false,
231            is_query: false,
232            children: vec![],
233        });
234        let output = export_spec(&spec);
235        assert!(output.contains("dis: Str"));
236    }
237
238    #[test]
239    fn export_spec_with_query_slot() {
240        let mut meta = HashMap::new();
241        meta.insert("of".into(), Kind::Str("Point".into()));
242        meta.insert("via".into(), Kind::Str("equipRef+".into()));
243        let mut spec = Spec::new("test::Equip", "test", "Equip");
244        spec.slots.push(Slot {
245            name: "points".into(),
246            type_ref: None,
247            meta,
248            default: None,
249            is_marker: false,
250            is_query: true,
251            children: vec![],
252        });
253        let output = export_spec(&spec);
254        assert!(output.contains("Query"));
255        assert!(output.contains("of:Point"));
256        assert!(output.contains("via:\"equipRef+\""));
257    }
258
259    #[test]
260    fn export_lib_with_pragma() {
261        let spec = Spec::new("mylib::Thing", "mylib", "Thing");
262        let output = export_lib("mylib", "2.0.0", "My library", &["sys".into()], &[&spec]);
263        assert!(output.contains("pragma: Lib"));
264        assert!(output.contains("version: \"2.0.0\""));
265        assert!(output.contains("doc: \"My library\""));
266        assert!(output.contains("lib: \"sys\""));
267        assert!(output.contains("Thing: Obj"));
268    }
269
270    #[test]
271    fn export_roundtrip() {
272        use crate::xeto::parser::parse_xeto;
273
274        let source = "Foo: Obj {\n  active\n  dis: Str\n}\n";
275        let xf = parse_xeto(source).unwrap();
276        let spec = crate::xeto::spec::spec_from_def(&xf.specs[0], "test");
277        let exported = export_spec(&spec);
278        // Re-parse the exported text
279        let xf2 = parse_xeto(&exported).unwrap();
280        assert_eq!(xf2.specs[0].name, "Foo");
281        assert_eq!(xf2.specs[0].slots.len(), 2);
282    }
283
284    #[test]
285    fn export_spec_with_doc() {
286        let mut spec = Spec::new("test::Foo", "test", "Foo");
287        spec.doc = "A foo thing\nWith multiple lines".into();
288        let output = export_spec(&spec);
289        assert!(output.contains("// A foo thing"));
290        assert!(output.contains("// With multiple lines"));
291    }
292
293    #[test]
294    fn export_maybe_slot() {
295        let mut meta = HashMap::new();
296        meta.insert("maybe".into(), Kind::Marker);
297        let mut spec = Spec::new("test::Foo", "test", "Foo");
298        spec.slots.push(Slot {
299            name: "optional".into(),
300            type_ref: None,
301            meta: meta.clone(),
302            default: None,
303            is_marker: true,
304            is_query: false,
305            children: vec![],
306        });
307        spec.slots.push(Slot {
308            name: "optStr".into(),
309            type_ref: Some("Str".into()),
310            meta,
311            default: None,
312            is_marker: false,
313            is_query: false,
314            children: vec![],
315        });
316        let output = export_spec(&spec);
317        assert!(output.contains("optional?"));
318        assert!(output.contains("optStr: Str?"));
319    }
320
321    #[test]
322    fn format_meta_tag_escapes_strings() {
323        let result = format_meta_tag(
324            "doc",
325            &Kind::Str("has \"quotes\" and \\backslash".to_string()),
326        );
327        assert_eq!(result, r#"doc: "has \"quotes\" and \\backslash""#);
328    }
329
330    #[test]
331    fn format_meta_tag_escapes_newlines_and_tabs() {
332        let result = format_meta_tag("note", &Kind::Str("line1\nline2\there".to_string()));
333        assert_eq!(result, r#"note: "line1\nline2\there""#);
334    }
335
336    #[test]
337    fn export_slot_default_value_escapes_strings() {
338        let mut spec = Spec::new("test::Esc", "test", "Esc");
339        spec.slots.push(Slot {
340            name: "greeting".into(),
341            type_ref: Some("Str".into()),
342            meta: HashMap::new(),
343            default: Some(Kind::Str("say \"hello\"".to_string())),
344            is_marker: false,
345            is_query: false,
346            children: vec![],
347        });
348        let output = export_spec(&spec);
349        assert!(
350            output.contains(r#""say \"hello\"""#),
351            "default value string should be escaped, got: {}",
352            output
353        );
354    }
355}