isoloom_render/hcl_builder/
write.rs1use hcl::expr::{Expression, FuncCall, Object, ObjectKey};
9use hcl::format::{Format, Formatter};
10use hcl::{Block, BlockLabel, Body, Structure};
11
12use super::escape::{quoted_inside, strings_escaped};
13
14pub const WIDTH: usize = 100;
16
17#[derive(Debug, Clone, Default, PartialEq)]
20pub struct Module {
21 preamble: String,
22 sections: Vec<Section>,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26enum Section {
27 Block { comment: Option<String>, block: Block },
28 Fragment(String),
29}
30
31impl Module {
32 #[must_use]
35 pub fn new(preamble: impl Into<String>) -> Module {
36 Module {
37 preamble: preamble.into(),
38 sections: Vec::new(),
39 }
40 }
41
42 pub fn block(&mut self, block: Block) {
44 self.sections.push(Section::Block { comment: None, block });
45 }
46
47 pub fn commented(&mut self, comment: &str, block: Block) {
49 self.sections.push(Section::Block {
50 comment: Some(comment.to_owned()),
51 block,
52 });
53 }
54
55 pub fn blocks(&mut self, blocks: impl IntoIterator<Item = Block>) {
57 for b in blocks {
58 self.block(b);
59 }
60 }
61
62 pub fn fragment(&mut self, hcl: &str) {
65 self.sections.push(Section::Fragment(hcl.trim_matches('\n').to_owned()));
66 }
67
68 pub fn render(&self) -> Result<String, hcl::Error> {
75 let mut out = self.preamble.clone();
76 for s in &self.sections {
77 if !out.is_empty() {
79 out.push('\n');
80 }
81 match s {
82 Section::Block { comment, block: b } => {
83 for line in comment.iter().flat_map(|c| c.lines()) {
84 out.push_str(if line.is_empty() { "#" } else { "# " });
85 out.push_str(line);
86 out.push('\n');
87 }
88 block(&mut out, b, 0)?;
89 }
90 Section::Fragment(f) => {
91 out.push_str(f);
92 out.push('\n');
93 }
94 }
95 }
96 Ok(out)
97 }
98}
99
100fn pad(out: &mut String, indent: usize) {
102 for _ in 0..indent {
103 out.push_str(" ");
104 }
105}
106
107fn block(out: &mut String, b: &Block, indent: usize) -> Result<(), hcl::Error> {
108 pad(out, indent);
109 out.push_str(b.identifier());
110 for l in b.labels() {
111 out.push(' ');
112 match l {
113 BlockLabel::Identifier(i) => out.push_str(i.as_str()),
114 BlockLabel::String(s) => {
115 out.push('"');
116 out.push_str("ed_inside(s));
117 out.push('"');
118 }
119 }
120 }
121 if b.body().0.is_empty() {
122 out.push_str(" {}\n");
123 return Ok(());
124 }
125 out.push_str(" {\n");
126 body(out, b.body(), indent + 1)?;
127 pad(out, indent);
128 out.push_str("}\n");
129 Ok(())
130}
131
132fn body(out: &mut String, body: &Body, indent: usize) -> Result<(), hcl::Error> {
133 let mut run: Vec<(String, String)> = Vec::new();
134 for s in body {
135 match s {
136 Structure::Attribute(a) => {
137 let value = value(&strings_escaped(&a.expr), indent)?;
138 run.push((a.key.as_str().to_owned(), value));
139 }
140 Structure::Block(b) => {
141 entries(out, &std::mem::take(&mut run), indent);
142 block(out, b, indent)?;
143 }
144 }
145 }
146 entries(out, &run, indent);
147 Ok(())
148}
149
150fn entries(out: &mut String, entries: &[(String, String)], indent: usize) {
153 for run in entries.split_inclusive(|(_, v)| v.contains('\n')) {
154 let one_line = |v: &str| !v.contains('\n');
155 let width = run.iter().filter(|(_, v)| one_line(v)).map(|(k, _)| k.chars().count()).max().unwrap_or(0);
156 for (k, v) in run {
157 pad(out, indent);
158 out.push_str(k);
159 if one_line(v) {
160 out.push_str(&" ".repeat(width.saturating_sub(k.chars().count())));
161 }
162 out.push_str(" = ");
163 out.push_str(v);
164 out.push('\n');
165 }
166 }
167}
168
169fn value(e: &Expression, indent: usize) -> Result<String, hcl::Error> {
171 let flat = compact(e)?;
172 if flat.chars().count() <= WIDTH && !flat.contains('\n') {
173 return Ok(flat);
174 }
175 match e {
177 Expression::Array(items) => array(items, indent),
178 Expression::Object(o) => object(o, indent),
179 Expression::FuncCall(c) => func_call(c, indent),
180 _ => Ok(flat),
181 }
182}
183
184fn array(items: &[Expression], indent: usize) -> Result<String, hcl::Error> {
186 let lines = items
187 .iter()
188 .map(|item| Ok(format!("{}{}", " ".repeat(indent + 1), value(item, indent + 1)?)))
189 .collect::<Result<Vec<String>, hcl::Error>>()?;
190 Ok(format!("[\n{}\n{}]", lines.join(",\n"), " ".repeat(indent)))
191}
192
193fn object(o: &Object<ObjectKey, Expression>, indent: usize) -> Result<String, hcl::Error> {
195 let mut items = Vec::new();
196 for (k, v) in o {
197 let key = match k {
198 ObjectKey::Identifier(i) => i.as_str().to_owned(),
199 ObjectKey::Expression(e) => compact(e)?,
200 other => other.to_string(),
202 };
203 items.push((key, value(v, indent + 1)?));
204 }
205 let mut out = String::from("{\n");
206 entries(&mut out, &items, indent + 1);
207 pad(&mut out, indent);
208 out.push('}');
209 Ok(out)
210}
211
212fn func_call(c: &FuncCall, indent: usize) -> Result<String, hcl::Error> {
214 let mut out = format!("{}(", c.name);
215 let (last, first) = c.args.split_last().map_or((None, &[][..]), |(l, f)| (Some(l), f));
216 for a in first {
217 out.push_str(&compact(a)?);
218 out.push_str(", ");
219 }
220 if let Some(l) = last {
221 out.push_str(&value(l, indent)?);
222 }
223 out.push_str(if c.expand_final { "...)" } else { ")" });
224 Ok(out)
225}
226
227pub(super) fn compact(e: &Expression) -> Result<String, hcl::Error> {
229 let mut f = Formatter::builder().compact(true).build_vec();
230 e.format_string(&mut f)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::Module;
236 use crate::hcl_builder::{Block, Template, call, list, number, object, reference, string};
237
238 fn rendered(b: Block) -> String {
239 let mut m = Module::new("");
240 m.block(b);
241 m.render().unwrap()
242 }
243
244 #[test]
245 fn one_line_attributes_are_aligned_and_blocks_nest() {
246 let b = Block::builder("resource")
247 .add_label("aws_vpc")
248 .add_label("env")
249 .add_attribute(("cidr_block", string("10.0.0.0/24")))
250 .add_attribute(("tags", object([("Name", reference("local.name"))])))
251 .add_block(
252 Block::builder("lifecycle")
253 .add_attribute(("ignore_changes", list([reference("input")])))
254 .build(),
255 )
256 .add_block(Block::builder("serial_device").build())
257 .build();
258 assert_eq!(
259 rendered(b),
260 "resource \"aws_vpc\" \"env\" {\n cidr_block = \"10.0.0.0/24\"\n tags = { Name = local.name }\n lifecycle {\n ignore_changes = [input]\n }\n serial_device {}\n}\n"
261 );
262 }
263
264 #[test]
265 fn long_lists_go_one_item_a_line_and_break_the_alignment() {
266 let long: Vec<_> = (0..12).map(|i| string(format!("command number {i}"))).collect();
267 let b = Block::builder("x")
268 .add_attribute(("a", number(1)))
269 .add_attribute(("inline", list(long)))
270 .add_attribute(("on_failure", reference("continue")))
271 .build();
272 let text = rendered(b);
273 assert!(text.starts_with("x {\n a = 1\n inline = [\n \"command number 0\",\n"), "{text}");
274 assert!(text.ends_with(" \"command number 11\"\n ]\n on_failure = continue\n}\n"), "{text}");
275 }
276
277 #[test]
278 fn a_call_hugs_its_long_last_argument() {
279 let items: Vec<_> = (0..8).map(|i| (format!("key{i}"), string(format!("a long enough value {i}")))).collect();
280 let b = Block::builder("x").add_attribute(("data", call("yamlencode", [object(items)]))).build();
281 let text = rendered(b);
282 assert!(text.starts_with("x {\n data = yamlencode({\n key0 = \"a long enough value 0\"\n"), "{text}");
283 assert!(text.ends_with("\n })\n}\n"), "{text}");
284 }
285
286 #[test]
287 fn a_key_that_is_a_keyword_is_quoted() {
288 let b = Block::builder("x")
289 .add_attribute(("m", object([("for", number(1)), ("web", number(2))])))
290 .build();
291 assert_eq!(rendered(b), "x {\n m = { \"for\" = 1, web = 2 }\n}\n");
292 }
293
294 #[test]
295 fn a_long_list_inside_an_object_is_indented_one_level_deeper() {
296 let long: Vec<_> = (0..12).map(|i| string(format!("command number {i}"))).collect();
297 let b = Block::builder("x")
298 .add_attribute(("o", object([("k", number(1)), ("inline", list(long))])))
299 .build();
300 let text = rendered(b);
301 assert!(
302 text.starts_with("x {\n o = {\n k = 1\n inline = [\n \"command number 0\",\n"),
303 "{text}"
304 );
305 assert!(text.ends_with(" \"command number 11\"\n ]\n }\n}\n"), "{text}");
306 }
307
308 #[test]
309 fn top_level_blocks_are_one_blank_line_apart() {
310 let mut m = Module::new("");
311 m.block(Block::builder("a").build());
312 m.block(Block::builder("b").build());
313 m.fragment("\nlocals {}\n");
314 assert_eq!(m.render().unwrap(), "a {}\n\nb {}\n\nlocals {}\n");
315 }
316
317 #[test]
318 fn comments_and_blank_lines_between_blocks() {
319 let mut m = Module::new("# head\n");
320 m.commented("one\ntwo", Block::builder("a").build());
321 m.block(
322 Block::builder("b")
323 .add_attribute(("t", Template::text("x").with_expr(reference("var.y"))))
324 .build(),
325 );
326 assert_eq!(m.render().unwrap(), "# head\n\n# one\n# two\na {}\n\nb {\n t = \"x${var.y}\"\n}\n");
327 }
328
329 #[test]
330 fn blocks_are_added_in_order() {
331 let mut m = Module::new("");
332 m.blocks([Block::builder("a").build(), Block::builder("b").build()]);
333 assert_eq!(m.render().unwrap(), "a {}\n\nb {}\n");
334 }
335}