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