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