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 metadata: HashMap<String, MetaMap> = HashMap::new();
96    let mut includes: Vec<IncludeDirective> = Vec::new();
97
98    let mut block: Option<BlockState> = None;
99    let mut list: Option<ListState> = None;
100    let mut in_block_comment = false;
101
102    let mut i = 0;
103    while i < line_count {
104        // Extract line without allocating
105        let start = line_starts[i];
106        let end = if i + 1 < line_count { line_starts[i + 1] - 1 } else { bytes.len() };
107        // Handle \r\n
108        let end = if end > start && end > 0 && bytes.get(end - 1) == Some(&b'\r') { end - 1 } else { end };
109        let raw = &text[start..end];
110
111        let trimmed = raw.trim();
112
113        // Mode declaration
114        if trimmed == "!active" {
115            mode = Mode::Active;
116            i += 1;
117            continue;
118        }
119        if trimmed == "!lock" {
120            locked = true;
121            i += 1;
122            continue;
123        }
124        if trimmed == "!tool" {
125            tool = true;
126            i += 1;
127            continue;
128        }
129        if trimmed == "!schema" {
130            schema = true;
131            i += 1;
132            continue;
133        }
134        if trimmed.starts_with("!include ") {
135            if includes.len() < MAX_INCLUDE_DIRECTIVES {
136                let rest = trimmed[9..].trim();
137                let mut parts = rest.splitn(2, char::is_whitespace);
138                let path = parts.next().unwrap_or("").to_string();
139                let alias = parts.next().map(|s| s.trim().to_string()).unwrap_or_else(|| {
140                    // Auto-derive alias from filename
141                    let name = path.rsplit(&['/', '\\'][..]).next().unwrap_or(&path);
142                    name.strip_suffix(".synx").or_else(|| name.strip_suffix(".SYNX")).unwrap_or(name).to_string()
143                });
144                includes.push(IncludeDirective { path, alias });
145            }
146            i += 1;
147            continue;
148        }
149        if trimmed.starts_with("#!mode:") {
150            let declared = trimmed.splitn(2, ':').nth(1).unwrap_or("static").trim();
151            mode = if declared == "active" { Mode::Active } else { Mode::Static };
152            i += 1;
153            continue;
154        }
155
156        // Block comment toggle: ###
157        if trimmed == "###" {
158            in_block_comment = !in_block_comment;
159            i += 1;
160            continue;
161        }
162        if in_block_comment {
163            i += 1;
164            continue;
165        }
166
167        // Skip empty / comments
168        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
169            i += 1;
170            continue;
171        }
172
173        let indent = (raw.len() - raw.trim_start().len()) as i32;
174
175        // Continue multiline block
176        if let Some(ref mut blk) = block {
177            if indent > blk.indent {
178                if blk.content.len() < MAX_MULTILINE_BLOCK_BYTES {
179                    if !blk.content.is_empty() {
180                        blk.content.push('\n');
181                    }
182                    let room = MAX_MULTILINE_BLOCK_BYTES.saturating_sub(blk.content.len());
183                    if room > 0 {
184                        let n = trimmed.len().min(room);
185                        blk.content.push_str(&trimmed[..n]);
186                    }
187                }
188                i += 1;
189                continue;
190            } else {
191                let content = std::mem::take(&mut blk.content);
192                let blk_key = blk.key.clone();
193                let blk_stack_idx = blk.stack_idx;
194                block = None;
195                insert_value(&mut root, &stack, blk_stack_idx, &blk_key, Value::String(content));
196            }
197        }
198
199        // Continue list items
200        if trimmed.starts_with("- ") {
201            if let Some(ref mut lst) = list {
202                if indent > lst.indent {
203                    if lst.items.len() < MAX_LIST_ITEMS {
204                        let val_str = strip_comment(trimmed[2..].trim());
205                        lst.items.push(cast(&val_str));
206                    }
207                    i += 1;
208                    continue;
209                }
210            }
211        } else if let Some(ref lst) = list {
212            if indent <= lst.indent {
213                let items = list.take().unwrap();
214                let arr = Value::Array(items.items);
215                insert_value(&mut root, &stack, items.stack_idx, &items.key, arr);
216            }
217        }
218
219        // Parse key line
220        if let Some(parsed) = parse_line(trimmed) {
221            // Pop stack to correct parent
222            while stack.len() > 1 && stack.last().unwrap().0 >= indent {
223                stack.pop();
224            }
225
226            let parent_idx = stack.len() - 1;
227
228            // Save metadata if in active mode
229            if mode == Mode::Active
230                && (!parsed.markers.is_empty()
231                    || parsed.constraints.is_some()
232                    || parsed.type_hint.is_some())
233            {
234                let path = build_path(&stack);
235                let meta_map = metadata.entry(path).or_default();
236                meta_map.insert(
237                    parsed.key.clone(),
238                    Meta {
239                        markers: parsed.markers.clone(),
240                        args: parsed.marker_args.clone(),
241                        type_hint: parsed.type_hint.clone(),
242                        constraints: parsed.constraints.clone(),
243                    },
244                );
245            }
246
247            let is_block = parsed.value == "|";
248            let is_list_marker = parsed.markers.iter().any(|m| {
249                matches!(m.as_str(), "random" | "unique" | "geo" | "join")
250            });
251
252            if is_block {
253                insert_value(
254                    &mut root,
255                    &stack,
256                    parent_idx,
257                    &parsed.key,
258                    Value::String(String::new()),
259                );
260                block = Some(BlockState {
261                    indent,
262                    key: parsed.key,
263                    content: String::new(),
264                    stack_idx: parent_idx,
265                });
266            } else if is_list_marker && parsed.value.is_empty() {
267                list = Some(ListState {
268                    indent,
269                    key: parsed.key,
270                    items: Vec::new(),
271                    stack_idx: parent_idx,
272                });
273            } else if parsed.value.is_empty() {
274                // Peek ahead for list
275                let mut peek = i + 1;
276                while peek < line_count {
277                    let ps = line_starts[peek];
278                    let pe = if peek + 1 < line_count {
279                        line_starts[peek + 1] - 1
280                    } else {
281                        bytes.len()
282                    };
283                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
284                    let pt = text[ps..pe].trim();
285                    if !pt.is_empty() {
286                        break;
287                    }
288                    peek += 1;
289                }
290
291                if peek < line_count {
292                    let ps = line_starts[peek];
293                    let pe = if peek + 1 < line_count {
294                        line_starts[peek + 1] - 1
295                    } else {
296                        bytes.len()
297                    };
298                    let pe = if pe > ps && bytes.get(pe - 1) == Some(&b'\r') { pe - 1 } else { pe };
299                    let pt = text[ps..pe].trim();
300                    if pt.starts_with("- ") {
301                        list = Some(ListState {
302                            indent,
303                            key: parsed.key,
304                            items: Vec::new(),
305                            stack_idx: parent_idx,
306                        });
307                        i += 1;
308                        continue;
309                    }
310                }
311
312                insert_value(
313                    &mut root,
314                    &stack,
315                    parent_idx,
316                    &parsed.key,
317                    Value::Object(HashMap::new()),
318                );
319                // Guard against pathological inputs that create extremely deep nesting,
320                // which can lead to large allocations (metadata path building, parent navigation, etc).
321                // If the cap is hit, we still insert the object but stop increasing nesting.
322                if stack.len() < MAX_PARSE_NESTING_DEPTH {
323                    stack.push((indent, StackEntry::Key(parsed.key)));
324                }
325            } else {
326                let value = if let Some(ref hint) = parsed.type_hint {
327                    cast_typed(&parsed.value, hint)
328                } else {
329                    cast(&parsed.value)
330                };
331                insert_value(&mut root, &stack, parent_idx, &parsed.key, value);
332            }
333        }
334
335        i += 1;
336    }
337
338    // Flush pending block
339    if let Some(blk) = block {
340        insert_value(
341            &mut root,
342            &stack,
343            blk.stack_idx,
344            &blk.key,
345            Value::String(blk.content),
346        );
347    }
348
349    // Flush pending list
350    if let Some(lst) = list {
351        let arr = Value::Array(lst.items);
352        insert_value(&mut root, &stack, lst.stack_idx, &lst.key, arr);
353    }
354
355    let parsed_root = Value::Object(root);
356
357    // !tool reshaping is deferred — done after engine resolution for !active compatibility.
358    // Non-active !tool files are reshaped via Synx::parse_tool() or resolve_tool_output().
359
360    ParseResult {
361        root: parsed_root,
362        mode,
363        locked,
364        tool,
365        schema,
366        metadata,
367        includes,
368    }
369}
370
371// ─── !tool output reshaping ──────────────────────────────
372
373/// Reshape parsed tree for `!tool` mode.
374///
375/// **Call mode** (`!tool` without `!schema`):
376///   First top-level key = tool name, its children = params.
377///   Output: `{ tool: "name", params: { ... } }`
378///
379/// **Schema mode** (`!tool` + `!schema`):
380///   Each top-level key = tool name, children = param type definitions.
381///   Output: `{ tools: [ { name: "tool1", params: { key: "type", ... } }, ... ] }`
382pub fn reshape_tool_output(root: &Value, schema: bool) -> Value {
383    let map = match root {
384        Value::Object(m) => m,
385        _ => return root.clone(),
386    };
387
388    if schema {
389        // Schema mode: list of tool definitions
390        let mut tools = Vec::new();
391        // Sort for deterministic output
392        let mut keys: Vec<&String> = map.keys().collect();
393        keys.sort();
394        for key in keys {
395            let val = &map[key];
396            let mut def = HashMap::new();
397            def.insert("name".to_string(), Value::String(key.clone()));
398            def.insert("params".to_string(), val.clone());
399            tools.push(Value::Object(def));
400        }
401        let mut out = HashMap::new();
402        out.insert("tools".to_string(), Value::Array(tools));
403        Value::Object(out)
404    } else {
405        // Call mode: first key = tool name, children = params
406        if map.is_empty() {
407            let mut out = HashMap::new();
408            out.insert("tool".to_string(), Value::Null);
409            out.insert("params".to_string(), Value::Object(HashMap::new()));
410            return Value::Object(out);
411        }
412
413        // Deterministic: pick the first key in source order.
414        // Since HashMap doesn't preserve order, sort and take first.
415        let mut keys: Vec<&String> = map.keys().collect();
416        keys.sort();
417        let tool_key = keys[0];
418        let tool_value = &map[tool_key];
419
420        let params = match tool_value {
421            Value::Object(m) => Value::Object(m.clone()),
422            // If tool has a single value (no nested params), wrap it
423            _ => Value::Object(HashMap::new()),
424        };
425
426        let mut out = HashMap::new();
427        out.insert("tool".to_string(), Value::String(tool_key.clone()));
428        out.insert("params".to_string(), params);
429        Value::Object(out)
430    }
431}
432
433// ─── Internal types ──────────────────────────────────────
434
435#[derive(Debug)]
436enum StackEntry {
437    Root,
438    Key(String),
439}
440
441struct BlockState {
442    indent: i32,
443    key: String,
444    content: String,
445    stack_idx: usize,
446}
447
448struct ListState {
449    indent: i32,
450    key: String,
451    items: Vec<Value>,
452    stack_idx: usize,
453}
454
455struct ParsedLine {
456    key: String,
457    type_hint: Option<String>,
458    value: String,
459    markers: Vec<String>,
460    marker_args: Vec<String>,
461    constraints: Option<Constraints>,
462}
463
464// ─── Line parser ─────────────────────────────────────────
465
466fn parse_line(trimmed: &str) -> Option<ParsedLine> {
467    if trimmed.is_empty()
468        || trimmed.starts_with('#')
469        || trimmed.starts_with("//")
470        || trimmed.starts_with("- ")
471    {
472        return None;
473    }
474
475    let bytes = trimmed.as_bytes();
476    let len = bytes.len();
477
478    let first = bytes[0];
479    if first == b'[' || first == b':' || first == b'-' || first == b'#' || first == b'/' || first == b'(' {
480        return None;
481    }
482
483    // Extract key
484    let mut pos = 0;
485    while pos < len {
486        let ch = bytes[pos];
487        if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b':' || ch == b'(' {
488            break;
489        }
490        pos += 1;
491    }
492    let key = trimmed[..pos].to_string();
493
494    // Optional (type)
495    let mut type_hint = None;
496    if pos < len && bytes[pos] == b'(' {
497        let start = pos + 1;
498        if let Some(c) = trimmed[start..].find(')') {
499            type_hint = Some(trimmed[start..start + c].to_string());
500            pos = start + c + 1;
501        } else {
502            pos += 1;
503        }
504    }
505
506    // Optional [constraints]
507    let mut constraints = None;
508    if pos < len && bytes[pos] == b'[' {
509        if let Some(close) = trimmed[pos..].find(']') {
510            let constraint_str = &trimmed[pos + 1..pos + close];
511            constraints = Some(parse_constraints(constraint_str));
512            pos += close + 1;
513        } else {
514            pos += 1;
515        }
516    }
517
518    // Optional :markers
519    let mut markers = Vec::new();
520    let mut marker_args = Vec::new();
521    if pos < len && bytes[pos] == b':' {
522        let marker_start = pos + 1;
523        let mut marker_end = marker_start;
524        while marker_end < len && bytes[marker_end] != b' ' && bytes[marker_end] != b'\t' {
525            marker_end += 1;
526        }
527        let chain = &trimmed[marker_start..marker_end];
528        markers = chain
529            .split(':')
530            .take(MAX_MARKER_CHAIN_SEGMENTS)
531            .map(|s| s.to_string())
532            .collect();
533        pos = marker_end;
534    }
535
536    // Skip whitespace
537    while pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
538        pos += 1;
539    }
540
541    // Value
542    let mut raw_value = if pos < len {
543        strip_comment(&trimmed[pos..])
544    } else {
545        String::new()
546    };
547
548    // For :random — parse weight percentages from value
549    if markers.contains(&"random".to_string()) && !raw_value.is_empty() {
550        let parts: Vec<&str> = raw_value.split_whitespace().collect();
551        let nums: Vec<String> = parts
552            .iter()
553            .filter(|s| s.parse::<f64>().is_ok())
554            .map(|s| s.to_string())
555            .collect();
556        if !nums.is_empty() {
557            marker_args = nums;
558            raw_value.clear();
559        }
560    }
561
562    Some(ParsedLine {
563        key,
564        type_hint,
565        value: raw_value,
566        markers,
567        marker_args,
568        constraints,
569    })
570}
571
572// ─── Constraints parser ──────────────────────────────────
573
574fn parse_constraints(raw: &str) -> Constraints {
575    let mut c = Constraints::default();
576    for part in raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
577        if part == "required" {
578            c.required = true;
579        } else if part == "readonly" {
580            c.readonly = true;
581        } else if let Some(colon) = part.find(':') {
582            let key = part[..colon].trim();
583            let val = part[colon + 1..].trim();
584            match key {
585                "min" => c.min = val.parse().ok(),
586                "max" => c.max = val.parse().ok(),
587                "type" => c.type_name = Some(val.to_string()),
588                "pattern" => c.pattern = Some(val.to_string()),
589                "enum" => {
590                    c.enum_values = Some(
591                        val.split('|')
592                            .take(MAX_CONSTRAINT_ENUM_PARTS)
593                            .map(|s| s.to_string())
594                            .collect(),
595                    );
596                }
597                _ => {}
598            }
599        }
600    }
601    c
602}
603
604// ─── Value casting ───────────────────────────────────────
605
606fn cast(val: &str) -> Value {
607    // Quoted strings preserve literal value (bypass auto-casting)
608    // "null" → String("null"), "true" → String("true"), "123" → String("123")
609    if val.len() >= 2 {
610        let bytes = val.as_bytes();
611        if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
612            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
613        {
614            return Value::String(val[1..val.len() - 1].to_string());
615        }
616    }
617
618    match val {
619        "true" => Value::Bool(true),
620        "false" => Value::Bool(false),
621        "null" => Value::Null,
622        _ => {
623            let bytes = val.as_bytes();
624            let len = bytes.len();
625            if len == 0 {
626                return Value::String(String::new());
627            }
628
629            let mut start = 0;
630            if bytes[0] == b'-' {
631                if len == 1 {
632                    return Value::String(val.to_string());
633                }
634                start = 1;
635            }
636
637            if bytes[start] >= b'0' && bytes[start] <= b'9' {
638                let mut dot_pos = None;
639                let mut all_numeric = true;
640                for j in start..len {
641                    if bytes[j] == b'.' {
642                        if dot_pos.is_some() {
643                            all_numeric = false;
644                            break;
645                        }
646                        dot_pos = Some(j);
647                    } else if bytes[j] < b'0' || bytes[j] > b'9' {
648                        all_numeric = false;
649                        break;
650                    }
651                }
652                if all_numeric {
653                    if let Some(dp) = dot_pos {
654                        if dp > start && dp < len - 1 {
655                            if let Ok(f) = val.parse::<f64>() {
656                                return Value::Float(f);
657                            }
658                        }
659                    } else if let Ok(n) = val.parse::<i64>() {
660                        return Value::Int(n);
661                    }
662                }
663            }
664
665            Value::String(val.to_string())
666        }
667    }
668}
669
670fn cast_typed(val: &str, hint: &str) -> Value {
671    match hint {
672        "int" => Value::Int(val.parse().unwrap_or(0)),
673        "float" => Value::Float(val.parse().unwrap_or(0.0)),
674        "bool" => Value::Bool(val.trim() == "true"),
675        "string" => Value::String(val.to_string()),
676        "random" | "random:int" => Value::Int(rng::random_i64()),
677        "random:float" => Value::Float(rng::random_f64_01()),
678        "random:bool" => Value::Bool(rng::random_bool()),
679        _ => cast(val),
680    }
681}
682
683fn strip_comment(val: &str) -> String {
684    let mut result = val.to_string();
685    if let Some(idx) = result.find(" //") {
686        result.truncate(idx);
687    }
688    if let Some(idx) = result.find(" #") {
689        result.truncate(idx);
690    }
691    result.trim_end().to_string()
692}
693
694// ─── Tree helpers ────────────────────────────────────────
695
696fn build_path(stack: &[(i32, StackEntry)]) -> String {
697    let mut parts = Vec::new();
698    for (_, entry) in stack.iter().skip(1) {
699        if let StackEntry::Key(ref k) = entry {
700            parts.push(k.as_str());
701        }
702    }
703    parts.join(".")
704}
705
706fn insert_value(
707    root: &mut HashMap<String, Value>,
708    stack: &[(i32, StackEntry)],
709    parent_idx: usize,
710    key: &str,
711    value: Value,
712) {
713    if let Some(target) = navigate_to_parent(root, stack, parent_idx) {
714        target.insert(key.to_string(), value);
715    }
716    // If the path is broken the line is silently skipped — this should not
717    // happen under well-formed input; malformed input simply loses the entry
718    // rather than inserting it at the wrong nesting level.
719}
720
721fn navigate_to_parent<'a>(
722    root: &'a mut HashMap<String, Value>,
723    stack: &[(i32, StackEntry)],
724    target_idx: usize,
725) -> Option<&'a mut HashMap<String, Value>> {
726    if target_idx == 0 {
727        return Some(root);
728    }
729
730    let path: Vec<&str> = stack
731        .iter()
732        .skip(1)
733        .take(target_idx)
734        .filter_map(|(_, entry)| match entry {
735            StackEntry::Key(k) => Some(k.as_str()),
736            _ => None,
737        })
738        .collect();
739
740    // SAFETY: We navigate a tree of nested HashMaps using a raw pointer to
741    // work around the borrow-checker's inability to track that successive
742    // `get_mut` calls target disjoint subtrees.  The invariants that make
743    // this sound are:
744    //   1. `root` is a valid, exclusively-owned mutable reference for 'a.
745    //   2. We descend strictly downward and never alias: at each step we
746    //      replace `current` with a pointer to a child map, discarding the
747    //      parent pointer.
748    //   3. The returned reference re-borrows from `root`'s lifetime 'a and
749    //      is the only mutable reference handed out by this function.
750    let mut current = root as *mut HashMap<String, Value>;
751    for key in path {
752        let child = unsafe { (*current).get_mut(key) };
753        match child {
754            Some(Value::Object(map)) => current = map as *mut HashMap<String, Value>,
755            _ => return None, // Path segment missing or not an Object
756        }
757    }
758    Some(unsafe { &mut *current })
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn test_simple_key_value() {
767        let data = parse("name Wario\nage 30\nactive true\nscore 99.5\nempty null");
768        let root = data.root.as_object().unwrap();
769        assert_eq!(root["name"], Value::String("Wario".into()));
770        assert_eq!(root["age"], Value::Int(30));
771        assert_eq!(root["active"], Value::Bool(true));
772        assert_eq!(root["score"], Value::Float(99.5));
773        assert_eq!(root["empty"], Value::Null);
774        assert_eq!(data.mode, Mode::Static);
775    }
776
777    #[test]
778    fn test_nested_objects() {
779        let data = parse("server\n  host 0.0.0.0\n  port 8080\n  ssl\n    enabled true");
780        let root = data.root.as_object().unwrap();
781        let server = root["server"].as_object().unwrap();
782        assert_eq!(server["host"], Value::String("0.0.0.0".into()));
783        assert_eq!(server["port"], Value::Int(8080));
784        let ssl = server["ssl"].as_object().unwrap();
785        assert_eq!(ssl["enabled"], Value::Bool(true));
786    }
787
788    #[test]
789    fn test_lists() {
790        let data = parse("inventory\n  - Sword\n  - Shield\n  - Potion");
791        let root = data.root.as_object().unwrap();
792        let inv = root["inventory"].as_array().unwrap();
793        assert_eq!(inv.len(), 3);
794        assert_eq!(inv[0], Value::String("Sword".into()));
795    }
796
797    #[test]
798    fn test_multiline_block() {
799        let data = parse("rules |\n  Rule one.\n  Rule two.\n  Rule three.");
800        let root = data.root.as_object().unwrap();
801        assert_eq!(
802            root["rules"],
803            Value::String("Rule one.\nRule two.\nRule three.".into())
804        );
805    }
806
807    #[test]
808    fn test_comments() {
809        let data = parse("# comment\nname Wario # inline\nage 30 // inline");
810        let root = data.root.as_object().unwrap();
811        assert_eq!(root["name"], Value::String("Wario".into()));
812        assert_eq!(root["age"], Value::Int(30));
813    }
814
815    #[test]
816    fn test_active_mode() {
817        let data = parse("!active\nprice 100\ntax:calc price * 0.2");
818        assert_eq!(data.mode, Mode::Active);
819        let root = data.root.as_object().unwrap();
820        assert_eq!(root["price"], Value::Int(100));
821        // Before engine resolution, :calc value is a string
822        assert_eq!(root["tax"], Value::String("price * 0.2".into()));
823        // Metadata should be saved
824        let meta = data.metadata.get("").unwrap();
825        assert!(meta.contains_key("tax"));
826        assert_eq!(meta["tax"].markers, vec!["calc"]);
827    }
828
829    #[test]
830    fn test_markers_env_default() {
831        let data = parse("!active\nport:env:default:3000 PORT");
832        let meta = data.metadata.get("").unwrap();
833        assert_eq!(meta["port"].markers, vec!["env", "default", "3000"]);
834    }
835
836    #[test]
837    fn test_type_hint() {
838        let data = parse("zip(string) 90210");
839        let root = data.root.as_object().unwrap();
840        assert_eq!(root["zip"], Value::String("90210".into()));
841    }
842
843    #[test]
844    fn test_constraints() {
845        let data = parse("!active\nname[min:3, max:30, required] Wario");
846        let meta = data.metadata.get("").unwrap();
847        let c = meta["name"].constraints.as_ref().unwrap();
848        assert_eq!(c.min, Some(3.0));
849        assert_eq!(c.max, Some(30.0));
850        assert!(c.required);
851    }
852
853    #[test]
854    fn test_random_weights() {
855        let data = parse("!active\ntier:random 90 5 5");
856        let meta = data.metadata.get("").unwrap();
857        assert_eq!(meta["tier"].markers, vec!["random"]);
858        assert_eq!(meta["tier"].args, vec!["90", "5", "5"]);
859    }
860
861    #[test]
862    fn test_tool_directive_flags() {
863        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
864        assert!(data.tool);
865        assert!(!data.schema);
866        assert_eq!(data.mode, Mode::Static);
867        // Raw parse keeps original tree structure
868        let root = data.root.as_object().unwrap();
869        let ws = root["web_search"].as_object().unwrap();
870        assert_eq!(ws["query"], Value::String("test".into()));
871        assert_eq!(ws["lang"], Value::String("ru".into()));
872    }
873
874    #[test]
875    fn test_tool_schema_flags() {
876        let data = parse("!tool\n!schema\nweb_search\n  query string\n");
877        assert!(data.tool);
878        assert!(data.schema);
879    }
880
881    #[test]
882    fn test_parse_caps_nesting_depth() {
883        // Pathological input: one key per line, increasing indentation each time,
884        // with empty values so every line would normally create a new nested object.
885        let mut s = String::new();
886        for i in 0..(MAX_PARSE_NESTING_DEPTH as usize + 64) {
887            s.push_str(&" ".repeat(i));
888            s.push_str(&format!("k{i}\n"));
889        }
890
891        let data = parse(&s);
892        let mut cur = data.root.as_object().unwrap();
893        let mut depth = 0usize;
894        // Follow the single-child chain while it stays nested.
895        loop {
896            if cur.len() != 1 {
897                break;
898            }
899            let (_, v) = cur.iter().next().unwrap();
900            match v {
901                Value::Object(next) => {
902                    depth += 1;
903                    cur = next;
904                }
905                _ => break,
906            }
907        }
908
909        assert!(depth <= MAX_PARSE_NESTING_DEPTH);
910    }
911
912    #[test]
913    fn test_tool_call_reshape() {
914        let data = parse("!tool\nweb_search\n  query test\n  lang ru\n");
915        let shaped = reshape_tool_output(&data.root, false);
916        let m = shaped.as_object().unwrap();
917        assert_eq!(m["tool"], Value::String("web_search".into()));
918        let params = m["params"].as_object().unwrap();
919        assert_eq!(params["query"], Value::String("test".into()));
920        assert_eq!(params["lang"], Value::String("ru".into()));
921    }
922
923    #[test]
924    fn test_tool_schema_reshape() {
925        let data = parse("!tool\n!schema\nweb_search\n  query string\n  lang string\nmemory_write\n  path string\n  value string\n");
926        let shaped = reshape_tool_output(&data.root, true);
927        let m = shaped.as_object().unwrap();
928        let tools = m["tools"].as_array().unwrap();
929        assert_eq!(tools.len(), 2);
930        // Sorted: memory_write before web_search
931        let t0 = tools[0].as_object().unwrap();
932        assert_eq!(t0["name"], Value::String("memory_write".into()));
933        let p0 = t0["params"].as_object().unwrap();
934        assert_eq!(p0["path"], Value::String("string".into()));
935        let t1 = tools[1].as_object().unwrap();
936        assert_eq!(t1["name"], Value::String("web_search".into()));
937    }
938
939    #[test]
940    fn test_tool_empty() {
941        let data = parse("!tool\n");
942        assert!(data.tool);
943        let shaped = reshape_tool_output(&data.root, false);
944        let m = shaped.as_object().unwrap();
945        assert_eq!(m["tool"], Value::Null);
946    }
947
948    #[test]
949    fn test_tool_with_active() {
950        let data = parse("!tool\n!active\nweb_search\n  port:env:default:8080 PORT\n");
951        assert!(data.tool);
952        assert_eq!(data.mode, Mode::Active);
953        // Metadata should be captured for :env:default
954        let meta = data.metadata.get("web_search").unwrap();
955        assert_eq!(meta["port"].markers, vec!["env", "default", "8080"]);
956    }
957}