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