Skip to main content

xianyun_core/variables/
expand.rs

1//! Port of `internal/variables/expand.go`: shell-like `${VAR}`/`$VAR`
2//! expansion of config values and variable definitions.
3//!
4//! The Go original parses each string as a `mvdan.cc/sh/v3` shell document
5//! word and walks/rewrites that AST. There is no equivalent Rust crate with
6//! this exact operator set, so this is a hand-rolled scanner/parser scoped
7//! exactly to the grammar caproni uses: `$VAR`/`${VAR}` references, the
8//! `${VAR:-default}`/`${VAR:?message}`/`${VAR:+alternate}` (and their
9//! unset-only `${VAR-x}`/`${VAR?x}`/`${VAR+x}` variants) operators for
10//! variable definitions, `${#VAR}` length, `${!VAR}` indirection,
11//! `${VAR:off:len}` slicing, `${VAR/pat/repl}`/`${VAR//pat/repl}`
12//! replacement, and `${VAR#pat}`/`${VAR##pat}`/`${VAR%pat}`/`${VAR%%pat}`
13//! prefix/suffix removal -- plus verbatim (never evaluated) preservation of
14//! `$(...)`, `` `...` ``, `$((...))` and `<(...)`/`>(...)`.
15//!
16//! Pattern arguments to `/`, `#` and `%` are matched as literal substrings,
17//! not shell globs, because nothing in caproni's config surface uses glob
18//! metacharacters there; this is a deliberate scope reduction from the Go
19//! original's full glob matching.
20
21use serde_json::Value;
22use thiserror::Error;
23
24use super::resolver::Lookup;
25
26/// A predicate deciding whether a config path should be skipped during
27/// `expand_map`/`expand_value`'s walk (e.g. a deployer/reloader type
28/// field that must stay a literal, not a `${...}` reference).
29type SkipPath<'a> = &'a dyn Fn(&[String]) -> bool;
30
31#[derive(Debug, Error)]
32pub enum ExpandError {
33    #[error("required variable is not set: {0}")]
34    RequiredVariable(String),
35    #[error("invalid variable reference: {0}")]
36    ExpandSyntax(String),
37    #[error("unsupported expansion: {0}")]
38    UnsupportedExpansion(String),
39    #[error("expanding variable reference: {0}")]
40    ExpandFailed(String),
41}
42
43/// Records the references in one config value that resolved to nothing.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Unresolved {
46    pub path: String,
47    pub variables: Vec<String>,
48}
49
50/// Selects which references a string may use. Config values take bare
51/// `${VAR}`/`$VAR` references; variable definitions also take the
52/// substitution operators, such as `${VAR:-default}`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum ReferenceLanguage {
55    Value,
56    Definition,
57}
58
59/// A parsed segment of a scanned word.
60#[derive(Debug, Clone)]
61enum Segment {
62    Literal(String),
63    /// A `$(...)`/backtick/`$((...))`/`<(...)`/`>(...)` span, preserved
64    /// verbatim (source text including the delimiters).
65    Verbatim(String),
66    Param(ParamExp),
67}
68
69#[derive(Debug, Clone)]
70struct ParamExp {
71    name: String,
72    op: ParamOp,
73}
74
75#[derive(Debug, Clone)]
76enum ParamOp {
77    Bare,
78    Length,
79    Indirect,
80    /// `${!name*}` / `${!name@}` name listing: unsupported, but still parsed
81    /// so the error names the right form.
82    NameListing,
83    DefaultUnset(Vec<Segment>),
84    DefaultUnsetOrNull(Vec<Segment>),
85    ErrorUnset(Vec<Segment>),
86    ErrorUnsetOrNull(Vec<Segment>),
87    AlternateUnset(Vec<Segment>),
88    AlternateUnsetOrNull(Vec<Segment>),
89    AssignUnset(Vec<Segment>),
90    AssignUnsetOrNull(Vec<Segment>),
91    Slice(String, Option<String>),
92    ReplaceFirst(String, String),
93    ReplaceAll(String, String),
94    RemovePrefix(String),
95    RemovePrefixLongest(String),
96    RemoveSuffix(String),
97    RemoveSuffixLongest(String),
98}
99
100/// Expands `${VAR}`/`$VAR` references in `s`. A reference that resolves to
101/// nothing becomes the empty string.
102pub fn expand_string(s: &str, lookup: &dyn Lookup) -> Result<String, ExpandError> {
103    let (value, _) = expand_impl(s, lookup, ReferenceLanguage::Value)?;
104    Ok(value)
105}
106
107/// Expands a variable definition, where the `${VAR:-default}`,
108/// `${VAR:?message}` and `${VAR:+alternate}` operators are available on top
109/// of the bare references config values take.
110pub fn expand_definition(
111    s: &str,
112    lookup: &dyn Lookup,
113) -> Result<(String, Vec<String>), ExpandError> {
114    expand_impl(s, lookup, ReferenceLanguage::Definition)
115}
116
117fn expand_impl(
118    s: &str,
119    lookup: &dyn Lookup,
120    lang: ReferenceLanguage,
121) -> Result<(String, Vec<String>), ExpandError> {
122    if !s.contains('$') && !s.contains('`') {
123        return Ok((s.to_string(), Vec::new()));
124    }
125
126    let segments = parse_word(s)?;
127    check_supported(&segments, lang)?;
128
129    let mut unresolved = Vec::new();
130    let value = eval_segments(&segments, lookup, &mut unresolved)?;
131
132    Ok((value, unresolved))
133}
134
135/// Parses `s` into a flat sequence of segments. Mirrors `parseWord` +
136/// `literalizeArithmetic` in the Go original, except arithmetic and command
137/// substitution are recognized (and preserved verbatim) directly during
138/// scanning rather than parsed and then literalized after the fact.
139fn parse_word(s: &str) -> Result<Vec<Segment>, ExpandError> {
140    let chars: Vec<char> = s.chars().collect();
141    let mut i = 0;
142    let mut segments = Vec::new();
143    let mut literal = String::new();
144
145    while i < chars.len() {
146        let c = chars[i];
147
148        if c == '\\' && i + 1 < chars.len() {
149            let next = chars[i + 1];
150            if matches!(next, '$' | '`' | '"' | '\\') {
151                literal.push(next);
152                i += 2;
153                continue;
154            }
155            literal.push(c);
156            i += 1;
157            continue;
158        }
159
160        if c == '`' {
161            let (span, next_i) = scan_delimited(&chars, i + 1, '`', '`')?;
162            flush_literal(&mut segments, &mut literal);
163            segments.push(Segment::Verbatim(format!("`{span}`")));
164            i = next_i;
165            continue;
166        }
167
168        if (c == '<' || c == '>') && i + 1 < chars.len() && chars[i + 1] == '(' {
169            let (span, next_i) = scan_balanced_parens(&chars, i + 2)?;
170            flush_literal(&mut segments, &mut literal);
171            segments.push(Segment::Verbatim(format!("{c}({span})")));
172            i = next_i;
173            continue;
174        }
175
176        if c == '$' && i + 1 < chars.len() {
177            let next = chars[i + 1];
178
179            if next == '(' {
180                if i + 2 < chars.len() && chars[i + 2] == '(' {
181                    let (span, next_i) = scan_arithmetic(&chars, i + 3)?;
182                    flush_literal(&mut segments, &mut literal);
183                    segments.push(Segment::Verbatim(format!("$(({span}))")));
184                    i = next_i;
185                    continue;
186                }
187
188                let (span, next_i) = scan_balanced_parens(&chars, i + 2)?;
189                flush_literal(&mut segments, &mut literal);
190                segments.push(Segment::Verbatim(format!("$({span})")));
191                i = next_i;
192                continue;
193            }
194
195            if next == '{' {
196                flush_literal(&mut segments, &mut literal);
197                let (param, next_i) = parse_braced_param(&chars, i + 2)?;
198                segments.push(Segment::Param(param));
199                i = next_i;
200                continue;
201            }
202
203            if let Some((name, next_i)) = scan_short_param_name(&chars, i + 1) {
204                flush_literal(&mut segments, &mut literal);
205                segments.push(Segment::Param(ParamExp {
206                    name,
207                    op: ParamOp::Bare,
208                }));
209                i = next_i;
210                continue;
211            }
212        }
213
214        literal.push(c);
215        i += 1;
216    }
217
218    flush_literal(&mut segments, &mut literal);
219    Ok(segments)
220}
221
222fn flush_literal(segments: &mut Vec<Segment>, literal: &mut String) {
223    if !literal.is_empty() {
224        segments.push(Segment::Literal(std::mem::take(literal)));
225    }
226}
227
228/// Scans a short-form `$name` reference starting right after the `$`.
229/// Returns the parameter name and the index just past it, or `None` if `$`
230/// is not followed by a valid short-form parameter (in which case `$` is
231/// literal).
232fn scan_short_param_name(chars: &[char], start: usize) -> Option<(String, usize)> {
233    let c = *chars.get(start)?;
234
235    if matches!(c, '@' | '*' | '#' | '?' | '-' | '$' | '!') {
236        return Some((c.to_string(), start + 1));
237    }
238
239    if c.is_ascii_digit() {
240        return Some((c.to_string(), start + 1));
241    }
242
243    if c.is_ascii_alphabetic() || c == '_' {
244        let mut end = start + 1;
245        while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
246            end += 1;
247        }
248        return Some((chars[start..end].iter().collect(), end));
249    }
250
251    None
252}
253
254/// Scans `${...}` content starting right after `${`. Returns the parsed
255/// parameter expansion and the index just past the closing `}`.
256fn parse_braced_param(chars: &[char], start: usize) -> Result<(ParamExp, usize), ExpandError> {
257    let indirect = chars.get(start) == Some(&'!');
258    let length = chars.get(start) == Some(&'#')
259        && !matches!(chars.get(start + 1), Some('#') | None)
260        && chars.get(start + 1) != Some(&'}');
261    let name_start = if indirect || length { start + 1 } else { start };
262
263    let name_end = scan_name(chars, name_start);
264    if name_end == name_start {
265        return Err(ExpandError::ExpandSyntax(format!(
266            "missing variable name in ${{{}",
267            chars[start..].iter().collect::<String>()
268        )));
269    }
270    let name: String = chars[name_start..name_end].iter().collect();
271
272    if indirect {
273        if matches!(chars.get(name_end), Some('*') | Some('@')) {
274            let end = find_closing_brace(chars, name_end + 1)?;
275            return Ok((
276                ParamExp {
277                    name,
278                    op: ParamOp::NameListing,
279                },
280                end + 1,
281            ));
282        }
283
284        let end = find_closing_brace(chars, name_end)?;
285        return Ok((
286            ParamExp {
287                name,
288                op: ParamOp::Indirect,
289            },
290            end + 1,
291        ));
292    }
293
294    if length {
295        let end = find_closing_brace(chars, name_end)?;
296        return Ok((
297            ParamExp {
298                name,
299                op: ParamOp::Length,
300            },
301            end + 1,
302        ));
303    }
304
305    if chars.get(name_end) == Some(&'}') {
306        return Ok((
307            ParamExp {
308                name,
309                op: ParamOp::Bare,
310            },
311            name_end + 1,
312        ));
313    }
314
315    parse_param_operator(chars, name, name_end)
316}
317
318/// Scans a bare variable name (`[a-zA-Z_][a-zA-Z0-9_]*`) starting at
319/// `start`, returning the index just past it (equal to `start` if there is
320/// no valid name there).
321fn scan_name(chars: &[char], start: usize) -> usize {
322    let Some(&c) = chars.get(start) else {
323        return start;
324    };
325
326    if !(c.is_ascii_alphabetic() || c == '_') {
327        return start;
328    }
329
330    let mut end = start + 1;
331    while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
332        end += 1;
333    }
334    end
335}
336
337/// Parses the operator (and its word, recursively) following a variable
338/// name inside `${...}`, up to and including the closing `}`. Returns the
339/// index just past that `}`.
340fn parse_param_operator(
341    chars: &[char],
342    name: String,
343    pos: usize,
344) -> Result<(ParamExp, usize), ExpandError> {
345    let has_colon = chars.get(pos) == Some(&':');
346    let op_pos = if has_colon { pos + 1 } else { pos };
347    let op_char = chars.get(op_pos).copied();
348
349    match op_char {
350        Some('-') => {
351            let (word, end) = parse_word_until_brace(chars, op_pos + 1)?;
352            let op = if has_colon {
353                ParamOp::DefaultUnsetOrNull(word)
354            } else {
355                ParamOp::DefaultUnset(word)
356            };
357            Ok((ParamExp { name, op }, end + 1))
358        }
359        Some('?') => {
360            let (word, end) = parse_word_until_brace(chars, op_pos + 1)?;
361            let op = if has_colon {
362                ParamOp::ErrorUnsetOrNull(word)
363            } else {
364                ParamOp::ErrorUnset(word)
365            };
366            Ok((ParamExp { name, op }, end + 1))
367        }
368        Some('+') => {
369            let (word, end) = parse_word_until_brace(chars, op_pos + 1)?;
370            let op = if has_colon {
371                ParamOp::AlternateUnsetOrNull(word)
372            } else {
373                ParamOp::AlternateUnset(word)
374            };
375            Ok((ParamExp { name, op }, end + 1))
376        }
377        Some('=') => {
378            let (word, end) = parse_word_until_brace(chars, op_pos + 1)?;
379            let op = if has_colon {
380                ParamOp::AssignUnsetOrNull(word)
381            } else {
382                ParamOp::AssignUnset(word)
383            };
384            Ok((ParamExp { name, op }, end + 1))
385        }
386        _ if has_colon => {
387            let (offset, sep_pos) = parse_literal_until(chars, pos + 1, &[':', '}'])?;
388            if chars.get(sep_pos) == Some(&':') {
389                let (len, end) = parse_literal_until(chars, sep_pos + 1, &['}'])?;
390                Ok((
391                    ParamExp {
392                        name,
393                        op: ParamOp::Slice(offset, Some(len)),
394                    },
395                    end + 1,
396                ))
397            } else {
398                Ok((
399                    ParamExp {
400                        name,
401                        op: ParamOp::Slice(offset, None),
402                    },
403                    sep_pos + 1,
404                ))
405            }
406        }
407        Some('/') => {
408            let all = chars.get(pos + 1) == Some(&'/');
409            let pat_start = if all { pos + 2 } else { pos + 1 };
410            let (pat, sep_pos) = parse_literal_until(chars, pat_start, &['/', '}'])?;
411            if chars.get(sep_pos) == Some(&'/') {
412                let (repl, end) = parse_literal_until(chars, sep_pos + 1, &['}'])?;
413                let op = if all {
414                    ParamOp::ReplaceAll(pat, repl)
415                } else {
416                    ParamOp::ReplaceFirst(pat, repl)
417                };
418                Ok((ParamExp { name, op }, end + 1))
419            } else {
420                let op = if all {
421                    ParamOp::ReplaceAll(pat, String::new())
422                } else {
423                    ParamOp::ReplaceFirst(pat, String::new())
424                };
425                Ok((ParamExp { name, op }, sep_pos + 1))
426            }
427        }
428        Some('#') => {
429            let longest = chars.get(pos + 1) == Some(&'#');
430            let pat_start = if longest { pos + 2 } else { pos + 1 };
431            let (pat, end) = parse_literal_until(chars, pat_start, &['}'])?;
432            let op = if longest {
433                ParamOp::RemovePrefixLongest(pat)
434            } else {
435                ParamOp::RemovePrefix(pat)
436            };
437            Ok((ParamExp { name, op }, end + 1))
438        }
439        Some('%') => {
440            let longest = chars.get(pos + 1) == Some(&'%');
441            let pat_start = if longest { pos + 2 } else { pos + 1 };
442            let (pat, end) = parse_literal_until(chars, pat_start, &['}'])?;
443            let op = if longest {
444                ParamOp::RemoveSuffixLongest(pat)
445            } else {
446                ParamOp::RemoveSuffix(pat)
447            };
448            Ok((ParamExp { name, op }, end + 1))
449        }
450        _ => Err(ExpandError::ExpandSyntax(format!(
451            "unrecognized ${{{name}...}} form"
452        ))),
453    }
454}
455
456/// Parses a nested word (segments, so recursive `${...}` inside a
457/// default/alternate/error word still expand) up to (not including) the
458/// matching closing `}`.
459fn parse_word_until_brace(
460    chars: &[char],
461    start: usize,
462) -> Result<(Vec<Segment>, usize), ExpandError> {
463    let end = find_closing_brace(chars, start)?;
464    let inner: String = chars[start..end].iter().collect();
465    let segments = parse_word(&inner)?;
466    Ok((segments, end))
467}
468
469/// Finds the index of the `}` matching the `${` this content is inside,
470/// starting the scan at `start`. Nested `${` increases depth; any other
471/// `}` at depth 0 is the match. `$(`, `$((` and `` ` `` spans are skipped
472/// verbatim so their contents can't be mistaken for brace nesting.
473fn find_closing_brace(chars: &[char], start: usize) -> Result<usize, ExpandError> {
474    let mut depth = 0usize;
475    let mut i = start;
476
477    while i < chars.len() {
478        match chars[i] {
479            '\\' if i + 1 < chars.len() => {
480                i += 2;
481                continue;
482            }
483            '`' => {
484                let (_, next_i) = scan_delimited(chars, i + 1, '`', '`')?;
485                i = next_i;
486                continue;
487            }
488            '$' if chars.get(i + 1) == Some(&'(') && chars.get(i + 2) == Some(&'(') => {
489                let (_, next_i) = scan_arithmetic(chars, i + 3)?;
490                i = next_i;
491                continue;
492            }
493            '$' if chars.get(i + 1) == Some(&'(') => {
494                let (_, next_i) = scan_balanced_parens(chars, i + 2)?;
495                i = next_i;
496                continue;
497            }
498            '$' if chars.get(i + 1) == Some(&'{') => {
499                depth += 1;
500                i += 2;
501                continue;
502            }
503            '}' => {
504                if depth == 0 {
505                    return Ok(i);
506                }
507                depth -= 1;
508                i += 1;
509                continue;
510            }
511            _ => {
512                i += 1;
513            }
514        }
515    }
516
517    Err(ExpandError::ExpandSyntax(
518        "unterminated ${...} reference".to_string(),
519    ))
520}
521
522/// Parses a literal (non-expandable) chunk up to (not including) the first
523/// occurrence of any char in `stop`, honouring backslash escapes. Used for
524/// pattern/offset arguments (`/pat/repl`, `#pat`, `%pat`, `:off:len`) which
525/// caproni's config surface never needs to nest expansions inside.
526fn parse_literal_until(
527    chars: &[char],
528    start: usize,
529    stop: &[char],
530) -> Result<(String, usize), ExpandError> {
531    let mut out = String::new();
532    let mut i = start;
533
534    while i < chars.len() {
535        if chars[i] == '\\' && i + 1 < chars.len() {
536            out.push(chars[i + 1]);
537            i += 2;
538            continue;
539        }
540
541        if stop.contains(&chars[i]) {
542            return Ok((out, i));
543        }
544
545        out.push(chars[i]);
546        i += 1;
547    }
548
549    Err(ExpandError::ExpandSyntax(
550        "unterminated ${...} reference".to_string(),
551    ))
552}
553
554/// Scans until the closing delimiter, handling backslash escapes of that
555/// delimiter. Returns the content between the delimiters and the index
556/// just past the closing delimiter.
557fn scan_delimited(
558    chars: &[char],
559    start: usize,
560    open_escape: char,
561    close: char,
562) -> Result<(String, usize), ExpandError> {
563    let mut out = String::new();
564    let mut i = start;
565
566    while i < chars.len() {
567        if chars[i] == '\\' && i + 1 < chars.len() && chars[i + 1] == open_escape {
568            out.push(chars[i + 1]);
569            i += 2;
570            continue;
571        }
572
573        if chars[i] == close {
574            return Ok((out, i + 1));
575        }
576
577        out.push(chars[i]);
578        i += 1;
579    }
580
581    Err(ExpandError::ExpandSyntax(format!(
582        "unterminated {close}...{close}"
583    )))
584}
585
586/// Scans a `$(...)`/`<(...)`/`>(...)` span's content starting right after
587/// the opening paren, tracking paren depth (and skipping over quoted
588/// strings and backtick spans so parens inside them don't unbalance the
589/// count). Returns the content up to (not including) the matching `)` and
590/// the index just past it.
591fn scan_balanced_parens(chars: &[char], start: usize) -> Result<(String, usize), ExpandError> {
592    let mut depth = 1usize;
593    let mut i = start;
594
595    while i < chars.len() {
596        match chars[i] {
597            '\\' if i + 1 < chars.len() => {
598                i += 2;
599                continue;
600            }
601            '\'' => {
602                let (_, next_i) = scan_delimited(chars, i + 1, '\0', '\'')?;
603                i = next_i;
604                continue;
605            }
606            '"' => {
607                let (_, next_i) = scan_delimited(chars, i + 1, '"', '"')?;
608                i = next_i;
609                continue;
610            }
611            '(' => {
612                depth += 1;
613                i += 1;
614            }
615            ')' => {
616                depth -= 1;
617                if depth == 0 {
618                    return Ok((chars[start..i].iter().collect(), i + 1));
619                }
620                i += 1;
621            }
622            _ => i += 1,
623        }
624    }
625
626    Err(ExpandError::ExpandSyntax(
627        "unterminated command/process substitution".to_string(),
628    ))
629}
630
631/// Scans a `$((...))` span's content starting right after the opening
632/// `$((`. Returns the content up to (not including) the matching `))` and
633/// the index just past it.
634fn scan_arithmetic(chars: &[char], start: usize) -> Result<(String, usize), ExpandError> {
635    let mut depth = 0usize;
636    let mut i = start;
637
638    while i < chars.len() {
639        match chars[i] {
640            '\\' if i + 1 < chars.len() => {
641                i += 2;
642                continue;
643            }
644            '(' => {
645                depth += 1;
646                i += 1;
647            }
648            ')' if depth > 0 => {
649                depth -= 1;
650                i += 1;
651            }
652            ')' if chars.get(i + 1) == Some(&')') => {
653                return Ok((chars[start..i].iter().collect(), i + 2));
654            }
655            _ => i += 1,
656        }
657    }
658
659    Err(ExpandError::ExpandSyntax(
660        "unterminated arithmetic expansion".to_string(),
661    ))
662}
663
664/// Rejects the expansion forms this site does not serve. Mirrors
665/// `checkSupported`/`unsupportedParamExp` in the Go original.
666fn check_supported(segments: &[Segment], lang: ReferenceLanguage) -> Result<(), ExpandError> {
667    for segment in segments {
668        if let Segment::Param(pe) = segment {
669            check_supported_param(pe, lang)?;
670        }
671    }
672    Ok(())
673}
674
675fn check_supported_param(pe: &ParamExp, lang: ReferenceLanguage) -> Result<(), ExpandError> {
676    match &pe.op {
677        ParamOp::NameListing => Err(ExpandError::UnsupportedExpansion(
678            "${!prefix*} name listing".to_string(),
679        )),
680        ParamOp::AssignUnsetOrNull(word) | ParamOp::AssignUnset(word) => {
681            check_supported(word, lang)?;
682            let op = if matches!(pe.op, ParamOp::AssignUnsetOrNull(_)) {
683                ":="
684            } else {
685                "="
686            };
687            if lang == ReferenceLanguage::Definition {
688                Err(ExpandError::UnsupportedExpansion(format!(
689                    "${{name{op}word}} assignment, use ${{name:-word}} instead"
690                )))
691            } else {
692                Err(ExpandError::UnsupportedExpansion(format!(
693                    "${{name{op}word}} assignment, use ${{name:-default}} in a variable definition"
694                )))
695            }
696        }
697        ParamOp::DefaultUnset(word)
698        | ParamOp::DefaultUnsetOrNull(word)
699        | ParamOp::ErrorUnset(word)
700        | ParamOp::ErrorUnsetOrNull(word)
701        | ParamOp::AlternateUnset(word)
702        | ParamOp::AlternateUnsetOrNull(word) => {
703            check_supported(word, lang)?;
704            if lang == ReferenceLanguage::Value {
705                let op = match &pe.op {
706                    ParamOp::DefaultUnset(_) => "-",
707                    ParamOp::DefaultUnsetOrNull(_) => ":-",
708                    ParamOp::ErrorUnset(_) => "?",
709                    ParamOp::ErrorUnsetOrNull(_) => ":?",
710                    ParamOp::AlternateUnset(_) => "+",
711                    ParamOp::AlternateUnsetOrNull(_) => ":+",
712                    _ => unreachable!(),
713                };
714                Err(ExpandError::UnsupportedExpansion(format!(
715                    "the ${{name{op}word}} operator is only available in a variable definition"
716                )))
717            } else {
718                Ok(())
719            }
720        }
721        _ => Ok(()),
722    }
723}
724
725/// Evaluates a parsed word, resolving references and preserving verbatim
726/// spans, and reports the bare references (no default/alternate) that
727/// resolved to nothing. Mirrors `resolveDefaults` + `expandWord`.
728fn eval_segments(
729    segments: &[Segment],
730    lookup: &dyn Lookup,
731    unresolved: &mut Vec<String>,
732) -> Result<String, ExpandError> {
733    let mut out = String::new();
734
735    for segment in segments {
736        match segment {
737            Segment::Literal(s) => out.push_str(s),
738            Segment::Verbatim(s) => out.push_str(s),
739            Segment::Param(pe) => out.push_str(&eval_param(pe, lookup, unresolved)?),
740        }
741    }
742
743    Ok(out)
744}
745
746fn eval_param(
747    pe: &ParamExp,
748    lookup: &dyn Lookup,
749    unresolved: &mut Vec<String>,
750) -> Result<String, ExpandError> {
751    let value = lookup.lookup(&pe.name);
752    let known = value.is_some();
753    let value = value.unwrap_or_default();
754
755    match &pe.op {
756        ParamOp::Bare => {
757            if !known && !unresolved.contains(&pe.name) {
758                unresolved.push(pe.name.clone());
759            }
760            Ok(value)
761        }
762        ParamOp::Length => Ok(value.chars().count().to_string()),
763        ParamOp::Indirect => {
764            let Some(pointee) = lookup.lookup(&pe.name) else {
765                return Err(ExpandError::ExpandFailed(format!(
766                    "indirect reference: {} is not set",
767                    pe.name
768                )));
769            };
770            Ok(lookup.lookup(&pointee).unwrap_or_default())
771        }
772        ParamOp::NameListing => unreachable!("rejected by check_supported"),
773        ParamOp::DefaultUnset(word) => {
774            if known {
775                Ok(value)
776            } else {
777                eval_segments(word, lookup, unresolved)
778            }
779        }
780        ParamOp::DefaultUnsetOrNull(word) => {
781            if known && !value.is_empty() {
782                Ok(value)
783            } else {
784                eval_segments(word, lookup, unresolved)
785            }
786        }
787        ParamOp::ErrorUnset(word) => {
788            if known {
789                Ok(value)
790            } else {
791                let message = eval_segments(word, lookup, unresolved)?;
792                Err(ExpandError::RequiredVariable(format!(
793                    "{}: {message}",
794                    pe.name
795                )))
796            }
797        }
798        ParamOp::ErrorUnsetOrNull(word) => {
799            if known && !value.is_empty() {
800                Ok(value)
801            } else {
802                let message = eval_segments(word, lookup, unresolved)?;
803                Err(ExpandError::RequiredVariable(format!(
804                    "{}: {message}",
805                    pe.name
806                )))
807            }
808        }
809        ParamOp::AlternateUnset(word) => {
810            if known {
811                eval_segments(word, lookup, unresolved)
812            } else {
813                Ok(String::new())
814            }
815        }
816        ParamOp::AlternateUnsetOrNull(word) => {
817            if known && !value.is_empty() {
818                eval_segments(word, lookup, unresolved)
819            } else {
820                Ok(String::new())
821            }
822        }
823        ParamOp::AssignUnset(_) | ParamOp::AssignUnsetOrNull(_) => {
824            unreachable!("rejected by check_supported")
825        }
826        ParamOp::Slice(offset, len) => {
827            let chars: Vec<char> = value.chars().collect();
828            let offset: usize = offset.parse().unwrap_or(0);
829            let start = offset.min(chars.len());
830            let end = match len {
831                Some(len) => {
832                    let len: usize = len.parse().unwrap_or(0);
833                    (start + len).min(chars.len())
834                }
835                None => chars.len(),
836            };
837            Ok(chars[start..end].iter().collect())
838        }
839        ParamOp::ReplaceFirst(pat, repl) => {
840            if pat.is_empty() {
841                return Ok(value);
842            }
843            Ok(value.replacen(pat.as_str(), repl, 1))
844        }
845        ParamOp::ReplaceAll(pat, repl) => {
846            if pat.is_empty() {
847                return Ok(value);
848            }
849            Ok(value.replace(pat.as_str(), repl))
850        }
851        ParamOp::RemovePrefix(pat) | ParamOp::RemovePrefixLongest(pat) => Ok(value
852            .strip_prefix(pat.as_str())
853            .unwrap_or(&value)
854            .to_string()),
855        ParamOp::RemoveSuffix(pat) | ParamOp::RemoveSuffixLongest(pat) => Ok(value
856            .strip_suffix(pat.as_str())
857            .unwrap_or(&value)
858            .to_string()),
859    }
860}
861
862/// Walks a parsed config mapping and expands references in every string
863/// value, in place. The top-level `"variables"` subtree is left untouched.
864pub fn expand_map(
865    root: &mut Value,
866    lookup: &dyn Lookup,
867    skip: Option<SkipPath<'_>>,
868) -> Result<Vec<Unresolved>, ExpandError> {
869    let mut unresolved = Vec::new();
870
871    let Value::Object(map) = root else {
872        return Ok(unresolved);
873    };
874
875    for (key, val) in map.iter_mut() {
876        if key == "variables" {
877            continue;
878        }
879
880        expand_value(&mut vec![key.clone()], val, lookup, skip, &mut unresolved)?;
881    }
882
883    Ok(unresolved)
884}
885
886fn expand_value(
887    path: &mut Vec<String>,
888    v: &mut Value,
889    lookup: &dyn Lookup,
890    skip: Option<SkipPath<'_>>,
891    unresolved: &mut Vec<Unresolved>,
892) -> Result<(), ExpandError> {
893    if let Some(skip) = skip
894        && skip(path)
895    {
896        return Ok(());
897    }
898
899    match v {
900        Value::String(s) => {
901            let (expanded, names) = expand_impl(s, lookup, ReferenceLanguage::Value)
902                .map_err(|e| path_error(path, e))?;
903
904            if !names.is_empty() {
905                unresolved.push(Unresolved {
906                    path: path.join("."),
907                    variables: names,
908                });
909            }
910
911            *s = expanded;
912            Ok(())
913        }
914        Value::Object(map) => {
915            for (key, child) in map.iter_mut() {
916                path.push(key.clone());
917                expand_value(path, child, lookup, skip, unresolved)?;
918                path.pop();
919            }
920            Ok(())
921        }
922        Value::Array(items) => {
923            for (i, child) in items.iter_mut().enumerate() {
924                path.push(i.to_string());
925                expand_value(path, child, lookup, skip, unresolved)?;
926                path.pop();
927            }
928            Ok(())
929        }
930        _ => Ok(()),
931    }
932}
933
934fn path_error(path: &[String], err: ExpandError) -> ExpandError {
935    let prefix = path.join(".");
936    match err {
937        ExpandError::RequiredVariable(m) => ExpandError::RequiredVariable(format!("{prefix}: {m}")),
938        ExpandError::ExpandSyntax(m) => ExpandError::ExpandSyntax(format!("{prefix}: {m}")),
939        ExpandError::UnsupportedExpansion(m) => {
940            ExpandError::UnsupportedExpansion(format!("{prefix}: {m}"))
941        }
942        ExpandError::ExpandFailed(m) => ExpandError::ExpandFailed(format!("{prefix}: {m}")),
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use serde_json::json;
950
951    struct TestLookup;
952
953    impl Lookup for TestLookup {
954        fn lookup(&self, name: &str) -> Option<String> {
955            match name {
956                "HOST" => Some("gitlab".to_string()),
957                "POINTER" => Some("SHA".to_string()),
958                "SHA" => Some("abc123".to_string()),
959                "EMPTY" => Some(String::new()),
960                _ => None,
961            }
962        }
963    }
964
965    // Port of TestExpandString.
966    #[test]
967    fn expand_string_cases() {
968        let cases: &[(&str, &str, &str)] = &[
969            ("resolved reference", "${HOST}", "gitlab"),
970            ("short form", "$HOST", "gitlab"),
971            (
972                "embedded reference",
973                "${HOST}.example.com",
974                "gitlab.example.com",
975            ),
976            (
977                "embedded unresolved reference substitutes empty",
978                "prefix${MISSING}.example.com",
979                "prefix.example.com",
980            ),
981            ("unresolved reference substitutes empty", "${MISSING}", ""),
982            ("unresolved short form substitutes empty", "$MISSING", ""),
983            ("set but empty substitutes empty", "${EMPTY}", ""),
984            (
985                "literal text survives an unresolved reference",
986                "x${MISSING}",
987                "x",
988            ),
989            ("backslash escapes a reference", r"\$HOST", "$HOST"),
990            (
991                "backslash keeps arithmetic out of interpolation",
992                r"echo \$((i+1))",
993                "echo $((i+1))",
994            ),
995            ("command substitution is preserved", "$(date)", "$(date)"),
996            ("arithmetic is preserved", "$((1+2))", "$((1+2))"),
997            ("arithmetic keeps its own variables", "$((i+1))", "$((i+1))"),
998            (
999                "backticks are preserved verbatim",
1000                "back`tick`s",
1001                "back`tick`s",
1002            ),
1003            (
1004                "process substitution is preserved",
1005                "<(cat foo)",
1006                "<(cat foo)",
1007            ),
1008            (
1009                "expansion around command substitution still applies",
1010                "$(git rev-parse HEAD) $HOST",
1011                "$(git rev-parse HEAD) gitlab",
1012            ),
1013            ("positional parameter is unresolved", "$1", ""),
1014            ("status parameter is unresolved", "$?", ""),
1015            ("regex anchor is literal", "^(0|1|3)$", "^(0|1|3)$"),
1016            ("single quote is literal", "it's fine", "it's fine"),
1017            ("glob is literal", "*.yaml", "*.yaml"),
1018            ("tilde is literal", "~/foo", "~/foo"),
1019            (
1020                "trailing dollar is literal",
1021                "cost: 100$ USD",
1022                "cost: 100$ USD",
1023            ),
1024            (
1025                "comment marker is literal",
1026                "# not a comment",
1027                "# not a comment",
1028            ),
1029            (
1030                "url query is literal",
1031                "http://example.com/?a=b&c=d",
1032                "http://example.com/?a=b&c=d",
1033            ),
1034        ];
1035
1036        for (name, input, want) in cases {
1037            let got = expand_string(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1038            assert_eq!(&got, want, "{name}");
1039        }
1040    }
1041
1042    // Port of TestExpandString_SyntaxError.
1043    #[test]
1044    fn expand_string_syntax_error() {
1045        let err = expand_string("${UNTERMINATED", &TestLookup).unwrap_err();
1046        assert!(matches!(err, ExpandError::ExpandSyntax(_)));
1047    }
1048
1049    // Port of TestExpandMap.
1050    #[test]
1051    fn expand_map_basic() {
1052        let mut root = json!({
1053            "version": 2,
1054            "cluster": {
1055                "network": {
1056                    "hostname": "${HOST}.example.com",
1057                    "additional_hostnames": ["api.${HOST}.example.com", "${MISSING}.example.com"],
1058                }
1059            },
1060            "variables": {"KEEP": "${HOST}"},
1061            "deployers": {"charts": {"timeout": "10m"}},
1062        });
1063
1064        expand_map(&mut root, &TestLookup, None).expect("expand should succeed");
1065
1066        assert_eq!(
1067            root["cluster"]["network"]["hostname"],
1068            json!("gitlab.example.com")
1069        );
1070        assert_eq!(
1071            root["cluster"]["network"]["additional_hostnames"][0],
1072            json!("api.gitlab.example.com")
1073        );
1074        assert_eq!(
1075            root["cluster"]["network"]["additional_hostnames"][1],
1076            json!(".example.com")
1077        );
1078        assert_eq!(
1079            root["variables"]["KEEP"],
1080            json!("${HOST}"),
1081            "variables subtree is excluded from expansion"
1082        );
1083        assert_eq!(root["version"], json!(2));
1084        assert_eq!(root["deployers"]["charts"]["timeout"], json!("10m"));
1085    }
1086
1087    // Port of TestExpandMap_ErrorNamesThePath.
1088    #[test]
1089    fn expand_map_error_names_the_path() {
1090        let mut root = json!({
1091            "deployers": {"gitlab": {"helm": {"release": {"version": "${CHART_VERSION:=pinned}"}}}}
1092        });
1093
1094        let err = expand_map(&mut root, &TestLookup, None).unwrap_err();
1095        assert!(matches!(err, ExpandError::UnsupportedExpansion(_)));
1096        assert!(
1097            err.to_string()
1098                .contains("deployers.gitlab.helm.release.version")
1099        );
1100    }
1101
1102    // Port of TestExpandMap_SkipsLiteralSubtrees.
1103    #[test]
1104    fn expand_map_skips_literal_subtrees() {
1105        let mut root = json!({
1106            "reloaders": {"gitlab": {
1107                "command": "${HOST}-webservice",
1108                "mapping": {"literal": "./log/$file", "nested": ["${HOST}/keep-literal"]},
1109            }}
1110        });
1111
1112        let skip =
1113            |path: &[String]| path.len() >= 3 && path[0] == "reloaders" && path[2] == "mapping";
1114
1115        expand_map(&mut root, &TestLookup, Some(&skip)).expect("expand should succeed");
1116
1117        assert_eq!(
1118            root["reloaders"]["gitlab"]["command"],
1119            json!("gitlab-webservice")
1120        );
1121        assert_eq!(
1122            root["reloaders"]["gitlab"]["mapping"]["literal"],
1123            json!("./log/$file")
1124        );
1125        assert_eq!(
1126            root["reloaders"]["gitlab"]["mapping"]["nested"][0],
1127            json!("${HOST}/keep-literal")
1128        );
1129    }
1130
1131    // Port of TestExpandMap_ReportsUnresolvedReferences.
1132    #[test]
1133    fn expand_map_reports_unresolved_references() {
1134        let mut root = json!({
1135            "value_map": {
1136                "bare": "${GITALY_TAG}",
1137                "resolved": "${HOST}",
1138                "composed": "prefix-${ALSO_MISSING}",
1139                "repeated": "${GITALY_TAG}-${GITALY_TAG}",
1140            }
1141        });
1142
1143        let unresolved = expand_map(&mut root, &TestLookup, None).expect("expand should succeed");
1144
1145        let mut reported: std::collections::HashMap<String, Vec<String>> =
1146            std::collections::HashMap::new();
1147        for entry in unresolved {
1148            reported.insert(entry.path, entry.variables);
1149        }
1150
1151        assert_eq!(
1152            reported.get("value_map.bare"),
1153            Some(&vec!["GITALY_TAG".to_string()])
1154        );
1155        assert_eq!(
1156            reported.get("value_map.composed"),
1157            Some(&vec!["ALSO_MISSING".to_string()])
1158        );
1159        assert_eq!(
1160            reported.get("value_map.repeated"),
1161            Some(&vec!["GITALY_TAG".to_string()])
1162        );
1163    }
1164
1165    // Port of TestExpandString_ShellExpansionsResolve.
1166    #[test]
1167    fn expand_string_shell_expansions_resolve() {
1168        let cases: &[(&str, &str, &str)] = &[
1169            ("length", "${#HOST}", "6"),
1170            (
1171                "indirection reaches the variable the pointer names",
1172                "${!POINTER}",
1173                "abc123",
1174            ),
1175            ("slice", "${HOST:1:2}", "it"),
1176            ("replacement", "${HOST/git/lab}", "lablab"),
1177            ("suffix removal", "${HOST%lab}", "git"),
1178            ("prefix removal", "${HOST#git}", "lab"),
1179        ];
1180
1181        for (name, input, want) in cases {
1182            let got = expand_string(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1183            assert_eq!(&got, want, "{name}");
1184        }
1185    }
1186
1187    // Port of TestExpandString_RejectsUnsupportedExpansions.
1188    #[test]
1189    fn expand_string_rejects_unsupported_expansions() {
1190        let cases: &[(&str, &str, Option<&str>)] = &[
1191            ("name listing", "${!HOST*}", None),
1192            ("name listing, other spelling", "${!HOST@}", None),
1193            (
1194                "assignment",
1195                "${HOST:=assigned}",
1196                Some("use ${name:-default} in a variable definition"),
1197            ),
1198            (
1199                "assignment, unset-only spelling",
1200                "${HOST=assigned}",
1201                Some("use ${name:-default} in a variable definition"),
1202            ),
1203            ("default", "${HOST:-fallback}", None),
1204            ("required", "${HOST:?message}", None),
1205            ("alternate", "${HOST:+text}", None),
1206        ];
1207
1208        for (name, input, want_msg) in cases {
1209            let err = expand_string(input, &TestLookup).unwrap_err();
1210            assert!(
1211                matches!(err, ExpandError::UnsupportedExpansion(_)),
1212                "{name}"
1213            );
1214
1215            if let Some(msg) = want_msg {
1216                assert!(err.to_string().contains(msg), "{name}");
1217            }
1218        }
1219    }
1220
1221    // Port of TestExpandString_EvaluationFailureIsNotASyntaxError.
1222    #[test]
1223    fn expand_string_evaluation_failure_is_not_a_syntax_error() {
1224        let err = expand_string("${!MISSING}", &TestLookup).unwrap_err();
1225        assert!(matches!(err, ExpandError::ExpandFailed(_)));
1226        assert!(!matches!(err, ExpandError::ExpandSyntax(_)));
1227    }
1228
1229    // Port of TestExpandString_PreservesExpansionsInsideCommandSubstitution.
1230    #[test]
1231    fn expand_string_preserves_expansions_inside_command_substitution() {
1232        let got =
1233            expand_string("$(echo $((1+2)) ${x#y})", &TestLookup).expect("expand should succeed");
1234        assert_eq!(got, "$(echo $((1+2)) ${x#y})");
1235    }
1236
1237    // Port of TestExpandDefinition.
1238    #[test]
1239    fn expand_definition_cases() {
1240        let cases: &[(&str, &str, &str)] = &[
1241            ("bare reference resolves", "${HOST}", "gitlab"),
1242            ("composition", "${HOST}.example.com", "gitlab.example.com"),
1243            (
1244                "default applies when unset",
1245                "${MISSING:-fallback}",
1246                "fallback",
1247            ),
1248            (
1249                "default applies when empty",
1250                "${EMPTY:-fallback}",
1251                "fallback",
1252            ),
1253            ("empty default produces an empty string", "${MISSING:-}", ""),
1254            (
1255                "default is not used when set",
1256                "${HOST:-fallback}",
1257                "gitlab",
1258            ),
1259            (
1260                "required reference passes when set",
1261                "${HOST:?must be set}",
1262                "gitlab",
1263            ),
1264            ("alternate applies when set", "${HOST:+set}", "set"),
1265            (
1266                "alternate decorates the value it tests",
1267                "${HOST:+v$HOST}",
1268                "vgitlab",
1269            ),
1270            (
1271                "alternate produces nothing when unset",
1272                "${MISSING:+set}",
1273                "",
1274            ),
1275            ("alternate produces nothing when empty", "${EMPTY:+set}", ""),
1276            (
1277                "unset-only alternate applies to an empty value",
1278                "${EMPTY+set}",
1279                "set",
1280            ),
1281            ("backslash escapes a reference", r"\$HOST", "$HOST"),
1282        ];
1283
1284        for (name, input, want) in cases {
1285            let (got, _) =
1286                expand_definition(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1287            assert_eq!(&got, want, "{name}");
1288        }
1289    }
1290
1291    // Port of TestExpandDefinition_DefaultIsEvaluatedLazily.
1292    #[test]
1293    fn expand_definition_default_is_evaluated_lazily() {
1294        let cases: &[(&str, &str, &str)] = &[
1295            (
1296                "value wins and the fallback is never evaluated",
1297                "${HOST:-${MISSING:?set HOST or MISSING}}",
1298                "gitlab",
1299            ),
1300            (
1301                "fallback applies when the value is unset",
1302                "${MISSING:-${SHA:?set MISSING or SHA}}",
1303                "abc123",
1304            ),
1305        ];
1306
1307        for (name, input, want) in cases {
1308            let (got, _) =
1309                expand_definition(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1310            assert_eq!(&got, want, "{name}");
1311        }
1312    }
1313
1314    // Port of TestExpandDefinition_AlternateIsEvaluatedLazily.
1315    #[test]
1316    fn expand_definition_alternate_is_evaluated_lazily() {
1317        let cases: &[(&str, &str, &str)] = &[
1318            (
1319                "unset value never reaches the word",
1320                "${MISSING:+${OTHER:?not reached}}",
1321                "",
1322            ),
1323            (
1324                "empty value never reaches the word",
1325                "${EMPTY:+${OTHER:?not reached}}",
1326                "",
1327            ),
1328            (
1329                "a value reaches the word",
1330                "${HOST:+${SHA:?reached}}",
1331                "abc123",
1332            ),
1333        ];
1334
1335        for (name, input, want) in cases {
1336            let (got, _) =
1337                expand_definition(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1338            assert_eq!(&got, want, "{name}");
1339        }
1340    }
1341
1342    // Port of TestExpandDefinition_DefaultChainFailsWhenNothingResolves.
1343    #[test]
1344    fn expand_definition_default_chain_fails_when_nothing_resolves() {
1345        let err = expand_definition(
1346            "${MISSING:-${ALSO_MISSING:?set MISSING or ALSO_MISSING}}",
1347            &TestLookup,
1348        )
1349        .unwrap_err();
1350        assert!(matches!(err, ExpandError::RequiredVariable(_)));
1351        assert!(err.to_string().contains("ALSO_MISSING"));
1352    }
1353
1354    // Port of TestExpandDefinition_RequiredVariable.
1355    #[test]
1356    fn expand_definition_required_variable() {
1357        let err = expand_definition("${GITALY_TAG:?set the image tag}", &TestLookup).unwrap_err();
1358        assert!(matches!(err, ExpandError::RequiredVariable(_)));
1359        assert!(err.to_string().contains("GITALY_TAG"));
1360        assert!(err.to_string().contains("set the image tag"));
1361
1362        let err = expand_definition("${EMPTY:?needs a value}", &TestLookup).unwrap_err();
1363        assert!(matches!(err, ExpandError::RequiredVariable(_)));
1364    }
1365
1366    // Port of TestExpandDefinition_ReportsOnlyReferencesWithoutADefault.
1367    #[test]
1368    fn expand_definition_reports_only_references_without_a_default() {
1369        let cases: &[(&str, &str, &[&str])] = &[
1370            (
1371                "defaulted reference is not reported",
1372                "${MISSING:-fallback}",
1373                &[],
1374            ),
1375            (
1376                "bare reference is reported",
1377                "${GITALY_TAG}",
1378                &["GITALY_TAG"],
1379            ),
1380            (
1381                "repeated reference is reported once",
1382                "${GITALY_TAG}-${GITALY_TAG}",
1383                &["GITALY_TAG"],
1384            ),
1385            ("resolved reference is not reported", "${HOST}", &[]),
1386        ];
1387
1388        for (name, input, want) in cases {
1389            let (_, unresolved) =
1390                expand_definition(input, &TestLookup).unwrap_or_else(|e| panic!("{name}: {e}"));
1391            assert_eq!(unresolved, want.to_vec(), "{name}");
1392        }
1393    }
1394
1395    // Port of TestExpandDefinition_RejectsUnsupportedExpansions.
1396    #[test]
1397    fn expand_definition_rejects_unsupported_expansions() {
1398        let err = expand_definition("${!HOST*}", &TestLookup).unwrap_err();
1399        assert!(matches!(err, ExpandError::UnsupportedExpansion(_)));
1400
1401        let err = expand_definition("${HOST:=assigned}", &TestLookup).unwrap_err();
1402        assert!(matches!(err, ExpandError::UnsupportedExpansion(_)));
1403        assert!(err.to_string().contains("use ${name:-word} instead"));
1404    }
1405}