Repository: martinohmann/hcl-rs
Pull request #126 (2022-11-08 18:38:31)

fix!(eval): correctly handle interpolation unwrapping
BREAKING CHANGE: the `Evaluate` implementation of `TemplateExpr` returns a `Value` instead of a `String` now to support interpolation unwrapping.

This implements template interpolation unwrapping as described in https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#template-interpolation-unwrapping

It also fixes a bug in the object key evaluation logic. See the comment on `evaluate_object_key` (renamed from `evaluate_string`).


Patch:
diff --git a/README.md b/README.md
index 0359150..7721735 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,7 @@ details and examples for expression and template evaluation, but here's a very
 short example:
 
 ```rust
+use hcl::Value;
 use hcl::eval::{Context, Evaluate};
 use hcl::expr::TemplateExpr;
 
@@ -160,7 +161,7 @@ let expr = TemplateExpr::from("Hello ${name}!");
 let mut ctx = Context::new();
 ctx.declare_var("name", "World");
 
-assert_eq!(expr.evaluate(&ctx).unwrap(), "Hello World!");
+assert_eq!(expr.evaluate(&ctx).unwrap(), Value::from("Hello World!"));
 ```
 
 ## Macros
diff --git a/src/eval/expr.rs b/src/eval/expr.rs
index 6e582b3..59ec2b0 100644
--- a/src/eval/expr.rs
+++ b/src/eval/expr.rs
@@ -8,10 +8,15 @@ pub(super) fn evaluate_bool(expr: &Expression, ctx: &Context) -> EvalResult<bool
     }
 }
 
