Skip to main content

synx_core/
engine.rs

1//! SYNX Engine — resolves active markers (:random, :calc, :env, :alias, :secret, etc.)
2//! in a parsed SYNX value tree. Only runs in !active mode.
3
4use std::collections::HashMap;
5use std::sync::{Mutex, OnceLock};
6use std::time::{Duration, Instant};
7use crate::calc::safe_calc;
8use crate::parser;
9use crate::rng;
10use crate::value::*;
11
12static SPAM_BUCKETS: OnceLock<Mutex<HashMap<String, Vec<Instant>>>> = OnceLock::new();
13
14/// Maximum expression length accepted by :calc (prevents ReDoS/stack abuse).
15const MAX_CALC_EXPR_LEN: usize = 4096;
16/// Maximum resolved expression length produced by :calc substitutions.
17/// Prevents pathological inputs from growing the expression until OOM.
18const MAX_CALC_RESOLVED_LEN: usize = 64 * 1024;
19/// Maximum file size for :include / :watch reads (10 MB).
20const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
21/// Default maximum include depth.
22const DEFAULT_MAX_INCLUDE_DEPTH: usize = 16;
23/// Maximum object nesting depth for active-mode resolution (prevents stack overflow).
24const MAX_RESOLVE_DEPTH: usize = 512;
25
26/// Upper bound for single `String` scratch buffers built from hostile `:template` / replace paths.
27const MAX_ENGINE_SCRATCH_STRING: usize = 4 * 1024 * 1024;
28
29/// Validate that `full` path stays within the `base` directory (jail).
30/// Returns `Ok(canonical)` or an `Err` describing the violation.
31fn jail_path(base: &str, file_path: &str) -> Result<std::path::PathBuf, String> {
32    // Always check leading "/" or "\" first so the message is portable:
33    // POSIX absolute paths and Windows rooted paths both produce the same
34    // "rooted paths are not allowed" string.
35    if let Some(first) = file_path.chars().next() {
36        if first == '/' || first == '\\' {
37            return Err(format!("SECURITY: rooted paths are not allowed: '{}'", file_path));
38        }
39    }
40    // Block any other absolute paths (Windows drive letters, UNC, etc.).
41    let fp = std::path::Path::new(file_path);
42    if fp.is_absolute() {
43        return Err(format!("SECURITY: absolute paths are not allowed: '{}'", file_path));
44    }
45
46    let base_canonical = match std::fs::canonicalize(base) {
47        Ok(p) => p,
48        Err(_) => std::path::PathBuf::from(base),
49    };
50    let full = base_canonical.join(file_path);
51    let full_canonical = match std::fs::canonicalize(&full) {
52        Ok(p) => p,
53        Err(_) => {
54            // File may not exist yet — at least verify no ".." escapes.
55            let normalized = full.to_string_lossy();
56            if normalized.contains("..") {
57                return Err(format!("SECURITY: path traversal detected: '{}'", file_path));
58            }
59            // Without canonicalisation we cannot prove containment; only
60            // accept if the un-canonicalised join still starts with the base.
61            if !full.starts_with(&base_canonical) {
62                return Err(format!("SECURITY: path escapes base directory: '{}'", file_path));
63            }
64            return Ok(full);
65        }
66    };
67    if !full_canonical.starts_with(&base_canonical) {
68        return Err(format!("SECURITY: path escapes base directory: '{}'", file_path));
69    }
70    Ok(full_canonical)
71}
72
73/// Check file size before reading.
74fn check_file_size(path: &std::path::Path) -> Result<(), String> {
75    match std::fs::metadata(path) {
76        Ok(meta) if meta.len() > MAX_FILE_SIZE => {
77            Err(format!("SECURITY: file too large ({} bytes, max {})", meta.len(), MAX_FILE_SIZE))
78        }
79        _ => Ok(()),
80    }
81}
82
83/// Normalise std::io::Error kinds to a portable, OS-agnostic message so that
84/// INCLUDE_ERR / WATCH_ERR strings don't drift between Windows ("The system
85/// cannot find the file specified. (os error 2)") and POSIX ("No such file or
86/// directory"). Other kinds fall through to the platform-specific message.
87fn fmt_io_err(e: &std::io::Error, ctx: &str) -> String {
88    use std::io::ErrorKind;
89    match e.kind() {
90        ErrorKind::NotFound => format!("file not found: {}", ctx),
91        ErrorKind::PermissionDenied => format!("permission denied: {}", ctx),
92        _ => e.to_string(),
93    }
94}
95
96/// Resolve all active-mode markers in a ParseResult.
97/// Returns the resolved root Value.
98pub fn resolve(result: &mut ParseResult, options: &Options) {
99    if result.mode != Mode::Active {
100        return;
101    }
102    let metadata = std::mem::take(&mut result.metadata);
103    let includes_directives = std::mem::take(&mut result.includes);
104    let use_directives = std::mem::take(&mut result.uses);
105
106    // ── Load !use packages (before includes, so packages are available) ──
107    #[cfg(feature = "wasm")]
108    let mut wasm_runtime = crate::wasm::WasmMarkerRuntime::new();
109    let packages_map = load_packages(
110        &use_directives,
111        options,
112        #[cfg(feature = "wasm")]
113        &mut wasm_runtime,
114    );
115
116    // If wasm feature is enabled and markers were loaded, create options with runtime
117    #[cfg(feature = "wasm")]
118    let wasm_options;
119    #[cfg(feature = "wasm")]
120    let options = if !wasm_runtime.marker_names().is_empty() {
121        wasm_options = Options {
122            wasm_runtime: Some(std::sync::Arc::new(wasm_runtime)),
123            ..options.clone()
124        };
125        &wasm_options
126    } else {
127        options
128    };
129
130    // ── Load !include files ──
131    let mut includes_map = load_includes(&includes_directives, options);
132
133    // ── Pre-pass: also register `:include`/`:import` marker keys as aliases ──
134    // This makes `{leaf:<key>}` interpolation work for the README pattern:
135    //
136    //   db:include ./common.synx
137    //   greeting Hello, {site_name:db}!
138    //
139    // Without this, only `!include` directives feed the alias map.
140    if let Value::Object(ref root_map) = result.root {
141        for (key, _) in root_map.iter() {
142            let meta = match metadata.get("").and_then(|mm| mm.get(key)) {
143                Some(m) => m,
144                None => continue,
145            };
146            let is_inc = meta.markers.iter().any(|m| m == "include" || m == "import");
147            if !is_inc { continue; }
148            // The current value is the path string from the parser.
149            let path = match root_map.get(key) {
150                Some(Value::String(s)) => s.clone(),
151                _ => continue,
152            };
153            let base = options.base_path.as_deref().unwrap_or(".");
154            let full = match jail_path(base, &path) {
155                Ok(p) => p,
156                Err(_) => continue,
157            };
158            if check_file_size(&full).is_err() { continue; }
159            let text = match std::fs::read_to_string(&full) {
160                Ok(t) => t,
161                Err(_) => continue,
162            };
163            let mut included = parser::parse(&text);
164            if included.mode == Mode::Active {
165                let mut child_opts = options.clone();
166                child_opts._include_depth += 1;
167                if let Some(parent) = full.parent() {
168                    child_opts.base_path = Some(parent.to_string_lossy().into_owned());
169                }
170                resolve(&mut included, &child_opts);
171            }
172            includes_map.entry(key.clone()).or_insert(included.root);
173        }
174    }
175
176    // ── Merge packages into root before resolution ──
177    if let Value::Object(ref mut root_map) = result.root {
178        for (alias, pkg_value) in &packages_map {
179            root_map.entry(alias.clone()).or_insert_with(|| pkg_value.clone());
180        }
181    }
182
183    // ── :inherit pre-pass ──
184    apply_inheritance(&mut result.root, &metadata);
185    // Remove private blocks (keys starting with _)
186    if let Value::Object(ref mut root_map) = result.root {
187        root_map.retain(|k, _| !k.starts_with('_'));
188    }
189
190    // ── Build type registry ──
191    let type_registry = build_type_registry(&metadata);
192    // ── Build constraint registry ──
193    let constraint_registry = build_constraint_registry(&metadata);
194
195    // SAFETY: `root_ptr` is a raw pointer to `result.root` used exclusively
196    // for *immutable* read access inside marker handlers (:calc, :alias,
197    // :map, :watch) that need to look up other keys in the root
198    // while also holding a mutable reference to a child object.
199    // The invariants that keep this sound:
200    //   1. We never write through `root_ptr` — only reads via `&*root_ptr`.
201    //   2. Mutable writes go through `map` (the current object), which is
202    //      always a distinct subtree from what we read via `root_ptr`.
203    //   3. The pointer is valid for the entire duration of `resolve_value`.
204    let root_ptr = &mut result.root as *mut Value;
205    resolve_value(&mut result.root, root_ptr, options, &metadata, "", &includes_map, 0);
206
207    // ── Validate field constraints (global, by field name) ──
208    validate_field_constraints(&mut result.root, &constraint_registry);
209    
210    // ── Validate field types ──
211    validate_field_types(&mut result.root, &type_registry, "");
212    
213    result.metadata = metadata;
214    result.includes = includes_directives;
215}
216
217fn resolve_value(
218    value: &mut Value,
219    root_ptr: *mut Value,
220    options: &Options,
221    metadata: &HashMap<String, MetaMap>,
222    path: &str,
223    includes: &HashMap<String, Value>,
224    depth: usize,
225) {
226    // Guard: prevent stack overflow from deeply nested objects
227    if depth >= MAX_RESOLVE_DEPTH {
228        // Safety: recursion only descends into Object variants (see lines below),
229        // so value is always an Object here. Non-Object values are safe to skip.
230        if let Value::Object(ref mut map) = value {
231            for val in map.values_mut() {
232                *val = Value::String(
233                    "NESTING_ERR: maximum object nesting depth exceeded".to_string()
234                );
235            }
236        }
237        return;
238    }
239
240    let meta_map = metadata.get(path).cloned();
241
242    if let Value::Object(ref mut map) = value {
243        let keys: Vec<String> = map.keys().cloned().collect();
244
245        // First pass: recurse into nested objects/arrays
246        for key in &keys {
247            let child_path = if path.is_empty() {
248                key.clone()
249            } else {
250                format!("{}.{}", path, key)
251            };
252
253            if let Some(child) = map.get_mut(key) {
254                match child {
255                    Value::Object(_) => {
256                        resolve_value(child, root_ptr, options, metadata, &child_path, includes, depth + 1);
257                    }
258                    Value::Array(arr) => {
259                        for item in arr.iter_mut() {
260                            if let Value::Object(_) = item {
261                                resolve_value(item, root_ptr, options, metadata, &child_path, includes, depth + 1);
262                            }
263                        }
264                    }
265                    _ => {}
266                }
267            }
268        }
269
270        // Second pass: apply markers
271        if let Some(ref mm) = meta_map {
272            for key in &keys {
273                let meta = match mm.get(key) {
274                    Some(m) => m.clone(),
275                    None => continue,
276                };
277
278                apply_markers(map, key, &meta, root_ptr, options, path, metadata, includes);
279            }
280        }
281
282        // Third pass: auto-{} interpolation on all string values
283        let keys2: Vec<String> = map.keys().cloned().collect();
284        for key in &keys2 {
285            if let Some(Value::String(s)) = map.get(key) {
286                if s.contains('{') {
287                    let root_ref = unsafe { &*root_ptr };
288                    let result = resolve_interpolation(s, root_ref, map, includes);
289                    if result != *s {
290                        map.insert(key.to_string(), Value::String(result));
291                    }
292                }
293            }
294        }
295    }
296}
297
298fn apply_markers(
299    map: &mut HashMap<String, Value>,
300    key: &str,
301    meta: &Meta,
302    root_ptr: *mut Value,
303    options: &Options,
304    path: &str,
305    metadata: &HashMap<String, MetaMap>,
306    _includes: &HashMap<String, Value>,
307) {
308    let markers = &meta.markers;
309
310    // ── :spam ──
311    // Syntax: key:spam:MAX_CALLS:WINDOW_SEC target
312    // WINDOW_SEC defaults to 1 when omitted.
313    // If target is a key path, resolves its value after passing the limit check.
314    if markers.contains(&"spam".to_string()) {
315        let spam_idx = markers.iter().position(|m| m == "spam").unwrap();
316        let max_calls = markers
317            .get(spam_idx + 1)
318            .and_then(|s| s.parse::<usize>().ok())
319            .unwrap_or(0);
320        let window_sec = markers
321            .get(spam_idx + 2)
322            .and_then(|s| s.parse::<u64>().ok())
323            .unwrap_or(1);
324
325        if max_calls == 0 {
326            map.insert(
327                key.to_string(),
328                Value::String("SPAM_ERR: invalid limit, use :spam:MAX[:WINDOW_SEC]".to_string()),
329            );
330            return;
331        }
332
333        let target = map
334            .get(key)
335            .map(value_to_string)
336            .unwrap_or_else(|| key.to_string());
337        let bucket_key = format!("{}::{}", key, target);
338
339        if !allow_spam_access(&bucket_key, max_calls, window_sec) {
340            map.insert(
341                key.to_string(),
342                Value::String(format!(
343                    "SPAM_ERR: '{}' exceeded {} calls per {}s",
344                    target, max_calls, window_sec
345                )),
346            );
347            return;
348        }
349
350        if let Some(resolved) = map
351            .get(key)
352            .and_then(|v| {
353                let t = value_to_string(v);
354                let root_ref = unsafe { &*root_ptr };
355                deep_get(root_ref, &t).or_else(|| map.get(t.as_str()).cloned())
356            })
357        {
358            map.insert(key.to_string(), resolved);
359        }
360    }
361
362    // ── :include / :import ──
363    if markers.contains(&"include".to_string()) || markers.contains(&"import".to_string()) {
364        if let Some(Value::String(file_path)) = map.get(key) {
365            let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
366            if options._include_depth >= max_depth {
367                map.insert(
368                    key.to_string(),
369                    Value::String(format!("INCLUDE_ERR: max include depth ({}) exceeded", max_depth)),
370                );
371                return;
372            }
373            let base = options
374                .base_path
375                .as_deref()
376                .unwrap_or(".");
377            let full = match jail_path(base, file_path) {
378                Ok(p) => p,
379                Err(e) => {
380                    map.insert(key.to_string(), Value::String(format!("INCLUDE_ERR: {}", e)));
381                    return;
382                }
383            };
384            if let Err(e) = check_file_size(&full) {
385                map.insert(key.to_string(), Value::String(format!("INCLUDE_ERR: {}", e)));
386                return;
387            }
388            match std::fs::read_to_string(&full) {
389                Ok(text) => {
390                    let mut included = parser::parse(&text);
391                    if included.mode == Mode::Active {
392                        let mut child_opts = options.clone();
393                        child_opts._include_depth += 1;
394                        if let Some(parent) = full.parent() {
395                            child_opts.base_path = Some(parent.to_string_lossy().into_owned());
396                        }
397                        resolve(&mut included, &child_opts);
398                    }
399                    map.insert(key.to_string(), included.root);
400                }
401                Err(e) => {
402                    map.insert(
403                        key.to_string(),
404                        Value::String(format!("INCLUDE_ERR: {}", fmt_io_err(&e, file_path))),
405                    );
406                }
407            }
408        }
409        return;
410    }
411
412    // ── :env ──
413    if markers.contains(&"env".to_string()) {
414        if let Some(Value::String(var_name)) = map.get(key) {
415            let env_val = if let Some(ref env_map) = options.env {
416                env_map.get(var_name.as_str()).cloned()
417            } else {
418                std::env::var(var_name).ok()
419            };
420
421            let force_string = meta.type_hint.as_deref() == Some("string");
422            let default_idx = markers.iter().position(|m| m == "default");
423            if let Some(val) = env_val.filter(|v| !v.is_empty()) {
424                let resolved = if force_string {
425                    Value::String(val)
426                } else {
427                    cast_primitive(&val)
428                };
429                map.insert(key.to_string(), resolved);
430            } else if let Some(di) = default_idx {
431                if markers.len() > di + 1 {
432                    // Join all parts after 'default' back with ':'
433                    // to preserve IPs (0.0.0.0) and compound values
434                    let fallback = markers[di + 1..].join(":");
435                    let resolved = if force_string {
436                        Value::String(fallback)
437                    } else {
438                        cast_primitive(&fallback)
439                    };
440                    map.insert(key.to_string(), resolved);
441                } else {
442                    map.insert(key.to_string(), Value::Null);
443                }
444            } else {
445                map.insert(key.to_string(), Value::Null);
446            }
447        }
448    }
449
450    // ── :random ──
451    if markers.contains(&"random".to_string()) {
452        if let Some(Value::Array(arr)) = map.get(key) {
453            if arr.is_empty() {
454                map.insert(key.to_string(), Value::Null);
455                return;
456            }
457            let picked = if !meta.args.is_empty() {
458                let weights: Vec<f64> = meta.args.iter().filter_map(|s| s.parse().ok()).collect();
459                weighted_random(arr, &weights)
460            } else {
461                arr[rng::random_usize(arr.len())].clone()
462            };
463            map.insert(key.to_string(), picked);
464        }
465    }
466
467    // ── :ref ──
468    // Like :alias but feeds the resolved value into subsequent markers.
469    // Supports :ref:calc shorthand: key:ref:calc:*2 base_rate → resolves base_rate, then applies "VALUE * 2".
470    if markers.contains(&"ref".to_string()) {
471        if let Some(Value::String(target)) = map.get(key) {
472            let root_ref = unsafe { &*root_ptr };
473            let resolved = deep_get(root_ref, target)
474                .or_else(|| map.get(target.as_str()).cloned())
475                .unwrap_or(Value::Null);
476
477            // If :calc follows with a shorthand expression
478            if markers.contains(&"calc".to_string()) {
479                if let Some(n) = value_as_number(&resolved) {
480                    let calc_idx = markers.iter().position(|m| m == "calc").unwrap();
481                    if let Some(calc_expr) = markers.get(calc_idx + 1) {
482                        let first = calc_expr.chars().next().unwrap_or(' ');
483                        if "+-*/%".contains(first) {
484                            let expr = format!("{} {}", format_number(n), calc_expr);
485                            match safe_calc(&expr) {
486                                Ok(result) => {
487                                    let v = if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
488                                        Value::Int(result as i64)
489                                    } else {
490                                        Value::Float(result)
491                                    };
492                                    map.insert(key.to_string(), v);
493                                }
494                                Err(e) => {
495                                    map.insert(key.to_string(), Value::String(format!("CALC_ERR: {}", e)));
496                                }
497                            }
498                        } else {
499                            map.insert(key.to_string(), resolved);
500                        }
501                    } else {
502                        map.insert(key.to_string(), resolved);
503                    }
504                } else {
505                    map.insert(key.to_string(), resolved);
506                }
507            } else {
508                map.insert(key.to_string(), resolved);
509            }
510        }
511    }
512
513    // ── :i18n ──
514    // Selects a localized value from a nested object based on options.lang.
515    // Supports pluralization: key:i18n:COUNT_FIELD
516    //   When count field is specified, the language entry must contain plural forms:
517    //   title:i18n:item_count
518    //     en
519    //       one {count} item
520    //       other {count} items
521    //     ru
522    //       one {count} предмет
523    //       few {count} предмета
524    //       many {count} предметов
525    //       other {count} предметов
526    if markers.contains(&"i18n".to_string()) {
527        if let Some(Value::Object(translations)) = map.get(key) {
528            let lang = options.lang.as_deref().unwrap_or("en");
529            let val = translations.get(lang)
530                .or_else(|| translations.get("en"))
531                .or_else(|| translations.values().next())
532                .cloned()
533                .unwrap_or(Value::Null);
534
535            // Check for pluralization: i18n:count_field
536            let i18n_idx = markers.iter().position(|m| m == "i18n").unwrap();
537            let count_field = markers.get(i18n_idx + 1).cloned();
538
539            if let (Some(ref cf), Value::Object(ref plural_forms)) = (&count_field, &val) {
540                // Look up count value from current map or root
541                let count_val = map.get(cf)
542                    .and_then(value_as_number)
543                    .or_else(|| {
544                        let root_ref = unsafe { &*root_ptr };
545                        deep_get(root_ref, cf).and_then(|v| value_as_number(&v))
546                    })
547                    .unwrap_or(0.0) as i64;
548
549                let category = plural_category(lang, count_val);
550                let chosen = plural_forms.get(category)
551                    .or_else(|| plural_forms.get("other"))
552                    .or_else(|| plural_forms.values().next())
553                    .cloned()
554                    .unwrap_or(Value::Null);
555
556                // Substitute {count} in the result string
557                if let Value::String(ref s) = chosen {
558                    let replaced = s.replace("{count}", &count_val.to_string());
559                    map.insert(key.to_string(), Value::String(replaced));
560                } else {
561                    map.insert(key.to_string(), chosen);
562                }
563            } else {
564                map.insert(key.to_string(), val);
565            }
566        }
567    }
568
569    // ── :calc ──
570    if markers.contains(&"calc".to_string()) {
571        if let Some(Value::String(expr)) = map.get(key) {
572            if expr.len() > MAX_CALC_EXPR_LEN {
573                map.insert(
574                    key.to_string(),
575                    Value::String(format!("CALC_ERR: expression too long ({} chars, max {})", expr.len(), MAX_CALC_EXPR_LEN)),
576                );
577                return;
578            }
579            let mut resolved = expr.clone();
580
581            // Substitute variables from root (flat keys)
582            let root_ref = unsafe { &*root_ptr };
583            if let Value::Object(ref root_map) = root_ref {
584                for (rk, rv) in root_map {
585                    if let Some(n) = value_as_number(rv) {
586                        resolved = replace_word(&resolved, rk, &format_number(n));
587                        if resolved.len() > MAX_CALC_RESOLVED_LEN {
588                            map.insert(
589                                key.to_string(),
590                                Value::String(format!(
591                                    "CALC_ERR: resolved expression too long (max {} bytes)",
592                                    MAX_CALC_RESOLVED_LEN
593                                )),
594                            );
595                            return;
596                        }
597                    }
598                }
599            }
600
601            // Substitute from current object (flat keys)
602            for (rk, rv) in map.iter() {
603                if rk != key {
604                    if let Some(n) = value_as_number(rv) {
605                        resolved = replace_word(&resolved, rk, &format_number(n));
606                        if resolved.len() > MAX_CALC_RESOLVED_LEN {
607                            map.insert(
608                                key.to_string(),
609                                Value::String(format!(
610                                    "CALC_ERR: resolved expression too long (max {} bytes)",
611                                    MAX_CALC_RESOLVED_LEN
612                                )),
613                            );
614                            return;
615                        }
616                    }
617                }
618            }
619
620            // Substitute dot-path references (e.g., base.hp, server.port)
621            let root_ref2 = unsafe { &*root_ptr };
622            let mut dot_resolved = String::new();
623            let bytes = resolved.as_bytes();
624            let len = bytes.len();
625            let mut i = 0;
626            while i < len {
627                if is_word_char(bytes[i]) {
628                    let start = i;
629                    let mut has_dot = false;
630                    while i < len && (is_word_char(bytes[i]) || bytes[i] == b'.') {
631                        if bytes[i] == b'.' { has_dot = true; }
632                        i += 1;
633                    }
634                    let token = &resolved[start..i];
635                    if has_dot && token.contains('.') {
636                        if let Some(val) = deep_get(root_ref2, token) {
637                            if let Some(n) = value_as_number(&val) {
638                                dot_resolved.push_str(&format_number(n));
639                                if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
640                                    map.insert(
641                                        key.to_string(),
642                                        Value::String(format!(
643                                            "CALC_ERR: resolved expression too long (max {} bytes)",
644                                            MAX_CALC_RESOLVED_LEN
645                                        )),
646                                    );
647                                    return;
648                                }
649                                continue;
650                            }
651                        }
652                    }
653                    dot_resolved.push_str(token);
654                    if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
655                        map.insert(
656                            key.to_string(),
657                            Value::String(format!(
658                                "CALC_ERR: resolved expression too long (max {} bytes)",
659                                MAX_CALC_RESOLVED_LEN
660                            )),
661                        );
662                        return;
663                    }
664                } else {
665                    dot_resolved.push(bytes[i] as char);
666                    i += 1;
667                    if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
668                        map.insert(
669                            key.to_string(),
670                            Value::String(format!(
671                                "CALC_ERR: resolved expression too long (max {} bytes)",
672                                MAX_CALC_RESOLVED_LEN
673                            )),
674                        );
675                        return;
676                    }
677                }
678            }
679            resolved = dot_resolved;
680
681            match safe_calc(&resolved) {
682                Ok(result) => {
683                    let v = if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
684                        Value::Int(result as i64)
685                    } else {
686                        Value::Float(result)
687                    };
688                    map.insert(key.to_string(), v);
689                }
690                Err(e) => {
691                    map.insert(
692                        key.to_string(),
693                        Value::String(format!("CALC_ERR: {}", e)),
694                    );
695                }
696            }
697        }
698    }
699
700    // ── :alias ──
701    if markers.contains(&"alias".to_string()) {
702        if let Some(Value::String(target)) = map.get(key) {
703            let target = target.clone();
704            // Build the full dot-path of the current key
705            let current_path = if path.is_empty() {
706                key.to_string()
707            } else {
708                format!("{}.{}", path, key)
709            };
710            // Detect direct self-reference: key:alias key
711            if target == key || target == current_path {
712                map.insert(
713                    key.to_string(),
714                    Value::String(format!("ALIAS_ERR: self-referential alias: {} → {}", current_path, target)),
715                );
716            } else {
717                // Detect one-hop cycle: a → b → a
718                // Only flag as cycle if the target key ALSO has an :alias marker.
719                // Without this check, plain string values that happen to match the current
720                // key name would produce false-positive ALIAS_ERR results.
721                let root_ref = unsafe { &*root_ptr };
722                let target_val = deep_get(root_ref, &target);
723                // Determine the metadata path of the target key
724                let (target_parent, target_key_name) = if let Some(dot) = target.rfind('.') {
725                    (target[..dot].to_string(), target[dot + 1..].to_string())
726                } else {
727                    (String::new(), target.clone())
728                };
729                let target_has_alias = metadata
730                    .get(&target_parent)
731                    .and_then(|mm| mm.get(&target_key_name))
732                    .map(|m| m.markers.contains(&"alias".to_string()))
733                    .unwrap_or(false);
734                let is_cycle = target_has_alias && match &target_val {
735                    Some(Value::String(s)) => s == key || s == &current_path,
736                    _ => false,
737                };
738                if is_cycle {
739                    // Stable, order-independent message: sort the participants
740                    // lexicographically so both keys produce the same string
741                    // regardless of HashMap iteration order.
742                    let (a, b) = if current_path <= target {
743                        (current_path.as_str(), target.as_str())
744                    } else {
745                        (target.as_str(), current_path.as_str())
746                    };
747                    map.insert(
748                        key.to_string(),
749                        Value::String(format!("ALIAS_ERR: circular alias detected: {} → {}", a, b)),
750                    );
751                } else {
752                    let val = target_val.unwrap_or(Value::Null);
753                    map.insert(key.to_string(), val);
754                }
755            }
756        }
757    }
758
759    // ── :secret ──
760    if markers.contains(&"secret".to_string()) {
761        if let Some(val) = map.get(key) {
762            let s = value_to_string(val);
763            map.insert(key.to_string(), Value::Secret(s));
764        }
765    }
766
767    // ── :unique ──
768    if markers.contains(&"unique".to_string()) {
769        if let Some(Value::Array(arr)) = map.get(key) {
770            let mut seen = Vec::new();
771            let mut unique = Vec::new();
772            for item in arr {
773                let s = value_to_string(item);
774                if !seen.contains(&s) {
775                    seen.push(s);
776                    unique.push(item.clone());
777                }
778            }
779            map.insert(key.to_string(), Value::Array(unique));
780        }
781    }
782
783    // ── :geo ──
784    if markers.contains(&"geo".to_string()) {
785        if let Some(Value::Array(arr)) = map.get(key) {
786            let region = options.region.as_deref().unwrap_or("US");
787            let prefix = format!("{} ", region);
788            let found = arr.iter().find(|item| {
789                if let Value::String(s) = item {
790                    s.starts_with(&prefix)
791                } else {
792                    false
793                }
794            });
795
796            let result = if let Some(Value::String(s)) = found {
797                Value::String(s[prefix.len()..].trim().to_string())
798            } else if let Some(first) = arr.first() {
799                if let Value::String(s) = first {
800                    if let Some(space) = s.find(' ') {
801                        Value::String(s[space + 1..].trim().to_string())
802                    } else {
803                        first.clone()
804                    }
805                } else {
806                    first.clone()
807                }
808            } else {
809                Value::Null
810            };
811            map.insert(key.to_string(), result);
812        }
813    }
814
815    // ── :template (legacy — handled by auto-{} in resolve_value) ──
816
817    // ── :split ──
818    if markers.contains(&"split".to_string()) {
819        if let Some(Value::String(s)) = map.get(key) {
820            let split_idx = markers.iter().position(|m| m == "split").unwrap();
821            let sep = if split_idx + 1 < markers.len() {
822                delimiter_from_keyword(&markers[split_idx + 1])
823            } else {
824                ",".to_string()
825            };
826            let items: Vec<Value> = s
827                .split(&sep)
828                .map(|p| p.trim())
829                .filter(|p| !p.is_empty())
830                .map(|p| cast_primitive(p))
831                .collect();
832            map.insert(key.to_string(), Value::Array(items));
833        }
834    }
835
836    // ── :join ──
837    if markers.contains(&"join".to_string()) {
838        if let Some(Value::Array(arr)) = map.get(key) {
839            let join_idx = markers.iter().position(|m| m == "join").unwrap();
840            let sep = if join_idx + 1 < markers.len() {
841                delimiter_from_keyword(&markers[join_idx + 1])
842            } else {
843                ",".to_string()
844            };
845            let joined: String = arr
846                .iter()
847                .map(|v| value_to_string(v))
848                .collect::<Vec<_>>()
849                .join(&sep);
850            map.insert(key.to_string(), Value::String(joined));
851        }
852    }
853
854    // ── :default (standalone, without :env) ──
855    if markers.contains(&"default".to_string()) && !markers.contains(&"env".to_string()) {
856        let is_empty = match map.get(key) {
857            Some(Value::Null) | None => true,
858            Some(Value::String(s)) if s.is_empty() => true,
859            _ => false,
860        };
861        if is_empty {
862            let di = markers.iter().position(|m| m == "default").unwrap();
863            if markers.len() > di + 1 {
864                let fallback = markers[di + 1..].join(":");
865                let resolved = if meta.type_hint.as_deref() == Some("string") {
866                    Value::String(fallback)
867                } else {
868                    cast_primitive(&fallback)
869                };
870                map.insert(key.to_string(), resolved);
871            }
872        }
873    }
874
875    // ── :clamp ──
876    // Syntax: key:clamp:MIN:MAX value
877    // Clamps a numeric value to [MIN, MAX].
878    if markers.contains(&"clamp".to_string()) {
879        let clamp_idx = markers.iter().position(|m| m == "clamp").unwrap();
880        let min_s = markers.get(clamp_idx + 1).cloned().unwrap_or_default();
881        let max_s = markers.get(clamp_idx + 2).cloned().unwrap_or_default();
882        if let (Ok(lo), Ok(hi)) = (min_s.parse::<f64>(), max_s.parse::<f64>()) {
883            if lo > hi {
884                map.insert(key.to_string(), Value::String(
885                    format!("CONSTRAINT_ERR: clamp min ({}) > max ({})", lo, hi),
886                ));
887            } else if let Some(n) = map.get(key).and_then(value_as_number) {
888                let clamped = n.clamp(lo, hi);
889                let v = if clamped.fract() == 0.0 && clamped.abs() < i64::MAX as f64 {
890                    Value::Int(clamped as i64)
891                } else {
892                    Value::Float(clamped)
893                };
894                map.insert(key.to_string(), v);
895            }
896        }
897    }
898
899    // ── :round ──
900    // Syntax: key:round:N value  (N = decimal places, default 0)
901    // Works standalone or after :calc: key:calc:round:2 expr
902    if markers.contains(&"round".to_string()) {
903        let round_idx = markers.iter().position(|m| m == "round").unwrap();
904        let decimals: u32 = markers.get(round_idx + 1)
905            .and_then(|s| s.parse().ok())
906            .unwrap_or(0);
907        if let Some(n) = map.get(key).and_then(value_as_number) {
908            let factor = 10f64.powi(decimals as i32);
909            let rounded = (n * factor).round() / factor;
910            let v = if decimals == 0 {
911                Value::Int(rounded as i64)
912            } else {
913                Value::Float(rounded)
914            };
915            map.insert(key.to_string(), v);
916        }
917    }
918
919    // ── :map ──
920    // Syntax: key:map:source_key\n  - lookup_val result
921    // Looks up `source_key` in root, finds matching "lookup_val result" entry in the array.
922    if markers.contains(&"map".to_string()) {
923        if let Some(Value::Array(arr)) = map.get(key) {
924            let map_idx = markers.iter().position(|m| m == "map").unwrap();
925            let source_key = markers.get(map_idx + 1).cloned().unwrap_or_default();
926            let lookup_val = if !source_key.is_empty() {
927                let root_ref = unsafe { &*root_ptr };
928                deep_get(root_ref, &source_key)
929                    .or_else(|| map.get(&source_key).cloned())
930                    .map(|v| value_to_string(&v))
931                    .unwrap_or_default()
932            } else {
933                // Use the current string value as lookup key
934                match map.get(key) {
935                    Some(Value::String(s)) => s.clone(),
936                    _ => String::new(),
937                }
938            };
939
940            // Find matching entry: "lookup_val result_text"
941            let arr_clone = arr.clone();
942            let result = arr_clone.iter().find_map(|item| {
943                if let Value::String(s) = item {
944                    if let Some(space) = s.find(' ') {
945                        if s[..space].trim() == lookup_val {
946                            return Some(cast_primitive(s[space + 1..].trim()));
947                        }
948                    }
949                }
950                None
951            });
952            map.insert(key.to_string(), result.unwrap_or(Value::Null));
953        }
954    }
955
956    // ── :format ──
957    // Syntax: key:format:PATTERN value  (printf-style: %.2f, %d, %05d, %e)
958    // Converts numeric or string value to a formatted string.
959    if markers.contains(&"format".to_string()) {
960        let fmt_idx = markers.iter().position(|m| m == "format").unwrap();
961        let pattern = markers.get(fmt_idx + 1).cloned().unwrap_or_else(|| "%s".to_string());
962        if let Some(current) = map.get(key) {
963            let formatted = apply_format_pattern(&pattern, current);
964            map.insert(key.to_string(), Value::String(formatted));
965        }
966    }
967
968    // ── :replace:FROM:TO ──    (since 3.6.2)
969    // Literal substring replacement on a string value. `TO` defaults to "" (deletion).
970    // `FROM`/`TO` cannot contain ':' because the marker chain is colon-delimited;
971    // for those cases use `{interpolation}` instead.
972    if markers.contains(&"replace".to_string()) {
973        if let Some(Value::String(s)) = map.get(key) {
974            let idx = markers.iter().position(|m| m == "replace").unwrap();
975            let from = markers.get(idx + 1).cloned().unwrap_or_default();
976            let to = markers.get(idx + 2).cloned().unwrap_or_default();
977            if !from.is_empty() {
978                let replaced = s.replace(&from, &to);
979                map.insert(key.to_string(), Value::String(replaced));
980            }
981        }
982    }
983
984    // ── :sort  /  :sort:desc ──    (since 3.6.2)
985    // Sort an array. Numeric items compare numerically; otherwise lexicographic.
986    if markers.contains(&"sort".to_string()) {
987        if let Some(Value::Array(arr)) = map.get(key) {
988            let idx = markers.iter().position(|m| m == "sort").unwrap();
989            let desc = matches!(markers.get(idx + 1).map(|s| s.as_str()), Some("desc"));
990            let mut sorted = arr.clone();
991            sorted.sort_by(|a, b| {
992                match (value_as_number(a), value_as_number(b)) {
993                    (Some(an), Some(bn)) => an
994                        .partial_cmp(&bn)
995                        .unwrap_or(std::cmp::Ordering::Equal),
996                    _ => value_to_string(a).cmp(&value_to_string(b)),
997                }
998            });
999            if desc { sorted.reverse(); }
1000            map.insert(key.to_string(), Value::Array(sorted));
1001        }
1002    }
1003
1004    // ── :sum ──    (since 3.6.2)
1005    // Sum the numeric items of an array. Non-numeric items are ignored.
1006    // Returns Int when all summands are integers, Float otherwise.
1007    if markers.contains(&"sum".to_string()) {
1008        if let Some(Value::Array(arr)) = map.get(key) {
1009            let mut total: f64 = 0.0;
1010            let mut all_int = true;
1011            for v in arr {
1012                match v {
1013                    Value::Int(n) => total += *n as f64,
1014                    Value::Float(f) => {
1015                        total += *f;
1016                        if f.fract() != 0.0 { all_int = false; }
1017                    }
1018                    Value::String(s) => {
1019                        if let Ok(f) = s.parse::<f64>() {
1020                            total += f;
1021                            if f.fract() != 0.0 { all_int = false; }
1022                        }
1023                    }
1024                    _ => {}
1025                }
1026            }
1027            let result = if all_int && total.fract() == 0.0 && total.abs() < i64::MAX as f64 {
1028                Value::Int(total as i64)
1029            } else {
1030                Value::Float(total)
1031            };
1032            map.insert(key.to_string(), result);
1033        }
1034    }
1035
1036    // ── :fallback ──
1037    // Syntax: key:fallback:DEFAULT_PATH value
1038    // If the value (treated as a file path) doesn't exist on disk, use the fallback.
1039    // Falls back to default if value is also null/empty.
1040    if markers.contains(&"fallback".to_string()) {
1041        let fb_idx = markers.iter().position(|m| m == "fallback").unwrap();
1042        let default_val = markers.get(fb_idx + 1).cloned().unwrap_or_default();
1043        let use_fallback = match map.get(key) {
1044            None | Some(Value::Null) => true,
1045            Some(Value::String(s)) if s.is_empty() => true,
1046            Some(Value::String(s)) => {
1047                let base = options.base_path.as_deref().unwrap_or(".");
1048                match jail_path(base, s) {
1049                    Ok(safe) => !safe.exists(),
1050                    Err(_) => true, // path escapes jail → treat as missing → use fallback
1051                }
1052            }
1053            _ => false,
1054        };
1055        if use_fallback && !default_val.is_empty() {
1056            map.insert(key.to_string(), Value::String(default_val));
1057        }
1058    }
1059
1060    // ── :once ──
1061    // Syntax: key:once  or  key:once:uuid  or  key:once:random  or  key:once:timestamp
1062    // Generates a value once and persists it in a .synx.lock sidecar file.
1063    if markers.contains(&"once".to_string()) {
1064        let once_idx = markers.iter().position(|m| m == "once").unwrap();
1065        let gen_type = markers.get(once_idx + 1).map(|s| s.as_str()).unwrap_or("uuid");
1066        let lock_path = options.base_path.as_deref()
1067            .map(|b| std::path::Path::new(b).join(".synx.lock"))
1068            .unwrap_or_else(|| std::path::Path::new(".synx.lock").to_path_buf());
1069
1070        // Try to read existing value from lock file
1071        let existing = read_lock_value(&lock_path, key);
1072        if let Some(locked) = existing {
1073            map.insert(key.to_string(), Value::String(locked));
1074        } else {
1075            let generated = match gen_type {
1076                "uuid" => rng::generate_uuid(),
1077                "timestamp" => std::time::SystemTime::now()
1078                    .duration_since(std::time::UNIX_EPOCH)
1079                    .unwrap_or_default()
1080                    .as_secs()
1081                    .to_string(),
1082                "random" => rng::random_usize(u32::MAX as usize).to_string(),
1083                _ => rng::generate_uuid(),
1084            };
1085            write_lock_value(&lock_path, key, &generated);
1086            map.insert(key.to_string(), Value::String(generated));
1087        }
1088    }
1089
1090    // ── :version ──
1091    // Syntax: key:version:OP:REQUIRED value
1092    // Compares the value (current version) against REQUIRED using OP (>=, <=, >, <, ==, !=).
1093    // Returns a bool.
1094    if markers.contains(&"version".to_string()) {
1095        if let Some(Value::String(current_ver)) = map.get(key) {
1096            let ver_idx = markers.iter().position(|m| m == "version").unwrap();
1097            let op = markers.get(ver_idx + 1).map(|s| s.as_str()).unwrap_or(">=");
1098            let required = markers.get(ver_idx + 2).cloned().unwrap_or_default();
1099            let result = compare_versions(current_ver, op, &required);
1100            map.insert(key.to_string(), Value::Bool(result));
1101        }
1102    }
1103
1104    // ── :watch ──
1105    // Syntax: key:watch:KEY_PATH ./file.json  (or ./file.synx)
1106    // Reads the referenced file at parse time. Optionally extracts a key path (JSON/SYNX).
1107    if markers.contains(&"watch".to_string()) {
1108        if let Some(Value::String(file_path)) = map.get(key) {
1109            let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
1110            if options._include_depth >= max_depth {
1111                map.insert(
1112                    key.to_string(),
1113                    Value::String(format!("WATCH_ERR: max include depth ({}) exceeded", max_depth)),
1114                );
1115                return;
1116            }
1117            let base = options.base_path.as_deref().unwrap_or(".");
1118            let full = match jail_path(base, file_path) {
1119                Ok(p) => p,
1120                Err(e) => {
1121                    map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", e)));
1122                    return;
1123                }
1124            };
1125            if let Err(e) = check_file_size(&full) {
1126                map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", e)));
1127                return;
1128            }
1129            let watch_idx = markers.iter().position(|m| m == "watch").unwrap();
1130            let key_path = markers.get(watch_idx + 1).cloned();
1131
1132            match std::fs::read_to_string(&full) {
1133                Ok(content) => {
1134                    let value = if let Some(ref kp) = key_path {
1135                        extract_from_file_content(&content, kp, full.extension().and_then(|e| e.to_str()).unwrap_or("")).unwrap_or(Value::Null)
1136                    } else {
1137                        Value::String(content.trim().to_string())
1138                    };
1139                    map.insert(key.to_string(), value);
1140                }
1141                Err(e) => {
1142                    map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", fmt_io_err(&e, file_path))));
1143                }
1144            }
1145        }
1146    }
1147
1148    // ── :prompt ──
1149    // Syntax: key:prompt:LABEL subtree
1150    // Converts the resolved subtree (object) into a SYNX-formatted string
1151    // wrapped in a labeled code fence, ready for LLM prompt embedding.
1152    if markers.contains(&"prompt".to_string()) {
1153        let prompt_idx = markers.iter().position(|m| m == "prompt").unwrap();
1154        let label = markers.get(prompt_idx + 1).cloned().unwrap_or_else(|| key.to_string());
1155        if let Some(val) = map.get(key) {
1156            let synx_text = stringify_value(val, 0);
1157            let block = format!("{} (SYNX):\n```synx\n{}```", label, synx_text);
1158            map.insert(key.to_string(), Value::String(block));
1159        }
1160    }
1161
1162    // ── :vision ──
1163    // Metadata-only marker. Recognized by the engine (no error), value passes through.
1164    // Applications detect this marker via metadata to dispatch image generation.
1165
1166    // ── :audio ──
1167    // Metadata-only marker. Recognized by the engine (no error), value passes through.
1168    // Applications detect this marker via metadata to dispatch audio generation.
1169
1170    // ── WASM custom markers ──
1171    // If a marker is not built-in and a WASM runtime is loaded, dispatch to it.
1172    #[cfg(feature = "wasm")]
1173    if let Some(ref wasm_rt) = options.wasm_runtime {
1174        for marker in markers {
1175            if crate::wasm::BUILTIN_MARKERS.contains(&marker.as_str()) {
1176                continue;
1177            }
1178            if wasm_rt.has_marker(marker) {
1179                // Collect args: all marker parts after this marker name
1180                let marker_idx = markers.iter().position(|m| m == marker).unwrap();
1181                let args: Vec<String> = markers[marker_idx + 1..].to_vec();
1182                let current_value = map.get(key).cloned().unwrap_or(Value::Null);
1183                match wasm_rt.apply_marker(marker, &current_value, &args) {
1184                    Ok(result) => {
1185                        map.insert(key.to_string(), result);
1186                    }
1187                    Err(e) => {
1188                        map.insert(key.to_string(), Value::String(format!("WASM_ERR: {}", e)));
1189                    }
1190                }
1191                break; // Only apply one WASM marker per key
1192            }
1193        }
1194    }
1195
1196    // ── Constraint validation (always last, after all markers resolved) ──
1197    if let Some(ref c) = meta.constraints {
1198        validate_constraints(map, key, c);
1199    }
1200}
1201
1202// ─── Constraint enforcement ───────────────────────────────
1203
1204fn validate_constraints(map: &mut HashMap<String, Value>, key: &str, c: &Constraints) {
1205    let val = match map.get(key) {
1206        Some(v) => v.clone(),
1207        None => {
1208            if c.required {
1209                map.insert(key.to_string(), Value::String(
1210                    format!("CONSTRAINT_ERR: '{}' is required", key),
1211                ));
1212            }
1213            return;
1214        }
1215    };
1216
1217    // required
1218    if c.required {
1219        let empty = matches!(val, Value::Null)
1220            || matches!(&val, Value::String(s) if s.is_empty());
1221        if empty {
1222            map.insert(key.to_string(), Value::String(
1223                format!("CONSTRAINT_ERR: '{}' is required", key),
1224            ));
1225            return;
1226        }
1227    }
1228
1229    // type check
1230    if let Some(ref type_name) = c.type_name {
1231        let ok = match type_name.as_str() {
1232            "int"    => matches!(val, Value::Int(_)),
1233            "float"  => matches!(val, Value::Float(_) | Value::Int(_)),
1234            "bool"   => matches!(val, Value::Bool(_)),
1235            "string" => matches!(val, Value::String(_)),
1236            _        => true,
1237        };
1238        if !ok {
1239            map.insert(key.to_string(), Value::String(
1240                format!("CONSTRAINT_ERR: '{}' expected type '{}'", key, type_name),
1241            ));
1242            return;
1243        }
1244    }
1245
1246    // enum check
1247    if let Some(ref enum_vals) = c.enum_values {
1248        let val_str = match &val {
1249            Value::String(s) => s.clone(),
1250            Value::Int(n)    => n.to_string(),
1251            Value::Float(f)  => f.to_string(),
1252            Value::Bool(b)   => b.to_string(),
1253            _                => String::new(),
1254        };
1255        if !enum_vals.contains(&val_str) {
1256            map.insert(key.to_string(), Value::String(
1257                format!("CONSTRAINT_ERR: '{}' must be one of [{}]", key, enum_vals.join("|")),
1258            ));
1259            return;
1260        }
1261    }
1262
1263    // min / max  (numbers: value range; strings: length range)
1264    let num = match &val {
1265        Value::Int(n)    => Some(*n as f64),
1266        Value::Float(f)  => Some(*f),
1267        Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
1268        _                => None,
1269    };
1270    if let Some(n) = num {
1271        if let Some(min) = c.min {
1272            if n < min {
1273                map.insert(key.to_string(), Value::String(
1274                    format!("CONSTRAINT_ERR: '{}' value {} is below min {}", key, n, min),
1275                ));
1276                return;
1277            }
1278        }
1279        if let Some(max) = c.max {
1280            if n > max {
1281                map.insert(key.to_string(), Value::String(
1282                    format!("CONSTRAINT_ERR: '{}' value {} exceeds max {}", key, n, max),
1283                ));
1284                return;
1285            }
1286        }
1287    }
1288
1289    // pattern (regex match — bounded length to avoid pathological compilation costs)
1290    if let Some(ref pat) = c.pattern {
1291        if pat.len() <= 256 {
1292            if let Value::String(ref s) = val {
1293                match regex::Regex::new(pat) {
1294                    Ok(re) if !re.is_match(s) => {
1295                        map.insert(key.to_string(), Value::String(
1296                            format!("CONSTRAINT_ERR: '{}' does not match pattern /{}/", key, pat),
1297                        ));
1298                        return;
1299                    }
1300                    Ok(_) => {}
1301                    // Invalid regex — skip silently, matching the JS engine.
1302                    Err(_) => {}
1303                }
1304            }
1305        }
1306    }
1307}
1308
1309// ─── New-marker helpers ───────────────────────────────────
1310
1311/// Apply a printf-style format pattern to a value.
1312fn apply_format_pattern(pattern: &str, value: &Value) -> String {
1313    match value {
1314        Value::Int(n) => {
1315            if pattern.contains('d') || pattern.contains('i') {
1316                format_int_pattern(pattern, *n)
1317            } else if pattern.contains('f') || pattern.contains('e') {
1318                format_float_pattern(pattern, *n as f64)
1319            } else {
1320                n.to_string()
1321            }
1322        }
1323        Value::Float(f) => {
1324            if pattern.contains('f') || pattern.contains('e') {
1325                format_float_pattern(pattern, *f)
1326            } else {
1327                format_number(*f)
1328            }
1329        }
1330        Value::String(s) => s.clone(),
1331        other => value_to_string(other),
1332    }
1333}
1334
1335fn format_int_pattern(pattern: &str, n: i64) -> String {
1336    // Guardrail: user-controlled width can be enormous (esp. under fuzzing).
1337    // Large widths can cause pathological allocations or panics in formatting internals.
1338    const MAX_FMT_WIDTH: usize = 4096;
1339    if let Some(s) = pattern.strip_prefix('%') {
1340        if let Some(inner) = s.strip_suffix('d').or_else(|| s.strip_suffix('i')) {
1341            if let Some(w) = inner.strip_prefix('0') {
1342                if let Ok(width) = w.parse::<usize>() {
1343                    let width = width.min(MAX_FMT_WIDTH);
1344                    return format!("{:0>width$}", n, width = width);
1345                }
1346            }
1347            if let Ok(width) = inner.parse::<usize>() {
1348                let width = width.min(MAX_FMT_WIDTH);
1349                return format!("{:>width$}", n, width = width);
1350            }
1351        }
1352    }
1353    n.to_string()
1354}
1355
1356fn format_float_pattern(pattern: &str, f: f64) -> String {
1357    // Same rationale as MAX_FMT_WIDTH: avoid pathological precision values.
1358    const MAX_FMT_PREC: usize = 1024;
1359    if let Some(s) = pattern.strip_prefix('%') {
1360        // %e — exponential form, matches the JS `Number.toExponential()` shape:
1361        //   e.g. 123456.789 → "1.23456789e+5"
1362        if s == "e" {
1363            if f == 0.0 {
1364                return "0e+0".to_string();
1365            }
1366            // Use Rust's exponential formatter (which uses ryu-like shortest
1367            // round-trip digits) and then reshape the exponent token to match
1368            // JS `toExponential()` (always signed, no leading zeros).
1369            let raw = format!("{:e}", f); // e.g. "1.23456789e5" or "1.23456789e-5"
1370            if let Some(epos) = raw.rfind('e') {
1371                let mantissa = &raw[..epos];
1372                let exp_part = &raw[epos + 1..];
1373                let (sign, digits) = match exp_part.chars().next() {
1374                    Some('+') => ('+', &exp_part[1..]),
1375                    Some('-') => ('-', &exp_part[1..]),
1376                    _ => ('+', exp_part),
1377                };
1378                // Strip the decimal point if the mantissa is an integer (1.0 → 1)
1379                let mantissa_clean = if mantissa.ends_with(".0") {
1380                    &mantissa[..mantissa.len() - 2]
1381                } else {
1382                    mantissa
1383                };
1384                return format!("{}e{}{}", mantissa_clean, sign, digits);
1385            }
1386            return raw;
1387        }
1388        if let Some(inner) = s.strip_suffix('f').or_else(|| s.strip_suffix('e')) {
1389            if let Some(prec_s) = inner.strip_prefix('.') {
1390                if let Ok(prec) = prec_s.parse::<usize>() {
1391                    let prec = prec.min(MAX_FMT_PREC);
1392                    if s.ends_with('e') {
1393                        return format!("{:.prec$e}", f, prec = prec);
1394                    }
1395                    return format!("{:.prec$}", f, prec = prec);
1396                }
1397            }
1398        }
1399    }
1400    f.to_string()
1401}
1402
1403/// Read a persisted value from the .synx.lock file.
1404fn read_lock_value(lock_path: &std::path::Path, key: &str) -> Option<String> {
1405    let content = std::fs::read_to_string(lock_path).ok()?;
1406    for line in content.lines() {
1407        if let Some(rest) = line.strip_prefix(key) {
1408            if rest.starts_with(' ') {
1409                return Some(rest.trim_start().to_string());
1410            }
1411        }
1412    }
1413    None
1414}
1415
1416/// Write/update a key value pair in the .synx.lock file.
1417fn write_lock_value(lock_path: &std::path::Path, key: &str, value: &str) {
1418    let mut lines: Vec<String> = std::fs::read_to_string(lock_path)
1419        .unwrap_or_default()
1420        .lines()
1421        .map(|l| l.to_string())
1422        .collect();
1423
1424    let new_line = format!("{} {}", key, value);
1425    let mut found = false;
1426    for line in lines.iter_mut() {
1427        if line.starts_with(key) && line[key.len()..].starts_with(' ') {
1428            *line = new_line.clone();
1429            found = true;
1430            break;
1431        }
1432    }
1433    if !found {
1434        lines.push(new_line);
1435    }
1436    let _ = std::fs::write(lock_path, lines.join("\n") + "\n");
1437}
1438
1439/// Compare two version strings using a comparison operator.
1440fn compare_versions(current: &str, op: &str, required: &str) -> bool {
1441    let parse_ver = |s: &str| -> Vec<u64> {
1442        s.split('.').filter_map(|p| p.parse().ok()).collect()
1443    };
1444    let cv = parse_ver(current);
1445    let rv = parse_ver(required);
1446    let len = cv.len().max(rv.len());
1447    let mut ord = std::cmp::Ordering::Equal;
1448    for i in 0..len {
1449        let a = cv.get(i).copied().unwrap_or(0);
1450        let b = rv.get(i).copied().unwrap_or(0);
1451        if a != b {
1452            ord = a.cmp(&b);
1453            break;
1454        }
1455    }
1456    match op {
1457        ">=" => ord != std::cmp::Ordering::Less,
1458        "<=" => ord != std::cmp::Ordering::Greater,
1459        ">"  => ord == std::cmp::Ordering::Greater,
1460        "<"  => ord == std::cmp::Ordering::Less,
1461        "==" | "=" => ord == std::cmp::Ordering::Equal,
1462        "!=" => ord != std::cmp::Ordering::Equal,
1463        _ => false,
1464    }
1465}
1466
1467fn allow_spam_access(bucket_key: &str, max_calls: usize, window_sec: u64) -> bool {
1468    let now = Instant::now();
1469    let window = Duration::from_secs(window_sec.max(1));
1470
1471    let buckets = SPAM_BUCKETS.get_or_init(|| Mutex::new(HashMap::new()));
1472    let mut guard = match buckets.lock() {
1473        Ok(g) => g,
1474        Err(poisoned) => poisoned.into_inner(),
1475    };
1476
1477    let calls = guard.entry(bucket_key.to_string()).or_default();
1478    calls.retain(|ts| now.duration_since(*ts) <= window);
1479
1480    if calls.len() >= max_calls {
1481        return false;
1482    }
1483
1484    calls.push(now);
1485    true
1486}
1487
1488#[cfg(test)]
1489fn clear_spam_buckets() {
1490    let buckets = SPAM_BUCKETS.get_or_init(|| Mutex::new(HashMap::new()));
1491    if let Ok(mut guard) = buckets.lock() {
1492        guard.clear();
1493    }
1494}
1495
1496/// Extract a value from file content by key path (JSON dot-path or SYNX key).
1497fn extract_from_file_content(content: &str, key_path: &str, ext: &str) -> Option<Value> {
1498    if ext == "json" {
1499        // Real JSON parse via serde_json — supports dot-path traversal.
1500        let parsed: serde_json::Value = serde_json::from_str(content).ok()?;
1501        let mut current = &parsed;
1502        for part in key_path.split('.') {
1503            current = current.get(part)?;
1504        }
1505        return Some(json_value_to_synx(current));
1506    }
1507
1508    // SYNX file: parse it and follow the dot-path through the resulting tree.
1509    let parsed = crate::parser::parse(content);
1510    let mut current = &parsed.root;
1511    for part in key_path.split('.') {
1512        match current {
1513            Value::Object(map) => {
1514                current = map.get(part)?;
1515            }
1516            _ => return None,
1517        }
1518    }
1519    Some(current.clone())
1520}
1521
1522/// Convert a `serde_json::Value` to `Value` (best-effort).
1523fn json_value_to_synx(v: &serde_json::Value) -> Value {
1524    match v {
1525        serde_json::Value::Null => Value::Null,
1526        serde_json::Value::Bool(b) => Value::Bool(*b),
1527        serde_json::Value::Number(n) => {
1528            if let Some(i) = n.as_i64() {
1529                Value::Int(i)
1530            } else if let Some(f) = n.as_f64() {
1531                Value::Float(f)
1532            } else {
1533                Value::Null
1534            }
1535        }
1536        serde_json::Value::String(s) => Value::String(s.clone()),
1537        serde_json::Value::Array(arr) => {
1538            Value::Array(arr.iter().map(json_value_to_synx).collect())
1539        }
1540        serde_json::Value::Object(map) => {
1541            let mut out = HashMap::new();
1542            for (k, v) in map {
1543                out.insert(k.clone(), json_value_to_synx(v));
1544            }
1545            Value::Object(out)
1546        }
1547    }
1548}
1549
1550// ─── Helpers ─────────────────────────────────────────────
1551
1552/// Serialize a Value to SYNX format string (for :prompt marker).
1553fn stringify_value(value: &Value, indent: usize) -> String {
1554    let spaces = " ".repeat(indent);
1555    match value {
1556        Value::Object(map) => {
1557            let mut out = String::new();
1558            let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
1559            keys.sort_unstable();
1560            for key in keys {
1561                let val = &map[key];
1562                match val {
1563                    Value::Object(_) => {
1564                        out.push_str(&format!("{}{}\n", spaces, key));
1565                        out.push_str(&stringify_value(val, indent + 2));
1566                    }
1567                    Value::Array(arr) => {
1568                        out.push_str(&format!("{}{}\n", spaces, key));
1569                        for item in arr {
1570                            out.push_str(&format!("{}  - {}\n", spaces, value_to_string(item)));
1571                        }
1572                    }
1573                    _ => {
1574                        out.push_str(&format!("{}{} {}\n", spaces, key, value_to_string(val)));
1575                    }
1576                }
1577            }
1578            out
1579        }
1580        _ => format!("{}{}\n", spaces, value_to_string(value)),
1581    }
1582}
1583
1584pub(crate) fn cast_primitive(val: &str) -> Value {
1585    // Quoted strings preserve literal value
1586    if val.len() >= 2 {
1587        let bytes = val.as_bytes();
1588        if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1589            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1590        {
1591            return Value::String(val[1..val.len() - 1].to_string());
1592        }
1593    }
1594    match val {
1595        "true" => Value::Bool(true),
1596        "false" => Value::Bool(false),
1597        "null" => Value::Null,
1598        _ => {
1599            if let Ok(i) = val.parse::<i64>() {
1600                Value::Int(i)
1601            } else if let Ok(f) = val.parse::<f64>() {
1602                Value::Float(f)
1603            } else {
1604                Value::String(val.to_string())
1605            }
1606        }
1607    }
1608}
1609
1610fn delimiter_from_keyword(keyword: &str) -> String {
1611    match keyword {
1612        "space" => " ".to_string(),
1613        "pipe" => "|".to_string(),
1614        "dash" => "-".to_string(),
1615        "dot" => ".".to_string(),
1616        "semi" => ";".to_string(),
1617        "tab" => "\t".to_string(),
1618        "slash" => "/".to_string(),
1619        other => other.to_string(),
1620    }
1621}
1622
1623fn value_as_number(v: &Value) -> Option<f64> {
1624    match v {
1625        Value::Int(n) => Some(*n as f64),
1626        Value::Float(f) => Some(*f),
1627        _ => None,
1628    }
1629}
1630
1631fn value_to_string(v: &Value) -> String {
1632    match v {
1633        Value::String(s) => s.clone(),
1634        Value::Int(n) => n.to_string(),
1635        Value::Float(f) => format_number(*f as f64),
1636        Value::Bool(b) => b.to_string(),
1637        Value::Null => "null".to_string(),
1638        Value::Secret(s) => s.clone(),
1639        Value::Array(_) | Value::Object(_) => String::new(),
1640    }
1641}
1642
1643fn format_number(n: f64) -> String {
1644    if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
1645        (n as i64).to_string()
1646    } else {
1647        n.to_string()
1648    }
1649}
1650
1651/// Replace whole-word occurrences of `word` with `replacement`.
1652fn replace_word(haystack: &str, word: &str, replacement: &str) -> String {
1653    let word_bytes = word.as_bytes();
1654    let word_len = word_bytes.len();
1655    let hay_bytes = haystack.as_bytes();
1656    let hay_len = hay_bytes.len();
1657
1658    if word_len > hay_len {
1659        return haystack.to_string();
1660    }
1661
1662    let mut result = String::with_capacity(hay_len.min(MAX_ENGINE_SCRATCH_STRING));
1663    let mut i = 0;
1664
1665    while i <= hay_len - word_len {
1666        if result.len() >= MAX_ENGINE_SCRATCH_STRING {
1667            break;
1668        }
1669        if &hay_bytes[i..i + word_len] == word_bytes {
1670            let before_ok = i == 0 || !is_word_char(hay_bytes[i - 1]);
1671            let after_ok = i + word_len >= hay_len || !is_word_char(hay_bytes[i + word_len]);
1672            if before_ok && after_ok {
1673                let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1674                if room > 0 {
1675                    let take = replacement.len().min(room);
1676                    let end = replacement.floor_char_boundary(take);
1677                    result.push_str(&replacement[..end]);
1678                }
1679                i += word_len;
1680                continue;
1681            }
1682        }
1683        if result.len() < MAX_ENGINE_SCRATCH_STRING {
1684            result.push(hay_bytes[i] as char);
1685        }
1686        i += 1;
1687    }
1688    while i < hay_len && result.len() < MAX_ENGINE_SCRATCH_STRING {
1689        result.push(hay_bytes[i] as char);
1690        i += 1;
1691    }
1692    result
1693}
1694
1695fn is_word_char(b: u8) -> bool {
1696    b.is_ascii_alphanumeric() || b == b'_'
1697}
1698
1699fn weighted_random(items: &[Value], weights: &[f64]) -> Value {
1700    let mut w: Vec<f64> = weights.to_vec();
1701    if w.len() < items.len() {
1702        let assigned: f64 = w.iter().sum();
1703        // If the explicit weights already exceed 100, give unassigned items
1704        // the same average weight as the assigned ones so they remain visible.
1705        // If there is room left under 100, distribute the remainder equally.
1706        let per_item = if assigned < 100.0 {
1707            (100.0 - assigned) / (items.len() - w.len()) as f64
1708        } else {
1709            assigned / w.len() as f64
1710        };
1711        while w.len() < items.len() {
1712            w.push(per_item);
1713        }
1714    }
1715    let total: f64 = w.iter().sum();
1716    if total <= 0.0 {
1717        return items[rng::random_usize(items.len())].clone();
1718    }
1719
1720    let rand_val = rng::random_f64_01();
1721    let mut cumulative = 0.0;
1722    for (i, item) in items.iter().enumerate() {
1723        cumulative += w[i] / total;
1724        if rand_val <= cumulative {
1725            return item.clone();
1726        }
1727    }
1728    items.last().cloned().unwrap_or(Value::Null)
1729}
1730
1731// ─── Inheritance pre-pass ─────────────────────────────────
1732
1733fn apply_inheritance(root: &mut Value, metadata: &HashMap<String, MetaMap>) {
1734    let root_meta = match metadata.get("") {
1735        Some(m) => m.clone(),
1736        None => return,
1737    };
1738
1739    let root_map = match root.as_object_mut() {
1740        Some(m) => m as *mut HashMap<String, Value>,
1741        None => return,
1742    };
1743
1744    // Collect inherit targets: child_key → [parent1, parent2, ...]
1745    //
1746    // Two surface syntaxes feed this list:
1747    //   production:inherit:base   → parents come from markers[idx+1..]
1748    //   production:inherit base   → parser promotes "base" into meta.args
1749    let mut inherits: Vec<(String, Vec<String>)> = Vec::new();
1750    for (key, meta) in &root_meta {
1751        if meta.markers.contains(&"inherit".to_string()) {
1752            let idx = meta.markers.iter().position(|m| m == "inherit").unwrap();
1753            let mut parents: Vec<String> = meta.markers[idx + 1..].to_vec();
1754            // Fall back / extend with marker args promoted from a positional value.
1755            if parents.is_empty() && !meta.args.is_empty() {
1756                parents = meta.args.clone();
1757            }
1758            if !parents.is_empty() {
1759                inherits.push((key.clone(), parents));
1760            }
1761        }
1762    }
1763
1764    let map = unsafe { &mut *root_map };
1765    for (child_key, parents) in &inherits {
1766        // Merge parents left-to-right: first parent is base, each subsequent overrides
1767        let mut merged: HashMap<String, Value> = HashMap::new();
1768        for parent_name in parents {
1769            if let Some(Value::Object(p)) = map.get(parent_name) {
1770                for (k, v) in p {
1771                    merged.insert(k.clone(), v.clone());
1772                }
1773            }
1774        }
1775        // Child fields override all parents
1776        if let Some(Value::Object(c)) = map.get(child_key) {
1777            for (k, v) in c {
1778                merged.insert(k.clone(), v.clone());
1779            }
1780        }
1781        map.insert(child_key.clone(), Value::Object(merged));
1782    }
1783}
1784
1785fn deep_get(root: &Value, path: &str) -> Option<Value> {
1786    // Try direct key
1787    if let Value::Object(map) = root {
1788        if let Some(val) = map.get(path) {
1789            return Some(val.clone());
1790        }
1791    }
1792    // Dot-path traversal
1793    let parts: Vec<&str> = path.split('.').collect();
1794    let mut current = root;
1795    for part in parts {
1796        match current {
1797            Value::Object(map) => match map.get(part) {
1798                Some(v) => current = v,
1799                None => return None,
1800            },
1801            _ => return None,
1802        }
1803    }
1804    Some(current.clone())
1805}
1806
1807/// Resolve {placeholder} references in a template string.
1808/// Supports: {key}, {key.nested}, {key:alias}, {key:include}
1809fn resolve_interpolation(
1810    tpl: &str,
1811    root: &Value,
1812    local_map: &HashMap<String, Value>,
1813    includes: &HashMap<String, Value>,
1814) -> String {
1815    let bytes = tpl.as_bytes();
1816    let len = bytes.len();
1817    let mut result = String::with_capacity(len.min(MAX_ENGINE_SCRATCH_STRING));
1818    let mut i = 0;
1819
1820    while i < len {
1821        if result.len() >= MAX_ENGINE_SCRATCH_STRING {
1822            break;
1823        }
1824        if bytes[i] == b'{' {
1825            if let Some(close) = tpl[i + 1..].find('}') {
1826                let inner = &tpl[i + 1..i + 1 + close];
1827                // Check for scope separator ':'
1828                if let Some(colon) = inner.find(':') {
1829                    let ref_name = &inner[..colon];
1830                    let scope = &inner[colon + 1..];
1831                    // Valid ref name?
1832                    if ref_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
1833                        let resolved = if scope == "include" {
1834                            // {key:include} — first/only include
1835                            if includes.len() == 1 {
1836                                let first = includes.values().next().unwrap();
1837                                deep_get(first, ref_name)
1838                            } else {
1839                                None
1840                            }
1841                        } else {
1842                            // {key:alias} — look up by alias
1843                            includes.get(scope).and_then(|inc| deep_get(inc, ref_name))
1844                        };
1845                        if let Some(val) = resolved {
1846                            let s = value_to_string(&val);
1847                            let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1848                            if room > 0 {
1849                                let take = s.len().min(room);
1850                                let end = s.floor_char_boundary(take);
1851                                result.push_str(&s[..end]);
1852                            }
1853                        } else {
1854                            result.push('{');
1855                            let rem = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len() + 1);
1856                            if rem > 0 {
1857                                let end = inner.floor_char_boundary(inner.len().min(rem));
1858                                result.push_str(&inner[..end]);
1859                            }
1860                            if result.len() < MAX_ENGINE_SCRATCH_STRING {
1861                                result.push('}');
1862                            }
1863                        }
1864                        i += 2 + close;
1865                        continue;
1866                    }
1867                } else {
1868                    // {key} — local
1869                    let ref_name = inner;
1870                    if ref_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
1871                        let resolved = deep_get(root, ref_name).or_else(|| {
1872                            local_map.get(ref_name).cloned()
1873                        });
1874                        if let Some(val) = resolved {
1875                            let s = value_to_string(&val);
1876                            let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1877                            if room > 0 {
1878                                let take = s.len().min(room);
1879                                let end = s.floor_char_boundary(take);
1880                                result.push_str(&s[..end]);
1881                            }
1882                        } else {
1883                            result.push('{');
1884                            let rem = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len() + 1);
1885                            if rem > 0 {
1886                                let end = ref_name.floor_char_boundary(ref_name.len().min(rem));
1887                                result.push_str(&ref_name[..end]);
1888                            }
1889                            if result.len() < MAX_ENGINE_SCRATCH_STRING {
1890                                result.push('}');
1891                            }
1892                        }
1893                        i += 2 + close;
1894                        continue;
1895                    }
1896                }
1897            }
1898        }
1899        if result.len() < MAX_ENGINE_SCRATCH_STRING {
1900            result.push(bytes[i] as char);
1901        }
1902        i += 1;
1903    }
1904    result
1905}
1906
1907/// Load !include files into a map<alias, Value>.
1908fn load_includes(
1909    directives: &[IncludeDirective],
1910    options: &Options,
1911) -> HashMap<String, Value> {
1912    let mut map = HashMap::new();
1913    let base = options.base_path.as_deref().unwrap_or(".");
1914    let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
1915    if options._include_depth >= max_depth {
1916        return map;
1917    }
1918    for inc in directives {
1919        let full = match jail_path(base, &inc.path) {
1920            Ok(p) => p,
1921            Err(_) => continue,
1922        };
1923        if check_file_size(&full).is_err() {
1924            continue;
1925        }
1926        if let Ok(text) = std::fs::read_to_string(&full) {
1927            let mut included = parser::parse(&text);
1928            if included.mode == Mode::Active {
1929                let mut child_opts = options.clone();
1930                child_opts._include_depth += 1;
1931                if let Some(parent) = full.parent() {
1932                    child_opts.base_path = Some(parent.to_string_lossy().into_owned());
1933                }
1934                resolve(&mut included, &child_opts);
1935            }
1936            map.insert(inc.alias.clone(), included.root);
1937        }
1938    }
1939    map
1940}
1941
1942/// Load !use packages into a map<alias, Value>.
1943///
1944/// Looks for `<packages_path>/@scope/name/src/main.synx` on disk.
1945/// Falls back to `./synx_packages/` when `options.packages_path` is unset.
1946fn load_packages(
1947    directives: &[UseDirective],
1948    options: &Options,
1949    #[cfg(feature = "wasm")] wasm_runtime: &mut crate::wasm::WasmMarkerRuntime,
1950) -> HashMap<String, Value> {
1951    let mut map = HashMap::new();
1952    let pkg_base = options
1953        .packages_path
1954        .as_deref()
1955        .unwrap_or("./synx_packages");
1956    let base = options.base_path.as_deref().unwrap_or(".");
1957    let pkg_root = std::path::Path::new(base).join(pkg_base);
1958
1959    for ud in directives {
1960        // @scope/name  → package dir is <pkg_root>/@scope/name
1961        let pkg_dir = pkg_root.join(&ud.package);
1962
1963        // Check if this is a WASM marker package (type markers in manifest)
1964        #[cfg(feature = "wasm")]
1965        {
1966            if is_marker_package(&pkg_dir) {
1967                // Load the .wasm file from the package
1968                let wasm_entry = read_manifest_wasm(&pkg_dir)
1969                    .unwrap_or_else(|| pkg_dir.join("src").join("main.wasm"));
1970                let caps = read_manifest_capabilities(&pkg_dir);
1971                if wasm_entry.is_file() {
1972                    if let Ok(wasm_bytes) = std::fs::read(&wasm_entry) {
1973                        match wasm_runtime.load_module(&wasm_bytes, caps) {
1974                            Ok(_markers) => {}
1975                            Err(e) => {
1976                                map.insert(
1977                                    ud.alias.clone(),
1978                                    Value::String(format!("WASM_ERR: {}", e)),
1979                                );
1980                            }
1981                        }
1982                    }
1983                }
1984                continue;
1985            }
1986        }
1987
1988        // Read entry point from manifest (synx-pkg.synx), fall back to src/main.synx
1989        let entry = read_manifest_main(&pkg_dir)
1990            .unwrap_or_else(|| pkg_dir.join("src").join("main.synx"));
1991
1992        if !entry.is_file() {
1993            continue;
1994        }
1995        if check_file_size(&entry).is_err() {
1996            continue;
1997        }
1998        if let Ok(text) = std::fs::read_to_string(&entry) {
1999            let mut parsed = parser::parse(&text);
2000            if parsed.mode == Mode::Active {
2001                let mut child_opts = options.clone();
2002                child_opts._include_depth += 1;
2003                child_opts.base_path = Some(
2004                    entry.parent().unwrap_or(&pkg_dir).to_string_lossy().into_owned()
2005                );
2006                resolve(&mut parsed, &child_opts);
2007            }
2008            map.insert(ud.alias.clone(), parsed.root);
2009        }
2010    }
2011    map
2012}
2013
2014/// Read synx-pkg.synx manifest and extract the `main` entry point path.
2015/// Returns the absolute path to the entry file, or None if manifest is missing/invalid.
2016fn read_manifest_main(pkg_dir: &std::path::Path) -> Option<std::path::PathBuf> {
2017    let manifest = pkg_dir.join("synx-pkg.synx");
2018    let text = std::fs::read_to_string(&manifest).ok()?;
2019    for line in text.lines() {
2020        let trimmed = line.trim();
2021        if let Some(rest) = trimmed.strip_prefix("main ") {
2022            let entry_path = rest.trim();
2023            if !entry_path.is_empty() {
2024                return Some(pkg_dir.join(entry_path));
2025            }
2026        }
2027        // Legacy field name support
2028        if let Some(rest) = trimmed.strip_prefix("entry ") {
2029            let entry_path = rest.trim();
2030            if !entry_path.is_empty() {
2031                return Some(pkg_dir.join(entry_path));
2032            }
2033        }
2034    }
2035    None
2036}
2037
2038/// Check if a package is a WASM marker package.
2039/// Matches `type markers` in manifest, or `main` pointing to a `.wasm` file.
2040#[cfg(feature = "wasm")]
2041fn is_marker_package(pkg_dir: &std::path::Path) -> bool {
2042    let manifest = pkg_dir.join("synx-pkg.synx");
2043    if let Ok(text) = std::fs::read_to_string(&manifest) {
2044        for line in text.lines() {
2045            let trimmed = line.trim();
2046            if trimmed == "type markers" {
2047                return true;
2048            }
2049            if let Some(rest) = trimmed.strip_prefix("main ") {
2050                if rest.trim().ends_with(".wasm") {
2051                    return true;
2052                }
2053            }
2054        }
2055    }
2056    false
2057}
2058
2059/// Read the WASM entry point from a marker package manifest.
2060#[cfg(feature = "wasm")]
2061fn read_manifest_wasm(pkg_dir: &std::path::Path) -> Option<std::path::PathBuf> {
2062    let manifest = pkg_dir.join("synx-pkg.synx");
2063    let text = std::fs::read_to_string(&manifest).ok()?;
2064    for line in text.lines() {
2065        let trimmed = line.trim();
2066        if let Some(rest) = trimmed.strip_prefix("wasm ") {
2067            let wasm_path = rest.trim();
2068            if !wasm_path.is_empty() {
2069                return Some(pkg_dir.join(wasm_path));
2070            }
2071        }
2072        // Also check `main` if it points to a .wasm file
2073        if let Some(rest) = trimmed.strip_prefix("main ") {
2074            let path = rest.trim();
2075            if path.ends_with(".wasm") {
2076                return Some(pkg_dir.join(path));
2077            }
2078        }
2079    }
2080    None
2081}
2082
2083/// Read capability permissions from a marker package manifest.
2084#[cfg(feature = "wasm")]
2085fn read_manifest_capabilities(pkg_dir: &std::path::Path) -> crate::wasm::WasmCapabilities {
2086    let manifest = pkg_dir.join("synx-pkg.synx");
2087    let text = match std::fs::read_to_string(&manifest) {
2088        Ok(t) => t,
2089        Err(_) => return crate::wasm::WasmCapabilities::default(),
2090    };
2091    for line in text.lines() {
2092        let trimmed = line.trim();
2093        if let Some(rest) = trimmed.strip_prefix("permissions ") {
2094            return crate::wasm::WasmCapabilities::from_manifest_line(rest);
2095        }
2096    }
2097    crate::wasm::WasmCapabilities::default()
2098}
2099
2100// ─── Type validation ──────────────────────────────────────
2101
2102/// Build a global type registry from all metadata.
2103/// Maps field name → expected type (e.g., "hp" → "int").
2104fn build_type_registry(metadata: &HashMap<String, MetaMap>) -> HashMap<String, String> {
2105    let mut registry: HashMap<String, String> = HashMap::new();
2106
2107    for meta_map in metadata.values() {
2108        for (key, meta) in meta_map {
2109            if let Some(ref type_hint) = meta.type_hint {
2110                // If type already registered, check for conflict
2111                if let Some(existing) = registry.get(key) {
2112                    if existing != type_hint {
2113                        // Type conflict: same field defined with different types
2114                        // For now, first definition wins; could also log error
2115                    }
2116                } else {
2117                    registry.insert(key.clone(), type_hint.clone());
2118                }
2119            }
2120        }
2121    }
2122
2123    registry
2124}
2125
2126/// Build a global constraint registry from all metadata.
2127/// Maps field name → merged constraints from [] declarations.
2128fn build_constraint_registry(metadata: &HashMap<String, MetaMap>) -> HashMap<String, Constraints> {
2129    let mut registry: HashMap<String, Constraints> = HashMap::new();
2130
2131    for meta_map in metadata.values() {
2132        for (key, meta) in meta_map {
2133            if let Some(ref constraints) = meta.constraints {
2134                registry
2135                    .entry(key.clone())
2136                    .and_modify(|existing| merge_constraints(existing, constraints))
2137                    .or_insert_with(|| constraints.clone());
2138            }
2139        }
2140    }
2141
2142    registry
2143}
2144
2145/// Merge constraints when the same field is declared multiple times.
2146/// Strategy is intentionally strict to keep schemas consistent across templates.
2147fn merge_constraints(base: &mut Constraints, incoming: &Constraints) {
2148    if incoming.required {
2149        base.required = true;
2150    }
2151    if incoming.readonly {
2152        base.readonly = true;
2153    }
2154
2155    // Stricter numeric bounds win.
2156    base.min = match (base.min, incoming.min) {
2157        (Some(a), Some(b)) => Some(a.max(b)),
2158        (None, Some(b)) => Some(b),
2159        (a, None) => a,
2160    };
2161    base.max = match (base.max, incoming.max) {
2162        (Some(a), Some(b)) => Some(a.min(b)),
2163        (None, Some(b)) => Some(b),
2164        (a, None) => a,
2165    };
2166
2167    // Keep first non-empty type/pattern/enum declaration.
2168    if base.type_name.is_none() {
2169        base.type_name = incoming.type_name.clone();
2170    }
2171    if base.pattern.is_none() {
2172        base.pattern = incoming.pattern.clone();
2173    }
2174    if base.enum_values.is_none() {
2175        base.enum_values = incoming.enum_values.clone();
2176    }
2177}
2178
2179/// Validate [] constraints recursively for all object fields that have
2180/// a registered constraint rule.
2181///
2182/// Skips values that already hold a CONSTRAINT_ERR string from the per-key
2183/// validation pass in `apply_markers` — otherwise we'd re-validate the error
2184/// message itself and report bogus numbers (the length of the error string)
2185/// instead of the original value.
2186fn validate_field_constraints(value: &mut Value, registry: &HashMap<String, Constraints>) {
2187    if let Value::Object(ref mut map) = value {
2188        let keys: Vec<String> = map.keys().cloned().collect();
2189        for key in &keys {
2190            if let Some(constraints) = registry.get(key) {
2191                let already_errored = matches!(
2192                    map.get(key),
2193                    Some(Value::String(s)) if s.starts_with("CONSTRAINT_ERR:")
2194                        || s.starts_with("TYPE_ERR:")
2195                );
2196                if !already_errored {
2197                    validate_constraints(map, key, constraints);
2198                }
2199            }
2200
2201            if let Some(child) = map.get_mut(key) {
2202                match child {
2203                    Value::Object(_) => validate_field_constraints(child, registry),
2204                    Value::Array(arr) => {
2205                        for item in arr.iter_mut() {
2206                            if let Value::Object(_) = item {
2207                                validate_field_constraints(item, registry);
2208                            }
2209                        }
2210                    }
2211                    _ => {}
2212                }
2213            }
2214        }
2215    }
2216}
2217
2218/// Validate that all values in the tree match their registered types.
2219fn validate_field_types(value: &mut Value, registry: &HashMap<String, String>, path: &str) {
2220    match value {
2221        Value::Object(ref mut map) => {
2222            let keys: Vec<String> = map.keys().cloned().collect();
2223            for key in &keys {
2224                if let Some(expected_type) = registry.get(key) {
2225                    if let Some(val) = map.get(key) {
2226                        if !value_matches_type(val, expected_type) {
2227                            // Type mismatch: replace with error string
2228                            let current_type = value_type_name(val);
2229                            map.insert(key.clone(), Value::String(
2230                                format!("TYPE_ERR: '{}' expected {} but got {}", key, expected_type, current_type)
2231                            ));
2232                        }
2233                    }
2234                }
2235                
2236                // Recurse into nested objects and arrays
2237                if let Some(child) = map.get_mut(key) {
2238                    match child {
2239                        Value::Object(_) => {
2240                            let child_path = if path.is_empty() {
2241                                key.clone()
2242                            } else {
2243                                format!("{}.{}", path, key)
2244                            };
2245                            validate_field_types(child, registry, &child_path);
2246                        }
2247                        Value::Array(ref mut arr) => {
2248                            for item in arr.iter_mut() {
2249                                if let Value::Object(_) = item {
2250                                    validate_field_types(item, registry, path);
2251                                }
2252                            }
2253                        }
2254                        _ => {}
2255                    }
2256                }
2257            }
2258        }
2259        _ => {}
2260    }
2261}
2262
2263/// Check if a value matches an expected type.
2264fn value_matches_type(value: &Value, expected_type: &str) -> bool {
2265    match expected_type {
2266        "int" => matches!(value, Value::Int(_)),
2267        "float" => matches!(value, Value::Float(_) | Value::Int(_)),
2268        "bool" => matches!(value, Value::Bool(_)),
2269        "string" => matches!(value, Value::String(_) | Value::Secret(_)),
2270        "array" => matches!(value, Value::Array(_)),
2271        "object" => matches!(value, Value::Object(_)),
2272        _ => true, // Unknown types are accepted
2273    }
2274}
2275
2276/// Get the human-readable name of a value's type.
2277fn value_type_name(value: &Value) -> String {
2278    match value {
2279        Value::Int(_) => "int".to_string(),
2280        Value::Float(_) => "float".to_string(),
2281        Value::Bool(_) => "bool".to_string(),
2282        Value::String(_) => "string".to_string(),
2283        Value::Secret(_) => "secret".to_string(),
2284        Value::Array(_) => "array".to_string(),
2285        Value::Object(_) => "object".to_string(),
2286        Value::Null => "null".to_string(),
2287    }
2288}
2289
2290// ─── CLDR plural rules ───────────────────────────────────
2291
2292/// Return the CLDR plural category for a given language and integer count.
2293/// Categories: "zero", "one", "two", "few", "many", "other".
2294fn plural_category(lang: &str, n: i64) -> &'static str {
2295    let abs_n = n.unsigned_abs();
2296    let n10 = abs_n % 10;
2297    let n100 = abs_n % 100;
2298
2299    match lang {
2300        // East Slavic: Russian, Ukrainian, Belarusian
2301        "ru" | "uk" | "be" => {
2302            if n10 == 1 && n100 != 11 {
2303                "one"
2304            } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
2305                "few"
2306            } else {
2307                "many"
2308            }
2309        }
2310        // West/South Slavic: Polish
2311        "pl" => {
2312            if n10 == 1 && n100 != 11 {
2313                "one"
2314            } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
2315                "few"
2316            } else {
2317                "many"
2318            }
2319        }
2320        // Czech, Slovak
2321        "cs" | "sk" => {
2322            if abs_n == 1 { "one" }
2323            else if (2..=4).contains(&abs_n) { "few" }
2324            else { "other" }
2325        }
2326        // Arabic
2327        "ar" => {
2328            if abs_n == 0 { "zero" }
2329            else if abs_n == 1 { "one" }
2330            else if abs_n == 2 { "two" }
2331            else if (3..=10).contains(&n100) { "few" }
2332            else if (11..=99).contains(&n100) { "many" }
2333            else { "other" }
2334        }
2335        // French, Portuguese (Brazilian) — 0 and 1 are "one"
2336        "fr" | "pt" => {
2337            if abs_n <= 1 { "one" } else { "other" }
2338        }
2339        // Japanese, Chinese, Korean, Vietnamese, Thai — no plural forms
2340        "ja" | "zh" | "ko" | "vi" | "th" => "other",
2341        // English, German, Spanish, Italian, Dutch, Swedish, Norwegian, Danish, etc.
2342        _ => {
2343            if abs_n == 1 { "one" } else { "other" }
2344        }
2345    }
2346}
2347
2348#[cfg(test)]
2349mod tests {
2350    use crate::{parse, Options, Value};
2351    use super::{resolve, read_manifest_main};
2352
2353    #[test]
2354    fn test_ref_simple() {
2355        let mut r = parse("!active\nbase_rate 50\nquick_rate:ref base_rate");
2356        resolve(&mut r, &Options::default());
2357        let map = r.root.as_object().unwrap();
2358        assert_eq!(map["quick_rate"], Value::Int(50));
2359    }
2360
2361    #[test]
2362    fn test_ref_calc_shorthand() {
2363        let mut r = parse("!active\nbase_rate 50\ndouble_rate:ref:calc:*2 base_rate");
2364        resolve(&mut r, &Options::default());
2365        let map = r.root.as_object().unwrap();
2366        assert_eq!(map["double_rate"], Value::Int(100));
2367    }
2368
2369    #[test]
2370    fn test_inherit() {
2371        let mut r = parse("!active\n_base\n  weight 10\n  stackable true\nsteel:inherit:_base\n  weight 25\n  material metal");
2372        resolve(&mut r, &Options::default());
2373        let map = r.root.as_object().unwrap();
2374        assert!(!map.contains_key("_base"));
2375        let steel = map["steel"].as_object().unwrap();
2376        assert_eq!(steel["weight"], Value::Int(25));
2377        assert_eq!(steel["stackable"], Value::Bool(true));
2378        assert_eq!(steel["material"], Value::String("metal".into()));
2379    }
2380
2381    #[test]
2382    fn test_i18n_select_lang() {
2383        let mut r = parse("!active\ntitle:i18n\n  en Hello\n  ru Привет\n  de Hallo");
2384        let opts = Options { lang: Some("ru".into()), ..Default::default() };
2385        resolve(&mut r, &opts);
2386        let map = r.root.as_object().unwrap();
2387        assert_eq!(map["title"], Value::String("Привет".into()));
2388    }
2389
2390    #[test]
2391    fn test_i18n_fallback_en() {
2392        let mut r = parse("!active\ntitle:i18n\n  en Hello\n  ru Привет");
2393        let opts = Options { lang: Some("fr".into()), ..Default::default() };
2394        resolve(&mut r, &opts);
2395        let map = r.root.as_object().unwrap();
2396        assert_eq!(map["title"], Value::String("Hello".into()));
2397    }
2398
2399    #[test]
2400    fn test_auto_interpolation_simple() {
2401        let mut r = parse("!active\nname Wario\ngreeting Hello, {name}!");
2402        resolve(&mut r, &Options::default());
2403        let map = r.root.as_object().unwrap();
2404        assert_eq!(map["greeting"], Value::String("Hello, Wario!".into()));
2405    }
2406
2407    #[test]
2408    fn test_auto_interpolation_nested() {
2409        let mut r = parse("!active\nserver\n  host localhost\n  port 8080\nurl http://{server.host}:{server.port}/api");
2410        resolve(&mut r, &Options::default());
2411        let map = r.root.as_object().unwrap();
2412        assert_eq!(map["url"], Value::String("http://localhost:8080/api".into()));
2413    }
2414
2415    #[test]
2416    fn test_template_legacy_still_works() {
2417        let mut r = parse("!active\nname Wario\ngreeting:template Hello, {name}!");
2418        resolve(&mut r, &Options::default());
2419        let map = r.root.as_object().unwrap();
2420        assert_eq!(map["greeting"], Value::String("Hello, Wario!".into()));
2421    }
2422
2423    #[test]
2424    fn test_type_validation() {
2425        // Test that type validation works: hp(int) defined in _base_unit,
2426        // then used in other places with correct type
2427        let mut r = parse(
2428            "!active\n\
2429            _base_unit\n  \
2430              hp(int) 100\n  \
2431              speed(float) 1.5\n\
2432            infantry:inherit:_base_unit\n  \
2433              name Infantry\n  \
2434              hp 80"
2435        );
2436        resolve(&mut r, &Options::default());
2437        let map = r.root.as_object().unwrap();
2438        
2439        // _base_unit should be removed (private)
2440        assert!(!map.contains_key("_base_unit"));
2441        
2442        // infantry should exist with correct types
2443        let infantry = map["infantry"].as_object().unwrap();
2444        assert_eq!(infantry["hp"], Value::Int(80));  // Correct: int
2445        assert_eq!(infantry["speed"], Value::Float(1.5));  // Correct: float
2446    }
2447
2448    #[test]
2449    fn test_type_validation_error() {
2450        // Test that type mismatch is detected and replaced with error
2451        let mut r = parse(
2452            "!active\n\
2453            _base_unit\n  \
2454              hp(int) 100\n\
2455            infantry:inherit:_base_unit\n  \
2456              hp hello"  // Type mismatch: string instead of int
2457        );
2458        resolve(&mut r, &Options::default());
2459        let map = r.root.as_object().unwrap();
2460        
2461        let infantry = map["infantry"].as_object().unwrap();
2462        // Should be replaced with error message
2463        if let Value::String(s) = &infantry["hp"] {
2464            assert!(s.contains("TYPE_ERR"));
2465        } else {
2466            panic!("Expected error string for type mismatch");
2467        }
2468    }
2469
2470    #[test]
2471    fn test_constraint_validation_inherited_range() {
2472        let mut r = parse(
2473            "!active\n\
2474            _base_unit\n  \
2475              hp[min:1, max:50000] 1000\n\
2476            infantry:inherit:_base_unit\n  \
2477              hp 60000"
2478        );
2479        resolve(&mut r, &Options::default());
2480        let map = r.root.as_object().unwrap();
2481        let infantry = map["infantry"].as_object().unwrap();
2482
2483        if let Value::String(s) = &infantry["hp"] {
2484            assert!(s.contains("CONSTRAINT_ERR"));
2485            assert!(s.contains("exceeds max"));
2486        } else {
2487            panic!("Expected constraint error string");
2488        }
2489    }
2490
2491    #[test]
2492    fn test_constraint_validation_required() {
2493        let mut r = parse(
2494            "!active\n\
2495            _base_unit\n  \
2496                            description[type:string, required] hello\n\
2497            scout:inherit:_base_unit\n  \
2498                            description null"
2499        );
2500        resolve(&mut r, &Options::default());
2501        let map = r.root.as_object().unwrap();
2502        let scout = map["scout"].as_object().unwrap();
2503
2504        if let Value::String(s) = &scout["description"] {
2505            assert!(s.contains("CONSTRAINT_ERR"));
2506            assert!(s.contains("required"));
2507        } else {
2508            panic!("Expected required-constraint error string");
2509        }
2510    }
2511
2512    #[test]
2513    fn test_multi_parent_inherit() {
2514        let mut r = parse(
2515            "!active\n\
2516            _movable\n  \
2517              speed 10\n  \
2518              can_move true\n\
2519            _damageable\n  \
2520              hp 100\n  \
2521              armor 5\n\
2522            tank:inherit:_movable:_damageable\n  \
2523              name Tank\n  \
2524              armor 20"
2525        );
2526        resolve(&mut r, &Options::default());
2527        let map = r.root.as_object().unwrap();
2528
2529        assert!(!map.contains_key("_movable"));
2530        assert!(!map.contains_key("_damageable"));
2531
2532        let tank = map["tank"].as_object().unwrap();
2533        assert_eq!(tank["speed"], Value::Int(10));        // from _movable
2534        assert_eq!(tank["can_move"], Value::Bool(true));   // from _movable
2535        assert_eq!(tank["hp"], Value::Int(100));           // from _damageable
2536        assert_eq!(tank["armor"], Value::Int(20));         // child overrides _damageable's 5
2537        assert_eq!(tank["name"], Value::String("Tank".into()));
2538    }
2539
2540    #[test]
2541    fn test_calc_dot_path() {
2542        let mut r = parse(
2543            "!active\n\
2544            stats\n  \
2545              base_hp 100\n  \
2546              multiplier 3\n\
2547            total_hp:calc stats.base_hp * stats.multiplier"
2548        );
2549        resolve(&mut r, &Options::default());
2550        let map = r.root.as_object().unwrap();
2551        assert_eq!(map["total_hp"], Value::Int(300));
2552    }
2553
2554    #[test]
2555    fn test_i18n_plural_en() {
2556        let mut r = parse(
2557            "!active\n\
2558            count 5\n\
2559            items:i18n:count\n  \
2560              en\n    \
2561                one item\n    \
2562                other items"
2563        );
2564        let opts = Options { lang: Some("en".into()), ..Default::default() };
2565        resolve(&mut r, &opts);
2566        let map = r.root.as_object().unwrap();
2567        assert_eq!(map["items"], Value::String("items".into()));
2568    }
2569
2570    #[test]
2571    fn test_i18n_plural_en_one() {
2572        let mut r = parse(
2573            "!active\n\
2574            count 1\n\
2575            items:i18n:count\n  \
2576              en\n    \
2577                one {count} item\n    \
2578                other {count} items"
2579        );
2580        let opts = Options { lang: Some("en".into()), ..Default::default() };
2581        resolve(&mut r, &opts);
2582        let map = r.root.as_object().unwrap();
2583        assert_eq!(map["items"], Value::String("1 item".into()));
2584    }
2585
2586    #[test]
2587    fn test_i18n_plural_ru() {
2588        let mut r = parse(
2589            "!active\n\
2590            count 3\n\
2591            items:i18n:count\n  \
2592              ru\n    \
2593                one предмет\n    \
2594                few предмета\n    \
2595                many предметов\n    \
2596                other предметов"
2597        );
2598        let opts = Options { lang: Some("ru".into()), ..Default::default() };
2599        resolve(&mut r, &opts);
2600        let map = r.root.as_object().unwrap();
2601        assert_eq!(map["items"], Value::String("предмета".into()));
2602    }
2603
2604    #[test]
2605    fn test_quoted_null_preserved() {
2606        let r = parse("status \"null\"\nenabled \"true\"\ncount \"42\"");
2607        let map = r.root.as_object().unwrap();
2608        assert_eq!(map["status"], Value::String("null".into()));
2609        assert_eq!(map["enabled"], Value::String("true".into()));
2610        assert_eq!(map["count"], Value::String("42".into()));
2611    }
2612
2613    #[test]
2614    fn test_unquoted_null_is_null() {
2615        let r = parse("status null\nenabled true\ncount 42");
2616        let map = r.root.as_object().unwrap();
2617        assert_eq!(map["status"], Value::Null);
2618        assert_eq!(map["enabled"], Value::Bool(true));
2619        assert_eq!(map["count"], Value::Int(42));
2620    }
2621
2622    #[test]
2623    fn test_spam_rate_limit_exceeded() {
2624        super::clear_spam_buckets();
2625
2626        let mut r1 = parse("!active\nsecret_token abc\naccess:spam:1:5 secret_token");
2627        resolve(&mut r1, &Options::default());
2628        let map1 = r1.root.as_object().unwrap();
2629        assert_eq!(map1["access"], Value::String("abc".into()));
2630
2631        let mut r2 = parse("!active\nsecret_token abc\naccess:spam:1:5 secret_token");
2632        resolve(&mut r2, &Options::default());
2633        let map2 = r2.root.as_object().unwrap();
2634        match &map2["access"] {
2635            Value::String(s) => assert!(s.starts_with("SPAM_ERR:")),
2636            _ => panic!("Expected SPAM_ERR string"),
2637        }
2638    }
2639
2640    #[test]
2641    fn test_spam_default_window_sec_is_one() {
2642        super::clear_spam_buckets();
2643
2644        let mut r = parse("!active\na 1\nx:spam:2 a");
2645        resolve(&mut r, &Options::default());
2646        let map = r.root.as_object().unwrap();
2647        assert_eq!(map["x"], Value::Int(1));
2648    }
2649
2650    #[test]
2651    fn test_deep_nesting_does_not_overflow() {
2652        // Deep indentation chain: parser caps nesting (see `MAX_PARSE_NESTING_DEPTH` in parser);
2653        // this test only checks resolve + navigation do not panic and yield a bounded tree.
2654        let mut synx = String::from("!active\n");
2655        let mut indent = String::new();
2656        for i in 0..200 {
2657            synx.push_str(&format!("{}level_{}\n", indent, i));
2658            indent.push_str("  ");
2659        }
2660        synx.push_str(&format!("{}value deep\n", indent));
2661
2662        let mut result = parse(&synx);
2663        resolve(&mut result, &Default::default());
2664        assert!(matches!(result.root, Value::Object(_)));
2665
2666        let mut cur = &result.root;
2667        let mut depth = 0usize;
2668        loop {
2669            let Value::Object(map) = cur else { break };
2670            let key = format!("level_{}", depth);
2671            match map.get(&key) {
2672                Some(next) => {
2673                    cur = next;
2674                    depth += 1;
2675                }
2676                None => break,
2677            }
2678        }
2679        assert!(
2680            depth >= 100,
2681            "expected at least 100 chained levels from parse, got {}",
2682            depth
2683        );
2684        assert!(
2685            depth <= 130,
2686            "parser nesting cap should keep chain shallow, got {}",
2687            depth
2688        );
2689    }
2690
2691    #[test]
2692    fn test_circular_alias_returns_error() {
2693        let mut r = parse("!active\na:alias b\nb:alias a");
2694        resolve(&mut r, &Default::default());
2695        let root = r.root.as_object().unwrap();
2696        let a_val = root.get("a").unwrap();
2697        let b_val = root.get("b").unwrap();
2698        assert!(
2699            matches!(a_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2700            "expected ALIAS_ERR for 'a', got: {:?}", a_val
2701        );
2702        assert!(
2703            matches!(b_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2704            "expected ALIAS_ERR for 'b', got: {:?}", b_val
2705        );
2706    }
2707
2708    #[test]
2709    fn test_self_alias_returns_error() {
2710        let mut r = parse("!active\na:alias a");
2711        resolve(&mut r, &Default::default());
2712        let root = r.root.as_object().unwrap();
2713        let a_val = root.get("a").unwrap();
2714        assert!(
2715            matches!(a_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2716            "expected ALIAS_ERR for self-alias, got: {:?}", a_val
2717        );
2718    }
2719
2720    #[test]
2721    fn test_valid_alias_still_works() {
2722        let mut r = parse("!active\nbase 42\ncopy:alias base");
2723        resolve(&mut r, &Default::default());
2724        let root = r.root.as_object().unwrap();
2725        assert_eq!(root.get("copy"), Some(&Value::Int(42)));
2726    }
2727
2728    #[test]
2729    fn test_alias_to_string_valued_key_no_false_positive() {
2730        // 'a' holds a literal string "b". 'b' aliases 'a'.
2731        // b should resolve to "b" — NOT trigger ALIAS_ERR.
2732        let mut r = parse("!active\na b\nb:alias a");
2733        resolve(&mut r, &Default::default());
2734        let root = r.root.as_object().unwrap();
2735        assert_eq!(
2736            root.get("b"),
2737            Some(&Value::String("b".to_string())),
2738            "alias to a string-valued key should not produce ALIAS_ERR"
2739        );
2740    }
2741
2742    #[test]
2743    fn test_prompt_marker() {
2744        let mut r = parse("!active\nmemory:prompt:Core\n  identity ASAI\n  creator APERTURESyndicate");
2745        resolve(&mut r, &Options::default());
2746        let map = r.root.as_object().unwrap();
2747        if let Value::String(s) = &map["memory"] {
2748            assert!(s.starts_with("Core (SYNX):"));
2749            assert!(s.contains("```synx"));
2750            assert!(s.contains("identity ASAI"));
2751        } else {
2752            panic!("Expected :prompt to produce a string");
2753        }
2754    }
2755
2756    #[test]
2757    fn test_vision_marker_passthrough() {
2758        let mut r = parse("!active\nimage:vision Generate a sunset");
2759        resolve(&mut r, &Options::default());
2760        let map = r.root.as_object().unwrap();
2761        assert_eq!(map["image"], Value::String("Generate a sunset".into()));
2762    }
2763
2764    #[test]
2765    fn test_audio_marker_passthrough() {
2766        let mut r = parse("!active\nnarration:audio Read this summary aloud");
2767        resolve(&mut r, &Options::default());
2768        let map = r.root.as_object().unwrap();
2769        assert_eq!(map["narration"], Value::String("Read this summary aloud".into()));
2770    }
2771
2772    #[test]
2773    fn test_use_directive_loads_package() {
2774        // Set up a temp package for `!use`
2775        let tmp = std::env::temp_dir().join("synx-use-test-load");
2776        let _ = std::fs::remove_dir_all(&tmp);
2777        let pkg_dir = tmp.join("synx_packages/@test/config");
2778        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2779        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/config\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2780        std::fs::write(pkg_dir.join("src/main.synx"), "identity APERTURESyndicate\ndefault_port 8080\n").unwrap();
2781
2782        let mut r = parse("!active\n!use @test/config\napp MyApp");
2783        let opts = Options {
2784            base_path: Some(tmp.to_string_lossy().into_owned()),
2785            ..Default::default()
2786        };
2787        resolve(&mut r, &opts);
2788        let map = r.root.as_object().unwrap();
2789        assert!(map.contains_key("config"), "package not loaded");
2790        let pkg = map["config"].as_object().unwrap();
2791        assert_eq!(pkg["identity"], Value::String("APERTURESyndicate".into()));
2792        assert_eq!(pkg["default_port"], Value::Int(8080));
2793        assert_eq!(map["app"], Value::String("MyApp".into()));
2794        let _ = std::fs::remove_dir_all(&tmp);
2795    }
2796
2797    #[test]
2798    fn test_use_directive_with_alias() {
2799        let tmp = std::env::temp_dir().join("synx-use-test-alias");
2800        let _ = std::fs::remove_dir_all(&tmp);
2801        let pkg_dir = tmp.join("synx_packages/@test/config");
2802        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2803        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/config\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2804        std::fs::write(pkg_dir.join("src/main.synx"), "identity APERTURESyndicate\ndefault_port 8080\n").unwrap();
2805
2806        let mut r = parse("!active\n!use @test/config as defaults\napp MyApp");
2807        let opts = Options {
2808            base_path: Some(tmp.to_string_lossy().into_owned()),
2809            ..Default::default()
2810        };
2811        resolve(&mut r, &opts);
2812        let map = r.root.as_object().unwrap();
2813        assert!(map.contains_key("defaults"), "aliased package not loaded");
2814        assert!(!map.contains_key("config"), "should use alias, not auto name");
2815        let pkg = map["defaults"].as_object().unwrap();
2816        assert_eq!(pkg["identity"], Value::String("APERTURESyndicate".into()));
2817        let _ = std::fs::remove_dir_all(&tmp);
2818    }
2819
2820    #[test]
2821    fn test_use_directive_missing_package_ignored() {
2822        let mut r = parse("!active\n!use @nonexistent/pkg\napp MyApp");
2823        resolve(&mut r, &Options::default());
2824        let map = r.root.as_object().unwrap();
2825        // Missing package should be silently skipped
2826        assert!(!map.contains_key("pkg"));
2827        assert_eq!(map["app"], Value::String("MyApp".into()));
2828    }
2829
2830    #[test]
2831    fn test_use_reads_manifest_main_field() {
2832        let tmp = std::env::temp_dir().join("synx-use-test-main");
2833        let _ = std::fs::remove_dir_all(&tmp);
2834        let pkg_dir = tmp.join("synx_packages/@test/myapp");
2835        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2836        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/myapp\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2837        std::fs::write(pkg_dir.join("src/main.synx"), "app_name MyApp\nversion 2.0.0\n").unwrap();
2838
2839        let mut r = parse("!active\n!use @test/myapp as myapp\napp Test");
2840        let opts = Options {
2841            base_path: Some(tmp.to_string_lossy().into_owned()),
2842            ..Default::default()
2843        };
2844        resolve(&mut r, &opts);
2845        let map = r.root.as_object().unwrap();
2846        assert!(map.contains_key("myapp"), "package not loaded via manifest");
2847        let pkg = map["myapp"].as_object().unwrap();
2848        assert_eq!(pkg["app_name"], Value::String("MyApp".into()));
2849        let _ = std::fs::remove_dir_all(&tmp);
2850    }
2851
2852    #[test]
2853    fn test_read_manifest_main_function() {
2854        let tmp = std::env::temp_dir().join("synx-manifest-main-test");
2855        let _ = std::fs::remove_dir_all(&tmp);
2856        std::fs::create_dir_all(tmp.join("src")).unwrap();
2857        std::fs::write(tmp.join("synx-pkg.synx"), "name @test/pkg\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2858        std::fs::write(tmp.join("src/main.synx"), "key value\n").unwrap();
2859
2860        let result = read_manifest_main(&tmp);
2861        assert!(result.is_some(), "should read main from synx-pkg.synx");
2862        let path = result.unwrap();
2863        assert!(path.ends_with("src/main.synx") || path.ends_with("src\\main.synx"));
2864        let _ = std::fs::remove_dir_all(&tmp);
2865    }
2866}