Skip to main content

synx_core/
parser.rs

1//! SYNX Parser — converts raw .synx text into a structured value tree
2//! with metadata for engine resolution.
3
4use std::collections::HashMap;
5use memchr::memchr;
6use crate::value::*;
7use crate::rng;
8
9// ─── Resource limits (fuzz / hostile input) ─────────────────
10// All caps are documented here so callers know parsing is bounded.
11
12/// Maximum UTF-8 bytes accepted per `parse()` (truncate with valid UTF-8 boundary).
13pub(crate) const MAX_SYNX_INPUT_BYTES: usize = 16 * 1024 * 1024;
14
15/// Maximum indexed line starts (1 + number of `\n` before truncate). Bounds `line_starts` RAM (~8× on 64-bit).
16const MAX_LINE_STARTS: usize = 2_000_000;
17
18/// Indentation-tree depth for nested objects (stack size). Iterative parser — prevents giant parent chains.
19pub(crate) const MAX_PARSE_NESTING_DEPTH: usize = 128;
20
21/// Multiline `key |` block body: max accumulated UTF-8 bytes.
22const MAX_MULTILINE_BLOCK_BYTES: usize = 1024 * 1024;
23
24/// `- list item` entries per single list.
25const MAX_LIST_ITEMS: usize = 1_048_576;
26
27/// `!include` lines per file.
28const MAX_INCLUDE_DIRECTIVES: usize = 4096;
29
30/// Max comma-separated parts when parsing `[constraints]` enum values.
31const MAX_CONSTRAINT_ENUM_PARTS: usize = 4096;
32
33/// Max `:a:b:c` marker segments on one key line.
34const MAX_MARKER_CHAIN_SEGMENTS: usize = 512;
35
36/// Truncate `text` to a UTF-8-safe prefix (used by `parse` and canonical `format`).
37///
38/// A single leading U+FEFF byte-order mark is dropped first (SYNX §2.1: a BOM at
39/// the start MAY be treated as whitespace; §6 requires directives to be matched
40/// with a leading BOM ignored). `str::trim` does not treat U+FEFF as whitespace,
41/// so without this a BOM-prefixed `!active` produced a `"\u{feff}!active"` key
42/// and silently disabled Active mode — the JS parser already strips it, so this
43/// also removes a cross-implementation divergence.
44pub(crate) fn clamp_synx_text(text: &str) -> &str {
45    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
46    if text.len() <= MAX_SYNX_INPUT_BYTES {
47        return text;
48    }
49    let slice = &text.as_bytes()[..MAX_SYNX_INPUT_BYTES];
50    let end = core::str::from_utf8(slice)
51        .map(|s| s.len())
52        .unwrap_or_else(|e| e.valid_up_to());
53    &text[..end]
54}
55
56/// Byte offset of the first character at or past `strip` bytes of leading whitespace.
57///
58/// Indentation is measured over *Unicode* whitespace (SYNX §4), so a line may
59/// carry a multi-byte indent character such as U+00A0. Slicing at the raw byte
60/// count would then land inside that character and panic; this walks whole
61/// characters instead and never returns a non-boundary offset. For an
62/// ASCII-only indent — the overwhelmingly common case — the result is `strip`.
63fn strip_boundary(s: &str, strip: usize) -> usize {
64    let mut off = 0usize;
65    for ch in s.chars() {
66        if off >= strip || !ch.is_whitespace() {
67            break;
68        }
69        off += ch.len_utf8();
70    }
71    off
72}
73
74/// Keys that would reach a JS consumer's prototype chain instead of its data.
75fn is_reserved_js_key(key: &str) -> bool {
76    key == "__proto__" || key == "constructor" || key == "prototype"
77}
78
79/// Longest prefix of `s` that fits in `limit` bytes without splitting a character.
80fn utf8_prefix_len(s: &str, limit: usize) -> usize {
81    if s.len() <= limit {
82        return s.len();
83    }
84    let mut end = limit;
85    while end > 0 && !s.is_char_boundary(end) {
86        end -= 1;
87    }
88    end
89}
90
91/// Byte length to parse: full slice, or truncate before the newline that would exceed
92/// `MAX_LINE_STARTS` lines (at most `MAX_LINE_STARTS.saturating_sub(1)` `\n` bytes kept).
93fn find_parse_end_bytes(bytes: &[u8]) -> usize {
94    let max_newlines = MAX_LINE_STARTS.saturating_sub(1);
95    let mut seen_newlines = 0usize;
96    let mut scan = 0usize;
97    while scan < bytes.len() {
98        if let Some(rel) = memchr(b'\n', &bytes[scan..]) {
99            if seen_newlines >= max_newlines {
100                return scan + rel;
101            }
102            seen_newlines += 1;
103            scan += rel + 1;
104        } else {
105            break;
106        }
107    }
108    bytes.len()
109}
110
111/// Options for a single [`parse_with`] run.
112///
113/// The default reproduces plain SYNX 3.7 behaviour; the non-default settings
114/// exist for *embedded* parses, where the host format owns the directive
115/// surface and the SYNX document is untrusted payload.
116#[derive(Debug, Clone, Copy)]
117pub struct ParserOptions {
118    /// Recognise `!`-prefixed directive lines (`!active`, `!lock`, `!tool`,
119    /// `!schema`, `!llm`, `!include`, `!use`) and the `#!mode:` pragma.
120    ///
121    /// SYNXL block delegation sets this to `false` (SYNXL §9.4): a record that
122    /// originates from an untrusted dataset must not be able to turn
123    /// `!include` into a file-read primitive against the consuming process,
124    /// nor flip the document into `!active` mode.
125    ///
126    /// Neutralisation happens *inside* this parser, after the multiline-block
127    /// check, so that `|` / `|+` bodies keep their `!` lines verbatim — a
128    /// pre-filter over the raw lines would corrupt those bodies.
129    pub directives: bool,
130
131    /// Honour the `random`, `random:int`, `random:float` and `random:bool`
132    /// type hints.
133    ///
134    /// SYNXL block delegation sets this to `false` (SYNXL §8.3): a hint
135    /// carried by dataset data would make the §12 projection differ between
136    /// two reads of the same bytes, which defeats hashing, deduplication and
137    /// the byte-identical projection §1.4 is measured on. Disabled, the hints
138    /// fall back to automatic casting like any other unrecognised hint.
139    pub nondeterministic_hints: bool,
140}
141
142impl Default for ParserOptions {
143    fn default() -> Self {
144        Self { directives: true, nondeterministic_hints: true }
145    }
146}
147
148/// Parse a SYNX text string into a value tree with metadata.
149pub fn parse(text: &str) -> ParseResult {
150    parse_with(text, ParserOptions::default())
151}
152
153/// Parse a SYNX text string with explicit [`ParserOptions`].
154///
155/// `parse(text)` is `parse_with(text, ParserOptions::default())`.
156pub fn parse_with(text: &str, opts: ParserOptions) -> ParseResult {
157    let original_len = text.len();
158    let text = clamp_synx_text(text);
159    let parse_end = find_parse_end_bytes(text.as_bytes());
160    let text = &text[..parse_end];
161    let bytes = text.as_bytes();
162    // Either cap having bitten means input was dropped before parsing began.
163    let mut truncated_multiline = text.len() < original_len;
164
165    let mut line_starts: Vec<usize> = Vec::new();
166    line_starts.push(0);
167    let mut scan = 0usize;
168    while scan < bytes.len() {
169        if let Some(rel) = memchr(b'\n', &bytes[scan..]) {
170            let pos = scan + rel;
171            line_starts.push(pos + 1);
172            scan = pos + 1;
173        } else {
174            break;
175        }
176    }
177    let line_count = line_starts.len();
178
179    let mut root = HashMap::new();
180    let mut stack: Vec<(i32, StackEntry)> = vec![(-1, StackEntry::Root)];
181    // Parent-navigation cache; cleared on every stack mutation (see NavCache).
182    let mut nav_cache: NavCache = None;
183    let mut mode = Mode::Static;
184    let mut locked = false;
185    let mut tool = false;
186    let mut schema = false;
187    let mut llm = false;
188    let mut metadata: HashMap<String, MetaMap> = HashMap::new();
189    let mut includes: Vec<IncludeDirective> = Vec::new();
190    let mut uses: Vec<UseDirective> = Vec::new();
191
192    let mut block: Option<BlockState> = None;
193    let mut list: Option<ListState> = None;
194    let mut in_block_comment = false;
195
196    let mut i = 0;
197    while i < line_count {
198        // Extract line without allocating
199        let start = line_starts[i];
200        let end = if i + 1 < line_count { line_starts[i + 1] - 1 } else { bytes.len() };
201        // Handle \r\n
202        let end = if end > start && end > 0 && bytes.get(end - 1) == Some(&b'\r') { end - 1 } else { end };
203        let raw = &text[start..end];
204
205        let trimmed = raw.trim();
206
207        // A blank line is never structural and never body: SYNX §8.4.1 does not
208        // preserve blank lines inside a multiline body.
209        if trimmed.is_empty() {
210            i += 1;
211            continue;
212        }
213
214        let indent = (raw.len() - raw.trim_start().len()) as i32;
215
216        // Continue multiline block.
217        //
218        // This branch runs *before* every line-class rule below, and that order
219        // is load-bearing: a `|` / `|+` body is a raw byte slice (SYNX §8.4.1),
220        // so `#`, `//`, `###`, `!include` and `#!mode:` are ordinary content
221        // there. Classifying first would delete comment lines out of code
222        // samples, let a stray `###` swallow the rest of the record, and turn a
223        // dataset row into a directive — which SYNXL §9.4 calls out explicitly.
224        if let Some(ref mut blk) = block {
225            if indent > blk.indent {
226                if blk.content.len() < MAX_MULTILINE_BLOCK_BYTES {
227                    if !blk.content.is_empty() {
228                        blk.content.push('\n');
229                    }
230                    let room = MAX_MULTILINE_BLOCK_BYTES.saturating_sub(blk.content.len());
231                    if room > 0 {
232                        let slice: &str = if blk.preserve_indent {
233                            // `|+` (SYNX 3.7): lock the strip prefix to the
234                            // indent of the first non-empty continuation line,
235                            // then strip exactly that many leading whitespace
236                            // bytes from each subsequent line. Anything beyond
237                            // the base indent is preserved verbatim.
238                            if blk.base_indent < 0 {
239                                blk.base_indent = indent;
240                            }
241                            let strip = blk.base_indent.min(indent) as usize;
242                            let raw_trim_end = raw.trim_end();
243                            let cut = strip_boundary(raw_trim_end, strip);
244                            if cut < raw_trim_end.len() {
245                                &raw_trim_end[cut..]
246                            } else {
247                                ""
248                            }
249                        } else {
250                            trimmed
251                        };
252                        let n = utf8_prefix_len(slice, room);
253                        blk.content.push_str(&slice[..n]);
254                        if n < slice.len() {
255                            truncated_multiline = true;
256                        }
257                    }
258                } else {
259                    truncated_multiline = true;
260                }
261                i += 1;
262                continue;
263            } else {
264                let content = std::mem::take(&mut blk.content);
265                let blk_key = blk.key.clone();
266                let blk_stack_idx = blk.stack_idx;
267                block = None;
268                insert_value(&mut root, &stack, blk_stack_idx, &blk_key, Value::String(content), &mut nav_cache);
269            }
270        }
271
272        // Block comment toggle: ###
273        if trimmed == "###" {
274            in_block_comment = !in_block_comment;
275            i += 1;
276            continue;
277        }
278        if in_block_comment {
279            i += 1;
280            continue;
281        }
282
283        // Directive lines (§6). Every form below starts with `!` or `#!mode:`,
284        // so gating the whole block on that prefix is behaviour-preserving and
285        // lets an embedded parse switch the class off wholesale (§9.4 of the
286        // SYNXL spec). Unknown `!…` lines still fall through to the generic
287        // line handling, exactly as in 3.6.
288        if opts.directives && (trimmed.starts_with('!') || trimmed.starts_with("#!mode:")) {
289            // Mode declaration
290            if trimmed == "!active" {
291                mode = Mode::Active;
292                i += 1;
293                continue;
294            }
295            if trimmed == "!lock" {
296                locked = true;
297                i += 1;
298                continue;
299            }
300            if trimmed == "!tool" {
301                tool = true;
302                i += 1;
303                continue;
304            }
305            if trimmed == "!schema" {
306                schema = true;
307                i += 1;
308                continue;
309            }
310            if trimmed == "!llm" {
311                llm = true;
312                i += 1;
313                continue;
314            }
315            if trimmed.starts_with("!include ") {
316                if includes.len() < MAX_INCLUDE_DIRECTIVES {
317                    let rest = trimmed[9..].trim();
318                    let mut parts = rest.splitn(2, char::is_whitespace);
319                    let path = parts.next().unwrap_or("").to_string();
320                    let alias = parts.next().map(|s| s.trim().to_string()).unwrap_or_else(|| {
321                        // Auto-derive alias from filename
322                        let name = path.rsplit(&['/', '\\'][..]).next().unwrap_or(&path);
323                        name.strip_suffix(".synx").or_else(|| name.strip_suffix(".SYNX")).unwrap_or(name).to_string()
324                    });
325                    includes.push(IncludeDirective { path, alias });
326                }
327                i += 1;
328                continue;
329            }
330            if trimmed.starts_with("!use ") {
331                let rest = trimmed[5..].trim();
332                if rest.starts_with('@') {
333                    // Parse: !use @scope/name [as alias]
334                    let mut parts = rest.splitn(2, " as ");
335                    let package = parts.next().unwrap_or("").trim().to_string();
336                    let alias = parts.next().map(|s| s.trim().to_string()).unwrap_or_else(|| {
337                        // Auto-derive alias from last segment: @scope/name → name
338                        package.rsplit('/').next().unwrap_or(&package).to_string()
339                    });
340                    if !package.is_empty() {
341                        uses.push(UseDirective { package, alias });
342                    }
343                }
344                i += 1;
345                continue;
346            }
347            if trimmed.starts_with("#!mode:") {
348                let declared = trimmed.splitn(2, ':').nth(1).unwrap_or("static").trim();
349                mode = if declared == "active" { Mode::Active } else { Mode::Static };
350                i += 1;
351                continue;
352            }
353        }
354
355        // SYNXL §9.4 — with directives disabled, a `!…` line that reaches this
356        // point is *not* multiline body (the block branch above already
357        // consumed and preserved those) and MUST be discarded exactly like a
358        // comment line: it must neither set a mode flag nor become a key.
359        if !opts.directives && trimmed.starts_with('!') {
360            i += 1;
361            continue;
362        }
363
364        // Skip comments. Checked after the directive block above so that the
365        // `#!mode:` pragma is still recognised.
366        if trimmed.starts_with('#') || trimmed.starts_with("//") {
367            i += 1;
368            continue;
369        }
370
371        // Continue list items
372        if trimmed.starts_with("- ") {
373            if let Some(ref lst) = list {
374                if indent > lst.indent {
375                    // Pop any stack frames belonging to a previous list item
376                    // at the same or deeper indent so items don't accumulate.
377                    while stack.len() > 1 {
378                        match stack.last() {
379                            Some((d, StackEntry::ListItem { .. })) if *d >= indent => { pop_frame(&mut stack, &mut nav_cache); }
380                            _ => break,
381                        }
382                    }
383
384                    let val_str = strip_comment(trimmed[2..].trim());
385
386                    // Peek next non-empty line — if it is more deeply
387                    // indented and not a `- ` continuation, this item is an
388                    // object, not a scalar.
389                    let mut peek = i + 1;
390                    let mut nested = false;
391                    while peek < line_count {
392                        let ps = line_starts[peek];
393                        let pe = if peek + 1 < line_count { line_starts[peek + 1] - 1 } else { bytes.len() };
394                        let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
395                        let pl = &text[ps..pe];
396                        let pt = pl.trim();
397                        if pt.is_empty() {
398                            peek += 1;
399                            continue;
400                        }
401                        let pi = (pl.len() - pl.trim_start().len()) as i32;
402                        if pi > indent
403                            && !pt.starts_with("- ")
404                            && !pt.starts_with('#')
405                            && !pt.starts_with("//")
406                        {
407                            nested = true;
408                        }
409                        break;
410                    }
411
412                    let list_key = lst.key.clone();
413                    let list_stack_idx = lst.stack_idx;
414
415                    // Locate the items array, creating it lazily in the parent map.
416                    if let Some(parent_map) = navigate_cached(&mut root, &stack, list_stack_idx, &mut nav_cache) {
417                        let arr_entry = parent_map
418                            .entry(list_key.clone())
419                            .or_insert_with(|| Value::Array(Vec::new()));
420                        if let Value::Array(arr) = arr_entry {
421                            if arr.len() >= MAX_LIST_ITEMS {
422                                i += 1;
423                                continue;
424                            }
425                            if nested {
426                                let mut item_obj: HashMap<String, Value> = HashMap::new();
427                                if let Some(parsed) = parse_line(&val_str) {
428                                    let val = if let Some(ref hint) = parsed.type_hint {
429                                        cast_typed_with(&parsed.value, hint, opts.nondeterministic_hints)
430                                    } else if !parsed.value.is_empty() {
431                                        cast(&parsed.value)
432                                    } else {
433                                        Value::Object(HashMap::new())
434                                    };
435                                    // Same prototype-pollution guard as the key-line
436                                    // branch below: a list item is just as capable of
437                                    // carrying `__proto__` into the JSON projection.
438                                    if !is_reserved_js_key(&parsed.key) {
439                                        item_obj.insert(parsed.key, val);
440                                    }
441                                } else {
442                                    item_obj.insert("_value".to_string(), cast(&val_str));
443                                }
444                                let item_idx = arr.len();
445                                arr.push(Value::Object(item_obj));
446                                if stack.len() < MAX_PARSE_NESTING_DEPTH {
447                                    push_frame(&mut stack, &mut nav_cache, (indent, StackEntry::ListItem { list_key, item_idx }));
448                                }
449                            } else {
450                                arr.push(cast(&val_str));
451                            }
452                        }
453                    }
454
455                    i += 1;
456                    continue;
457                }
458            }
459        } else {
460            // Close the list if a non-item line is at-or-below its indent.
461            let close = list.as_ref().map(|lst| indent <= lst.indent).unwrap_or(false);
462            if close {
463                list = None;
464                // Pop any list-item frames at-or-above this indent.
465                while stack.len() > 1 {
466                    match stack.last() {
467                        Some((d, StackEntry::ListItem { .. })) if *d >= indent => { pop_frame(&mut stack, &mut nav_cache); }
468                        _ => break,
469                    }
470                }
471            }
472        }
473
474        // Parse key line
475        if let Some(parsed) = parse_line(trimmed) {
476            // Reject prototype-polluting keys so downstream consumers (esp. JS
477            // applications consuming the JSON output) are not exposed to
478            // `__proto__` / `constructor` / `prototype` injection.
479            if is_reserved_js_key(&parsed.key) {
480                i += 1;
481                continue;
482            }
483
484            // Pop stack to correct parent
485            while stack.len() > 1 && stack.last().unwrap().0 >= indent {
486                pop_frame(&mut stack, &mut nav_cache);
487            }
488
489            let parent_idx = stack.len() - 1;
490
491            // Save metadata if in active mode
492            if mode == Mode::Active
493                && (!parsed.markers.is_empty()
494                    || parsed.constraints.is_some()
495                    || parsed.type_hint.is_some())
496            {
497                let path = build_path(&stack);
498                let meta_map = metadata.entry(path).or_default();
499                meta_map.insert(
500                    parsed.key.clone(),
501                    Meta {
502                        markers: parsed.markers.clone(),
503                        args: parsed.marker_args.clone(),
504                        type_hint: parsed.type_hint.clone(),
505                        constraints: parsed.constraints.clone(),
506                    },
507                );
508            }
509
510            // `|` (3.6 frozen) and `|+` (3.7 addition) both open multiline blocks.
511            // `|+` differs only in keeping each continuation line's indent relative
512            // to the first non-empty one — see BlockState docs and §8.4.1 of the
513            // spec. Old documents with literal value `|+` (extremely unlikely) would
514            // change meaning under 3.7; the bump is documented in CHANGELOG.
515            let is_block = parsed.value == "|" || parsed.value == "|+";
516            let preserve_indent = parsed.value == "|+";
517            let is_list_marker = parsed.markers.iter().any(|m| {
518                matches!(m.as_str(), "random" | "unique" | "geo" | "join")
519            });
520
521            if is_block {
522                insert_value(
523                    &mut root,
524                    &stack,
525                    parent_idx,
526                    &parsed.key,
527                    Value::String(String::new()),
528                    &mut nav_cache,
529                );
530                block = Some(BlockState {
531                    indent,
532                    key: parsed.key,
533                    content: String::new(),
534                    stack_idx: parent_idx,
535                    preserve_indent,
536                    base_indent: -1,
537                });
538            } else if is_list_marker && parsed.value.is_empty() {
539                // Insert an empty Array now so callers see the key even
540                // if the list ends up empty.
541                insert_value(
542                    &mut root,
543                    &stack,
544                    parent_idx,
545                    &parsed.key,
546                    Value::Array(Vec::new()),
547                    &mut nav_cache,
548                );
549                list = Some(ListState {
550                    indent,
551                    key: parsed.key,
552                    stack_idx: parent_idx,
553                });
554            } else if parsed.value.is_empty() {
555                // Peek ahead for list
556                let mut peek = i + 1;
557                while peek < line_count {
558                    let ps = line_starts[peek];
559                    let pe = if peek + 1 < line_count {
560                        line_starts[peek + 1] - 1
561                    } else {
562                        bytes.len()
563                    };
564                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
565                    let pt = text[ps..pe].trim();
566                    if !pt.is_empty() {
567                        break;
568                    }
569                    peek += 1;
570                }
571
572                if peek < line_count {
573                    let ps = line_starts[peek];
574                    let pe = if peek + 1 < line_count {
575                        line_starts[peek + 1] - 1
576                    } else {
577                        bytes.len()
578                    };
579                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
580                    let pt = text[ps..pe].trim();
581                    if pt.starts_with("- ") {
582                        insert_value(
583                            &mut root,
584                            &stack,
585                            parent_idx,
586                            &parsed.key,
587                            Value::Array(Vec::new()),
588                            &mut nav_cache,
589                        );
590                        list = Some(ListState {
591                            indent,
592                            key: parsed.key,
593                            stack_idx: parent_idx,
594                        });
595                        i += 1;
596                        continue;
597                    }
598                }
599
600                insert_value(
601                    &mut root,
602                    &stack,
603                    parent_idx,
604                    &parsed.key,
605                    Value::Object(HashMap::new()),
606                    &mut nav_cache,
607                );
608                // Guard against pathological inputs that create extremely deep nesting,
609                // which can lead to large allocations (metadata path building, parent navigation, etc).
610                // If the cap is hit, we still insert the object but stop increasing nesting.
611                if stack.len() < MAX_PARSE_NESTING_DEPTH {
612                    push_frame(&mut stack, &mut nav_cache, (indent, StackEntry::Key(parsed.key)));
613                }
614            } else {
615                let value = if let Some(ref hint) = parsed.type_hint {
616                    cast_typed_with(&parsed.value, hint, opts.nondeterministic_hints)
617                } else {
618                    cast(&parsed.value)
619                };
620                insert_value(&mut root, &stack, parent_idx, &parsed.key, value, &mut nav_cache);
621            }
622        }
623
624        i += 1;
625    }
626
627    // Flush pending block
628    if let Some(blk) = block {
629        insert_value(
630            &mut root,
631            &stack,
632            blk.stack_idx,
633            &blk.key,
634            Value::String(blk.content),
635            &mut nav_cache,
636        );
637    }
638
639    // List items now live directly in the parent map (see the rewritten
640    // "Continue list items" branch); no flush is required at end-of-input.
641    let _ = list;
642
643    let parsed_root = Value::Object(root);
644
645    // !tool reshaping is deferred — done after engine resolution for !active compatibility.
646    // Non-active !tool files are reshaped via Synx::parse_tool() or resolve_tool_output().
647
648    ParseResult {
649        root: parsed_root,
650        mode,
651        locked,
652        tool,
653        schema,
654        llm,
655        metadata,
656        includes,
657        uses,
658        truncated: truncated_multiline,
659    }
660}
661
662// ─── !tool output reshaping ──────────────────────────────
663
664/// Reshape parsed tree for `!tool` mode.
665///
666/// **Call mode** (`!tool` without `!schema`):
667///   First top-level key = tool name, its children = params.
668///   Output: `{ tool: "name", params: { ... } }`
669///
670/// **Schema mode** (`!tool` + `!schema`):
671///   Each top-level key = tool name, children = param type definitions.
672///   Output: `{ tools: [ { name: "tool1", params: { key: "type", ... } }, ... ] }`
673pub fn reshape_tool_output(root: &Value, schema: bool) -> Value {
674    let map = match root {
675        Value::Object(m) => m,
676        _ => return root.clone(),
677    };
678
679    if schema {
680        // Schema mode: list of tool definitions
681        let mut tools = Vec::new();
682        // Sort for deterministic output
683        let mut keys: Vec<&String> = map.keys().collect();
684        keys.sort();
685        for key in keys {
686            let val = &map[key];
687            let mut def = HashMap::new();
688            def.insert("name".to_string(), Value::String(key.clone()));
689            def.insert("params".to_string(), val.clone());
690            tools.push(Value::Object(def));
691        }
692        let mut out = HashMap::new();
693        out.insert("tools".to_string(), Value::Array(tools));
694        Value::Object(out)
695    } else {
696        // Call mode: first key = tool name, children = params
697        if map.is_empty() {
698            let mut out = HashMap::new();
699            out.insert("tool".to_string(), Value::Null);
700            out.insert("params".to_string(), Value::Object(HashMap::new()));
701            return Value::Object(out);
702        }
703
704        // Deterministic: pick the first key in source order.
705        // Since HashMap doesn't preserve order, sort and take first.
706        let mut keys: Vec<&String> = map.keys().collect();
707        keys.sort();
708        let tool_key = keys[0];
709        let tool_value = &map[tool_key];
710
711        let params = match tool_value {
712            Value::Object(m) => Value::Object(m.clone()),
713            // If tool has a single value (no nested params), wrap it
714            _ => Value::Object(HashMap::new()),
715        };
716
717        let mut out = HashMap::new();
718        out.insert("tool".to_string(), Value::String(tool_key.clone()));
719        out.insert("params".to_string(), params);
720        Value::Object(out)
721    }
722}
723
724// ─── Internal types ──────────────────────────────────────
725
726#[derive(Debug)]
727enum StackEntry {
728    Root,
729    Key(String),
730    /// We are inside a list item that turned out to be an object
731    /// (a `- key value` line followed by deeper-indented sub-keys).
732    /// `list_key` is the list's key in its parent map; `item_idx` is
733    /// the position in the list's `Array`.
734    ListItem { list_key: String, item_idx: usize },
735}
736
737struct BlockState {
738    indent: i32,
739    key: String,
740    content: String,
741    stack_idx: usize,
742    /// SYNX 3.7 `|+`: keep indent relative to the first continuation line.
743    /// `false` is plain `|` (3.6) — every continuation line is fully trimmed.
744    preserve_indent: bool,
745    /// Indent of the first non-empty continuation line, used as the strip
746    /// prefix when `preserve_indent` is true. `-1` means "not yet locked".
747    base_indent: i32,
748}
749
750struct ListState {
751    indent: i32,
752    key: String,
753    stack_idx: usize,
754}
755
756struct ParsedLine {
757    key: String,
758    type_hint: Option<String>,
759    value: String,
760    markers: Vec<String>,
761    marker_args: Vec<String>,
762    constraints: Option<Constraints>,
763}
764
765// ─── Line parser ─────────────────────────────────────────
766
767fn parse_line(trimmed: &str) -> Option<ParsedLine> {
768    if trimmed.is_empty()
769        || trimmed.starts_with('#')
770        || trimmed.starts_with("//")
771        || trimmed.starts_with("- ")
772    {
773        return None;
774    }
775
776    let bytes = trimmed.as_bytes();
777    let len = bytes.len();
778
779    let first = bytes[0];
780    if first == b'[' || first == b':' || first == b'-' || first == b'#' || first == b'/' || first == b'(' {
781        return None;
782    }
783
784    // Extract key
785    let mut pos = 0;
786    while pos < len {
787        let ch = bytes[pos];
788        if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b':' || ch == b'(' {
789            break;
790        }
791        pos += 1;
792    }
793    let key = trimmed[..pos].to_string();
794
795    // Optional (type)
796    let mut type_hint = None;
797    if pos < len && bytes[pos] == b'(' {
798        let start = pos + 1;
799        if let Some(c) = trimmed[start..].find(')') {
800            type_hint = Some(trimmed[start..start + c].to_string());
801            pos = start + c + 1;
802        } else {
803            pos += 1;
804        }
805    }
806
807    // Optional [constraints] — balanced bracket scan to support patterns like
808    // `^[A-Z]{2}$` whose own `]` would otherwise close the constraint block early.
809    let mut constraints = None;
810    if pos < len && bytes[pos] == b'[' {
811        let cstart = pos + 1;
812        let mut depth = 1usize;
813        let mut scan = cstart;
814        while scan < len && depth > 0 {
815            match bytes[scan] {
816                b'[' => depth += 1,
817                b']' => {
818                    depth -= 1;
819                    if depth == 0 {
820                        break;
821                    }
822                }
823                _ => {}
824            }
825            scan += 1;
826        }
827        if depth == 0 {
828            let constraint_str = &trimmed[cstart..scan];
829            constraints = Some(parse_constraints(constraint_str));
830            pos = scan + 1; // skip closing `]`
831        } else {
832            // Unbalanced — fall back to first `]` if any.
833            if let Some(rel) = trimmed[cstart..].find(']') {
834                let constraint_str = &trimmed[cstart..cstart + rel];
835                constraints = Some(parse_constraints(constraint_str));
836                pos = cstart + rel + 1;
837            } else {
838                constraints = Some(parse_constraints(&trimmed[cstart..]));
839                pos = len;
840            }
841        }
842    }
843
844    // Optional :markers
845    let mut markers = Vec::new();
846    let mut marker_args = Vec::new();
847    if pos < len && bytes[pos] == b':' {
848        let marker_start = pos + 1;
849        let mut marker_end = marker_start;
850        while marker_end < len && bytes[marker_end] != b' ' && bytes[marker_end] != b'\t' {
851            marker_end += 1;
852        }
853        let chain = &trimmed[marker_start..marker_end];
854        markers = chain
855            .split(':')
856            .take(MAX_MARKER_CHAIN_SEGMENTS)
857            .map(|s| s.to_string())
858            .collect();
859        pos = marker_end;
860    }
861
862    // Skip whitespace
863    while pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
864        pos += 1;
865    }
866
867    // Value
868    let mut raw_value = if pos < len {
869        strip_comment(&trimmed[pos..])
870    } else {
871        String::new()
872    };
873
874    // For :random — parse weight percentages from value
875    if markers.contains(&"random".to_string()) && !raw_value.is_empty() {
876        let parts: Vec<&str> = raw_value.split_whitespace().collect();
877        let nums: Vec<String> = parts
878            .iter()
879            .filter(|s| s.parse::<f64>().is_ok())
880            .map(|s| s.to_string())
881            .collect();
882        if !nums.is_empty() {
883            marker_args = nums;
884            raw_value.clear();
885        }
886    }
887
888    // For :inherit — a non-empty value names the parent key, not a scalar.
889    // Promote it into marker_args so the line opens a group instead of a leaf.
890    if markers.contains(&"inherit".to_string()) && !raw_value.is_empty() {
891        marker_args = vec![raw_value.trim().to_string()];
892        raw_value.clear();
893    }
894
895    Some(ParsedLine {
896        key,
897        type_hint,
898        value: raw_value,
899        markers,
900        marker_args,
901        constraints,
902    })
903}
904
905// ─── Constraints parser ──────────────────────────────────
906
907/// Parse the body of a `[…]` constraint block.
908///
909/// `pub(crate)` so the SYNXL field-list parser can reuse it verbatim, as
910/// required by SYNXL §5.2 ("MUST be parsed by the implementation's existing
911/// SYNX constraint parser").
912pub(crate) fn parse_constraints(raw: &str) -> Constraints {
913    let mut c = Constraints::default();
914    for part in raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
915        if part == "required" {
916            c.required = true;
917        } else if part == "readonly" {
918            c.readonly = true;
919        } else if let Some(colon) = part.find(':') {
920            let key = part[..colon].trim();
921            let val = part[colon + 1..].trim();
922            match key {
923                "min" => c.min = val.parse().ok(),
924                "max" => c.max = val.parse().ok(),
925                "type" => c.type_name = Some(val.to_string()),
926                "pattern" => c.pattern = Some(val.to_string()),
927                "enum" => {
928                    c.enum_values = Some(
929                        val.split('|')
930                            .take(MAX_CONSTRAINT_ENUM_PARTS)
931                            .map(|s| s.to_string())
932                            .collect(),
933                    );
934                }
935                _ => {}
936            }
937        }
938    }
939    c
940}
941
942// ─── Value casting ───────────────────────────────────────
943
944/// SYNX §8.3 automatic casting. `pub(crate)` for reuse by SYNXL §8.1.
945pub(crate) fn cast(val: &str) -> Value {
946    // Quoted strings preserve literal value (bypass auto-casting)
947    // "null" → String("null"), "true" → String("true"), "123" → String("123")
948    if val.len() >= 2 {
949        let bytes = val.as_bytes();
950        if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
951            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
952        {
953            return Value::String(val[1..val.len() - 1].to_string());
954        }
955    }
956
957    match val {
958        "true" => Value::Bool(true),
959        "false" => Value::Bool(false),
960        "null" => Value::Null,
961        _ => {
962            let bytes = val.as_bytes();
963            let len = bytes.len();
964            if len == 0 {
965                return Value::String(String::new());
966            }
967
968            let mut start = 0;
969            if bytes[0] == b'-' {
970                if len == 1 {
971                    return Value::String(val.to_string());
972                }
973                start = 1;
974            }
975
976            if bytes[start] >= b'0' && bytes[start] <= b'9' {
977                let mut dot_pos = None;
978                let mut all_numeric = true;
979                for j in start..len {
980                    if bytes[j] == b'.' {
981                        if dot_pos.is_some() {
982                            all_numeric = false;
983                            break;
984                        }
985                        dot_pos = Some(j);
986                    } else if bytes[j] < b'0' || bytes[j] > b'9' {
987                        all_numeric = false;
988                        break;
989                    }
990                }
991                if all_numeric {
992                    if let Some(dp) = dot_pos {
993                        if dp > start && dp < len - 1 {
994                            if let Ok(f) = val.parse::<f64>() {
995                                return Value::Float(f);
996                            }
997                        }
998                    } else if let Ok(n) = val.parse::<i64>() {
999                        return Value::Int(n);
1000                    }
1001                }
1002            }
1003
1004            Value::String(val.to_string())
1005        }
1006    }
1007}
1008
1009/// SYNX §8.3 typed casting. `pub(crate)` for reuse by SYNXL §8.2.
1010///
1011/// `nondeterministic` switches the `random:*` family off for embedded parses
1012/// (see [`ParserOptions::nondeterministic_hints`]).
1013pub(crate) fn cast_typed_with(val: &str, hint: &str, nondeterministic: bool) -> Value {
1014    match hint {
1015        "int" => Value::Int(val.parse().unwrap_or(0)),
1016        "float" => Value::Float(val.parse().unwrap_or(0.0)),
1017        "bool" => Value::Bool(val.trim() == "true"),
1018        "string" => Value::String(val.to_string()),
1019        "random" | "random:int" if nondeterministic => Value::Int(rng::random_i64()),
1020        "random:float" if nondeterministic => Value::Float(rng::random_f64_01()),
1021        "random:bool" if nondeterministic => Value::Bool(rng::random_bool()),
1022        _ => cast(val),
1023    }
1024}
1025
1026fn strip_comment(val: &str) -> String {
1027    let mut result = val.to_string();
1028    if let Some(idx) = result.find(" //") {
1029        result.truncate(idx);
1030    }
1031    if let Some(idx) = result.find(" #") {
1032        result.truncate(idx);
1033    }
1034    result.trim_end().to_string()
1035}
1036
1037// ─── Tree helpers ────────────────────────────────────────
1038
1039fn build_path(stack: &[(i32, StackEntry)]) -> String {
1040    // Metadata paths follow object keys only; list-item indices are not
1041    // part of the dot-path. This matches the JS engine and the README
1042    // contract that metadata is keyed by ancestor object keys.
1043    let mut parts = Vec::new();
1044    for (_, entry) in stack.iter().skip(1) {
1045        if let StackEntry::Key(ref k) = entry {
1046            parts.push(k.as_str());
1047        }
1048    }
1049    parts.join(".")
1050}
1051
1052/// Cache of the last resolved parent map: `(stack index of the parent, pointer)`.
1053///
1054/// Re-navigating the stack on every inserted line re-hashes each ancestor key.
1055/// With a multi-megabyte ancestor key that is O(children × key_len) — a
1056/// documented DoS: an 8 MiB file (well under the 16 MiB cap) took tens of
1057/// seconds. The cache reuses the parent pointer across consecutive inserts to
1058/// the same parent, making it linear again.
1059type NavCache = Option<(usize, *mut HashMap<String, Value>)>;
1060
1061/// Push a stack frame and invalidate the navigation cache.
1062///
1063/// safety: every stack mutation clears the cache, which is what makes reusing a
1064/// cached parent pointer sound. Between two mutations the cached map does not
1065/// move: inserts only ever target the current top (or an open list's parent),
1066/// never an ancestor, so no ancestor's bucket array reallocates under a live
1067/// pointer, and inserting into the cached map itself relocates only its own
1068/// buckets, not the `HashMap` struct the pointer refers to.
1069#[inline]
1070fn push_frame(stack: &mut Vec<(i32, StackEntry)>, cache: &mut NavCache, frame: (i32, StackEntry)) {
1071    stack.push(frame);
1072    *cache = None;
1073}
1074
1075/// Pop a stack frame and invalidate the navigation cache (see [`push_frame`]).
1076#[inline]
1077fn pop_frame(stack: &mut Vec<(i32, StackEntry)>, cache: &mut NavCache) -> Option<(i32, StackEntry)> {
1078    let popped = stack.pop();
1079    *cache = None;
1080    popped
1081}
1082
1083/// Resolve the parent map for `parent_idx`, reusing the cached pointer when the
1084/// stack has not changed since it was cached (see [`NavCache`]).
1085fn navigate_cached<'a>(
1086    root: &'a mut HashMap<String, Value>,
1087    stack: &[(i32, StackEntry)],
1088    parent_idx: usize,
1089    cache: &mut NavCache,
1090) -> Option<&'a mut HashMap<String, Value>> {
1091    if let Some((idx, ptr)) = *cache {
1092        if idx == parent_idx {
1093            // SAFETY: `cache` is cleared by push_frame/pop_frame on every stack
1094            // mutation, so a non-None entry means the stack is unchanged since
1095            // `ptr` was taken and the pointed-to map has not moved (see
1096            // push_frame docs). We hand out a single &mut re-borrowed for 'a.
1097            return Some(unsafe { &mut *ptr });
1098        }
1099    }
1100    let ptr = navigate_to_parent(root, stack, parent_idx)? as *mut HashMap<String, Value>;
1101    *cache = Some((parent_idx, ptr));
1102    // SAFETY: as above — `ptr` was just derived from the exclusive borrow of
1103    // `root` and no other reference to the same map is live.
1104    Some(unsafe { &mut *ptr })
1105}
1106
1107fn insert_value(
1108    root: &mut HashMap<String, Value>,
1109    stack: &[(i32, StackEntry)],
1110    parent_idx: usize,
1111    key: &str,
1112    value: Value,
1113    cache: &mut NavCache,
1114) {
1115    if let Some(target) = navigate_cached(root, stack, parent_idx, cache) {
1116        target.insert(key.to_string(), value);
1117    }
1118    // If the path is broken the line is silently skipped — this should not
1119    // happen under well-formed input; malformed input simply loses the entry
1120    // rather than inserting it at the wrong nesting level.
1121}
1122
1123fn navigate_to_parent<'a>(
1124    root: &'a mut HashMap<String, Value>,
1125    stack: &[(i32, StackEntry)],
1126    target_idx: usize,
1127) -> Option<&'a mut HashMap<String, Value>> {
1128    if target_idx == 0 {
1129        return Some(root);
1130    }
1131
1132    // SAFETY: We navigate a tree of nested HashMaps / Arrays using a raw
1133    // pointer to work around the borrow-checker's inability to track that
1134    // successive `get_mut` calls target disjoint subtrees.  The invariants
1135    // that make this sound:
1136    //   1. `root` is a valid, exclusively-owned mutable reference for 'a.
1137    //   2. We descend strictly downward and never alias: at each step we
1138    //      replace `current` with a pointer to a child map, discarding the
1139    //      parent pointer.
1140    //   3. The returned reference re-borrows from `root`'s lifetime 'a and
1141    //      is the only mutable reference handed out by this function.
1142    let mut current = root as *mut HashMap<String, Value>;
1143    for (_indent, entry) in stack.iter().skip(1).take(target_idx) {
1144        match entry {
1145            StackEntry::Root => unreachable!("Root only appears at index 0"),
1146            StackEntry::Key(k) => {
1147                let child = unsafe { (*current).get_mut(k) };
1148                match child {
1149                    Some(Value::Object(map)) => current = map as *mut HashMap<String, Value>,
1150                    _ => return None, // Path segment missing or not an Object
1151                }
1152            }
1153            StackEntry::ListItem { list_key, item_idx } => {
1154                let arr_val = unsafe { (*current).get_mut(list_key) };
1155                match arr_val {
1156                    Some(Value::Array(arr)) => {
1157                        if *item_idx >= arr.len() { return None; }
1158                        match &mut arr[*item_idx] {
1159                            Value::Object(map) => current = map as *mut HashMap<String, Value>,
1160                            _ => return None,
1161                        }
1162                    }
1163                    _ => return None,
1164                }
1165            }
1166        }
1167    }
1168    Some(unsafe { &mut *current })
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    #[test]
1176    fn test_simple_key_value() {
1177        let data = parse("name Wario\nage 30\nactive true\nscore 99.5\nempty null");
1178        let root = data.root.as_object().unwrap();
1179        assert_eq!(root["name"], Value::String("Wario".into()));
1180        assert_eq!(root["age"], Value::Int(30));
1181        assert_eq!(root["active"], Value::Bool(true));
1182        assert_eq!(root["score"], Value::Float(99.5));
1183        assert_eq!(root["empty"], Value::Null);
1184        assert_eq!(data.mode, Mode::Static);
1185    }
1186
1187    #[test]
1188    fn test_nested_objects() {
1189        let data = parse("server\n  host 0.0.0.0\n  port 8080\n  ssl\n    enabled true");
1190        let root = data.root.as_object().unwrap();
1191        let server = root["server"].as_object().unwrap();
1192        assert_eq!(server["host"], Value::String("0.0.0.0".into()));
1193        assert_eq!(server["port"], Value::Int(8080));
1194        let ssl = server["ssl"].as_object().unwrap();
1195        assert_eq!(ssl["enabled"], Value::Bool(true));
1196    }
1197
1198    #[test]
1199    fn test_lists() {
1200        let data = parse("inventory\n  - Sword\n  - Shield\n  - Potion");
1201        let root = data.root.as_object().unwrap();
1202        let inv = root["inventory"].as_array().unwrap();
1203        assert_eq!(inv.len(), 3);
1204        assert_eq!(inv[0], Value::String("Sword".into()));
1205    }
1206
1207    #[test]
1208    fn test_multiline_block() {
1209        let data = parse("rules |\n  Rule one.\n  Rule two.\n  Rule three.");
1210        let root = data.root.as_object().unwrap();
1211        assert_eq!(
1212            root["rules"],
1213            Value::String("Rule one.\nRule two.\nRule three.".into())
1214        );
1215    }
1216
1217    // SYNX 3.7 — `|+` preserves indentation relative to the first non-empty
1218    // continuation line. Required for embedding indent-sensitive content
1219    // (code, SYNX examples, ASCII diagrams) inside a multiline value.
1220    #[test]
1221    fn test_multiline_block_preserve_indent() {
1222        let src = "prompt |+\n  Top\n    Indented two\n      Indented four\n    Back to two\n  Top again";
1223        let data = parse(src);
1224        let root = data.root.as_object().unwrap();
1225        assert_eq!(
1226            root["prompt"],
1227            Value::String(
1228                "Top\n  Indented two\n    Indented four\n  Back to two\nTop again".into()
1229            )
1230        );
1231    }
1232
1233    #[test]
1234    fn test_multiline_block_preserve_indent_locks_base() {
1235        // Base indent locks to the first content line (4 spaces), so all
1236        // subsequent lines have exactly 4 leading spaces stripped.
1237        let src = "code |+\n    function foo() {\n      return 1;\n    }";
1238        let data = parse(src);
1239        let root = data.root.as_object().unwrap();
1240        assert_eq!(
1241            root["code"],
1242            Value::String("function foo() {\n  return 1;\n}".into())
1243        );
1244    }
1245
1246    #[test]
1247    fn test_multiline_block_preserve_indent_ends_at_opener_indent() {
1248        let src = "intro |+\n  hello\n    world\nnext plain";
1249        let data = parse(src);
1250        let root = data.root.as_object().unwrap();
1251        assert_eq!(root["intro"], Value::String("hello\n  world".into()));
1252        assert_eq!(root["next"], Value::String("plain".into()));
1253    }
1254
1255    #[test]
1256    fn test_comments() {
1257        let data = parse("# comment\nname Wario # inline\nage 30 // inline");
1258        let root = data.root.as_object().unwrap();
1259        assert_eq!(root["name"], Value::String("Wario".into()));
1260        assert_eq!(root["age"], Value::Int(30));
1261    }
1262
1263    #[test]
1264    fn test_active_mode() {
1265        let data = parse("!active\nprice 100\ntax:calc price * 0.2");
1266        assert_eq!(data.mode, Mode::Active);
1267        let root = data.root.as_object().unwrap();
1268        assert_eq!(root["price"], Value::Int(100));
1269        // Before engine resolution, :calc value is a string
1270        assert_eq!(root["tax"], Value::String("price * 0.2".into()));
1271        // Metadata should be saved
1272        let meta = data.metadata.get("").unwrap();
1273        assert!(meta.contains_key("tax"));
1274        assert_eq!(meta["tax"].markers, vec!["calc"]);
1275    }
1276
1277    #[test]
1278    fn test_markers_env_default() {
1279        let data = parse("!active\nport:env:default:3000 PORT");
1280        let meta = data.metadata.get("").unwrap();
1281        assert_eq!(meta["port"].markers, vec!["env", "default", "3000"]);
1282    }
1283
1284    #[test]
1285    fn test_type_hint() {
1286        let data = parse("zip(string) 90210");
1287        let root = data.root.as_object().unwrap();
1288        assert_eq!(root["zip"], Value::String("90210".into()));
1289    }
1290
1291    #[test]
1292    fn test_constraints() {
1293        let data = parse("!active\nname[min:3, max:30, required] Wario");
1294        let meta = data.metadata.get("").unwrap();
1295        let c = meta["name"].constraints.as_ref().unwrap();
1296        assert_eq!(c.min, Some(3.0));
1297        assert_eq!(c.max, Some(30.0));
1298        assert!(c.required);
1299    }
1300
1301    #[test]
1302    fn test_random_weights() {
1303        let data = parse("!active\ntier:random 90 5 5");
1304        let meta = data.metadata.get("").unwrap();
1305        assert_eq!(meta["tier"].markers, vec!["random"]);
1306        assert_eq!(meta["tier"].args, vec!["90", "5", "5"]);
1307    }
1308
1309    #[test]
1310    fn test_tool_directive_flags() {
1311        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
1312        assert!(data.tool);
1313        assert!(!data.schema);
1314        assert_eq!(data.mode, Mode::Static);
1315        // Raw parse keeps original tree structure
1316        let root = data.root.as_object().unwrap();
1317        let ws = root["web_search"].as_object().unwrap();
1318        assert_eq!(ws["query"], Value::String("test".into()));
1319        assert_eq!(ws["lang"], Value::String("ru".into()));
1320    }
1321
1322    #[test]
1323    fn test_tool_schema_flags() {
1324        let data = parse("!tool\n!schema\nweb_search\n  query string\n");
1325        assert!(data.tool);
1326        assert!(data.schema);
1327    }
1328
1329    #[test]
1330    fn test_llm_directive() {
1331        let data = parse("!llm\ncontext\n  user_profile demo\ntask summarize\n");
1332        assert!(data.llm);
1333        assert!(!data.tool);
1334        let root = data.root.as_object().unwrap();
1335        assert_eq!(root["task"], Value::String("summarize".into()));
1336        let ctx = root["context"].as_object().unwrap();
1337        assert_eq!(ctx["user_profile"], Value::String("demo".into()));
1338    }
1339
1340    #[test]
1341    fn test_parse_caps_nesting_depth() {
1342        // Pathological input: one key per line, increasing indentation each time,
1343        // with empty values so every line would normally create a new nested object.
1344        let mut s = String::new();
1345        for i in 0..(MAX_PARSE_NESTING_DEPTH as usize + 64) {
1346            s.push_str(&" ".repeat(i));
1347            s.push_str(&format!("k{i}\n"));
1348        }
1349
1350        let data = parse(&s);
1351        let mut cur = data.root.as_object().unwrap();
1352        let mut depth = 0usize;
1353        // Follow the single-child chain while it stays nested.
1354        loop {
1355            if cur.len() != 1 {
1356                break;
1357            }
1358            let (_, v) = cur.iter().next().unwrap();
1359            match v {
1360                Value::Object(next) => {
1361                    depth += 1;
1362                    cur = next;
1363                }
1364                _ => break,
1365            }
1366        }
1367
1368        assert!(depth <= MAX_PARSE_NESTING_DEPTH);
1369    }
1370
1371    #[test]
1372    fn test_tool_call_reshape() {
1373        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
1374        let shaped = reshape_tool_output(&data.root, false);
1375        let m = shaped.as_object().unwrap();
1376        assert_eq!(m["tool"], Value::String("web_search".into()));
1377        let params = m["params"].as_object().unwrap();
1378        assert_eq!(params["query"], Value::String("test".into()));
1379        assert_eq!(params["lang"], Value::String("ru".into()));
1380    }
1381
1382    #[test]
1383    fn test_tool_schema_reshape() {
1384        let data = parse("!tool\n!schema\nweb_search\n  query string\n  lang string\nmemory_write\n  path string\n  value string\n");
1385        let shaped = reshape_tool_output(&data.root, true);
1386        let m = shaped.as_object().unwrap();
1387        let tools = m["tools"].as_array().unwrap();
1388        assert_eq!(tools.len(), 2);
1389        // Sorted: memory_write before web_search
1390        let t0 = tools[0].as_object().unwrap();
1391        assert_eq!(t0["name"], Value::String("memory_write".into()));
1392        let p0 = t0["params"].as_object().unwrap();
1393        assert_eq!(p0["path"], Value::String("string".into()));
1394        let t1 = tools[1].as_object().unwrap();
1395        assert_eq!(t1["name"], Value::String("web_search".into()));
1396    }
1397
1398    #[test]
1399    fn test_tool_empty() {
1400        let data = parse("!tool\n");
1401        assert!(data.tool);
1402        let shaped = reshape_tool_output(&data.root, false);
1403        let m = shaped.as_object().unwrap();
1404        assert_eq!(m["tool"], Value::Null);
1405    }
1406
1407    // SYNXL §9.4 — an embedded parse must not honour directives: a dataset row
1408    // must never become a file-read primitive or flip the document's mode.
1409    #[test]
1410    fn test_directives_disabled_discards_directive_lines() {
1411        let src = "!active\n!include /etc/passwd\n!use @scope/pkg\nname Wario\n";
1412        let data = parse_with(src, ParserOptions { directives: false, nondeterministic_hints: false });
1413        assert_eq!(data.mode, Mode::Static);
1414        assert!(data.includes.is_empty());
1415        assert!(data.uses.is_empty());
1416        let root = data.root.as_object().unwrap();
1417        // Discarded like comments — not turned into keys either.
1418        assert_eq!(root.len(), 1);
1419        assert_eq!(root["name"], Value::String("Wario".into()));
1420
1421        // Default options keep 3.7 behaviour intact.
1422        let data = parse(src);
1423        assert_eq!(data.mode, Mode::Active);
1424        assert_eq!(data.includes.len(), 1);
1425    }
1426
1427    #[test]
1428    fn test_directives_disabled_preserves_bang_lines_in_multiline() {
1429        // Enforcement happens *inside* the parse, so a `!` line that is body
1430        // content of a `|` / `|+` block survives verbatim.
1431        let src = "body |+\n  !include /etc/passwd\n  !active\n  tail\n";
1432        let data = parse_with(src, ParserOptions { directives: false, nondeterministic_hints: false });
1433        assert_eq!(
1434            data.root.as_object().unwrap()["body"],
1435            Value::String("!include /etc/passwd\n!active\ntail".into())
1436        );
1437        assert!(data.includes.is_empty());
1438        assert_eq!(data.mode, Mode::Static);
1439
1440        let src = "body |\n  !include /etc/passwd\n";
1441        let data = parse_with(src, ParserOptions { directives: false, nondeterministic_hints: false });
1442        assert_eq!(
1443            data.root.as_object().unwrap()["body"],
1444            Value::String("!include /etc/passwd".into())
1445        );
1446    }
1447
1448    // A leading BOM (Notepad/Windows editors emit one) must not hide the
1449    // `!active` directive — SYNX §2.1/§6. Regression for the divergence where
1450    // BOM+!active produced a "\u{feff}!active" key and stayed Static.
1451    #[test]
1452    fn test_leading_bom_does_not_disable_active() {
1453        let data = parse("\u{feff}!active\nsum:calc 2 + 3\n");
1454        assert_eq!(data.mode, Mode::Active);
1455        assert!(data.root.as_object().unwrap().get("\u{feff}!active").is_none());
1456    }
1457
1458    #[test]
1459    fn test_leading_bom_stripped_from_first_key() {
1460        let data = parse("\u{feff}name Wario\n");
1461        let root = data.root.as_object().unwrap();
1462        assert_eq!(root["name"], Value::String("Wario".into()));
1463        assert!(root.get("\u{feff}name").is_none());
1464    }
1465
1466    // A multi-megabyte ancestor key with many children used to re-hash the key
1467    // on every child (O(children × key_len)) — an 8 MiB file took tens of
1468    // seconds. This must stay well under a second and produce the right tree.
1469    #[test]
1470    fn test_long_parent_key_is_linear() {
1471        let key = "K".repeat(1024 * 1024);
1472        let mut doc = String::with_capacity(2 * 1024 * 1024);
1473        doc.push_str(&key);
1474        doc.push('\n');
1475        for i in 0..20_000 {
1476            doc.push_str(&format!("  c{i} {i}\n"));
1477        }
1478        let start = std::time::Instant::now();
1479        let data = parse(&doc);
1480        assert!(start.elapsed().as_secs() < 5, "long-parent parse took {:?}", start.elapsed());
1481        let root = data.root.as_object().unwrap();
1482        let inner = root[&key].as_object().unwrap();
1483        assert_eq!(inner.len(), 20_000);
1484        assert_eq!(inner["c0"], Value::Int(0));
1485        assert_eq!(inner["c19999"], Value::Int(19_999));
1486    }
1487
1488    // The navigation cache must not leak a parent pointer across sibling
1489    // subtrees at the same depth (would misattach keys or alias maps).
1490    #[test]
1491    fn test_nav_cache_distinguishes_siblings() {
1492        let data = parse("a\n  x 1\nb\n  x 2\nc\n  y 3\n  z 4\n");
1493        let root = data.root.as_object().unwrap();
1494        assert_eq!(root["a"].as_object().unwrap()["x"], Value::Int(1));
1495        assert_eq!(root["b"].as_object().unwrap()["x"], Value::Int(2));
1496        let c = root["c"].as_object().unwrap();
1497        assert_eq!(c["y"], Value::Int(3));
1498        assert_eq!(c["z"], Value::Int(4));
1499        assert_eq!(c.len(), 2);
1500        assert_eq!(root["a"].as_object().unwrap().len(), 1);
1501    }
1502
1503    #[test]
1504    fn test_tool_with_active() {
1505        let data = parse("!tool\n!active\nweb_search\n  port:env:default:8080 PORT\n");
1506        assert!(data.tool);
1507        assert_eq!(data.mode, Mode::Active);
1508        // Metadata should be captured for :env:default
1509        let meta = data.metadata.get("web_search").unwrap();
1510        assert_eq!(meta["port"].markers, vec!["env", "default", "8080"]);
1511    }
1512}