1use serde_json::Value;
22use thiserror::Error;
23
24use super::resolver::Lookup;
25
26type 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#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Unresolved {
46 pub path: String,
47 pub variables: Vec<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum ReferenceLanguage {
55 Value,
56 Definition,
57}
58
59#[derive(Debug, Clone)]
61enum Segment {
62 Literal(String),
63 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 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
100pub 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
107pub 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
135fn 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
228fn 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
254fn 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
318fn 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
337fn 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
456fn 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
469fn 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
522fn 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
554fn 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
586fn 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
631fn 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
664fn 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
725fn 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
862pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}