Skip to main content

holoconf_core/
interpolation.rs

1//! Interpolation parsing per ADR-011
2//!
3//! Parses interpolation expressions like:
4//! - `${env:VAR}` - resolver with argument
5//! - `${env:VAR,default}` - resolver with default
6//! - `${path.to.value}` - self-reference
7//! - `${.sibling}` - relative self-reference
8//! - `\${escaped}` - escaped (literal) interpolation
9//! - `${env:VAR,${env:OTHER,fallback}}` - nested interpolations
10
11use crate::error::{Error, Result};
12use std::collections::HashMap;
13
14/// A parsed interpolation expression
15#[derive(Debug, Clone, PartialEq)]
16pub enum Interpolation {
17    /// A literal string (no interpolation or escaped interpolation)
18    Literal(String),
19    /// A resolver call: ${resolver:arg1,arg2,key=value}
20    Resolver {
21        /// Resolver name (e.g., "env", "file")
22        name: String,
23        /// Positional arguments
24        args: Vec<InterpolationArg>,
25        /// Keyword arguments
26        kwargs: HashMap<String, InterpolationArg>,
27    },
28    /// A self-reference: ${path.to.value}
29    SelfRef {
30        /// The path to reference
31        path: String,
32        /// Whether this is a relative path (starts with .)
33        relative: bool,
34    },
35    /// A concatenation of multiple parts
36    Concat(Vec<Interpolation>),
37}
38
39/// An argument to an interpolation (may itself contain interpolations)
40#[derive(Debug, Clone, PartialEq)]
41pub enum InterpolationArg {
42    /// A literal string value
43    Literal(String),
44    /// A nested interpolation
45    Nested(Box<Interpolation>),
46}
47
48impl InterpolationArg {
49    /// Check if this argument is a simple literal
50    pub fn is_literal(&self) -> bool {
51        matches!(self, InterpolationArg::Literal(_))
52    }
53
54    /// Get the literal value if this is a literal
55    pub fn as_literal(&self) -> Option<&str> {
56        match self {
57            InterpolationArg::Literal(s) => Some(s),
58            _ => None,
59        }
60    }
61}
62
63/// Parser for interpolation expressions
64pub struct InterpolationParser<'a> {
65    input: &'a str,
66    pos: usize,
67}
68
69impl<'a> InterpolationParser<'a> {
70    /// Create a new parser for the given input
71    pub fn new(input: &'a str) -> Self {
72        Self { input, pos: 0 }
73    }
74
75    /// Parse the entire input string
76    pub fn parse(&mut self) -> Result<Interpolation> {
77        let mut parts = Vec::new();
78
79        while !self.is_eof() {
80            if self.check_escape() {
81                // \${ -> literal ${
82                self.advance(); // skip backslash
83                self.advance(); // skip $
84                self.advance(); // skip {
85                parts.push(Interpolation::Literal("${".to_string()));
86            } else if self.check_interpolation_start() {
87                parts.push(self.parse_interpolation()?);
88            } else {
89                // Collect literal text until next interpolation or end
90                let literal = self.collect_literal();
91                if !literal.is_empty() {
92                    parts.push(Interpolation::Literal(literal));
93                }
94            }
95        }
96
97        // Simplify result
98        match parts.len() {
99            0 => Ok(Interpolation::Literal(String::new())),
100            1 => Ok(parts.remove(0)),
101            _ => {
102                // Merge adjacent literals
103                let merged = merge_adjacent_literals(parts);
104                if merged.len() == 1 {
105                    Ok(merged.into_iter().next().unwrap())
106                } else {
107                    Ok(Interpolation::Concat(merged))
108                }
109            }
110        }
111    }
112
113    /// Check if we're at end of input
114    fn is_eof(&self) -> bool {
115        self.pos >= self.input.len()
116    }
117
118    /// Get current character
119    fn current(&self) -> Option<char> {
120        self.input[self.pos..].chars().next()
121    }
122
123    /// Peek at the next character
124    fn peek(&self) -> Option<char> {
125        let mut chars = self.input[self.pos..].chars();
126        chars.next();
127        chars.next()
128    }
129
130    /// Peek at character n positions ahead
131    fn peek_n(&self, n: usize) -> Option<char> {
132        self.input[self.pos..].chars().nth(n)
133    }
134
135    /// Advance by one character
136    fn advance(&mut self) {
137        if let Some(c) = self.current() {
138            self.pos += c.len_utf8();
139        }
140    }
141
142    /// Check if we're at an escape sequence (\${)
143    fn check_escape(&self) -> bool {
144        self.current() == Some('\\') && self.peek() == Some('$') && self.peek_n(2) == Some('{')
145    }
146
147    /// Check if we're at an interpolation start (${)
148    fn check_interpolation_start(&self) -> bool {
149        self.current() == Some('$') && self.peek() == Some('{')
150    }
151
152    /// Collect literal text until interpolation or end
153    fn collect_literal(&mut self) -> String {
154        let mut result = String::new();
155
156        while !self.is_eof() {
157            if self.check_escape() {
158                break;
159            }
160            if self.check_interpolation_start() {
161                break;
162            }
163            if let Some(c) = self.current() {
164                result.push(c);
165                self.advance();
166            }
167        }
168
169        result
170    }
171
172    /// Parse an interpolation expression (starting at ${)
173    fn parse_interpolation(&mut self) -> Result<Interpolation> {
174        // Skip ${
175        self.advance(); // $
176        self.advance(); // {
177
178        // Skip whitespace
179        self.skip_whitespace();
180
181        if self.is_eof() {
182            return Err(Error::parse("Unexpected end of input in interpolation"));
183        }
184
185        // Check for relative path (.sibling or ..parent)
186        if self.current() == Some('.') {
187            return self.parse_self_ref(true);
188        }
189
190        // Collect the identifier (resolver name or path)
191        let identifier = self.collect_identifier();
192
193        if identifier.is_empty() {
194            return Err(Error::parse("Empty interpolation expression"));
195        }
196
197        self.skip_whitespace();
198
199        // Check what follows the identifier
200        match self.current() {
201            Some(':') => {
202                // This is a resolver call: ${resolver:args}
203                self.advance(); // skip :
204                self.parse_resolver_call(identifier)
205            }
206            Some('}') => {
207                // This is a simple self-reference: ${path.to.value}
208                // Convert to ref resolver: ${ref:path}
209                self.advance(); // skip }
210                Ok(Interpolation::Resolver {
211                    name: "ref".to_string(),
212                    args: vec![InterpolationArg::Literal(identifier)],
213                    kwargs: HashMap::new(),
214                })
215            }
216            Some(',') => {
217                // Self-reference with kwargs: ${path,default=value}
218                // Convert to ref resolver: ${ref:path,default=value}
219                self.advance(); // skip comma
220                self.parse_resolver_call_with_first_arg("ref".to_string(), identifier)
221            }
222            Some(c) => Err(Error::parse(format!(
223                "Unexpected character '{}' in interpolation",
224                c
225            ))),
226            None => Err(Error::parse("Unexpected end of input in interpolation")),
227        }
228    }
229
230    /// Parse a self-reference (possibly relative)
231    fn parse_self_ref(&mut self, relative: bool) -> Result<Interpolation> {
232        let mut path = String::new();
233
234        // Collect the full path including dots
235        while !self.is_eof() {
236            match self.current() {
237                Some('}') => {
238                    self.advance();
239                    break;
240                }
241                Some(c) if c.is_alphanumeric() || c == '_' || c == '.' || c == '[' || c == ']' => {
242                    path.push(c);
243                    self.advance();
244                }
245                Some(c) => {
246                    return Err(Error::parse(format!("Invalid character '{}' in path", c)));
247                }
248                None => {
249                    return Err(Error::parse("Unexpected end of input in path"));
250                }
251            }
252        }
253
254        Ok(Interpolation::SelfRef { path, relative })
255    }
256
257    /// Parse a resolver call after the colon
258    fn parse_resolver_call(&mut self, name: String) -> Result<Interpolation> {
259        let mut args = Vec::new();
260        let mut kwargs = HashMap::new();
261
262        // Parse arguments separated by commas
263        loop {
264            self.skip_whitespace();
265
266            if self.current() == Some('}') {
267                self.advance();
268                break;
269            }
270
271            if !args.is_empty() || !kwargs.is_empty() {
272                // Expect comma separator
273                if self.current() != Some(',') {
274                    return Err(Error::parse("Expected ',' between arguments"));
275                }
276                self.advance(); // skip comma
277                self.skip_whitespace();
278            }
279
280            // Try to parse as kwarg first (key=value)
281            // Look ahead for = before any comma or interpolation start
282            if let Some((key, value_arg)) = self.try_parse_kwarg()? {
283                kwargs.insert(key, value_arg);
284            } else {
285                // Parse as positional argument
286                let arg = self.parse_argument()?;
287                args.push(arg);
288            }
289        }
290
291        Ok(Interpolation::Resolver { name, args, kwargs })
292    }
293
294    /// Parse a resolver call with a pre-parsed first argument
295    /// Used for ${path,kwargs} syntax which becomes ${ref:path,kwargs}
296    fn parse_resolver_call_with_first_arg(
297        &mut self,
298        name: String,
299        first_arg: String,
300    ) -> Result<Interpolation> {
301        let mut args = vec![InterpolationArg::Literal(first_arg)];
302        let mut kwargs = HashMap::new();
303
304        // Parse remaining arguments/kwargs separated by commas
305        loop {
306            self.skip_whitespace();
307
308            if self.current() == Some('}') {
309                self.advance();
310                break;
311            }
312
313            // We already consumed the first comma, expect more args/kwargs
314            self.skip_whitespace();
315
316            // Try to parse as kwarg first (key=value)
317            if let Some((key, value_arg)) = self.try_parse_kwarg()? {
318                kwargs.insert(key, value_arg);
319            } else {
320                // Parse as positional argument
321                let arg = self.parse_argument()?;
322                args.push(arg);
323            }
324
325            self.skip_whitespace();
326
327            // Check for comma or end
328            match self.current() {
329                Some(',') => {
330                    self.advance();
331                    continue;
332                }
333                Some('}') => {
334                    // Will be handled at start of loop
335                    continue;
336                }
337                Some(c) => {
338                    return Err(Error::parse(format!(
339                        "Expected ',' or '}}' but found '{}'",
340                        c
341                    )));
342                }
343                None => {
344                    return Err(Error::parse("Unexpected end of input in interpolation"));
345                }
346            }
347        }
348
349        Ok(Interpolation::Resolver { name, args, kwargs })
350    }
351
352    /// Try to parse a kwarg (key=value pattern)
353    /// Returns Some((key, value)) if successful, None if this isn't a kwarg
354    fn try_parse_kwarg(&mut self) -> Result<Option<(String, InterpolationArg)>> {
355        // Save position for backtracking
356        let start_pos = self.pos;
357
358        // Try to collect an identifier followed by =
359        let mut key = String::new();
360        while !self.is_eof() {
361            match self.current() {
362                Some(c) if c.is_alphanumeric() || c == '_' => {
363                    key.push(c);
364                    self.advance();
365                }
366                Some('=') if !key.is_empty() => {
367                    // Found key=, now parse the value
368                    self.advance(); // skip =
369                    let value = self.parse_argument()?;
370                    return Ok(Some((key, value)));
371                }
372                _ => {
373                    // Not a kwarg pattern, backtrack
374                    self.pos = start_pos;
375                    return Ok(None);
376                }
377            }
378        }
379
380        // Reached EOF without finding =, backtrack
381        self.pos = start_pos;
382        Ok(None)
383    }
384
385    /// Parse a single argument (may be literal or nested interpolation)
386    fn parse_argument(&mut self) -> Result<InterpolationArg> {
387        self.skip_whitespace();
388
389        if self.check_interpolation_start() {
390            // Nested interpolation
391            let nested = self.parse_interpolation()?;
392            Ok(InterpolationArg::Nested(Box::new(nested)))
393        } else {
394            // Literal argument - collect until , or }
395            let mut value = String::new();
396            let mut depth = 0; // Track nested braces
397
398            while !self.is_eof() {
399                match self.current() {
400                    Some('$') if self.peek() == Some('{') => {
401                        // Nested interpolation - parse it
402                        let nested = self.parse_interpolation()?;
403                        return Ok(InterpolationArg::Nested(Box::new(if value.is_empty() {
404                            nested
405                        } else {
406                            // Concatenation: literal prefix + nested
407                            Interpolation::Concat(vec![Interpolation::Literal(value), nested])
408                        })));
409                    }
410                    Some('{') => {
411                        depth += 1;
412                        value.push('{');
413                        self.advance();
414                    }
415                    Some('}') => {
416                        if depth == 0 {
417                            break;
418                        }
419                        depth -= 1;
420                        value.push('}');
421                        self.advance();
422                    }
423                    Some(',') if depth == 0 => {
424                        break;
425                    }
426                    Some(c) => {
427                        value.push(c);
428                        self.advance();
429                    }
430                    None => break,
431                }
432            }
433
434            Ok(InterpolationArg::Literal(value.trim().to_string()))
435        }
436    }
437
438    /// Collect an identifier (alphanumeric, _, ., [, ])
439    fn collect_identifier(&mut self) -> String {
440        let mut result = String::new();
441
442        while !self.is_eof() {
443            match self.current() {
444                Some(c) if c.is_alphanumeric() || c == '_' || c == '.' || c == '[' || c == ']' => {
445                    result.push(c);
446                    self.advance();
447                }
448                _ => break,
449            }
450        }
451
452        result
453    }
454
455    /// Skip whitespace characters
456    fn skip_whitespace(&mut self) {
457        while let Some(c) = self.current() {
458            if c.is_whitespace() {
459                self.advance();
460            } else {
461                break;
462            }
463        }
464    }
465}
466
467/// Merge adjacent literal parts
468fn merge_adjacent_literals(parts: Vec<Interpolation>) -> Vec<Interpolation> {
469    let mut result = Vec::new();
470    let mut current_literal = String::new();
471
472    for part in parts {
473        match part {
474            Interpolation::Literal(s) => {
475                current_literal.push_str(&s);
476            }
477            other => {
478                if !current_literal.is_empty() {
479                    result.push(Interpolation::Literal(current_literal));
480                    current_literal = String::new();
481                }
482                result.push(other);
483            }
484        }
485    }
486
487    if !current_literal.is_empty() {
488        result.push(Interpolation::Literal(current_literal));
489    }
490
491    result
492}
493
494/// Parse an interpolation string
495pub fn parse(input: &str) -> Result<Interpolation> {
496    InterpolationParser::new(input).parse()
497}
498
499/// Check if a string contains any interpolation expressions (unescaped ${...})
500pub fn contains_interpolation(input: &str) -> bool {
501    let mut chars = input.chars().peekable();
502
503    while let Some(c) = chars.next() {
504        if c == '\\' {
505            // Skip escaped characters
506            chars.next();
507        } else if c == '$' && chars.peek() == Some(&'{') {
508            return true;
509        }
510    }
511
512    false
513}
514
515/// Check if a string needs processing (has interpolations OR escape sequences)
516pub fn needs_processing(input: &str) -> bool {
517    let mut chars = input.chars().peekable();
518
519    while let Some(c) = chars.next() {
520        if c == '\\' && chars.peek() == Some(&'$') {
521            // Has an escape sequence
522            return true;
523        } else if c == '$' && chars.peek() == Some(&'{') {
524            // Has an interpolation
525            return true;
526        }
527    }
528
529    false
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_parse_literal() {
538        let result = parse("hello world").unwrap();
539        assert_eq!(result, Interpolation::Literal("hello world".into()));
540    }
541
542    #[test]
543    fn test_parse_empty() {
544        let result = parse("").unwrap();
545        assert_eq!(result, Interpolation::Literal("".into()));
546    }
547
548    #[test]
549    fn test_parse_env_resolver() {
550        let result = parse("${env:MY_VAR}").unwrap();
551        assert_eq!(
552            result,
553            Interpolation::Resolver {
554                name: "env".into(),
555                args: vec![InterpolationArg::Literal("MY_VAR".into())],
556                kwargs: HashMap::new(),
557            }
558        );
559    }
560
561    #[test]
562    fn test_parse_env_with_default() {
563        let result = parse("${env:MY_VAR,default_value}").unwrap();
564        assert_eq!(
565            result,
566            Interpolation::Resolver {
567                name: "env".into(),
568                args: vec![
569                    InterpolationArg::Literal("MY_VAR".into()),
570                    InterpolationArg::Literal("default_value".into()),
571                ],
572                kwargs: HashMap::new(),
573            }
574        );
575    }
576
577    #[test]
578    fn test_parse_self_reference() {
579        let result = parse("${database.host}").unwrap();
580        // Non-relative self-references are now converted to ref resolver
581        assert_eq!(
582            result,
583            Interpolation::Resolver {
584                name: "ref".into(),
585                args: vec![InterpolationArg::Literal("database.host".into())],
586                kwargs: HashMap::new(),
587            }
588        );
589    }
590
591    #[test]
592    fn test_parse_self_reference_with_default() {
593        let result = parse("${database.host,default=fallback}").unwrap();
594        // Self-references with kwargs converted to ref resolver
595        if let Interpolation::Resolver { name, args, kwargs } = result {
596            assert_eq!(name, "ref");
597            assert_eq!(args.len(), 1);
598            assert_eq!(args[0].as_literal(), Some("database.host"));
599            assert_eq!(kwargs.len(), 1);
600            assert!(kwargs.contains_key("default"));
601            assert_eq!(
602                kwargs.get("default").and_then(|v| v.as_literal()),
603                Some("fallback")
604            );
605        } else {
606            panic!("Expected Resolver");
607        }
608    }
609
610    #[test]
611    fn test_parse_relative_self_reference() {
612        let result = parse("${.sibling}").unwrap();
613        // The path includes the leading dot(s) for relative references
614        assert_eq!(
615            result,
616            Interpolation::SelfRef {
617                path: ".sibling".into(),
618                relative: true,
619            }
620        );
621    }
622
623    #[test]
624    fn test_parse_array_access() {
625        let result = parse("${servers[0].host}").unwrap();
626        // Non-relative paths are now converted to ref resolver
627        assert_eq!(
628            result,
629            Interpolation::Resolver {
630                name: "ref".into(),
631                args: vec![InterpolationArg::Literal("servers[0].host".into())],
632                kwargs: HashMap::new(),
633            }
634        );
635    }
636
637    #[test]
638    fn test_parse_escaped() {
639        let result = parse(r"\${not_interpolated}").unwrap();
640        assert_eq!(result, Interpolation::Literal("${not_interpolated}".into()));
641    }
642
643    #[test]
644    fn test_parse_concatenation() {
645        let result = parse("prefix_${env:VAR}_suffix").unwrap();
646        assert!(matches!(result, Interpolation::Concat(_)));
647
648        if let Interpolation::Concat(parts) = result {
649            assert_eq!(parts.len(), 3);
650            assert_eq!(parts[0], Interpolation::Literal("prefix_".into()));
651            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
652            assert_eq!(parts[2], Interpolation::Literal("_suffix".into()));
653        }
654    }
655
656    #[test]
657    fn test_parse_nested_interpolation() {
658        let result = parse("${env:VAR,${env:DEFAULT,fallback}}").unwrap();
659
660        if let Interpolation::Resolver { name, args, .. } = result {
661            assert_eq!(name, "env");
662            assert_eq!(args.len(), 2);
663            assert!(matches!(args[0], InterpolationArg::Literal(_)));
664            assert!(matches!(args[1], InterpolationArg::Nested(_)));
665        } else {
666            panic!("Expected Resolver, got {:?}", result);
667        }
668    }
669
670    #[test]
671    fn test_parse_kwargs() {
672        let result = parse("${file:./config.yaml,parse=text}").unwrap();
673
674        if let Interpolation::Resolver { kwargs, .. } = result {
675            assert!(kwargs.contains_key("parse"));
676            if let Some(InterpolationArg::Literal(value)) = kwargs.get("parse") {
677                assert_eq!(value, "text");
678            } else {
679                panic!("Expected literal value for parse kwarg");
680            }
681        } else {
682            panic!("Expected Resolver");
683        }
684    }
685
686    #[test]
687    fn test_contains_interpolation() {
688        assert!(contains_interpolation("${env:VAR}"));
689        assert!(contains_interpolation("prefix ${env:VAR} suffix"));
690        assert!(!contains_interpolation("no interpolation"));
691        assert!(!contains_interpolation(r"\${escaped}"));
692        assert!(!contains_interpolation("just $dollar"));
693    }
694
695    #[test]
696    fn test_parse_unclosed_interpolation() {
697        let result = parse("${env:VAR");
698        assert!(result.is_err());
699    }
700
701    // Edge case tests for improved coverage
702
703    #[test]
704    fn test_parse_empty_interpolation() {
705        let result = parse("${}");
706        assert!(result.is_err());
707        let err = result.unwrap_err();
708        assert!(err.to_string().contains("Empty"));
709    }
710
711    #[test]
712    fn test_parse_resolver_no_args() {
713        // Resolver with colon but no args - returns empty arg list
714        let result = parse("${env:}").unwrap();
715        if let Interpolation::Resolver { name, args, .. } = result {
716            assert_eq!(name, "env");
717            // Empty resolver has no args (or one empty arg depending on implementation)
718            // The current behavior returns an empty arg list
719            assert!(
720                args.is_empty()
721                    || (args.len() == 1 && args[0] == InterpolationArg::Literal("".into()))
722            );
723        } else {
724            panic!("Expected Resolver");
725        }
726    }
727
728    #[test]
729    fn test_parse_whitespace_in_interpolation() {
730        let result = parse("${ env:VAR }").unwrap();
731        if let Interpolation::Resolver { name, .. } = result {
732            assert_eq!(name, "env");
733        } else {
734            panic!("Expected Resolver");
735        }
736    }
737
738    #[test]
739    fn test_parse_multiple_escapes() {
740        let result = parse(r"\${first}\${second}").unwrap();
741        assert_eq!(result, Interpolation::Literal("${first}${second}".into()));
742    }
743
744    #[test]
745    fn test_parse_interpolation_at_start() {
746        let result = parse("${env:VAR}suffix").unwrap();
747        if let Interpolation::Concat(parts) = result {
748            assert_eq!(parts.len(), 2);
749            assert!(matches!(parts[0], Interpolation::Resolver { .. }));
750            assert_eq!(parts[1], Interpolation::Literal("suffix".into()));
751        } else {
752            panic!("Expected Concat");
753        }
754    }
755
756    #[test]
757    fn test_parse_interpolation_at_end() {
758        let result = parse("prefix${env:VAR}").unwrap();
759        if let Interpolation::Concat(parts) = result {
760            assert_eq!(parts.len(), 2);
761            assert_eq!(parts[0], Interpolation::Literal("prefix".into()));
762            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
763        } else {
764            panic!("Expected Concat");
765        }
766    }
767
768    #[test]
769    fn test_parse_adjacent_interpolations() {
770        let result = parse("${env:A}${env:B}").unwrap();
771        if let Interpolation::Concat(parts) = result {
772            assert_eq!(parts.len(), 2);
773            assert!(matches!(parts[0], Interpolation::Resolver { .. }));
774            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
775        } else {
776            panic!("Expected Concat");
777        }
778    }
779
780    #[test]
781    fn test_parse_deeply_nested_path() {
782        let result = parse("${a.b.c.d.e.f.g.h}").unwrap();
783        // Non-relative paths now become ref resolver
784        if let Interpolation::Resolver { name, args, .. } = result {
785            assert_eq!(name, "ref");
786            assert_eq!(args.len(), 1);
787            assert_eq!(args[0].as_literal(), Some("a.b.c.d.e.f.g.h"));
788        } else {
789            panic!("Expected Resolver");
790        }
791    }
792
793    #[test]
794    fn test_parse_multiple_array_indices() {
795        let result = parse("${matrix[0][1][2]}").unwrap();
796        // Non-relative paths now become ref resolver
797        if let Interpolation::Resolver { name, args, .. } = result {
798            assert_eq!(name, "ref");
799            assert_eq!(args[0].as_literal(), Some("matrix[0][1][2]"));
800        } else {
801            panic!("Expected Resolver");
802        }
803    }
804
805    #[test]
806    fn test_parse_mixed_path_and_array() {
807        let result = parse("${data.items[0].nested[1].value}").unwrap();
808        // Non-relative paths now become ref resolver
809        if let Interpolation::Resolver { name, args, .. } = result {
810            assert_eq!(name, "ref");
811            assert_eq!(args[0].as_literal(), Some("data.items[0].nested[1].value"));
812        } else {
813            panic!("Expected Resolver");
814        }
815    }
816
817    #[test]
818    fn test_parse_underscore_in_identifiers() {
819        let result = parse("${my_var.some_path}").unwrap();
820        // Non-relative paths now become ref resolver
821        if let Interpolation::Resolver { name, args, .. } = result {
822            assert_eq!(name, "ref");
823            assert_eq!(args[0].as_literal(), Some("my_var.some_path"));
824        } else {
825            panic!("Expected Resolver");
826        }
827    }
828
829    #[test]
830    fn test_parse_resolver_with_multiple_args() {
831        let result = parse("${resolver:arg1,arg2,arg3}").unwrap();
832        if let Interpolation::Resolver { name, args, .. } = result {
833            assert_eq!(name, "resolver");
834            assert_eq!(args.len(), 3);
835        } else {
836            panic!("Expected Resolver");
837        }
838    }
839
840    #[test]
841    fn test_parse_mixed_escaped_and_interpolation() {
842        let result = parse(r"literal \${escaped} ${env:VAR} more").unwrap();
843        if let Interpolation::Concat(parts) = result {
844            assert!(parts.len() >= 3);
845        } else {
846            panic!("Expected Concat");
847        }
848    }
849
850    #[test]
851    fn test_needs_processing() {
852        assert!(needs_processing("${env:VAR}"));
853        assert!(needs_processing(r"\${escaped}"));
854        assert!(!needs_processing("no special chars"));
855        assert!(!needs_processing("just $dollar"));
856    }
857
858    #[test]
859    fn test_parse_invalid_char_in_path() {
860        let result = parse("${path!invalid}");
861        assert!(result.is_err());
862    }
863
864    #[test]
865    fn test_interpolation_arg_methods() {
866        let lit = InterpolationArg::Literal("test".into());
867        assert!(lit.is_literal());
868        assert_eq!(lit.as_literal(), Some("test"));
869
870        let nested = InterpolationArg::Nested(Box::new(Interpolation::Literal("x".into())));
871        assert!(!nested.is_literal());
872        assert_eq!(nested.as_literal(), None);
873    }
874
875    #[test]
876    fn test_parse_relative_parent_reference() {
877        let result = parse("${..parent.value}").unwrap();
878        if let Interpolation::SelfRef { path, relative } = result {
879            assert!(relative);
880            assert_eq!(path, "..parent.value");
881        } else {
882            panic!("Expected relative SelfRef");
883        }
884    }
885}