Skip to main content

openjd_expr/
format_string.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Format string parsing and resolution.
6
7use crate::error::ExpressionError;
8use crate::profile::ExprProfile;
9use crate::symbol_table::{SymbolTable, SymbolTableEntry};
10use crate::value::ExprValue;
11use serde::de::{self, Deserializer};
12use std::fmt;
13
14// Only stored in Vec<Segment> (heap-allocated), so the per-element size doesn't
15// matter much. Boxing Expression's ParsedExpression would add pointer indirection
16// on every evaluation for negligible memory savings.
17#[allow(clippy::large_enum_variant)]
18#[derive(Debug, Clone)]
19enum Segment {
20    Literal(String),
21    Expression {
22        start: usize,
23        end: usize,
24        parsed: crate::eval::ParsedExpression,
25    },
26}
27
28#[derive(Debug, Clone)]
29pub struct FormatString {
30    raw: String,
31    segments: Vec<Segment>,
32}
33
34/// Maximum permitted input length (in bytes) for [`FormatString::new`].
35///
36/// Caps the raw source text a format string may carry. Templates this large
37/// are almost certainly pathological inputs rather than real user content —
38/// 1 MB is several orders of magnitude above the size of the largest known
39/// legitimate template fields.
40///
41/// See `specs/expr/format-string.md` (Defensive caps) for rationale.
42pub const MAX_FORMAT_STRING_LEN: usize = 1024 * 1024;
43
44/// Maximum number of `{{...}}` interpolation segments in a single format string.
45///
46/// Each segment triggers a full ruff parse and an allocation of a
47/// `ParsedExpression`. Thousands of segments in one template field indicate
48/// abuse; 1,000 is two orders of magnitude above any realistic usage.
49///
50/// See `specs/expr/format-string.md` (Defensive caps) for rationale.
51pub const MAX_FORMAT_STRING_SEGMENTS: usize = 1_000;
52
53impl FormatString {
54    /// Parse a format string using the "latest" profile
55    /// ([`ExprProfile::latest`]): the current revision with every known
56    /// extension enabled.
57    ///
58    /// **This constructor is intentionally unstable across crate
59    /// versions.** The set of accepted syntax, functions, and types
60    /// grows as new extensions and revisions land. Use it for ad-hoc
61    /// parsing or prototyping.
62    ///
63    /// For stability across crate versions, build a profile with an
64    /// explicit revision and extension set and use
65    /// [`with_profile`](Self::with_profile).
66    pub fn new(input: &str) -> Result<Self, ExpressionError> {
67        Self::with_profile(input, &ExprProfile::latest())
68    }
69
70    /// Parse a format string under a caller-supplied profile.
71    ///
72    /// Each `{{...}}` interpolation is parsed as an expression under
73    /// the same profile, so the syntax features accepted inside
74    /// interpolations are determined by the profile in exactly the same
75    /// way as [`ParsedExpression::with_profile`](crate::ParsedExpression::with_profile).
76    pub fn with_profile(input: &str, profile: &ExprProfile) -> Result<Self, ExpressionError> {
77        if input.len() > MAX_FORMAT_STRING_LEN {
78            return Err(ExpressionError::new(format!(
79                "Format string length ({} bytes) exceeds maximum allowed size ({} bytes)",
80                input.len(),
81                MAX_FORMAT_STRING_LEN
82            )));
83        }
84        let segments = parse_segments(input, profile)?;
85        if segments.len() > MAX_FORMAT_STRING_SEGMENTS {
86            return Err(ExpressionError::new(format!(
87                "Format string contains too many interpolation segments ({}); maximum is {}",
88                segments.len(),
89                MAX_FORMAT_STRING_SEGMENTS
90            )));
91        }
92        Ok(Self {
93            raw: input.to_string(),
94            segments,
95        })
96    }
97
98    pub fn raw(&self) -> &str {
99        &self.raw
100    }
101
102    /// Resolve to an `ExprValue`.
103    ///
104    /// Configures evaluation via [`FormatStringOptions`]. If the format string
105    /// is a single expression with no surrounding literal text, returns the
106    /// raw typed `ExprValue` (which may be int, list, path, etc.); otherwise
107    /// it concatenates all segments and returns `ExprValue::String`.
108    ///
109    /// Pass `&FormatStringOptions::default()` for the simplest call. Build
110    /// options with `.with_library(...)`, `.with_path_format(...)`, and
111    /// `.with_target_type(...)`. Path mapping rules, if needed, are baked
112    /// into the library via [`FunctionLibrary::for_profile`] with an
113    /// [`ExprProfile`] whose host context is [`HostContext::WithRules`].
114    ///
115    /// [`FunctionLibrary::for_profile`]: crate::FunctionLibrary::for_profile
116    /// [`ExprProfile`]: crate::ExprProfile
117    /// [`HostContext::WithRules`]: crate::HostContext::WithRules
118    pub fn resolve_with(
119        &self,
120        symtab: &SymbolTable,
121        opts: &FormatStringOptions<'_>,
122    ) -> Result<ExprValue, ExpressionError> {
123        self.resolve_inner(symtab, opts.library, opts.path_format, opts.target_type)
124    }
125
126    /// Resolve to `String`, concatenating every segment.
127    ///
128    /// Single-expression format strings lose their typed value — use
129    /// [`resolve_with`](Self::resolve_with) when you need the native
130    /// `ExprValue`. The `target_type` field on `opts` is ignored here.
131    ///
132    /// `path_format` on the options controls how `path()` values are
133    /// constructed:
134    /// - `PathFormat::Posix` in template context (create_job, let bindings)
135    /// - `PathFormat::host()` in session/host context (action execution)
136    pub fn resolve_string_with(
137        &self,
138        symtab: &SymbolTable,
139        opts: &FormatStringOptions<'_>,
140    ) -> Result<String, ExpressionError> {
141        let FormatStringOptions {
142            library,
143            path_format,
144            target_type: _,
145        } = *opts;
146        let mut result = String::new();
147        for seg in &self.segments {
148            match seg {
149                Segment::Literal(s) => result.push_str(s),
150                Segment::Expression { parsed, .. } => {
151                    let val = self.eval_parsed(parsed, symtab, library, path_format, None)?;
152                    // None/null renders as empty string in format strings
153                    if !matches!(val, ExprValue::Null) {
154                        result.push_str(&val.to_display_string());
155                    }
156                }
157            }
158        }
159        Ok(result)
160    }
161
162    fn resolve_inner(
163        &self,
164        symtab: &SymbolTable,
165        library: Option<&crate::function_library::FunctionLibrary>,
166        path_format: crate::path_mapping::PathFormat,
167        target_type: Option<&crate::types::ExprType>,
168    ) -> Result<ExprValue, ExpressionError> {
169        if self.segments.len() == 1 {
170            if let Segment::Expression { parsed, .. } = &self.segments[0] {
171                return self.eval_parsed(parsed, symtab, library, path_format, target_type);
172            }
173        }
174        self.resolve_string_with(
175            symtab,
176            &FormatStringOptions {
177                library,
178                path_format,
179                target_type: None,
180            },
181        )
182        .map(ExprValue::String)
183    }
184
185    fn eval_parsed(
186        &self,
187        parsed: &crate::eval::ParsedExpression,
188        symtab: &SymbolTable,
189        library: Option<&crate::function_library::FunctionLibrary>,
190        path_format: crate::path_mapping::PathFormat,
191        target_type: Option<&crate::types::ExprType>,
192    ) -> Result<ExprValue, ExpressionError> {
193        let mut builder = parsed.with_path_format(path_format);
194        if let Some(lib) = library {
195            builder = builder.with_library(lib);
196        }
197        if let Some(tt) = target_type {
198            builder = builder.with_target_type(tt);
199        }
200        builder.evaluate(&[symtab])
201    }
202
203    /// Validate all expressions in this format string against a symbol table.
204    /// The symbol table should contain `ExprValue::unresolved(T)` for symbols
205    /// whose values are not yet known. This is the spec's approach to static
206    /// type checking — just evaluate normally with unresolved types.
207    pub fn validate_expressions(
208        &self,
209        symtab: &SymbolTable,
210        lib: &crate::function_library::FunctionLibrary,
211    ) -> Result<(), FormatStringValidationError> {
212        for seg in &self.segments {
213            let (parsed, start, end) = match seg {
214                Segment::Literal(_) => continue,
215                Segment::Expression { parsed, start, end } => (parsed, *start, *end),
216            };
217            if let Err(e) = parsed.with_library(lib).evaluate(&[symtab]) {
218                return Err(FormatStringValidationError {
219                    message: e.to_string(),
220                    input: self.raw.clone(),
221                    start,
222                    end,
223                    expression_error: Some(Box::new(e)),
224                });
225            }
226        }
227        Ok(())
228    }
229
230    /// Validate list comprehension loop variables in expressions.
231    /// Checks: must be lowercase, must not shadow let bindings.
232    pub fn validate_comprehension_vars(
233        &self,
234        let_names: &std::collections::HashSet<String>,
235    ) -> Result<(), ExpressionError> {
236        for seg in &self.segments {
237            if let Segment::Expression { parsed, .. } = seg {
238                check_comprehension_vars(&parsed.ast, let_names)?;
239            }
240        }
241        Ok(())
242    }
243
244    /// True if any `{{...}}` segment is more than a bare dotted-name lookup
245    /// (e.g., contains arithmetic, function calls, or list comprehensions —
246    /// anything that requires the EXPR extension).
247    pub fn has_complex_expressions(&self) -> bool {
248        self.segments.iter().any(|s| match s {
249            Segment::Expression { parsed, .. } => parsed.as_name_lookup().is_none(),
250            Segment::Literal(_) => false,
251        })
252    }
253
254    /// Names of all bare dotted-name interpolations (`{{Param.Name}}`-style).
255    /// Complex expressions (arithmetic, function calls, etc.) are not
256    /// included — callers that want all referenced symbols should use
257    /// [`accessed_symbols`](Self::accessed_symbols).
258    pub fn expression_names(&self) -> Vec<&str> {
259        self.segments
260            .iter()
261            .filter_map(|s| match s {
262                Segment::Expression { parsed, .. } => parsed.as_name_lookup(),
263                Segment::Literal(_) => None,
264            })
265            .collect()
266    }
267
268    pub fn is_literal(&self) -> bool {
269        self.segments
270            .iter()
271            .all(|s| matches!(s, Segment::Literal(_)))
272    }
273
274    /// Copy symbol table entries referenced by this format string's expressions
275    /// from `source` into `dest`. Only copies the actual symtab values that are
276    /// referenced, not properties/methods called on them.
277    ///
278    /// For example, if an expression references `Param.Name.upper()`, the symbol
279    /// `Param.Name` is a Value in the symtab (`.upper()` is a method call).
280    /// This method walks the dotted path into `source`, stops when it finds a
281    /// Value (not a Table), and copies that value into `dest` at the same path.
282    pub fn copy_used_symtab_values(&self, source: &SymbolTable, dest: &mut SymbolTable) {
283        for segment in &self.segments {
284            if let Segment::Expression { parsed, .. } = segment {
285                for symbol in &parsed.accessed_symbols {
286                    copy_symbol_value(symbol, source, dest);
287                }
288            }
289        }
290    }
291
292    /// Returns the set of symbol names accessed by this format string.
293    pub fn accessed_symbols(&self) -> std::collections::HashSet<String> {
294        let mut symbols = std::collections::HashSet::new();
295        for segment in &self.segments {
296            if let Segment::Expression { parsed, .. } = segment {
297                symbols.extend(parsed.accessed_symbols.iter().cloned());
298            }
299        }
300        symbols
301    }
302}
303
304/// Walk a dotted symbol name into `source`, find the deepest Value entry,
305/// and copy it into `dest` at the same path.
306///
307/// E.g. for "Param.Name.upper", if source has Param.Name = "hello" (a Value),
308/// we copy Param.Name into dest. The ".upper" part is a method call, not a
309/// symtab key.
310pub fn copy_symbol_value(symbol: &str, source: &SymbolTable, dest: &mut SymbolTable) {
311    let parts: Vec<&str> = symbol.split('.').collect();
312    // Walk into source, building the dotted key as we go.
313    // Stop when we find a Value (the rest is property/method access).
314    let mut current = source;
315    for i in 0..parts.len() {
316        match current.table.get(parts[i]) {
317            Some(SymbolTableEntry::Value(v)) => {
318                // Found the value — copy it at this dotted path
319                let key = parts[..=i].join(".");
320                let _ = dest.set(&key, v.clone());
321                return;
322            }
323            Some(SymbolTableEntry::Table(t)) => {
324                current = t;
325                // Continue walking deeper
326            }
327            None => return, // Symbol not in source, skip
328        }
329    }
330    // Reached the end and it's a table — copy the whole subtable
331    let key = parts.join(".");
332    dest.set_table(&key, current.clone());
333}
334
335impl fmt::Display for FormatString {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        write!(f, "{}", self.raw)
338    }
339}
340impl PartialEq for FormatString {
341    fn eq(&self, other: &Self) -> bool {
342        self.raw == other.raw
343    }
344}
345impl Eq for FormatString {}
346impl<'de> serde::Deserialize<'de> for FormatString {
347    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
348        struct FsVisitor;
349        impl<'de> serde::de::Visitor<'de> for FsVisitor {
350            type Value = FormatString;
351            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
352                write!(f, "a string or number")
353            }
354            fn visit_str<E: de::Error>(self, v: &str) -> Result<FormatString, E> {
355                FormatString::new(v).map_err(de::Error::custom)
356            }
357            fn visit_string<E: de::Error>(self, v: String) -> Result<FormatString, E> {
358                FormatString::new(&v).map_err(de::Error::custom)
359            }
360            fn visit_i64<E: de::Error>(self, v: i64) -> Result<FormatString, E> {
361                FormatString::new(&v.to_string()).map_err(de::Error::custom)
362            }
363            fn visit_u64<E: de::Error>(self, v: u64) -> Result<FormatString, E> {
364                FormatString::new(&v.to_string()).map_err(de::Error::custom)
365            }
366            fn visit_f64<E: de::Error>(self, v: f64) -> Result<FormatString, E> {
367                FormatString::new(&v.to_string()).map_err(de::Error::custom)
368            }
369            fn visit_bool<E: de::Error>(self, v: bool) -> Result<FormatString, E> {
370                FormatString::new(&v.to_string()).map_err(de::Error::custom)
371            }
372        }
373        deserializer.deserialize_any(FsVisitor)
374    }
375}
376impl serde::Serialize for FormatString {
377    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
378        self.raw.serialize(serializer)
379    }
380}
381
382/// Check list comprehension loop variables in an AST.
383fn check_comprehension_vars(
384    node: &ruff_python_ast::Expr,
385    let_names: &std::collections::HashSet<String>,
386) -> Result<(), ExpressionError> {
387    use ruff_python_ast as ast;
388    match node {
389        ast::Expr::ListComp(lc) => {
390            for gen in &lc.generators {
391                if let ast::Expr::Name(n) = &gen.target {
392                    let var = n.id.as_str();
393                    // Must start with lowercase or underscore
394                    if let Some(first) = var.chars().next() {
395                        if !first.is_ascii_lowercase() && first != '_' {
396                            return Err(ExpressionError::new(format!(
397                                "List comprehension variable '{var}' must start with a lowercase letter or underscore"
398                            )));
399                        }
400                    }
401                    // Must not shadow let bindings
402                    if let_names.contains(var) {
403                        return Err(ExpressionError::new(format!(
404                            "List comprehension variable '{var}' shadows a let binding"
405                        )));
406                    }
407                }
408            }
409            check_comprehension_vars(&lc.elt, let_names)?;
410        }
411        ast::Expr::BinOp(b) => {
412            check_comprehension_vars(&b.left, let_names)?;
413            check_comprehension_vars(&b.right, let_names)?;
414        }
415        ast::Expr::UnaryOp(u) => {
416            check_comprehension_vars(&u.operand, let_names)?;
417        }
418        ast::Expr::Compare(c) => {
419            check_comprehension_vars(&c.left, let_names)?;
420            for r in &c.comparators {
421                check_comprehension_vars(r, let_names)?;
422            }
423        }
424        ast::Expr::BoolOp(b) => {
425            for v in &b.values {
426                check_comprehension_vars(v, let_names)?;
427            }
428        }
429        ast::Expr::If(i) => {
430            check_comprehension_vars(&i.test, let_names)?;
431            check_comprehension_vars(&i.body, let_names)?;
432            check_comprehension_vars(&i.orelse, let_names)?;
433        }
434        ast::Expr::Call(c) => {
435            check_comprehension_vars(&c.func, let_names)?;
436            for a in &c.arguments.args {
437                check_comprehension_vars(a, let_names)?;
438            }
439        }
440        ast::Expr::List(l) => {
441            for e in &l.elts {
442                check_comprehension_vars(e, let_names)?;
443            }
444        }
445        ast::Expr::Tuple(t) => {
446            for e in &t.elts {
447                check_comprehension_vars(e, let_names)?;
448            }
449        }
450        ast::Expr::Subscript(s) => {
451            check_comprehension_vars(&s.value, let_names)?;
452            check_comprehension_vars(&s.slice, let_names)?;
453        }
454        ast::Expr::Attribute(a) => {
455            check_comprehension_vars(&a.value, let_names)?;
456        }
457        _ => {}
458    }
459    Ok(())
460}
461
462fn parse_segments(input: &str, profile: &ExprProfile) -> Result<Vec<Segment>, ExpressionError> {
463    let mut segments = Vec::new();
464    let len = input.len();
465    let mut pos = 0;
466    while pos < len {
467        match input[pos..].find("{{") {
468            None => {
469                if let Some(co) = input[pos..].find("}}") {
470                    let cp = pos + co;
471                    return Err(ExpressionError::new(format!(
472                        "Failed to parse interpolation expression at [{pos}, {}]. Reason: Missing opening braces.", cp + 2
473                    ))
474                    .with_span(input, cp, cp + 2));
475                }
476                let rest = &input[pos..];
477                if !rest.is_empty() {
478                    segments.push(Segment::Literal(rest.to_string()));
479                }
480                break;
481            }
482            Some(offset) => {
483                let op = pos + offset;
484                if let Some(co) = input[pos..].find("}}") {
485                    if pos + co < op {
486                        let cp = pos + co;
487                        return Err(ExpressionError::new(format!(
488                            "Failed to parse interpolation expression at [{pos}, {len}]. Reason: Braces mismatch."
489                        ))
490                        .with_span(input, cp, cp + 2));
491                    }
492                }
493                if op > pos {
494                    segments.push(Segment::Literal(input[pos..op].to_string()));
495                }
496                let es = op + 2;
497                match input[es..].find("}}") {
498                    None => return Err(ExpressionError::new(format!(
499                        "Failed to parse interpolation expression at [{op}, {len}]. Reason: Braces mismatch."
500                    ))
501                    .with_span(input, op, op + 2)),
502                    Some(co) => {
503                        let ee = es + co;
504                        let be = ee + 2;
505                        let et = input[es..ee].trim();
506                        if et.is_empty() {
507                            return Err(ExpressionError::new(format!(
508                                "Failed to parse interpolation expression at [{op}, {be}]. Reason: Empty expression."
509                            ))
510                            .with_span(input, op, be));
511                        }
512                        let parsed = crate::eval::ParsedExpression::with_profile(et, profile)
513                            .map_err(|e| {
514                                // Attach the raw format-string source + {{...}} span so
515                                // users see a caret on the failing interpolation rather
516                                // than a bare parse error.
517                                ExpressionError::new(format!(
518                                "Failed to parse interpolation expression at [{op}, {be}]. Reason: {}",
519                                e.message()
520                            ))
521                                .with_span(input, op, be)
522                            })?;
523                        segments.push(Segment::Expression { start: op, end: be, parsed });
524                        pos = be;
525                    }
526                }
527            }
528        }
529    }
530    Ok(segments)
531}
532
533/// Structured error from [`FormatString::validate_expressions`].
534///
535/// Carries the position of the failing interpolation within the format string
536/// so callers can produce caret-style diagnostics or structured error responses.
537#[derive(Debug, Clone)]
538pub struct FormatStringValidationError {
539    /// Description of what went wrong (e.g. "Undefined variable 'Param.X'").
540    pub message: String,
541    /// The raw format string that was being validated.
542    pub input: String,
543    /// Byte offset of the `{{` that opens the failing interpolation.
544    pub start: usize,
545    /// Byte offset of the `}}` that closes the failing interpolation.
546    pub end: usize,
547    /// The original expression error, if available. Contains sub_errors
548    /// for compound failures (e.g., if/else where both branches fail).
549    pub expression_error: Option<Box<ExpressionError>>,
550}
551
552impl std::fmt::Display for FormatStringValidationError {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        write!(
555            f,
556            "Failed to parse interpolation expression at [{}, {}]. {}",
557            self.start, self.end, self.message
558        )
559    }
560}
561
562impl std::error::Error for FormatStringValidationError {}
563
564/// Builder-style options for [`FormatString::resolve_with`] and
565/// [`FormatString::resolve_string_with`].
566///
567/// All fields are optional; defaults match the zero-argument shortcuts on
568/// `FormatString` (host-native path format, no library, no path mapping rules,
569/// no target type coercion). Chain the `with_*` methods to override any subset.
570///
571/// ```
572/// # use openjd_expr::{FormatString, FormatStringOptions, SymbolTable, PathFormat, ExprType};
573/// let fs = FormatString::new("{{Param.Frame}}").unwrap();
574/// let st = SymbolTable::new();
575/// let target = ExprType::INT;
576/// let opts = FormatStringOptions::new()
577///     .with_path_format(PathFormat::Posix)
578///     .with_target_type(&target);
579/// let _ = fs.resolve_with(&st, &opts);
580/// ```
581#[derive(Clone)]
582pub struct FormatStringOptions<'a> {
583    library: Option<&'a crate::function_library::FunctionLibrary>,
584    path_format: crate::path_mapping::PathFormat,
585    target_type: Option<&'a crate::types::ExprType>,
586}
587
588impl<'a> Default for FormatStringOptions<'a> {
589    fn default() -> Self {
590        Self {
591            library: None,
592            path_format: crate::path_mapping::PathFormat::host(),
593            target_type: None,
594        }
595    }
596}
597
598impl<'a> FormatStringOptions<'a> {
599    /// Construct options with all defaults (equivalent to `Default::default()`).
600    #[must_use]
601    pub fn new() -> Self {
602        Self::default()
603    }
604
605    /// Use the given function library instead of the default (built-in) one.
606    ///
607    /// Accepts either `&FunctionLibrary` or `Option<&FunctionLibrary>`; `None`
608    /// means "use the default library". To enable `apply_path_mapping`, pass
609    /// a library obtained from [`FunctionLibrary::for_profile`] with
610    /// [`HostContext::WithRules`] on the profile.
611    ///
612    /// [`FunctionLibrary::for_profile`]: crate::FunctionLibrary::for_profile
613    /// [`HostContext::WithRules`]: crate::HostContext::WithRules
614    #[must_use]
615    pub fn with_library(
616        mut self,
617        library: impl Into<Option<&'a crate::function_library::FunctionLibrary>>,
618    ) -> Self {
619        self.library = library.into();
620        self
621    }
622
623    /// Construct `path()` values in this format (default: host-native).
624    #[must_use]
625    pub fn with_path_format(mut self, fmt: crate::path_mapping::PathFormat) -> Self {
626        self.path_format = fmt;
627        self
628    }
629
630    /// Coerce the resolved value toward this target type.
631    ///
632    /// Ignored by `resolve_string_with`.
633    #[must_use]
634    pub fn with_target_type(mut self, t: &'a crate::types::ExprType) -> Self {
635        self.target_type = Some(t);
636        self
637    }
638}
639
640/// Escape `{{` and `}}` in a string so the format string parser treats them as literals.
641#[must_use]
642pub fn escape_format_string(value: &str) -> String {
643    let mut result = String::new();
644    let mut chars = value.chars().peekable();
645    while let Some(c) = chars.next() {
646        if c == '{' && chars.peek() == Some(&'{') {
647            chars.next();
648            result.push_str("{{ \"{{\" }}");
649        } else if c == '}' && chars.peek() == Some(&'}') {
650            chars.next();
651            result.push_str("{{ \"}\" + \"}\" }}");
652        } else {
653            result.push(c);
654        }
655    }
656    result
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn literal_only() {
665        let fs = FormatString::new("hello").unwrap();
666        assert!(fs.is_literal());
667        assert_eq!(
668            fs.resolve_string_with(&SymbolTable::new(), &FormatStringOptions::default())
669                .unwrap(),
670            "hello"
671        );
672    }
673    #[test]
674    fn simple_expr() {
675        let fs = FormatString::new("{{Param.X}}").unwrap();
676        let mut st = SymbolTable::new();
677        st.set_string("Param.X", "42").unwrap();
678        assert_eq!(
679            fs.resolve_string_with(&st, &FormatStringOptions::default())
680                .unwrap(),
681            "42"
682        );
683    }
684    #[test]
685    fn complex_parses() {
686        let fs = FormatString::new("{{Param.X + 1}}").unwrap();
687        assert!(fs.has_complex_expressions());
688    }
689    #[test]
690    fn missing_close() {
691        assert!(FormatString::new("{{x").is_err());
692    }
693    #[test]
694    fn missing_open() {
695        assert!(FormatString::new("x}}").is_err());
696    }
697    #[test]
698    fn empty_expr() {
699        assert!(FormatString::new("{{}}").is_err());
700    }
701    #[test]
702    fn resolve_expr_arithmetic() {
703        let fs = FormatString::new("{{ Param.X + 3 }}").unwrap();
704        let mut st = SymbolTable::new();
705        st.set("Param.X", ExprValue::Int(10)).unwrap();
706        assert_eq!(
707            fs.resolve_string_with(&st, &FormatStringOptions::default())
708                .unwrap(),
709            "13"
710        );
711    }
712    #[test]
713    fn validate_catches_bitwise() {
714        // Bitwise ops are rejected at parse time (structural validation in ParsedExpression::new)
715        assert!(FormatString::new("{{ 5 & 3 }}").is_err());
716    }
717    #[test]
718    fn validate_catches_dict() {
719        // Dict literals are rejected at parse time (structural validation in ParsedExpression::new)
720        assert!(FormatString::new("{{ {1: 2} }}").is_err());
721    }
722    #[test]
723    fn validate_catches_unknown_func() {
724        let fs = FormatString::new("{{ bad_func(1) }}").unwrap();
725        let host_lib =
726            crate::FunctionLibrary::for_profile(&crate::ExprProfile::current().with_host_context(
727                crate::HostContext::with_rules(Vec::<crate::path_mapping::PathMappingRule>::new()),
728            ));
729        assert!(fs
730            .validate_expressions(&SymbolTable::new(), &host_lib)
731            .is_err());
732    }
733    #[test]
734    fn validate_catches_empty_regex_pattern() {
735        // First verify the expression itself errors
736        let st = SymbolTable::new();
737        let result = crate::ParsedExpression::new("re_replace('hello', '', 'x')")
738            .and_then(|p| p.evaluate(&st));
739        assert!(
740            result.is_err(),
741            "Direct eval should error, got: {:?}",
742            result.map(|v| v.to_display_string())
743        );
744
745        let host_lib =
746            crate::FunctionLibrary::for_profile(&crate::ExprProfile::current().with_host_context(
747                crate::HostContext::with_rules(Vec::<crate::path_mapping::PathMappingRule>::new()),
748            ));
749        let fs = FormatString::new("{{ re_replace('hello', '', 'x') }}").unwrap();
750        let result = fs.validate_expressions(&SymbolTable::new(), &host_lib);
751        assert!(
752            result.is_err(),
753            "Format string validation should error, got: {:?}",
754            result
755        );
756    }
757    #[test]
758    fn validate_catches_regex_group_ref() {
759        let st = SymbolTable::new();
760        // Backslash group ref
761        let result = crate::ParsedExpression::new(r"re_replace('hello', '(h)', r'\1')")
762            .and_then(|p| p.evaluate(&st));
763        assert!(
764            result.is_err(),
765            "Should reject \\1 group ref, got: {:?}",
766            result.map(|v| v.to_display_string())
767        );
768        // Dollar group ref
769        let result = crate::ParsedExpression::new("re_replace('hello', '(h)', '$1')")
770            .and_then(|p| p.evaluate(&st));
771        assert!(
772            result.is_err(),
773            "Should reject $1 group ref, got: {:?}",
774            result.map(|v| v.to_display_string())
775        );
776    }
777    #[test]
778    fn validate_allows_known_func() {
779        let fs = FormatString::new("{{ len(Param.X) }}").unwrap();
780        let mut st = SymbolTable::new();
781        st.set(
782            "Param.X",
783            crate::ExprValue::unresolved(crate::ExprType::list(crate::ExprType::INT)),
784        )
785        .unwrap();
786        let host_lib =
787            crate::FunctionLibrary::for_profile(&crate::ExprProfile::current().with_host_context(
788                crate::HostContext::with_rules(Vec::<crate::path_mapping::PathMappingRule>::new()),
789            ));
790        assert!(fs.validate_expressions(&st, &host_lib).is_ok());
791    }
792    #[test]
793    fn validate_allows_arithmetic() {
794        let fs = FormatString::new("{{ Param.X + 3 }}").unwrap();
795        let mut st = SymbolTable::new();
796        st.set(
797            "Param.X",
798            crate::ExprValue::unresolved(crate::ExprType::INT),
799        )
800        .unwrap();
801        let host_lib =
802            crate::FunctionLibrary::for_profile(&crate::ExprProfile::current().with_host_context(
803                crate::HostContext::with_rules(Vec::<crate::path_mapping::PathMappingRule>::new()),
804            ));
805        assert!(fs.validate_expressions(&st, &host_lib).is_ok());
806    }
807
808    #[test]
809    fn escape_format_string_no_special_chars() {
810        assert_eq!(escape_format_string("hello world"), "hello world");
811    }
812    #[test]
813    fn escape_format_string_double_open_braces() {
814        assert_eq!(escape_format_string("{{"), "{{ \"{{\" }}");
815    }
816    #[test]
817    fn escape_format_string_double_close_braces() {
818        assert_eq!(escape_format_string("}}"), "{{ \"}\" + \"}\" }}");
819    }
820    #[test]
821    fn escape_format_string_mixed() {
822        assert_eq!(
823            escape_format_string("a{{b}}c"),
824            "a{{ \"{{\" }}b{{ \"}\" + \"}\" }}c"
825        );
826    }
827    #[test]
828    fn escape_format_string_empty() {
829        assert_eq!(escape_format_string(""), "");
830    }
831    #[test]
832    fn resolve_value_single_expr_int() {
833        let fs = FormatString::new("{{Param.X}}").unwrap();
834        let mut st = SymbolTable::new();
835        st.set("Param.X", ExprValue::Int(42)).unwrap();
836        let val = fs
837            .resolve_with(&st, &FormatStringOptions::default())
838            .unwrap();
839        assert!(matches!(val, ExprValue::Int(42)));
840    }
841    #[test]
842    fn resolve_value_single_expr_string() {
843        let fs = FormatString::new("{{Param.X}}").unwrap();
844        let mut st = SymbolTable::new();
845        st.set("Param.X", ExprValue::String("hello".into()))
846            .unwrap();
847        let val = fs
848            .resolve_with(&st, &FormatStringOptions::default())
849            .unwrap();
850        assert!(matches!(val, ExprValue::String(ref s) if s == "hello"));
851    }
852    #[test]
853    fn resolve_value_mixed() {
854        let fs = FormatString::new("hello {{Param.X}}").unwrap();
855        let mut st = SymbolTable::new();
856        st.set("Param.X", ExprValue::Int(42)).unwrap();
857        let val = fs
858            .resolve_with(&st, &FormatStringOptions::default())
859            .unwrap();
860        assert!(matches!(val, ExprValue::String(ref s) if s == "hello 42"));
861    }
862    #[test]
863    fn resolve_value_pure_literal() {
864        let fs = FormatString::new("hello").unwrap();
865        let val = fs
866            .resolve_with(&SymbolTable::new(), &FormatStringOptions::default())
867            .unwrap();
868        assert!(matches!(val, ExprValue::String(ref s) if s == "hello"));
869    }
870
871    #[test]
872    fn resolve_with_target_type_coerces_int_to_float() {
873        let fs = FormatString::new("{{Param.X}}").unwrap();
874        let mut st = SymbolTable::new();
875        st.set("Param.X", ExprValue::Int(42)).unwrap();
876        let target = crate::types::ExprType::FLOAT;
877        let val = fs
878            .resolve_with(
879                &st,
880                &FormatStringOptions::default().with_target_type(&target),
881            )
882            .unwrap();
883        assert!(matches!(val, ExprValue::Float(ref f) if f.value() == 42.0));
884    }
885
886    #[test]
887    fn resolve_with_target_type_none_preserves_int() {
888        let fs = FormatString::new("{{Param.X}}").unwrap();
889        let mut st = SymbolTable::new();
890        st.set("Param.X", ExprValue::Int(42)).unwrap();
891        let val = fs
892            .resolve_with(&st, &FormatStringOptions::default())
893            .unwrap();
894        assert!(matches!(val, ExprValue::Int(42)));
895    }
896
897    #[test]
898    fn resolve_with_target_type_path() {
899        let fs = FormatString::new("{{Param.X}}").unwrap();
900        let mut st = SymbolTable::new();
901        st.set("Param.X", ExprValue::String("/foo/bar".into()))
902            .unwrap();
903        let target = crate::types::ExprType::PATH;
904        let val = fs
905            .resolve_with(
906                &st,
907                &FormatStringOptions::default()
908                    .with_path_format(crate::path_mapping::PathFormat::Posix)
909                    .with_target_type(&target),
910            )
911            .unwrap();
912        assert!(matches!(val, ExprValue::Path { ref value, .. } if value == "/foo/bar"));
913    }
914
915    #[test]
916    fn copy_used_symtab_values_simple() {
917        let mut src = SymbolTable::new();
918        src.set("Param.Frame", ExprValue::Int(42)).unwrap();
919        src.set("Param.Name", ExprValue::String("test".into()))
920            .unwrap();
921        src.set("Param.Unused", ExprValue::Int(99)).unwrap();
922
923        let fs = FormatString::new("render --frame {{Param.Frame}}").unwrap();
924        let mut dest = SymbolTable::new();
925        fs.copy_used_symtab_values(&src, &mut dest);
926
927        assert!(dest.get_value("Param.Frame").is_some());
928        assert!(dest.get_value("Param.Name").is_none());
929        assert!(dest.get_value("Param.Unused").is_none());
930    }
931
932    #[test]
933    fn copy_used_symtab_values_method_call() {
934        // Param.Name.upper() — "Param.Name" is the value, ".upper()" is a method
935        let mut src = SymbolTable::new();
936        src.set("Param.Name", ExprValue::String("hello".into()))
937            .unwrap();
938
939        let fs = FormatString::new("{{Param.Name.upper()}}").unwrap();
940        let mut dest = SymbolTable::new();
941        fs.copy_used_symtab_values(&src, &mut dest);
942
943        assert_eq!(
944            dest.get_value("Param.Name").unwrap(),
945            &ExprValue::String("hello".into())
946        );
947    }
948
949    #[test]
950    fn copy_used_symtab_values_multiple_format_strings() {
951        let mut src = SymbolTable::new();
952        src.set("Param.Frame", ExprValue::Int(1)).unwrap();
953        src.set("Param.Name", ExprValue::String("job".into()))
954            .unwrap();
955        src.set("Task.Param.Index", ExprValue::Int(5)).unwrap();
956
957        let mut dest = SymbolTable::new();
958        FormatString::new("{{Param.Frame}}")
959            .unwrap()
960            .copy_used_symtab_values(&src, &mut dest);
961        FormatString::new("{{Task.Param.Index}}")
962            .unwrap()
963            .copy_used_symtab_values(&src, &mut dest);
964
965        assert!(dest.get_value("Param.Frame").is_some());
966        assert!(dest.get_value("Task.Param.Index").is_some());
967        assert!(dest.get_value("Param.Name").is_none());
968    }
969
970    #[test]
971    fn copy_used_symtab_values_literal_no_copy() {
972        let mut src = SymbolTable::new();
973        src.set("Param.X", ExprValue::Int(1)).unwrap();
974
975        let fs = FormatString::new("just a literal").unwrap();
976        let mut dest = SymbolTable::new();
977        fs.copy_used_symtab_values(&src, &mut dest);
978
979        assert!(dest.keys().next().is_none());
980    }
981
982    #[test]
983    fn copy_used_symtab_values_expression_with_multiple_refs() {
984        let mut src = SymbolTable::new();
985        src.set("Param.Start", ExprValue::Int(1)).unwrap();
986        src.set("Param.End", ExprValue::Int(10)).unwrap();
987        src.set("Param.Other", ExprValue::Int(99)).unwrap();
988
989        let fs = FormatString::new("{{Param.Start + Param.End}}").unwrap();
990        let mut dest = SymbolTable::new();
991        fs.copy_used_symtab_values(&src, &mut dest);
992
993        assert!(dest.get_value("Param.Start").is_some());
994        assert!(dest.get_value("Param.End").is_some());
995        assert!(dest.get_value("Param.Other").is_none());
996    }
997
998    #[test]
999    fn copy_used_symtab_values_property_access_stops_at_value() {
1000        // "Param.Name.upper()" — accessed_symbols is {"Param.Name.upper"}
1001        // but Param.Name is a Value, so we stop there and don't create Param.Name.upper
1002        let mut src = SymbolTable::new();
1003        src.set("Param.Name", ExprValue::String("hello".into()))
1004            .unwrap();
1005
1006        let fs = FormatString::new("{{Param.Name.upper()}}").unwrap();
1007        let mut dest = SymbolTable::new();
1008        fs.copy_used_symtab_values(&src, &mut dest);
1009
1010        // Param.Name is copied
1011        assert_eq!(
1012            dest.get_value("Param.Name"),
1013            Some(&ExprValue::String("hello".into()))
1014        );
1015        // Param.Name.upper is NOT a key (upper is a method, not a symtab entry)
1016        assert!(dest.get("Param.Name.upper").is_none());
1017    }
1018
1019    #[test]
1020    fn copy_used_symtab_values_chained_property() {
1021        // "Param.Path.stem.upper()" — Param.Path is a Value
1022        let mut src = SymbolTable::new();
1023        src.set("Param.Path", ExprValue::String("/foo/bar.exr".into()))
1024            .unwrap();
1025
1026        let fs = FormatString::new("{{Param.Path.stem.upper()}}").unwrap();
1027        let mut dest = SymbolTable::new();
1028        fs.copy_used_symtab_values(&src, &mut dest);
1029
1030        assert_eq!(
1031            dest.get_value("Param.Path"),
1032            Some(&ExprValue::String("/foo/bar.exr".into()))
1033        );
1034        assert!(dest.get("Param.Path.stem").is_none());
1035    }
1036
1037    #[test]
1038    fn copy_used_symtab_values_missing_symbol_no_error() {
1039        // Reference a symbol that doesn't exist in source — should silently skip
1040        let src = SymbolTable::new(); // empty
1041
1042        let fs = FormatString::new("{{Param.Missing + Task.Param.Also.Missing}}").unwrap();
1043        let mut dest = SymbolTable::new();
1044        fs.copy_used_symtab_values(&src, &mut dest);
1045
1046        // dest should be empty, no errors
1047        assert!(dest.keys().next().is_none());
1048    }
1049
1050    #[test]
1051    fn copy_used_symtab_values_partial_missing() {
1052        // One symbol exists, one doesn't
1053        let mut src = SymbolTable::new();
1054        src.set("Param.Frame", ExprValue::Int(1)).unwrap();
1055
1056        let fs = FormatString::new("{{Param.Frame + Param.Missing}}").unwrap();
1057        let mut dest = SymbolTable::new();
1058        fs.copy_used_symtab_values(&src, &mut dest);
1059
1060        assert_eq!(dest.get_value("Param.Frame"), Some(&ExprValue::Int(1)));
1061        assert!(dest.get("Param.Missing").is_none());
1062    }
1063
1064    #[test]
1065    fn accessed_symbols_simple() {
1066        let fs = FormatString::new("render --frame {{Param.Frame}}").unwrap();
1067        let syms = fs.accessed_symbols();
1068        assert!(syms.contains("Param.Frame"));
1069        assert_eq!(syms.len(), 1);
1070    }
1071
1072    #[test]
1073    fn accessed_symbols_multiple_refs() {
1074        let fs = FormatString::new("{{Param.Start + Param.End}}").unwrap();
1075        let syms = fs.accessed_symbols();
1076        assert!(syms.contains("Param.Start"));
1077        assert!(syms.contains("Param.End"));
1078        assert_eq!(syms.len(), 2);
1079    }
1080
1081    #[test]
1082    fn accessed_symbols_literal_returns_empty() {
1083        let fs = FormatString::new("just a literal").unwrap();
1084        assert!(fs.accessed_symbols().is_empty());
1085    }
1086
1087    #[test]
1088    fn accessed_symbols_method_call() {
1089        let fs = FormatString::new("{{Param.Name.upper()}}").unwrap();
1090        let syms = fs.accessed_symbols();
1091        // The parser resolves the attribute chain to the base symbol
1092        assert!(syms.contains("Param.Name"));
1093    }
1094
1095    #[test]
1096    fn accessed_symbols_multiple_segments() {
1097        let fs = FormatString::new("{{Param.A}}_{{Param.B}}").unwrap();
1098        let syms = fs.accessed_symbols();
1099        assert!(syms.contains("Param.A"));
1100        assert!(syms.contains("Param.B"));
1101        assert_eq!(syms.len(), 2);
1102    }
1103
1104    // ── FormatStringOptions builder tests ──
1105
1106    #[test]
1107    fn options_default_matches_host_format() {
1108        let opts = FormatStringOptions::new();
1109        assert_eq!(opts.path_format, crate::path_mapping::PathFormat::host());
1110        assert!(opts.library.is_none());
1111        assert!(opts.target_type.is_none());
1112    }
1113
1114    #[test]
1115    fn options_with_path_format() {
1116        let fs = FormatString::new("{{path('/tmp/out')}}").unwrap();
1117        let st = SymbolTable::new();
1118        let opts =
1119            FormatStringOptions::new().with_path_format(crate::path_mapping::PathFormat::Posix);
1120        let val = fs.resolve_with(&st, &opts).unwrap();
1121        match val {
1122            ExprValue::Path { format, .. } => {
1123                assert_eq!(format, crate::path_mapping::PathFormat::Posix);
1124            }
1125            _ => panic!("expected path value, got {:?}", val),
1126        }
1127    }
1128
1129    #[test]
1130    fn options_with_target_type_coerces() {
1131        let fs = FormatString::new("{{42}}").unwrap();
1132        let st = SymbolTable::new();
1133        let target = crate::types::ExprType::FLOAT;
1134        let opts = FormatStringOptions::new().with_target_type(&target);
1135        let val = fs.resolve_with(&st, &opts).unwrap();
1136        assert!(matches!(val, ExprValue::Float(_)), "got {:?}", val);
1137    }
1138
1139    #[test]
1140    fn options_default_equivalent_to_builder() {
1141        // FormatStringOptions::default() behaves the same as manually constructing
1142        // with all defaults, and both produce the correct Int result from a
1143        // single-expression format string.
1144        let fs = FormatString::new("{{Param.X + 1}}").unwrap();
1145        let mut st = SymbolTable::new();
1146        st.set("Param.X", ExprValue::Int(10)).unwrap();
1147
1148        let a = fs
1149            .resolve_with(&st, &FormatStringOptions::default())
1150            .unwrap();
1151        let b = fs.resolve_with(&st, &FormatStringOptions::new()).unwrap();
1152        match (a, b) {
1153            (ExprValue::Int(11), ExprValue::Int(11)) => {}
1154            (a, b) => panic!("expected Int(11) for both; got {:?} vs {:?}", a, b),
1155        }
1156    }
1157
1158    #[test]
1159    fn options_resolve_string_with_ignores_target_type() {
1160        // resolve_string_with always concatenates to string; target_type is ignored.
1161        let fs = FormatString::new("{{Param.X}}").unwrap();
1162        let mut st = SymbolTable::new();
1163        st.set("Param.X", ExprValue::Int(42)).unwrap();
1164        let t = crate::types::ExprType::FLOAT;
1165        let opts = FormatStringOptions::new().with_target_type(&t);
1166        let s = fs.resolve_string_with(&st, &opts).unwrap();
1167        assert_eq!(s, "42");
1168    }
1169
1170    #[test]
1171    fn options_with_library_is_plumbed() {
1172        // Build a library that only registers `len`, without `upper`, and confirm that
1173        // when we set it on the options, evaluation uses the restricted library.
1174        let fs = FormatString::new("{{ upper('hi') }}").unwrap();
1175        let st = SymbolTable::new();
1176        let mut minimal = crate::function_library::FunctionLibrary::new();
1177        minimal
1178            .register_sig("len", "(string) -> int", crate::functions::misc::len_string)
1179            .unwrap();
1180        let opts = FormatStringOptions::new().with_library(&minimal);
1181        assert!(
1182            fs.resolve_with(&st, &opts).is_err(),
1183            "should reject unknown function"
1184        );
1185    }
1186}