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