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