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.
19const 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 length to parse: full slice, or truncate before the newline that would exceed
49/// `MAX_LINE_STARTS` lines (at most `MAX_LINE_STARTS.saturating_sub(1)` `\n` bytes kept).
50fn find_parse_end_bytes(bytes: &[u8]) -> usize {
51    let max_newlines = MAX_LINE_STARTS.saturating_sub(1);
52    let mut seen_newlines = 0usize;
53    let mut scan = 0usize;
54    while scan < bytes.len() {
55        if let Some(rel) = memchr(b'\n', &bytes[scan..]) {
56            if seen_newlines >= max_newlines {
57                return scan + rel;
58            }
59            seen_newlines += 1;
60            scan += rel + 1;
61        } else {
62            break;
63        }
64    }
65    bytes.len()
66}
67
68/// Parse a SYNX text string into a value tree with metadata.
69pub fn parse(text: &str) -> ParseResult {
70    let text = clamp_synx_text(text);
71    let parse_end = find_parse_end_bytes(text.as_bytes());
72    let text = &text[..parse_end];
73    let bytes = text.as_bytes();
74
75    let mut line_starts: Vec<usize> = Vec::new();
76    line_starts.push(0);
77    let mut scan = 0usize;
78    while scan < bytes.len() {
79        if let Some(rel) = memchr(b'\n', &bytes[scan..]) {
80            let pos = scan + rel;
81            line_starts.push(pos + 1);
82            scan = pos + 1;
83        } else {
84            break;
85        }
86    }
87    let line_count = line_starts.len();
88
89    let mut root = HashMap::new();
90    let mut stack: Vec<(i32, StackEntry)> = vec![(-1, StackEntry::Root)];
91    let mut mode = Mode::Static;
92    let mut locked = false;
93    let mut tool = false;
94    let mut schema = false;
95    let mut llm = false;
96    let mut metadata: HashMap<String, MetaMap> = HashMap::new();
97    let mut includes: Vec<IncludeDirective> = Vec::new();
98    let mut uses: Vec<UseDirective> = Vec::new();
99
100    let mut block: Option<BlockState> = None;
101    let mut list: Option<ListState> = None;
102    let mut in_block_comment = false;
103
104    let mut i = 0;
105    while i < line_count {
106        // Extract line without allocating
107        let start = line_starts[i];
108        let end = if i + 1 < line_count { line_starts[i + 1] - 1 } else { bytes.len() };
109        // Handle \r\n
110        let end = if end > start && end > 0 && bytes.get(end - 1) == Some(&b'\r') { end - 1 } else { end };
111        let raw = &text[start..end];
112
113        let trimmed = raw.trim();
114
115        // Mode declaration
116        if trimmed == "!active" {
117            mode = Mode::Active;
118            i += 1;
119            continue;
120        }
121        if trimmed == "!lock" {
122            locked = true;
123            i += 1;
124            continue;
125        }
126        if trimmed == "!tool" {
127            tool = true;
128            i += 1;
129            continue;
130        }
131        if trimmed == "!schema" {
132            schema = true;
133            i += 1;
134            continue;
135        }
136        if trimmed == "!llm" {
137            llm = true;
138            i += 1;
139            continue;
140        }
141        if trimmed.starts_with("!include ") {
142            if includes.len() < MAX_INCLUDE_DIRECTIVES {
143                let rest = trimmed[9..].trim();
144                let mut parts = rest.splitn(2, char::is_whitespace);
145                let path = parts.next().unwrap_or("").to_string();
146                let alias = parts.next().map(|s| s.trim().to_string()).unwrap_or_else(|| {
147                    // Auto-derive alias from filename
148                    let name = path.rsplit(&['/', '\\'][..]).next().unwrap_or(&path);
149                    name.strip_suffix(".synx").or_else(|| name.strip_suffix(".SYNX")).unwrap_or(name).to_string()
150                });
151                includes.push(IncludeDirective { path, alias });
152            }
153            i += 1;
154            continue;
155        }
156        if trimmed.starts_with("!use ") {
157            let rest = trimmed[5..].trim();
158            if rest.starts_with('@') {
159                // Parse: !use @scope/name [as alias]
160                let mut parts = rest.splitn(2, " as ");
161                let package = parts.next().unwrap_or("").trim().to_string();
162                let alias = parts.next().map(|s| s.trim().to_string()).unwrap_or_else(|| {
163                    // Auto-derive alias from last segment: @scope/name → name
164                    package.rsplit('/').next().unwrap_or(&package).to_string()
165                });
166                if !package.is_empty() {
167                    uses.push(UseDirective { package, alias });
168                }
169            }
170            i += 1;
171            continue;
172        }
173        if trimmed.starts_with("#!mode:") {
174            let declared = trimmed.splitn(2, ':').nth(1).unwrap_or("static").trim();
175            mode = if declared == "active" { Mode::Active } else { Mode::Static };
176            i += 1;
177            continue;
178        }
179
180        // Block comment toggle: ###
181        if trimmed == "###" {
182            in_block_comment = !in_block_comment;
183            i += 1;
184            continue;
185        }
186        if in_block_comment {
187            i += 1;
188            continue;
189        }
190
191        // Skip empty / comments
192        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
193            i += 1;
194            continue;
195        }
196
197        let indent = (raw.len() - raw.trim_start().len()) as i32;
198
199        // Continue multiline block
200        if let Some(ref mut blk) = block {
201            if indent > blk.indent {
202                if blk.content.len() < MAX_MULTILINE_BLOCK_BYTES {
203                    if !blk.content.is_empty() {
204                        blk.content.push('\n');
205                    }
206                    let room = MAX_MULTILINE_BLOCK_BYTES.saturating_sub(blk.content.len());
207                    if room > 0 {
208                        let n = trimmed.len().min(room);
209                        blk.content.push_str(&trimmed[..n]);
210                    }
211                }
212                i += 1;
213                continue;
214            } else {
215                let content = std::mem::take(&mut blk.content);
216                let blk_key = blk.key.clone();
217                let blk_stack_idx = blk.stack_idx;
218                block = None;
219                insert_value(&mut root, &stack, blk_stack_idx, &blk_key, Value::String(content));
220            }
221        }
222
223        // Continue list items
224        if trimmed.starts_with("- ") {
225            if let Some(ref lst) = list {
226                if indent > lst.indent {
227                    // Pop any stack frames belonging to a previous list item
228                    // at the same or deeper indent so items don't accumulate.
229                    while stack.len() > 1 {
230                        match stack.last() {
231                            Some((d, StackEntry::ListItem { .. })) if *d >= indent => { stack.pop(); }
232                            _ => break,
233                        }
234                    }
235
236                    let val_str = strip_comment(trimmed[2..].trim());
237
238                    // Peek next non-empty line — if it is more deeply
239                    // indented and not a `- ` continuation, this item is an
240                    // object, not a scalar.
241                    let mut peek = i + 1;
242                    let mut nested = false;
243                    while peek < line_count {
244                        let ps = line_starts[peek];
245                        let pe = if peek + 1 < line_count { line_starts[peek + 1] - 1 } else { bytes.len() };
246                        let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
247                        let pl = &text[ps..pe];
248                        let pt = pl.trim();
249                        if pt.is_empty() {
250                            peek += 1;
251                            continue;
252                        }
253                        let pi = (pl.len() - pl.trim_start().len()) as i32;
254                        if pi > indent
255                            && !pt.starts_with("- ")
256                            && !pt.starts_with('#')
257                            && !pt.starts_with("//")
258                        {
259                            nested = true;
260                        }
261                        break;
262                    }
263
264                    let list_key = lst.key.clone();
265                    let list_stack_idx = lst.stack_idx;
266
267                    // Locate the items array, creating it lazily in the parent map.
268                    if let Some(parent_map) = navigate_to_parent(&mut root, &stack, list_stack_idx) {
269                        let arr_entry = parent_map
270                            .entry(list_key.clone())
271                            .or_insert_with(|| Value::Array(Vec::new()));
272                        if let Value::Array(arr) = arr_entry {
273                            if arr.len() >= MAX_LIST_ITEMS {
274                                i += 1;
275                                continue;
276                            }
277                            if nested {
278                                let mut item_obj: HashMap<String, Value> = HashMap::new();
279                                if let Some(parsed) = parse_line(&val_str) {
280                                    let val = if let Some(ref hint) = parsed.type_hint {
281                                        cast_typed(&parsed.value, hint)
282                                    } else if !parsed.value.is_empty() {
283                                        cast(&parsed.value)
284                                    } else {
285                                        Value::Object(HashMap::new())
286                                    };
287                                    item_obj.insert(parsed.key, val);
288                                } else {
289                                    item_obj.insert("_value".to_string(), cast(&val_str));
290                                }
291                                let item_idx = arr.len();
292                                arr.push(Value::Object(item_obj));
293                                if stack.len() < MAX_PARSE_NESTING_DEPTH {
294                                    stack.push((indent, StackEntry::ListItem { list_key, item_idx }));
295                                }
296                            } else {
297                                arr.push(cast(&val_str));
298                            }
299                        }
300                    }
301
302                    i += 1;
303                    continue;
304                }
305            }
306        } else {
307            // Close the list if a non-item line is at-or-below its indent.
308            let close = list.as_ref().map(|lst| indent <= lst.indent).unwrap_or(false);
309            if close {
310                list = None;
311                // Pop any list-item frames at-or-above this indent.
312                while stack.len() > 1 {
313                    match stack.last() {
314                        Some((d, StackEntry::ListItem { .. })) if *d >= indent => { stack.pop(); }
315                        _ => break,
316                    }
317                }
318            }
319        }
320
321        // Parse key line
322        if let Some(parsed) = parse_line(trimmed) {
323            // Reject prototype-polluting keys so downstream consumers (esp. JS
324            // applications consuming the JSON output) are not exposed to
325            // `__proto__` / `constructor` / `prototype` injection.
326            if parsed.key == "__proto__"
327                || parsed.key == "constructor"
328                || parsed.key == "prototype"
329            {
330                i += 1;
331                continue;
332            }
333
334            // Pop stack to correct parent
335            while stack.len() > 1 && stack.last().unwrap().0 >= indent {
336                stack.pop();
337            }
338
339            let parent_idx = stack.len() - 1;
340
341            // Save metadata if in active mode
342            if mode == Mode::Active
343                && (!parsed.markers.is_empty()
344                    || parsed.constraints.is_some()
345                    || parsed.type_hint.is_some())
346            {
347                let path = build_path(&stack);
348                let meta_map = metadata.entry(path).or_default();
349                meta_map.insert(
350                    parsed.key.clone(),
351                    Meta {
352                        markers: parsed.markers.clone(),
353                        args: parsed.marker_args.clone(),
354                        type_hint: parsed.type_hint.clone(),
355                        constraints: parsed.constraints.clone(),
356                    },
357                );
358            }
359
360            let is_block = parsed.value == "|";
361            let is_list_marker = parsed.markers.iter().any(|m| {
362                matches!(m.as_str(), "random" | "unique" | "geo" | "join")
363            });
364
365            if is_block {
366                insert_value(
367                    &mut root,
368                    &stack,
369                    parent_idx,
370                    &parsed.key,
371                    Value::String(String::new()),
372                );
373                block = Some(BlockState {
374                    indent,
375                    key: parsed.key,
376                    content: String::new(),
377                    stack_idx: parent_idx,
378                });
379            } else if is_list_marker && parsed.value.is_empty() {
380                // Insert an empty Array now so callers see the key even
381                // if the list ends up empty.
382                insert_value(
383                    &mut root,
384                    &stack,
385                    parent_idx,
386                    &parsed.key,
387                    Value::Array(Vec::new()),
388                );
389                list = Some(ListState {
390                    indent,
391                    key: parsed.key,
392                    items: Vec::new(),
393                    stack_idx: parent_idx,
394                });
395            } else if parsed.value.is_empty() {
396                // Peek ahead for list
397                let mut peek = i + 1;
398                while peek < line_count {
399                    let ps = line_starts[peek];
400                    let pe = if peek + 1 < line_count {
401                        line_starts[peek + 1] - 1
402                    } else {
403                        bytes.len()
404                    };
405                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
406                    let pt = text[ps..pe].trim();
407                    if !pt.is_empty() {
408                        break;
409                    }
410                    peek += 1;
411                }
412
413                if peek < line_count {
414                    let ps = line_starts[peek];
415                    let pe = if peek + 1 < line_count {
416                        line_starts[peek + 1] - 1
417                    } else {
418                        bytes.len()
419                    };
420                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
421                    let pt = text[ps..pe].trim();
422                    if pt.starts_with("- ") {
423                        insert_value(
424                            &mut root,
425                            &stack,
426                            parent_idx,
427                            &parsed.key,
428                            Value::Array(Vec::new()),
429                        );
430                        list = Some(ListState {
431                            indent,
432                            key: parsed.key,
433                            items: Vec::new(),
434                            stack_idx: parent_idx,
435                        });
436                        i += 1;
437                        continue;
438                    }
439                }
440
441                insert_value(
442                    &mut root,
443                    &stack,
444                    parent_idx,
445                    &parsed.key,
446                    Value::Object(HashMap::new()),
447                );
448                // Guard against pathological inputs that create extremely deep nesting,
449                // which can lead to large allocations (metadata path building, parent navigation, etc).
450                // If the cap is hit, we still insert the object but stop increasing nesting.
451                if stack.len() < MAX_PARSE_NESTING_DEPTH {
452                    stack.push((indent, StackEntry::Key(parsed.key)));
453                }
454            } else {
455                let value = if let Some(ref hint) = parsed.type_hint {
456                    cast_typed(&parsed.value, hint)
457                } else {
458                    cast(&parsed.value)
459                };
460                insert_value(&mut root, &stack, parent_idx, &parsed.key, value);
461            }
462        }
463
464        i += 1;
465    }
466
467    // Flush pending block
468    if let Some(blk) = block {
469        insert_value(
470            &mut root,
471            &stack,
472            blk.stack_idx,
473            &blk.key,
474            Value::String(blk.content),
475        );
476    }
477
478    // List items now live directly in the parent map (see the rewritten
479    // "Continue list items" branch); no flush is required at end-of-input.
480    let _ = list;
481
482    let parsed_root = Value::Object(root);
483
484    // !tool reshaping is deferred — done after engine resolution for !active compatibility.
485    // Non-active !tool files are reshaped via Synx::parse_tool() or resolve_tool_output().
486
487    ParseResult {
488        root: parsed_root,
489        mode,
490        locked,
491        tool,
492        schema,
493        llm,
494        metadata,
495        includes,
496        uses,
497    }
498}
499
500// ─── !tool output reshaping ──────────────────────────────
501
502/// Reshape parsed tree for `!tool` mode.
503///
504/// **Call mode** (`!tool` without `!schema`):
505///   First top-level key = tool name, its children = params.
506///   Output: `{ tool: "name", params: { ... } }`
507///
508/// **Schema mode** (`!tool` + `!schema`):
509///   Each top-level key = tool name, children = param type definitions.
510///   Output: `{ tools: [ { name: "tool1", params: { key: "type", ... } }, ... ] }`
511pub fn reshape_tool_output(root: &Value, schema: bool) -> Value {
512    let map = match root {
513        Value::Object(m) => m,
514        _ => return root.clone(),
515    };
516
517    if schema {
518        // Schema mode: list of tool definitions
519        let mut tools = Vec::new();
520        // Sort for deterministic output
521        let mut keys: Vec<&String> = map.keys().collect();
522        keys.sort();
523        for key in keys {
524            let val = &map[key];
525            let mut def = HashMap::new();
526            def.insert("name".to_string(), Value::String(key.clone()));
527            def.insert("params".to_string(), val.clone());
528            tools.push(Value::Object(def));
529        }
530        let mut out = HashMap::new();
531        out.insert("tools".to_string(), Value::Array(tools));
532        Value::Object(out)
533    } else {
534        // Call mode: first key = tool name, children = params
535        if map.is_empty() {
536            let mut out = HashMap::new();
537            out.insert("tool".to_string(), Value::Null);
538            out.insert("params".to_string(), Value::Object(HashMap::new()));
539            return Value::Object(out);
540        }
541
542        // Deterministic: pick the first key in source order.
543        // Since HashMap doesn't preserve order, sort and take first.
544        let mut keys: Vec<&String> = map.keys().collect();
545        keys.sort();
546        let tool_key = keys[0];
547        let tool_value = &map[tool_key];
548
549        let params = match tool_value {
550            Value::Object(m) => Value::Object(m.clone()),
551            // If tool has a single value (no nested params), wrap it
552            _ => Value::Object(HashMap::new()),
553        };
554
555        let mut out = HashMap::new();
556        out.insert("tool".to_string(), Value::String(tool_key.clone()));
557        out.insert("params".to_string(), params);
558        Value::Object(out)
559    }
560}
561
562// ─── Internal types ──────────────────────────────────────
563
564#[derive(Debug)]
565enum StackEntry {
566    Root,
567    Key(String),
568    /// We are inside a list item that turned out to be an object
569    /// (a `- key value` line followed by deeper-indented sub-keys).
570    /// `list_key` is the list's key in its parent map; `item_idx` is
571    /// the position in the list's `Array`.
572    ListItem { list_key: String, item_idx: usize },
573}
574
575struct BlockState {
576    indent: i32,
577    key: String,
578    content: String,
579    stack_idx: usize,
580}
581
582struct ListState {
583    indent: i32,
584    key: String,
585    items: Vec<Value>,
586    stack_idx: usize,
587}
588
589struct ParsedLine {
590    key: String,
591    type_hint: Option<String>,
592    value: String,
593    markers: Vec<String>,
594    marker_args: Vec<String>,
595    constraints: Option<Constraints>,
596}
597
598// ─── Line parser ─────────────────────────────────────────
599
600fn parse_line(trimmed: &str) -> Option<ParsedLine> {
601    if trimmed.is_empty()
602        || trimmed.starts_with('#')
603        || trimmed.starts_with("//")
604        || trimmed.starts_with("- ")
605    {
606        return None;
607    }
608
609    let bytes = trimmed.as_bytes();
610    let len = bytes.len();
611
612    let first = bytes[0];
613    if first == b'[' || first == b':' || first == b'-' || first == b'#' || first == b'/' || first == b'(' {
614        return None;
615    }
616
617    // Extract key
618    let mut pos = 0;
619    while pos < len {
620        let ch = bytes[pos];
621        if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b':' || ch == b'(' {
622            break;
623        }
624        pos += 1;
625    }
626    let key = trimmed[..pos].to_string();
627
628    // Optional (type)
629    let mut type_hint = None;
630    if pos < len && bytes[pos] == b'(' {
631        let start = pos + 1;
632        if let Some(c) = trimmed[start..].find(')') {
633            type_hint = Some(trimmed[start..start + c].to_string());
634            pos = start + c + 1;
635        } else {
636            pos += 1;
637        }
638    }
639
640    // Optional [constraints] — balanced bracket scan to support patterns like
641    // `^[A-Z]{2}$` whose own `]` would otherwise close the constraint block early.
642    let mut constraints = None;
643    if pos < len && bytes[pos] == b'[' {
644        let cstart = pos + 1;
645        let mut depth = 1usize;
646        let mut scan = cstart;
647        while scan < len && depth > 0 {
648            match bytes[scan] {
649                b'[' => depth += 1,
650                b']' => {
651                    depth -= 1;
652                    if depth == 0 {
653                        break;
654                    }
655                }
656                _ => {}
657            }
658            scan += 1;
659        }
660        if depth == 0 {
661            let constraint_str = &trimmed[cstart..scan];
662            constraints = Some(parse_constraints(constraint_str));
663            pos = scan + 1; // skip closing `]`
664        } else {
665            // Unbalanced — fall back to first `]` if any.
666            if let Some(rel) = trimmed[cstart..].find(']') {
667                let constraint_str = &trimmed[cstart..cstart + rel];
668                constraints = Some(parse_constraints(constraint_str));
669                pos = cstart + rel + 1;
670            } else {
671                constraints = Some(parse_constraints(&trimmed[cstart..]));
672                pos = len;
673            }
674        }
675    }
676
677    // Optional :markers
678    let mut markers = Vec::new();
679    let mut marker_args = Vec::new();
680    if pos < len && bytes[pos] == b':' {
681        let marker_start = pos + 1;
682        let mut marker_end = marker_start;
683        while marker_end < len && bytes[marker_end] != b' ' && bytes[marker_end] != b'\t' {
684            marker_end += 1;
685        }
686        let chain = &trimmed[marker_start..marker_end];
687        markers = chain
688            .split(':')
689            .take(MAX_MARKER_CHAIN_SEGMENTS)
690            .map(|s| s.to_string())
691            .collect();
692        pos = marker_end;
693    }
694
695    // Skip whitespace
696    while pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
697        pos += 1;
698    }
699
700    // Value
701    let mut raw_value = if pos < len {
702        strip_comment(&trimmed[pos..])
703    } else {
704        String::new()
705    };
706
707    // For :random — parse weight percentages from value
708    if markers.contains(&"random".to_string()) && !raw_value.is_empty() {
709        let parts: Vec<&str> = raw_value.split_whitespace().collect();
710        let nums: Vec<String> = parts
711            .iter()
712            .filter(|s| s.parse::<f64>().is_ok())
713            .map(|s| s.to_string())
714            .collect();
715        if !nums.is_empty() {
716            marker_args = nums;
717            raw_value.clear();
718        }
719    }
720
721    // For :inherit — a non-empty value names the parent key, not a scalar.
722    // Promote it into marker_args so the line opens a group instead of a leaf.
723    if markers.contains(&"inherit".to_string()) && !raw_value.is_empty() {
724        marker_args = vec![raw_value.trim().to_string()];
725        raw_value.clear();
726    }
727
728    Some(ParsedLine {
729        key,
730        type_hint,
731        value: raw_value,
732        markers,
733        marker_args,
734        constraints,
735    })
736}
737
738// ─── Constraints parser ──────────────────────────────────
739
740fn parse_constraints(raw: &str) -> Constraints {
741    let mut c = Constraints::default();
742    for part in raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
743        if part == "required" {
744            c.required = true;
745        } else if part == "readonly" {
746            c.readonly = true;
747        } else if let Some(colon) = part.find(':') {
748            let key = part[..colon].trim();
749            let val = part[colon + 1..].trim();
750            match key {
751                "min" => c.min = val.parse().ok(),
752                "max" => c.max = val.parse().ok(),
753                "type" => c.type_name = Some(val.to_string()),
754                "pattern" => c.pattern = Some(val.to_string()),
755                "enum" => {
756                    c.enum_values = Some(
757                        val.split('|')
758                            .take(MAX_CONSTRAINT_ENUM_PARTS)
759                            .map(|s| s.to_string())
760                            .collect(),
761                    );
762                }
763                _ => {}
764            }
765        }
766    }
767    c
768}
769
770// ─── Value casting ───────────────────────────────────────
771
772fn cast(val: &str) -> Value {
773    // Quoted strings preserve literal value (bypass auto-casting)
774    // "null" → String("null"), "true" → String("true"), "123" → String("123")
775    if val.len() >= 2 {
776        let bytes = val.as_bytes();
777        if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
778            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
779        {
780            return Value::String(val[1..val.len() - 1].to_string());
781        }
782    }
783
784    match val {
785        "true" => Value::Bool(true),
786        "false" => Value::Bool(false),
787        "null" => Value::Null,
788        _ => {
789            let bytes = val.as_bytes();
790            let len = bytes.len();
791            if len == 0 {
792                return Value::String(String::new());
793            }
794
795            let mut start = 0;
796            if bytes[0] == b'-' {
797                if len == 1 {
798                    return Value::String(val.to_string());
799                }
800                start = 1;
801            }
802
803            if bytes[start] >= b'0' && bytes[start] <= b'9' {
804                let mut dot_pos = None;
805                let mut all_numeric = true;
806                for j in start..len {
807                    if bytes[j] == b'.' {
808                        if dot_pos.is_some() {
809                            all_numeric = false;
810                            break;
811                        }
812                        dot_pos = Some(j);
813                    } else if bytes[j] < b'0' || bytes[j] > b'9' {
814                        all_numeric = false;
815                        break;
816                    }
817                }
818                if all_numeric {
819                    if let Some(dp) = dot_pos {
820                        if dp > start && dp < len - 1 {
821                            if let Ok(f) = val.parse::<f64>() {
822                                return Value::Float(f);
823                            }
824                        }
825                    } else if let Ok(n) = val.parse::<i64>() {
826                        return Value::Int(n);
827                    }
828                }
829            }
830
831            Value::String(val.to_string())
832        }
833    }
834}
835
836fn cast_typed(val: &str, hint: &str) -> Value {
837    match hint {
838        "int" => Value::Int(val.parse().unwrap_or(0)),
839        "float" => Value::Float(val.parse().unwrap_or(0.0)),
840        "bool" => Value::Bool(val.trim() == "true"),
841        "string" => Value::String(val.to_string()),
842        "random" | "random:int" => Value::Int(rng::random_i64()),
843        "random:float" => Value::Float(rng::random_f64_01()),
844        "random:bool" => Value::Bool(rng::random_bool()),
845        _ => cast(val),
846    }
847}
848
849fn strip_comment(val: &str) -> String {
850    let mut result = val.to_string();
851    if let Some(idx) = result.find(" //") {
852        result.truncate(idx);
853    }
854    if let Some(idx) = result.find(" #") {
855        result.truncate(idx);
856    }
857    result.trim_end().to_string()
858}
859
860// ─── Tree helpers ────────────────────────────────────────
861
862fn build_path(stack: &[(i32, StackEntry)]) -> String {
863    // Metadata paths follow object keys only; list-item indices are not
864    // part of the dot-path. This matches the JS engine and the README
865    // contract that metadata is keyed by ancestor object keys.
866    let mut parts = Vec::new();
867    for (_, entry) in stack.iter().skip(1) {
868        if let StackEntry::Key(ref k) = entry {
869            parts.push(k.as_str());
870        }
871    }
872    parts.join(".")
873}
874
875fn insert_value(
876    root: &mut HashMap<String, Value>,
877    stack: &[(i32, StackEntry)],
878    parent_idx: usize,
879    key: &str,
880    value: Value,
881) {
882    if let Some(target) = navigate_to_parent(root, stack, parent_idx) {
883        target.insert(key.to_string(), value);
884    }
885    // If the path is broken the line is silently skipped — this should not
886    // happen under well-formed input; malformed input simply loses the entry
887    // rather than inserting it at the wrong nesting level.
888}
889
890fn navigate_to_parent<'a>(
891    root: &'a mut HashMap<String, Value>,
892    stack: &[(i32, StackEntry)],
893    target_idx: usize,
894) -> Option<&'a mut HashMap<String, Value>> {
895    if target_idx == 0 {
896        return Some(root);
897    }
898
899    // SAFETY: We navigate a tree of nested HashMaps / Arrays using a raw
900    // pointer to work around the borrow-checker's inability to track that
901    // successive `get_mut` calls target disjoint subtrees.  The invariants
902    // that make this sound:
903    //   1. `root` is a valid, exclusively-owned mutable reference for 'a.
904    //   2. We descend strictly downward and never alias: at each step we
905    //      replace `current` with a pointer to a child map, discarding the
906    //      parent pointer.
907    //   3. The returned reference re-borrows from `root`'s lifetime 'a and
908    //      is the only mutable reference handed out by this function.
909    let mut current = root as *mut HashMap<String, Value>;
910    for (_indent, entry) in stack.iter().skip(1).take(target_idx) {
911        match entry {
912            StackEntry::Root => unreachable!("Root only appears at index 0"),
913            StackEntry::Key(k) => {
914                let child = unsafe { (*current).get_mut(k) };
915                match child {
916                    Some(Value::Object(map)) => current = map as *mut HashMap<String, Value>,
917                    _ => return None, // Path segment missing or not an Object
918                }
919            }
920            StackEntry::ListItem { list_key, item_idx } => {
921                let arr_val = unsafe { (*current).get_mut(list_key) };
922                match arr_val {
923                    Some(Value::Array(arr)) => {
924                        if *item_idx >= arr.len() { return None; }
925                        match &mut arr[*item_idx] {
926                            Value::Object(map) => current = map as *mut HashMap<String, Value>,
927                            _ => return None,
928                        }
929                    }
930                    _ => return None,
931                }
932            }
933        }
934    }
935    Some(unsafe { &mut *current })
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn test_simple_key_value() {
944        let data = parse("name Wario\nage 30\nactive true\nscore 99.5\nempty null");
945        let root = data.root.as_object().unwrap();
946        assert_eq!(root["name"], Value::String("Wario".into()));
947        assert_eq!(root["age"], Value::Int(30));
948        assert_eq!(root["active"], Value::Bool(true));
949        assert_eq!(root["score"], Value::Float(99.5));
950        assert_eq!(root["empty"], Value::Null);
951        assert_eq!(data.mode, Mode::Static);
952    }
953
954    #[test]
955    fn test_nested_objects() {
956        let data = parse("server\n  host 0.0.0.0\n  port 8080\n  ssl\n    enabled true");
957        let root = data.root.as_object().unwrap();
958        let server = root["server"].as_object().unwrap();
959        assert_eq!(server["host"], Value::String("0.0.0.0".into()));
960        assert_eq!(server["port"], Value::Int(8080));
961        let ssl = server["ssl"].as_object().unwrap();
962        assert_eq!(ssl["enabled"], Value::Bool(true));
963    }
964
965    #[test]
966    fn test_lists() {
967        let data = parse("inventory\n  - Sword\n  - Shield\n  - Potion");
968        let root = data.root.as_object().unwrap();
969        let inv = root["inventory"].as_array().unwrap();
970        assert_eq!(inv.len(), 3);
971        assert_eq!(inv[0], Value::String("Sword".into()));
972    }
973
974    #[test]
975    fn test_multiline_block() {
976        let data = parse("rules |\n  Rule one.\n  Rule two.\n  Rule three.");
977        let root = data.root.as_object().unwrap();
978        assert_eq!(
979            root["rules"],
980            Value::String("Rule one.\nRule two.\nRule three.".into())
981        );
982    }
983
984    #[test]
985    fn test_comments() {
986        let data = parse("# comment\nname Wario # inline\nage 30 // inline");
987        let root = data.root.as_object().unwrap();
988        assert_eq!(root["name"], Value::String("Wario".into()));
989        assert_eq!(root["age"], Value::Int(30));
990    }
991
992    #[test]
993    fn test_active_mode() {
994        let data = parse("!active\nprice 100\ntax:calc price * 0.2");
995        assert_eq!(data.mode, Mode::Active);
996        let root = data.root.as_object().unwrap();
997        assert_eq!(root["price"], Value::Int(100));
998        // Before engine resolution, :calc value is a string
999        assert_eq!(root["tax"], Value::String("price * 0.2".into()));
1000        // Metadata should be saved
1001        let meta = data.metadata.get("").unwrap();
1002        assert!(meta.contains_key("tax"));
1003        assert_eq!(meta["tax"].markers, vec!["calc"]);
1004    }
1005
1006    #[test]
1007    fn test_markers_env_default() {
1008        let data = parse("!active\nport:env:default:3000 PORT");
1009        let meta = data.metadata.get("").unwrap();
1010        assert_eq!(meta["port"].markers, vec!["env", "default", "3000"]);
1011    }
1012
1013    #[test]
1014    fn test_type_hint() {
1015        let data = parse("zip(string) 90210");
1016        let root = data.root.as_object().unwrap();
1017        assert_eq!(root["zip"], Value::String("90210".into()));
1018    }
1019
1020    #[test]
1021    fn test_constraints() {
1022        let data = parse("!active\nname[min:3, max:30, required] Wario");
1023        let meta = data.metadata.get("").unwrap();
1024        let c = meta["name"].constraints.as_ref().unwrap();
1025        assert_eq!(c.min, Some(3.0));
1026        assert_eq!(c.max, Some(30.0));
1027        assert!(c.required);
1028    }
1029
1030    #[test]
1031    fn test_random_weights() {
1032        let data = parse("!active\ntier:random 90 5 5");
1033        let meta = data.metadata.get("").unwrap();
1034        assert_eq!(meta["tier"].markers, vec!["random"]);
1035        assert_eq!(meta["tier"].args, vec!["90", "5", "5"]);
1036    }
1037
1038    #[test]
1039    fn test_tool_directive_flags() {
1040        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
1041        assert!(data.tool);
1042        assert!(!data.schema);
1043        assert_eq!(data.mode, Mode::Static);
1044        // Raw parse keeps original tree structure
1045        let root = data.root.as_object().unwrap();
1046        let ws = root["web_search"].as_object().unwrap();
1047        assert_eq!(ws["query"], Value::String("test".into()));
1048        assert_eq!(ws["lang"], Value::String("ru".into()));
1049    }
1050
1051    #[test]
1052    fn test_tool_schema_flags() {
1053        let data = parse("!tool\n!schema\nweb_search\n  query string\n");
1054        assert!(data.tool);
1055        assert!(data.schema);
1056    }
1057
1058    #[test]
1059    fn test_llm_directive() {
1060        let data = parse("!llm\ncontext\n  user_profile demo\ntask summarize\n");
1061        assert!(data.llm);
1062        assert!(!data.tool);
1063        let root = data.root.as_object().unwrap();
1064        assert_eq!(root["task"], Value::String("summarize".into()));
1065        let ctx = root["context"].as_object().unwrap();
1066        assert_eq!(ctx["user_profile"], Value::String("demo".into()));
1067    }
1068
1069    #[test]
1070    fn test_parse_caps_nesting_depth() {
1071        // Pathological input: one key per line, increasing indentation each time,
1072        // with empty values so every line would normally create a new nested object.
1073        let mut s = String::new();
1074        for i in 0..(MAX_PARSE_NESTING_DEPTH as usize + 64) {
1075            s.push_str(&" ".repeat(i));
1076            s.push_str(&format!("k{i}\n"));
1077        }
1078
1079        let data = parse(&s);
1080        let mut cur = data.root.as_object().unwrap();
1081        let mut depth = 0usize;
1082        // Follow the single-child chain while it stays nested.
1083        loop {
1084            if cur.len() != 1 {
1085                break;
1086            }
1087            let (_, v) = cur.iter().next().unwrap();
1088            match v {
1089                Value::Object(next) => {
1090                    depth += 1;
1091                    cur = next;
1092                }
1093                _ => break,
1094            }
1095        }
1096
1097        assert!(depth <= MAX_PARSE_NESTING_DEPTH);
1098    }
1099
1100    #[test]
1101    fn test_tool_call_reshape() {
1102        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
1103        let shaped = reshape_tool_output(&data.root, false);
1104        let m = shaped.as_object().unwrap();
1105        assert_eq!(m["tool"], Value::String("web_search".into()));
1106        let params = m["params"].as_object().unwrap();
1107        assert_eq!(params["query"], Value::String("test".into()));
1108        assert_eq!(params["lang"], Value::String("ru".into()));
1109    }
1110
1111    #[test]
1112    fn test_tool_schema_reshape() {
1113        let data = parse("!tool\n!schema\nweb_search\n  query string\n  lang string\nmemory_write\n  path string\n  value string\n");
1114        let shaped = reshape_tool_output(&data.root, true);
1115        let m = shaped.as_object().unwrap();
1116        let tools = m["tools"].as_array().unwrap();
1117        assert_eq!(tools.len(), 2);
1118        // Sorted: memory_write before web_search
1119        let t0 = tools[0].as_object().unwrap();
1120        assert_eq!(t0["name"], Value::String("memory_write".into()));
1121        let p0 = t0["params"].as_object().unwrap();
1122        assert_eq!(p0["path"], Value::String("string".into()));
1123        let t1 = tools[1].as_object().unwrap();
1124        assert_eq!(t1["name"], Value::String("web_search".into()));
1125    }
1126
1127    #[test]
1128    fn test_tool_empty() {
1129        let data = parse("!tool\n");
1130        assert!(data.tool);
1131        let shaped = reshape_tool_output(&data.root, false);
1132        let m = shaped.as_object().unwrap();
1133        assert_eq!(m["tool"], Value::Null);
1134    }
1135
1136    #[test]
1137    fn test_tool_with_active() {
1138        let data = parse("!tool\n!active\nweb_search\n  port:env:default:8080 PORT\n");
1139        assert!(data.tool);
1140        assert_eq!(data.mode, Mode::Active);
1141        // Metadata should be captured for :env:default
1142        let meta = data.metadata.get("web_search").unwrap();
1143        assert_eq!(meta["port"].markers, vec!["env", "default", "8080"]);
1144    }
1145}