Skip to main content

synx_core/
lib.rs

1//! # SYNX Core — The Active Data Format
2//!
3//! High-performance SYNX parser with full `!active` engine support.
4//! Single Rust crate powering all language bindings.
5//!
6//! ```rust
7//! use synx_core::{Synx, Value};
8//!
9//! let data = Synx::parse("name Wario\nage 30\nactive true");
10//! assert_eq!(data["name"], Value::String("Wario".into()));
11//! assert_eq!(data["age"], Value::Int(30));
12//! ```
13
14mod value;
15mod parser;
16mod engine;
17mod calc;
18pub(crate) mod rng;
19pub mod binary;
20pub mod diff;
21pub mod schema_json;
22#[cfg(feature = "wasm")]
23pub mod wasm;
24#[cfg(feature = "signing")]
25pub mod signing;
26
27pub use value::{Value, Mode, ParseResult, Meta, MetaMap, Options, Constraints, IncludeDirective, UseDirective};
28pub use schema_json::{metadata_to_json_schema, value_to_json_value};
29#[cfg(feature = "jsonschema")]
30pub use schema_json::{validate_serde_json, validate_with_json_schema};
31pub use parser::{parse, reshape_tool_output};
32pub use engine::resolve;
33pub use calc::safe_calc;
34pub use diff::{diff as diff_objects, DiffResult, DiffChange, diff_to_value};
35
36/// Main entry point for the SYNX parser.
37pub struct Synx;
38
39impl Synx {
40    /// Parse a SYNX string into a key-value map (static mode only).
41    pub fn parse(text: &str) -> std::collections::HashMap<String, Value> {
42        let result = parse(text);
43        match result.root {
44            Value::Object(map) => map,
45            _ => std::collections::HashMap::new(),
46        }
47    }
48
49    /// Parse with full engine resolution (!active mode).
50    pub fn parse_active(text: &str, opts: &Options) -> std::collections::HashMap<String, Value> {
51        let mut result = parse(text);
52        if result.mode == Mode::Active {
53            resolve(&mut result, opts);
54        }
55        match result.root {
56            Value::Object(map) => map,
57            _ => std::collections::HashMap::new(),
58        }
59    }
60
61    /// Parse and return full result including mode and metadata.
62    pub fn parse_full(text: &str) -> ParseResult {
63        parse(text)
64    }
65
66    /// Parse a `!tool` call: returns `{ tool: "name", params: { ... } }`.
67    ///
68    /// If the text is also `!active`, markers (`:env`, `:default`, etc.)
69    /// are resolved before reshaping.
70    pub fn parse_tool(text: &str, opts: &Options) -> std::collections::HashMap<String, Value> {
71        let mut result = parse(text);
72        if result.mode == Mode::Active {
73            resolve(&mut result, opts);
74        }
75        let shaped = reshape_tool_output(&result.root, result.schema);
76        match shaped {
77            Value::Object(map) => map,
78            _ => std::collections::HashMap::new(),
79        }
80    }
81
82    /// Stringify a Value back to SYNX format.
83    pub fn stringify(value: &Value) -> String {
84        serialize(value, 0)
85    }
86
87    /// Reformat a .synx string into canonical form:
88    /// - Keys sorted alphabetically at every nesting level
89    /// - Exactly 2 spaces per indentation level
90    /// - One blank line between top-level blocks (objects / lists)
91    /// - Comments stripped — canonical form is comment-free
92    /// - Directive lines (`!active`, `!lock`) preserved at the top
93    ///
94    /// The same data always produces byte-for-byte identical output,
95    /// making `.synx` files deterministic and noise-free in `git diff`.
96    pub fn format(text: &str) -> String {
97        fmt_canonical(text)
98    }
99
100    /// Compile a `.synx` string into compact binary `.synxb` format.
101    ///
102    /// If `resolved` is true, active markers are resolved first (requires
103    /// `!active` mode) and metadata is stripped from the output.
104    pub fn compile(text: &str, resolved: bool) -> Vec<u8> {
105        let mut result = parse(text);
106        if resolved && result.mode == Mode::Active {
107            resolve(&mut result, &Options::default());
108        }
109        binary::compile(&result, resolved)
110    }
111
112    /// Decompile a `.synxb` binary back into a human-readable `.synx` string.
113    pub fn decompile(data: &[u8]) -> Result<String, String> {
114        let result = binary::decompile(data)?;
115        let mut out = String::new();
116        if result.tool {
117            out.push_str("!tool\n");
118        }
119        if result.schema {
120            out.push_str("!schema\n");
121        }
122        if result.llm {
123            out.push_str("!llm\n");
124        }
125        if result.mode == Mode::Active {
126            out.push_str("!active\n");
127        }
128        if result.locked {
129            out.push_str("!lock\n");
130        }
131        if !out.is_empty() {
132            out.push('\n');
133        }
134        out.push_str(&serialize(&result.root, 0));
135        Ok(out)
136    }
137
138    /// Check if data is a `.synxb` binary file.
139    pub fn is_synxb(data: &[u8]) -> bool {
140        binary::is_synxb(data)
141    }
142
143    /// Structural diff between two parsed SYNX objects.
144    ///
145    /// Returns added / removed / changed / unchanged keys.
146    pub fn diff(
147        a: &std::collections::HashMap<String, Value>,
148        b: &std::collections::HashMap<String, Value>,
149    ) -> DiffResult {
150        diff::diff(a, b)
151    }
152}
153
154/// Nesting depth for `serialize` / stringify (prevents stack blowup on pathological `Value` trees).
155const MAX_SERIALIZE_DEPTH: usize = 128;
156
157fn serialize(value: &Value, depth_lvl: usize) -> String {
158    if depth_lvl > MAX_SERIALIZE_DEPTH {
159        return "[synx:max-depth]\n".to_string();
160    }
161    let indent = depth_lvl * 2;
162    match value {
163        Value::Object(map) => {
164            let mut out = String::new();
165            let spaces = " ".repeat(indent);
166            // Sort keys for deterministic output
167            let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
168            keys.sort_unstable();
169            for key in keys {
170                let val = &map[key];
171                match val {
172                    Value::Array(arr) => {
173                        out.push_str(&spaces);
174                        out.push_str(key);
175                        out.push('\n');
176                        for item in arr {
177                            match item {
178                                Value::Object(inner) => {
179                                    let entries: Vec<_> = inner.iter().collect();
180                                    if let Some((k, v)) = entries.first() {
181                                        out.push_str(&spaces);
182                                        out.push_str("  - ");
183                                        out.push_str(k);
184                                        out.push(' ');
185                                        out.push_str(&format_primitive(v));
186                                        out.push('\n');
187                                        for (k, v) in entries.iter().skip(1) {
188                                            out.push_str(&spaces);
189                                            out.push_str("    ");
190                                            out.push_str(k);
191                                            out.push(' ');
192                                            out.push_str(&format_primitive(v));
193                                            out.push('\n');
194                                        }
195                                    }
196                                }
197                                _ => {
198                                    out.push_str(&spaces);
199                                    out.push_str("  - ");
200                                    out.push_str(&format_primitive(item));
201                                    out.push('\n');
202                                }
203                            }
204                        }
205                    }
206                    Value::Object(_) => {
207                        out.push_str(&spaces);
208                        out.push_str(key);
209                        out.push('\n');
210                        out.push_str(&serialize(val, depth_lvl + 1));
211                    }
212                    Value::String(s) if s.contains('\n') => {
213                        out.push_str(&spaces);
214                        out.push_str(key);
215                        out.push_str(" |\n");
216                        for line in s.lines() {
217                            out.push_str(&spaces);
218                            out.push_str("  ");
219                            out.push_str(line);
220                            out.push('\n');
221                        }
222                    }
223                    _ => {
224                        out.push_str(&spaces);
225                        out.push_str(key);
226                        out.push(' ');
227                        out.push_str(&format_primitive(val));
228                        out.push('\n');
229                    }
230                }
231            }
232            out
233        }
234        _ => format_primitive(value),
235    }
236}
237
238fn format_primitive(value: &Value) -> String {
239    match value {
240        Value::String(s) => s.clone(),
241        Value::Int(n) => n.to_string(),
242        Value::Float(f) => {
243            let s = f.to_string();
244            if s.contains('.') { s } else { format!("{}.0", s) }
245        }
246        Value::Bool(b) => b.to_string(),
247        Value::Null => "null".to_string(),
248        Value::Array(arr) => {
249            let items: Vec<String> = arr.iter().map(format_primitive).collect();
250            format!("[{}]", items.join(", "))
251        }
252        Value::Object(_) => "[Object]".to_string(),
253        Value::Secret(_) => "[SECRET]".to_string(),
254    }
255}
256
257/// Max nesting for JSON emission (matches stringify guard).
258const MAX_JSON_DEPTH: usize = 128;
259
260/// Write a Value as JSON string (for FFI output).
261pub fn write_json(out: &mut String, val: &Value) {
262    write_json_depth(out, val, 0);
263}
264
265fn write_json_depth(out: &mut String, val: &Value, depth: usize) {
266    if depth > MAX_JSON_DEPTH {
267        out.push_str("null");
268        return;
269    }
270    match val {
271        Value::Null => out.push_str("null"),
272        Value::Bool(true) => out.push_str("true"),
273        Value::Bool(false) => out.push_str("false"),
274        Value::Int(n) => {
275            let mut buf = itoa::Buffer::new();
276            out.push_str(buf.format(*n));
277        }
278        Value::Float(f) => {
279            let mut buf = ryu::Buffer::new();
280            out.push_str(buf.format(*f));
281        }
282        Value::String(s) | Value::Secret(s) => {
283            out.push('"');
284            for ch in s.chars() {
285                match ch {
286                    '"' => out.push_str("\\\""),
287                    '\\' => out.push_str("\\\\"),
288                    '\n' => out.push_str("\\n"),
289                    '\r' => out.push_str("\\r"),
290                    '\t' => out.push_str("\\t"),
291                    c if (c as u32) < 0x20 => {
292                        out.push_str(&format!("\\u{:04x}", c as u32));
293                    }
294                    c => out.push(c),
295                }
296            }
297            out.push('"');
298        }
299        Value::Array(arr) => {
300            out.push('[');
301            for (i, item) in arr.iter().enumerate() {
302                if i > 0 { out.push(','); }
303                write_json_depth(out, item, depth + 1);
304            }
305            out.push(']');
306        }
307        Value::Object(map) => {
308            out.push('{');
309            let mut first = true;
310            // Sort keys for deterministic, diffable JSON output
311            let mut entries: Vec<(&str, &Value)> =
312                map.iter().map(|(k, v)| (k.as_str(), v)).collect();
313            entries.sort_unstable_by_key(|(k, _)| *k);
314            for (key, val) in entries {
315                if !first { out.push(','); }
316                first = false;
317                // Escape the key the same way string values are escaped
318                out.push('"');
319                for ch in key.chars() {
320                    match ch {
321                        '"'  => out.push_str("\\\""),
322                        '\\' => out.push_str("\\\\"),
323                        '\n' => out.push_str("\\n"),
324                        '\r' => out.push_str("\\r"),
325                        '\t' => out.push_str("\\t"),
326                        c if (c as u32) < 0x20 => {
327                            out.push_str(&format!("\\u{:04x}", c as u32));
328                        }
329                        c => out.push(c),
330                    }
331                }
332                out.push_str("\":");
333                write_json_depth(out, val, depth + 1);
334            }
335            out.push('}');
336        }
337    }
338}
339
340/// Convert a Value to a JSON string.
341pub fn to_json(val: &Value) -> String {
342    let mut out = String::with_capacity(2048);
343    write_json(&mut out, val);
344    out
345}
346
347// ─── Canonical Formatter ─────────────────────────────────────────────────────
348
349struct FmtNode {
350    header: String,
351    children: Vec<FmtNode>,
352    list_items: Vec<String>,
353    is_multiline: bool,
354}
355
356fn fmt_indent(line: &str) -> usize {
357    line.len() - line.trim_start().len()
358}
359
360const MAX_FMT_PARSE_DEPTH: usize = 128;
361
362fn fmt_parse(lines: &[&str], start: usize, base: usize, depth: usize) -> (Vec<FmtNode>, usize) {
363    if depth > MAX_FMT_PARSE_DEPTH {
364        return (Vec::new(), start);
365    }
366    let mut nodes = Vec::new();
367    let mut i = start;
368    while i < lines.len() {
369        let raw = lines[i];
370        let t = raw.trim();
371        if t.is_empty() { i += 1; continue; }
372        let ind = fmt_indent(raw);
373        if ind < base { break; }
374        if ind > base { i += 1; continue; }
375        if t.starts_with("- ") || t.starts_with('#') || t.starts_with("//") { i += 1; continue; }
376        let is_multiline = t.ends_with(" |") || t == "|";
377        let mut node = FmtNode {
378            header: t.to_string(),
379            children: Vec::new(),
380            list_items: Vec::new(),
381            is_multiline,
382        };
383        i += 1;
384        while i < lines.len() {
385            let cr = lines[i];
386            let ct = cr.trim();
387            if ct.is_empty() { i += 1; continue; }
388            let ci = fmt_indent(cr);
389            if ci <= base { break; }
390            if node.is_multiline || ct.starts_with("- ") {
391                node.list_items.push(ct.to_string());
392                i += 1;
393            } else if ct.starts_with('#') || ct.starts_with("//") {
394                i += 1;
395            } else {
396                let (subs, ni) = fmt_parse(lines, i, ci, depth + 1);
397                node.children.extend(subs);
398                i = ni;
399            }
400        }
401        nodes.push(node);
402    }
403    (nodes, i)
404}
405
406fn fmt_sort(nodes: &mut Vec<FmtNode>) {
407    nodes.sort_unstable_by(|a, b| {
408        let ka = a.header.split(|c: char| c.is_whitespace() || c == '[' || c == ':' || c == '(')
409            .next().unwrap_or("").to_lowercase();
410        let kb = b.header.split(|c: char| c.is_whitespace() || c == '[' || c == ':' || c == '(')
411            .next().unwrap_or("").to_lowercase();
412        ka.cmp(&kb)
413    });
414    for node in nodes.iter_mut() {
415        fmt_sort(&mut node.children);
416    }
417}
418
419fn fmt_emit(nodes: &[FmtNode], indent: usize, out: &mut String) {
420    let sp = " ".repeat(indent);
421    let item_sp = " ".repeat(indent + 2);
422    for n in nodes {
423        out.push_str(&sp);
424        out.push_str(&n.header);
425        out.push('\n');
426        if !n.children.is_empty() {
427            fmt_emit(&n.children, indent + 2, out);
428        }
429        for li in &n.list_items {
430            out.push_str(&item_sp);
431            out.push_str(li);
432            out.push('\n');
433        }
434        if indent == 0 && (!n.children.is_empty() || !n.list_items.is_empty()) {
435            out.push('\n');
436        }
437    }
438}
439
440fn fmt_canonical(text: &str) -> String {
441    let text = parser::clamp_synx_text(text);
442    let lines: Vec<&str> = text.lines().collect();
443    let mut directives: Vec<&str> = Vec::new();
444    let mut body_start = 0usize;
445
446    for (i, &line) in lines.iter().enumerate() {
447        let t = line.trim();
448        if t == "!active"
449            || t == "!lock"
450            || t == "!tool"
451            || t == "!schema"
452            || t == "!llm"
453            || t == "#!mode:active"
454        {
455            directives.push(t);
456            body_start = i + 1;
457        } else if t.is_empty() || t.starts_with('#') || t.starts_with("//") {
458            body_start = i + 1;
459        } else {
460            break;
461        }
462    }
463
464    let (mut nodes, _) = fmt_parse(&lines, body_start, 0, 0);
465    fmt_sort(&mut nodes);
466
467    let cap = text.len().min(parser::MAX_SYNX_INPUT_BYTES).max(64);
468    let mut out = String::with_capacity(cap);
469    if !directives.is_empty() {
470        out.push_str(&directives.join("\n"));
471        out.push_str("\n\n");
472    }
473    fmt_emit(&nodes, 0, &mut out);
474    // Trim trailing blank lines, ensure single newline at end
475    let trimmed = out.trim_end();
476    let mut result = trimmed.to_string();
477    result.push('\n');
478    result
479}