isoloom_render/hcl_builder/
template.rs1use hcl::expr::{Expression, TemplateExpr};
4
5use super::escape::{quoted_inside, strings_escaped};
6
7#[derive(Debug, Clone, Default, PartialEq)]
10pub struct Template {
11 parts: Vec<Part>,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum Piece<'a> {
17 Text(&'a str),
19 Expr(&'a Expression),
21}
22
23#[derive(Debug, Clone, PartialEq)]
24enum Part {
25 Text(String),
26 Expr(Expression),
27}
28
29impl Template {
30 #[must_use]
32 pub fn new() -> Template {
33 Template::default()
34 }
35
36 #[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 #[must_use]
46 pub fn with_text(mut self, text: impl Into<String>) -> Template {
47 self.push_text(text);
48 self
49 }
50
51 #[must_use]
53 pub fn with_expr(mut self, expr: Expression) -> Template {
54 self.push_expr(expr);
55 self
56 }
57
58 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 pub fn push_expr(&mut self, expr: Expression) {
70 self.parts.push(Part::Expr(expr));
71 }
72
73 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 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 #[must_use]
93 pub fn is_text(&self) -> bool {
94 self.parts.iter().all(|p| matches!(p, Part::Text(_)))
95 }
96
97 #[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 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 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}