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