use hcl::expr::{Expression, FuncCall, Object, ObjectKey};
use hcl::format::{Format, Formatter};
use hcl::{Block, BlockLabel, Body, Structure};
use super::escape::{quoted_inside, strings_escaped};
pub const WIDTH: usize = 100;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Module {
preamble: String,
sections: Vec<Section>,
}
#[derive(Debug, Clone, PartialEq)]
enum Section {
Block { comment: Option<String>, block: Block },
Fragment(String),
}
impl Module {
#[must_use]
pub fn new(preamble: impl Into<String>) -> Module {
Module {
preamble: preamble.into(),
sections: Vec::new(),
}
}
pub fn block(&mut self, block: Block) {
self.sections.push(Section::Block { comment: None, block });
}
pub fn commented(&mut self, comment: &str, block: Block) {
self.sections.push(Section::Block {
comment: Some(comment.to_owned()),
block,
});
}
pub fn blocks(&mut self, blocks: impl IntoIterator<Item = Block>) {
for b in blocks {
self.block(b);
}
}
pub fn fragment(&mut self, hcl: &str) {
self.sections.push(Section::Fragment(hcl.trim_matches('\n').to_owned()));
}
pub fn render(&self) -> Result<String, hcl::Error> {
let mut out = self.preamble.clone();
for s in &self.sections {
if !out.is_empty() {
out.push('\n');
}
match s {
Section::Block { comment, block: b } => {
for line in comment.iter().flat_map(|c| c.lines()) {
out.push_str(if line.is_empty() { "#" } else { "# " });
out.push_str(line);
out.push('\n');
}
block(&mut out, b, 0)?;
}
Section::Fragment(f) => {
out.push_str(f);
out.push('\n');
}
}
}
Ok(out)
}
}
fn pad(out: &mut String, indent: usize) {
for _ in 0..indent {
out.push_str(" ");
}
}
fn block(out: &mut String, b: &Block, indent: usize) -> Result<(), hcl::Error> {
pad(out, indent);
out.push_str(b.identifier());
for l in b.labels() {
out.push(' ');
match l {
BlockLabel::Identifier(i) => out.push_str(i.as_str()),
BlockLabel::String(s) => {
out.push('"');
out.push_str("ed_inside(s));
out.push('"');
}
}
}
if b.body().0.is_empty() {
out.push_str(" {}\n");
return Ok(());
}
out.push_str(" {\n");
body(out, b.body(), indent + 1)?;
pad(out, indent);
out.push_str("}\n");
Ok(())
}
fn body(out: &mut String, body: &Body, indent: usize) -> Result<(), hcl::Error> {
let mut run: Vec<(String, String)> = Vec::new();
for s in body {
match s {
Structure::Attribute(a) => {
let value = value(&strings_escaped(&a.expr), indent)?;
run.push((a.key.as_str().to_owned(), value));
}
Structure::Block(b) => {
entries(out, &std::mem::take(&mut run), indent);
block(out, b, indent)?;
}
}
}
entries(out, &run, indent);
Ok(())
}
fn entries(out: &mut String, entries: &[(String, String)], indent: usize) {
for run in entries.split_inclusive(|(_, v)| v.contains('\n')) {
let one_line = |v: &str| !v.contains('\n');
let width = run.iter().filter(|(_, v)| one_line(v)).map(|(k, _)| k.chars().count()).max().unwrap_or(0);
for (k, v) in run {
pad(out, indent);
out.push_str(k);
if one_line(v) {
out.push_str(&" ".repeat(width.saturating_sub(k.chars().count())));
}
out.push_str(" = ");
out.push_str(v);
out.push('\n');
}
}
}
fn value(e: &Expression, indent: usize) -> Result<String, hcl::Error> {
let flat = compact(e)?;
if flat.chars().count() <= WIDTH && !flat.contains('\n') {
return Ok(flat);
}
match e {
Expression::Array(items) => array(items, indent),
Expression::Object(o) => object(o, indent),
Expression::FuncCall(c) => func_call(c, indent),
_ => Ok(flat),
}
}
fn array(items: &[Expression], indent: usize) -> Result<String, hcl::Error> {
let lines = items
.iter()
.map(|item| Ok(format!("{}{}", " ".repeat(indent + 1), value(item, indent + 1)?)))
.collect::<Result<Vec<String>, hcl::Error>>()?;
Ok(format!("[\n{}\n{}]", lines.join(",\n"), " ".repeat(indent)))
}
fn object(o: &Object<ObjectKey, Expression>, indent: usize) -> Result<String, hcl::Error> {
let mut items = Vec::new();
for (k, v) in o {
let key = match k {
ObjectKey::Identifier(i) => i.as_str().to_owned(),
ObjectKey::Expression(e) => compact(e)?,
other => other.to_string(),
};
items.push((key, value(v, indent + 1)?));
}
let mut out = String::from("{\n");
entries(&mut out, &items, indent + 1);
pad(&mut out, indent);
out.push('}');
Ok(out)
}
fn func_call(c: &FuncCall, indent: usize) -> Result<String, hcl::Error> {
let mut out = format!("{}(", c.name);
let (last, first) = c.args.split_last().map_or((None, &[][..]), |(l, f)| (Some(l), f));
for a in first {
out.push_str(&compact(a)?);
out.push_str(", ");
}
if let Some(l) = last {
out.push_str(&value(l, indent)?);
}
out.push_str(if c.expand_final { "...)" } else { ")" });
Ok(out)
}
pub(super) fn compact(e: &Expression) -> Result<String, hcl::Error> {
let mut f = Formatter::builder().compact(true).build_vec();
e.format_string(&mut f)
}
#[cfg(test)]
mod tests {
use super::Module;
use crate::hcl_builder::{Block, Template, call, list, number, object, reference, string};
fn rendered(b: Block) -> String {
let mut m = Module::new("");
m.block(b);
m.render().unwrap()
}
#[test]
fn one_line_attributes_are_aligned_and_blocks_nest() {
let b = Block::builder("resource")
.add_label("aws_vpc")
.add_label("env")
.add_attribute(("cidr_block", string("10.0.0.0/24")))
.add_attribute(("tags", object([("Name", reference("local.name"))])))
.add_block(
Block::builder("lifecycle")
.add_attribute(("ignore_changes", list([reference("input")])))
.build(),
)
.add_block(Block::builder("serial_device").build())
.build();
assert_eq!(
rendered(b),
"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"
);
}
#[test]
fn long_lists_go_one_item_a_line_and_break_the_alignment() {
let long: Vec<_> = (0..12).map(|i| string(format!("command number {i}"))).collect();
let b = Block::builder("x")
.add_attribute(("a", number(1)))
.add_attribute(("inline", list(long)))
.add_attribute(("on_failure", reference("continue")))
.build();
let text = rendered(b);
assert!(text.starts_with("x {\n a = 1\n inline = [\n \"command number 0\",\n"), "{text}");
assert!(text.ends_with(" \"command number 11\"\n ]\n on_failure = continue\n}\n"), "{text}");
}
#[test]
fn a_call_hugs_its_long_last_argument() {
let items: Vec<_> = (0..8).map(|i| (format!("key{i}"), string(format!("a long enough value {i}")))).collect();
let b = Block::builder("x").add_attribute(("data", call("yamlencode", [object(items)]))).build();
let text = rendered(b);
assert!(text.starts_with("x {\n data = yamlencode({\n key0 = \"a long enough value 0\"\n"), "{text}");
assert!(text.ends_with("\n })\n}\n"), "{text}");
}
#[test]
fn a_key_that_is_a_keyword_is_quoted() {
let b = Block::builder("x")
.add_attribute(("m", object([("for", number(1)), ("web", number(2))])))
.build();
assert_eq!(rendered(b), "x {\n m = { \"for\" = 1, web = 2 }\n}\n");
}
#[test]
fn a_long_list_inside_an_object_is_indented_one_level_deeper() {
let long: Vec<_> = (0..12).map(|i| string(format!("command number {i}"))).collect();
let b = Block::builder("x")
.add_attribute(("o", object([("k", number(1)), ("inline", list(long))])))
.build();
let text = rendered(b);
assert!(
text.starts_with("x {\n o = {\n k = 1\n inline = [\n \"command number 0\",\n"),
"{text}"
);
assert!(text.ends_with(" \"command number 11\"\n ]\n }\n}\n"), "{text}");
}
#[test]
fn top_level_blocks_are_one_blank_line_apart() {
let mut m = Module::new("");
m.block(Block::builder("a").build());
m.block(Block::builder("b").build());
m.fragment("\nlocals {}\n");
assert_eq!(m.render().unwrap(), "a {}\n\nb {}\n\nlocals {}\n");
}
#[test]
fn comments_and_blank_lines_between_blocks() {
let mut m = Module::new("# head\n");
m.commented("one\ntwo", Block::builder("a").build());
m.block(
Block::builder("b")
.add_attribute(("t", Template::text("x").with_expr(reference("var.y"))))
.build(),
);
assert_eq!(m.render().unwrap(), "# head\n\n# one\n# two\na {}\n\nb {\n t = \"x${var.y}\"\n}\n");
}
#[test]
fn blocks_are_added_in_order() {
let mut m = Module::new("");
m.blocks([Block::builder("a").build(), Block::builder("b").build()]);
assert_eq!(m.render().unwrap(), "a {}\n\nb {}\n");
}
}