Skip to main content

isoloom_render/hcl_builder/
template.rs

1//! Strings that hold Terraform expressions: text and interpolations, kept apart until written.
2
3use hcl::expr::{Expression, TemplateExpr};
4
5use super::escape::{quoted_inside, strings_escaped};
6
7/// A quoted HCL template: text, escaped when written, and expressions, interpolated as
8/// `${...}`. Text can never become an interpolation, whatever it holds.
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct Template {
11    parts: Vec<Part>,
12}
13
14/// A piece of a template: text, or an interpolated expression.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum Piece<'a> {
17    /// Text, as it reads (unescaped).
18    Text(&'a str),
19    /// An expression.
20    Expr(&'a Expression),
21}
22
23#[derive(Debug, Clone, PartialEq)]
24enum Part {
25    Text(String),
26    Expr(Expression),
27}
28
29impl Template {
30    /// An empty template.
31    #[must_use]
32    pub fn new() -> Template {
33        Template::default()
34    }
35
36    /// A template made of `text` only.
37    #[must_use]
38    pub fn text(text: impl Into<String>) -> Template {
39        let mut t = Template::new();
40        t.push_text(text);
41        t
42    }
43
44    /// The template with `text` appended.
45    #[must_use]
46    pub fn with_text(mut self, text: impl Into<String>) -> Template {
47        self.push_text(text);
48        self
49    }
50
51    /// The template with `expr` interpolated at its end.
52    #[must_use]
53    pub fn with_expr(mut self, expr: Expression) -> Template {
54        self.push_expr(expr);
55        self
56    }
57
58    /// Appends `text`.
59    pub fn push_text(&mut self, text: impl Into<String>) {
60        let text = text.into();
61        match self.parts.last_mut() {
62            Some(Part::Text(t)) => t.push_str(&text),
63            _ if text.is_empty() => {}
64            _ => self.parts.push(Part::Text(text)),
65        }
66    }
67
68    /// Appends an interpolation of `expr`.
69    pub fn push_expr(&mut self, expr: Expression) {
70        self.parts.push(Part::Expr(expr));
71    }
72
73    /// Appends `other`.
74    pub fn append(&mut self, other: Template) {
75        for p in other.parts {
76            match p {
77                Part::Text(t) => self.push_text(t),
78                Part::Expr(e) => self.push_expr(e),
79            }
80        }
81    }
82
83    /// Its text and expressions, in order (for a caller that writes the template another way).
84    pub fn pieces(&self) -> impl Iterator<Item = Piece<'_>> {
85        self.parts.iter().map(|p| match p {
86            Part::Text(t) => Piece::Text(t),
87            Part::Expr(e) => Piece::Expr(e),
88        })
89    }
90
91    /// Whether it is plain text (no interpolation).
92    #[must_use]
93    pub fn is_text(&self) -> bool {
94        self.parts.iter().all(|p| matches!(p, Part::Text(_)))
95    }
96
97    /// The template as an expression: a plain string when it has no interpolation, the
98    /// expression itself when it is one interpolation and nothing else (`"${x}"` is a form
99    /// Terraform deprecates for `x`, which it converts where a string is wanted).
100    #[must_use]
101    pub fn into_expression(mut self) -> Expression {
102        if let [Part::Expr(_)] = self.parts.as_slice()
103            && let Some(Part::Expr(e)) = self.parts.pop()
104        {
105            return e;
106        }
107        if self.is_text() {
108            let text: String = self
109                .parts
110                .into_iter()
111                .filter_map(|p| match p {
112                    Part::Text(t) => Some(t),
113                    Part::Expr(_) => None,
114                })
115                .collect();
116            return Expression::String(text);
117        }
118        let next = self.parts.iter().skip(1).map(Some).chain([None]);
119        let raw = self
120            .parts
121            .iter()
122            .zip(next)
123            .map(|(p, after)| match p {
124                // A `$` right before an interpolation would make it `$${`, a literal: written as
125                // its escape instead.
126                Part::Text(t) => {
127                    let text = quoted_inside(t);
128                    if let Some(head) = text.strip_suffix('$').filter(|_| matches!(after, Some(Part::Expr(_)))) {
129                        format!("{head}\\u0024")
130                    } else {
131                        text
132                    }
133                }
134                // `hcl-rs` documents an expression's formatting as unable to fail.
135                Part::Expr(e) => format!("${{{}}}", strings_escaped(e)),
136            })
137            .collect::<Vec<_>>()
138            .concat();
139        Expression::TemplateExpr(Box::new(TemplateExpr::QuotedString(raw)))
140    }
141}
142
143impl From<&str> for Template {
144    fn from(text: &str) -> Template {
145        Template::text(text)
146    }
147}
148
149impl From<String> for Template {
150    fn from(text: String) -> Template {
151        Template::text(text)
152    }
153}
154
155impl From<Template> for Expression {
156    fn from(t: Template) -> Expression {
157        t.into_expression()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{Piece, Template};
164    use crate::hcl_builder::reference;
165
166    #[test]
167    fn text_is_escaped_and_expressions_interpolated() {
168        let t = Template::text("echo \"${HOME}\" %{x}\n").with_expr(reference("var.slot")).with_text("!");
169        assert_eq!(t.into_expression().to_string(), r#""echo \"$${HOME}\" %%{x}\n${var.slot}!""#);
170    }
171
172    #[test]
173    fn adjacent_text_is_one_piece_and_empty_text_none() {
174        let t = Template::text("a").with_text("b").with_text("").with_expr(reference("var.x")).with_text("");
175        let pieces: Vec<Piece<'_>> = t.pieces().collect();
176        assert_eq!(pieces, [Piece::Text("ab"), Piece::Expr(&reference("var.x"))]);
177        assert_eq!(Template::new().with_text("").pieces().count(), 0);
178        assert!(!t.is_text() && Template::text("x").is_text());
179    }
180
181    #[test]
182    fn a_dollar_before_an_interpolation_stays_a_dollar() {
183        let t = Template::text("cost: $").with_expr(reference("var.price"));
184        assert_eq!(t.into_expression().to_string(), r#""cost: \u0024${var.price}""#);
185    }
186
187    #[test]
188    fn appending_keeps_text_and_expressions_in_order() {
189        let mut t = Template::text("a");
190        t.append(Template::text("b").with_expr(reference("var.y")).with_text("c"));
191        assert_eq!(t.into_expression().to_string(), r#""ab${var.y}c""#);
192    }
193
194    #[test]
195    fn text_converts_into_a_template_of_it() {
196        assert_eq!(Template::from("a").into_expression(), hcl::Expression::String("a".into()));
197        assert_eq!(Template::from(String::from("b")).into_expression(), hcl::Expression::String("b".into()));
198        assert_eq!(hcl::Expression::from(Template::text("c")), hcl::Expression::String("c".into()));
199    }
200
201    #[test]
202    fn a_lone_interpolation_is_its_expression_not_a_string_around_it() {
203        assert_eq!(Template::new().with_expr(reference("var.x")).into_expression(), reference("var.x"));
204        assert_eq!(
205            Template::new().with_expr(reference("var.x")).with_text("").into_expression(),
206            reference("var.x")
207        );
208        assert_eq!(
209            Template::text(" ").with_expr(reference("var.x")).into_expression().to_string(),
210            r#"" ${var.x}""#
211        );
212    }
213
214    #[test]
215    fn plain_text_is_a_string() {
216        assert_eq!(Template::text("a").with_text("b").into_expression(), hcl::Expression::String("ab".into()));
217    }
218}