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                self.advance(); // skip }
209                Ok(Interpolation::SelfRef {
210                    path: identifier,
211                    relative: false,
212                })
213            }
214            Some(',') => {
215                // Could be a self-reference with default? Not supported in standard syntax.
216                // Treat as resolver with empty name - will fail at resolution time
217                Err(Error::parse(format!(
218                    "Unexpected ',' after identifier '{}'. Did you mean to use a resolver?",
219                    identifier
220                )))
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    /// Try to parse a kwarg (key=value pattern)
295    /// Returns Some((key, value)) if successful, None if this isn't a kwarg
296    fn try_parse_kwarg(&mut self) -> Result<Option<(String, InterpolationArg)>> {
297        // Save position for backtracking
298        let start_pos = self.pos;
299
300        // Try to collect an identifier followed by =
301        let mut key = String::new();
302        while !self.is_eof() {
303            match self.current() {
304                Some(c) if c.is_alphanumeric() || c == '_' => {
305                    key.push(c);
306                    self.advance();
307                }
308                Some('=') if !key.is_empty() => {
309                    // Found key=, now parse the value
310                    self.advance(); // skip =
311                    let value = self.parse_argument()?;
312                    return Ok(Some((key, value)));
313                }
314                _ => {
315                    // Not a kwarg pattern, backtrack
316                    self.pos = start_pos;
317                    return Ok(None);
318                }
319            }
320        }
321
322        // Reached EOF without finding =, backtrack
323        self.pos = start_pos;
324        Ok(None)
325    }
326
327    /// Parse a single argument (may be literal or nested interpolation)
328    fn parse_argument(&mut self) -> Result<InterpolationArg> {
329        self.skip_whitespace();
330
331        if self.check_interpolation_start() {
332            // Nested interpolation
333            let nested = self.parse_interpolation()?;
334            Ok(InterpolationArg::Nested(Box::new(nested)))
335        } else {
336            // Literal argument - collect until , or }
337            let mut value = String::new();
338            let mut depth = 0; // Track nested braces
339
340            while !self.is_eof() {
341                match self.current() {
342                    Some('$') if self.peek() == Some('{') => {
343                        // Nested interpolation - parse it
344                        let nested = self.parse_interpolation()?;
345                        return Ok(InterpolationArg::Nested(Box::new(if value.is_empty() {
346                            nested
347                        } else {
348                            // Concatenation: literal prefix + nested
349                            Interpolation::Concat(vec![Interpolation::Literal(value), nested])
350                        })));
351                    }
352                    Some('{') => {
353                        depth += 1;
354                        value.push('{');
355                        self.advance();
356                    }
357                    Some('}') => {
358                        if depth == 0 {
359                            break;
360                        }
361                        depth -= 1;
362                        value.push('}');
363                        self.advance();
364                    }
365                    Some(',') if depth == 0 => {
366                        break;
367                    }
368                    Some(c) => {
369                        value.push(c);
370                        self.advance();
371                    }
372                    None => break,
373                }
374            }
375
376            Ok(InterpolationArg::Literal(value.trim().to_string()))
377        }
378    }
379
380    /// Collect an identifier (alphanumeric, _, ., [, ])
381    fn collect_identifier(&mut self) -> String {
382        let mut result = String::new();
383
384        while !self.is_eof() {
385            match self.current() {
386                Some(c) if c.is_alphanumeric() || c == '_' || c == '.' || c == '[' || c == ']' => {
387                    result.push(c);
388                    self.advance();
389                }
390                _ => break,
391            }
392        }
393
394        result
395    }
396
397    /// Skip whitespace characters
398    fn skip_whitespace(&mut self) {
399        while let Some(c) = self.current() {
400            if c.is_whitespace() {
401                self.advance();
402            } else {
403                break;
404            }
405        }
406    }
407}
408
409/// Merge adjacent literal parts
410fn merge_adjacent_literals(parts: Vec<Interpolation>) -> Vec<Interpolation> {
411    let mut result = Vec::new();
412    let mut current_literal = String::new();
413
414    for part in parts {
415        match part {
416            Interpolation::Literal(s) => {
417                current_literal.push_str(&s);
418            }
419            other => {
420                if !current_literal.is_empty() {
421                    result.push(Interpolation::Literal(current_literal));
422                    current_literal = String::new();
423                }
424                result.push(other);
425            }
426        }
427    }
428
429    if !current_literal.is_empty() {
430        result.push(Interpolation::Literal(current_literal));
431    }
432
433    result
434}
435
436/// Parse an interpolation string
437pub fn parse(input: &str) -> Result<Interpolation> {
438    InterpolationParser::new(input).parse()
439}
440
441/// Check if a string contains any interpolation expressions (unescaped ${...})
442pub fn contains_interpolation(input: &str) -> bool {
443    let mut chars = input.chars().peekable();
444
445    while let Some(c) = chars.next() {
446        if c == '\\' {
447            // Skip escaped characters
448            chars.next();
449        } else if c == '$' && chars.peek() == Some(&'{') {
450            return true;
451        }
452    }
453
454    false
455}
456
457/// Check if a string needs processing (has interpolations OR escape sequences)
458pub fn needs_processing(input: &str) -> bool {
459    let mut chars = input.chars().peekable();
460
461    while let Some(c) = chars.next() {
462        if c == '\\' && chars.peek() == Some(&'$') {
463            // Has an escape sequence
464            return true;
465        } else if c == '$' && chars.peek() == Some(&'{') {
466            // Has an interpolation
467            return true;
468        }
469    }
470
471    false
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_parse_literal() {
480        let result = parse("hello world").unwrap();
481        assert_eq!(result, Interpolation::Literal("hello world".into()));
482    }
483
484    #[test]
485    fn test_parse_empty() {
486        let result = parse("").unwrap();
487        assert_eq!(result, Interpolation::Literal("".into()));
488    }
489
490    #[test]
491    fn test_parse_env_resolver() {
492        let result = parse("${env:MY_VAR}").unwrap();
493        assert_eq!(
494            result,
495            Interpolation::Resolver {
496                name: "env".into(),
497                args: vec![InterpolationArg::Literal("MY_VAR".into())],
498                kwargs: HashMap::new(),
499            }
500        );
501    }
502
503    #[test]
504    fn test_parse_env_with_default() {
505        let result = parse("${env:MY_VAR,default_value}").unwrap();
506        assert_eq!(
507            result,
508            Interpolation::Resolver {
509                name: "env".into(),
510                args: vec![
511                    InterpolationArg::Literal("MY_VAR".into()),
512                    InterpolationArg::Literal("default_value".into()),
513                ],
514                kwargs: HashMap::new(),
515            }
516        );
517    }
518
519    #[test]
520    fn test_parse_self_reference() {
521        let result = parse("${database.host}").unwrap();
522        assert_eq!(
523            result,
524            Interpolation::SelfRef {
525                path: "database.host".into(),
526                relative: false,
527            }
528        );
529    }
530
531    #[test]
532    fn test_parse_relative_self_reference() {
533        let result = parse("${.sibling}").unwrap();
534        // The path includes the leading dot(s) for relative references
535        assert_eq!(
536            result,
537            Interpolation::SelfRef {
538                path: ".sibling".into(),
539                relative: true,
540            }
541        );
542    }
543
544    #[test]
545    fn test_parse_array_access() {
546        let result = parse("${servers[0].host}").unwrap();
547        assert_eq!(
548            result,
549            Interpolation::SelfRef {
550                path: "servers[0].host".into(),
551                relative: false,
552            }
553        );
554    }
555
556    #[test]
557    fn test_parse_escaped() {
558        let result = parse(r"\${not_interpolated}").unwrap();
559        assert_eq!(result, Interpolation::Literal("${not_interpolated}".into()));
560    }
561
562    #[test]
563    fn test_parse_concatenation() {
564        let result = parse("prefix_${env:VAR}_suffix").unwrap();
565        assert!(matches!(result, Interpolation::Concat(_)));
566
567        if let Interpolation::Concat(parts) = result {
568            assert_eq!(parts.len(), 3);
569            assert_eq!(parts[0], Interpolation::Literal("prefix_".into()));
570            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
571            assert_eq!(parts[2], Interpolation::Literal("_suffix".into()));
572        }
573    }
574
575    #[test]
576    fn test_parse_nested_interpolation() {
577        let result = parse("${env:VAR,${env:DEFAULT,fallback}}").unwrap();
578
579        if let Interpolation::Resolver { name, args, .. } = result {
580            assert_eq!(name, "env");
581            assert_eq!(args.len(), 2);
582            assert!(matches!(args[0], InterpolationArg::Literal(_)));
583            assert!(matches!(args[1], InterpolationArg::Nested(_)));
584        } else {
585            panic!("Expected Resolver, got {:?}", result);
586        }
587    }
588
589    #[test]
590    fn test_parse_kwargs() {
591        let result = parse("${file:./config.yaml,parse=yaml}").unwrap();
592
593        if let Interpolation::Resolver { kwargs, .. } = result {
594            assert!(kwargs.contains_key("parse"));
595        } else {
596            panic!("Expected Resolver");
597        }
598    }
599
600    #[test]
601    fn test_contains_interpolation() {
602        assert!(contains_interpolation("${env:VAR}"));
603        assert!(contains_interpolation("prefix ${env:VAR} suffix"));
604        assert!(!contains_interpolation("no interpolation"));
605        assert!(!contains_interpolation(r"\${escaped}"));
606        assert!(!contains_interpolation("just $dollar"));
607    }
608
609    #[test]
610    fn test_parse_unclosed_interpolation() {
611        let result = parse("${env:VAR");
612        assert!(result.is_err());
613    }
614
615    // Edge case tests for improved coverage
616
617    #[test]
618    fn test_parse_empty_interpolation() {
619        let result = parse("${}");
620        assert!(result.is_err());
621        let err = result.unwrap_err();
622        assert!(err.to_string().contains("Empty"));
623    }
624
625    #[test]
626    fn test_parse_resolver_no_args() {
627        // Resolver with colon but no args - returns empty arg list
628        let result = parse("${env:}").unwrap();
629        if let Interpolation::Resolver { name, args, .. } = result {
630            assert_eq!(name, "env");
631            // Empty resolver has no args (or one empty arg depending on implementation)
632            // The current behavior returns an empty arg list
633            assert!(
634                args.is_empty()
635                    || (args.len() == 1 && args[0] == InterpolationArg::Literal("".into()))
636            );
637        } else {
638            panic!("Expected Resolver");
639        }
640    }
641
642    #[test]
643    fn test_parse_whitespace_in_interpolation() {
644        let result = parse("${ env:VAR }").unwrap();
645        if let Interpolation::Resolver { name, .. } = result {
646            assert_eq!(name, "env");
647        } else {
648            panic!("Expected Resolver");
649        }
650    }
651
652    #[test]
653    fn test_parse_multiple_escapes() {
654        let result = parse(r"\${first}\${second}").unwrap();
655        assert_eq!(result, Interpolation::Literal("${first}${second}".into()));
656    }
657
658    #[test]
659    fn test_parse_interpolation_at_start() {
660        let result = parse("${env:VAR}suffix").unwrap();
661        if let Interpolation::Concat(parts) = result {
662            assert_eq!(parts.len(), 2);
663            assert!(matches!(parts[0], Interpolation::Resolver { .. }));
664            assert_eq!(parts[1], Interpolation::Literal("suffix".into()));
665        } else {
666            panic!("Expected Concat");
667        }
668    }
669
670    #[test]
671    fn test_parse_interpolation_at_end() {
672        let result = parse("prefix${env:VAR}").unwrap();
673        if let Interpolation::Concat(parts) = result {
674            assert_eq!(parts.len(), 2);
675            assert_eq!(parts[0], Interpolation::Literal("prefix".into()));
676            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
677        } else {
678            panic!("Expected Concat");
679        }
680    }
681
682    #[test]
683    fn test_parse_adjacent_interpolations() {
684        let result = parse("${env:A}${env:B}").unwrap();
685        if let Interpolation::Concat(parts) = result {
686            assert_eq!(parts.len(), 2);
687            assert!(matches!(parts[0], Interpolation::Resolver { .. }));
688            assert!(matches!(parts[1], Interpolation::Resolver { .. }));
689        } else {
690            panic!("Expected Concat");
691        }
692    }
693
694    #[test]
695    fn test_parse_deeply_nested_path() {
696        let result = parse("${a.b.c.d.e.f.g.h}").unwrap();
697        if let Interpolation::SelfRef { path, relative } = result {
698            assert_eq!(path, "a.b.c.d.e.f.g.h");
699            assert!(!relative);
700        } else {
701            panic!("Expected SelfRef");
702        }
703    }
704
705    #[test]
706    fn test_parse_multiple_array_indices() {
707        let result = parse("${matrix[0][1][2]}").unwrap();
708        if let Interpolation::SelfRef { path, .. } = result {
709            assert_eq!(path, "matrix[0][1][2]");
710        } else {
711            panic!("Expected SelfRef");
712        }
713    }
714
715    #[test]
716    fn test_parse_mixed_path_and_array() {
717        let result = parse("${data.items[0].nested[1].value}").unwrap();
718        if let Interpolation::SelfRef { path, .. } = result {
719            assert_eq!(path, "data.items[0].nested[1].value");
720        } else {
721            panic!("Expected SelfRef");
722        }
723    }
724
725    #[test]
726    fn test_parse_underscore_in_identifiers() {
727        let result = parse("${my_var.some_path}").unwrap();
728        if let Interpolation::SelfRef { path, .. } = result {
729            assert_eq!(path, "my_var.some_path");
730        } else {
731            panic!("Expected SelfRef");
732        }
733    }
734
735    #[test]
736    fn test_parse_resolver_with_multiple_args() {
737        let result = parse("${resolver:arg1,arg2,arg3}").unwrap();
738        if let Interpolation::Resolver { name, args, .. } = result {
739            assert_eq!(name, "resolver");
740            assert_eq!(args.len(), 3);
741        } else {
742            panic!("Expected Resolver");
743        }
744    }
745
746    #[test]
747    fn test_parse_mixed_escaped_and_interpolation() {
748        let result = parse(r"literal \${escaped} ${env:VAR} more").unwrap();
749        if let Interpolation::Concat(parts) = result {
750            assert!(parts.len() >= 3);
751        } else {
752            panic!("Expected Concat");
753        }
754    }
755
756    #[test]
757    fn test_needs_processing() {
758        assert!(needs_processing("${env:VAR}"));
759        assert!(needs_processing(r"\${escaped}"));
760        assert!(!needs_processing("no special chars"));
761        assert!(!needs_processing("just $dollar"));
762    }
763
764    #[test]
765    fn test_parse_invalid_char_in_path() {
766        let result = parse("${path!invalid}");
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_interpolation_arg_methods() {
772        let lit = InterpolationArg::Literal("test".into());
773        assert!(lit.is_literal());
774        assert_eq!(lit.as_literal(), Some("test"));
775
776        let nested = InterpolationArg::Nested(Box::new(Interpolation::Literal("x".into())));
777        assert!(!nested.is_literal());
778        assert_eq!(nested.as_literal(), None);
779    }
780
781    #[test]
782    fn test_parse_relative_parent_reference() {
783        let result = parse("${..parent.value}").unwrap();
784        if let Interpolation::SelfRef { path, relative } = result {
785            assert!(relative);
786            assert_eq!(path, "..parent.value");
787        } else {
788            panic!("Expected relative SelfRef");
789        }
790    }
791}