Skip to main content

isoloom_render/hcl_builder/
write.rs

1//! Writing a module: `hcl-rs` structures in the layout `terraform fmt` leaves unchanged.
2//!
3//! `terraform fmt` indents by bracket depth and aligns the `=` of consecutive attributes whose
4//! value fits on their line (a value that opens a bracket it doesn't close starts no alignment
5//! run and ends the current one). Leaf expressions are written by `hcl-rs` in its compact form;
6//! lists, objects and calls are laid out here.
7
8use 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
14/// A list, an object or a call longer than this, on one line, is written across lines.
15pub const WIDTH: usize = 100;
16
17/// A Terraform file: its preamble (comment lines), then top-level blocks, each with the comment
18/// above it.
19#[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    /// A module starting with `preamble`, written as it is (comment lines, each ending with a
33    /// newline).
34    #[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    /// Adds a top-level block.
43    pub fn block(&mut self, block: Block) {
44        self.sections.push(Section::Block { comment: None, block });
45    }
46
47    /// Adds a top-level block with a comment above it (each line of `comment` becomes a `#` line).
48    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    /// Adds blocks.
56    pub fn blocks(&mut self, blocks: impl IntoIterator<Item = Block>) {
57        for b in blocks {
58            self.block(b);
59        }
60    }
61
62    /// Adds a fixed fragment of HCL, written as it is: for the static parts shared with modules
63    /// not built here yet (the `.isoloomignore` lists).
64    pub fn fragment(&mut self, hcl: &str) {
65        self.sections.push(Section::Fragment(hcl.trim_matches('\n').to_owned()));
66    }
67
68    /// The module as text.
69    ///
70    /// # Errors
71    ///
72    /// What `hcl-rs` reports when it can't write an expression (its formatting is documented as
73    /// infallible: the error is kept typed rather than assumed away).
74    pub fn render(&self) -> Result<String, hcl::Error> {
75        let mut out = self.preamble.clone();
76        for s in &self.sections {
77            // A blank line after the preamble and between sections (each ends with a newline).
78            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
100/// Two spaces per level.
101fn 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(&quoted_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
150/// `key = value` lines, the `=` of each run of one-line values aligned: a value written across
151/// lines ends its run (and takes no part in its alignment).
152fn 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
169/// An expression as written at nesting `indent` (the level of the line it starts on).
170fn 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    // An empty list, object or call is short: only a long one is laid out across lines.
176    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
184/// One item a line, a comma after each but the last.
185fn 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
193/// One item a line, aligned as a block's attributes are.
194fn 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            // Non-exhaustive upstream: any other key as `hcl-rs` writes it.
201            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
212/// The arguments on the call's line, the last one laid out from there (`f(a, [` ... `])`).
213fn 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
227/// `hcl-rs`'s one-line form of an expression.
228pub(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}