-pub(super) fn evaluate_string(expr: &Expression, ctx: &Context) -> EvalResult<String> {
+// It's not formally defined, but the go HCL implementation allows object key expressions to
+// evaluate to either a string, boolean value or number and will then convert all of these to
+// string. Any other value shall produce an error.
+pub(super) fn evaluate_object_key(expr: &Expression, ctx: &Context) -> EvalResult<String> {
     match expr.evaluate(ctx)? {
         Value::String(value) => Ok(value),
-        other => Err(ctx.error(Error::unexpected(other, "a string"))),
+        Value::Bool(value) => Ok(value.to_string()),
+        Value::Number(value) => Ok(value.to_string()),
+        other => Err(ctx.error(Error::unexpected(other, "a string, boolean or number"))),
     }
 }
 
diff --git a/src/eval/impls.rs b/src/eval/impls.rs
index ceb02a2..1808083 100644
--- a/src/eval/impls.rs
+++ b/src/eval/impls.rs
@@ -64,7 +64,7 @@ impl Evaluate for Expression {
         match self {
             Expression::Array(array) => array.evaluate(ctx).map(Value::Array),
             Expression::Object(object) => object.evaluate(ctx).map(Value::Object),
-            Expression::TemplateExpr(expr) => expr.evaluate(ctx).map(Value::String),
+            Expression::TemplateExpr(expr) => expr.evaluate(ctx),
             Expression::Variable(ident) => ctx.lookup_var(ident).cloned(),
             Expression::Traversal(traversal) => traversal.evaluate(ctx),
             Expression::FuncCall(func_call) => func_call.evaluate(ctx),
@@ -120,7 +120,7 @@ impl Evaluate for ObjectKey {
 
     fn evaluate(&self, ctx: &Context) -> EvalResult<Self::Output> {
         match self {
-            ObjectKey::Expression(expr) => expr::evaluate_string(expr, ctx),
+            ObjectKey::Expression(expr) => expr::evaluate_object_key(expr, ctx),
             ident => Ok(ident.to_string()),
         }
     }
@@ -129,11 +129,23 @@ impl Evaluate for ObjectKey {
 impl private::Sealed for TemplateExpr {}
 
 impl Evaluate for TemplateExpr {
-    type Output = String;
+    type Output = Value;
 
     fn evaluate(&self, ctx: &Context) -> EvalResult<Self::Output> {
         let template = Template::from_expr(self)?;
-        template.evaluate(ctx)
+        let elements = template.elements();
+
+        // If the template consists only of a single interpolation, with no surrounding literals,
+        // directives or other interpolations, perform interpolation unwrapping as described in the
+        // spec:
+        //
+        // https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#template-interpolation-unwrapping
+        match elements.get(0) {
+            Some(Element::Interpolation(interp)) if elements.len() == 1 => {
+                interp.expr.evaluate(ctx)
+            }
+            _ => template.evaluate(ctx).map(Value::String),
+        }
     }
 }
 
@@ -280,7 +292,7 @@ impl Evaluate for ForExpr {
 
                 for ctx in collection.into_iter() {
                     let ctx = &ctx?;
-                    let key = expr::evaluate_string(key_expr, ctx)?;
+                    let key = expr::evaluate_object_key(key_expr, ctx)?;
                     let value = self.value_expr.evaluate(ctx)?;
 
                     if self.grouping {
diff --git a/src/eval/mod.rs b/src/eval/mod.rs
index 983d13e..d65cbda 100644
--- a/src/eval/mod.rs
+++ b/src/eval/mod.rs
@@ -20,6 +20,7 @@
 //!
 //! ```
 //! # fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! use hcl::Value;
 //! use hcl::eval::{Context, Evaluate};
 //! use hcl::expr::TemplateExpr;
 //!
@@ -28,7 +29,7 @@
 //! let mut ctx = Context::new();
 //! ctx.declare_var("name", "World");
 //!
-//! assert_eq!(expr.evaluate(&ctx)?, "Hello World!");
+//! assert_eq!(expr.evaluate(&ctx)?, Value::from("Hello World!"));
 //! #   Ok(())
 //! # }
 //! ```
@@ -137,7 +138,7 @@
 //! ctx.declare_func("uppercase", uppercase_func);
 //!
 //! // Evaluate the expression.
-//! assert_eq!(expr.evaluate(&ctx)?, "Hello WORLD!");
+//! assert_eq!(expr.evaluate(&ctx)?, Value::from("Hello WORLD!"));
 //! #   Ok(())
 //! # }
 //! ```


Test patch:
diff --git a/src/eval/tests.rs b/src/eval/tests.rs
index 7e80e4c..d208276 100644
--- a/src/eval/tests.rs
+++ b/src/eval/tests.rs
@@ -1,5 +1,6 @@
 use super::*;
 use crate::expr::Variable;
+use crate::Number;
 use std::fmt;
 use std::str::FromStr;
 
@@ -157,6 +158,16 @@ fn eval_for_expr() {
         .with_grouping(true),
         Value::from_iter([("foo", vec![1, 2, 3])]),
     );
+
+    eval_error(
+        ForExpr::new(
+            Identifier::unchecked("v"),
+            Expression::from_iter(["a"]),
+            Expression::Bool(true),
+        )
+        .with_key_expr(Expression::Null),
+        ErrorKind::Unexpected(Value::Null, "a string, boolean or number"),
+    );
 }
 
 #[test]
@@ -392,6 +403,46 @@ fn expr_error_context() {
     )
 }
 
+#[test]
+fn interpolation_unwrapping() {
+    // unwrapping
+    eval_to(TemplateExpr::from("${null}"), Value::Null);
+    eval_to(TemplateExpr::from("${\"foo\"}"), Value::from("foo"));
+    eval_to(TemplateExpr::from("${true}"), Value::Bool(true));
+    eval_to(TemplateExpr::from("${\"${true}\"}"), Value::Bool(true));
+    eval_to(
+        TemplateExpr::from("${42}"),
+        Value::Number(Number::from(42u64)),
+    );
+    eval_to(
+        TemplateExpr::from("${1.5}"),
+        Value::Number(Number::from_f64(1.5).unwrap()),
+    );
+    eval_to(
+        TemplateExpr::from("${[1, 2, 3]}"),
+        Value::from_iter([1, 2, 3]),
+    );
+    eval_to(
+        TemplateExpr::from("${{ a = 1, b = 2 }}"),
+        Value::from_iter([("a", 1), ("b", 2)]),
+    );
+
+    let mut ctx = Context::new();
+    ctx.declare_var("var", true);
+    eval_to_ctx(&ctx, TemplateExpr::from("${\"${var}\"}"), Value::Bool(true));
+
+    // no unwrapping
+    eval_to(
+        TemplateExpr::from("hello ${true}"),
+        Value::from("hello true"),
+    );
+    eval_to(TemplateExpr::from("${\"\"}${true}"), Value::from("true"));
+    eval_to(
+        TemplateExpr::from("%{ for v in [true] }${v}%{ endfor }"),
+        Value::from("true"),
+    );
+}
+
 #[test]
 fn param_type() {
     let string = Value::from("a string");