Repository: martinohmann/hcl-rs (rust)

Issue:
`$${` should be evaluated to `${`
`TemplateExpr::evaluate` right now will return literal `$${` for `$${` sequence in input.

Pull request:
fix(template): properly handle escaping of interpolation/directive markers
Fixes #242
Closes #243

This fixes the handling of `${` and `%{` markers in template literals.

When a HCL template literal contains escaped interpolation sequence or directive control flow start markers (`$${` and `%%{` respectively), they will be automatically unescaped while parsing now.

Additionally, formatting a `Element::Literal` containing `${` or `%{` as HCL will now correctly emit `$${` and `%%{`.

Patch:
diff --git a/crates/hcl-edit/src/encode/template.rs b/crates/hcl-edit/src/encode/template.rs
index a16da24..f8d3a7e 100644
--- a/crates/hcl-edit/src/encode/template.rs
+++ b/crates/hcl-edit/src/encode/template.rs
@@ -8,6 +8,7 @@ use crate::template::{
     Strip, Template,
 };
 use crate::util::indent_by;
+use hcl_primitives::template::escape_markers;
 use std::fmt::{self, Write};
 
 const INTERPOLATION_START: &str = "${";
@@ -68,10 +69,11 @@ impl Encode for Element {
     fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
         match self {
             Element::Literal(lit) => {
+                let escaped = escape_markers(lit);
                 if buf.escape() {
-                    encode_escaped(buf, lit)
+                    encode_escaped(buf, &escaped)
                 } else {
-                    buf.write_str(lit)
+                    buf.write_str(&escaped)
                 }
             }
             Element::Interpolation(interp) => interp.encode(buf),
diff --git a/crates/hcl-edit/src/parser/string.rs b/crates/hcl-edit/src/parser/string.rs
index b8c4a53..9fd049f 100644
--- a/crates/hcl-edit/src/parser/string.rs
+++ b/crates/hcl-edit/src/parser/string.rs
@@ -17,10 +17,11 @@ use winnow::{
 pub(super) fn string(input: Input) -> IResult<Input, String> {
     delimited(b'"', opt(build_string), b'"')
         .map(Option::unwrap_or_default)
+        .output_into()
         .parse_next(input)
 }
 
-pub(super) fn build_string(input: Input) -> IResult<Input, String> {
+pub(super) fn build_string(input: Input) -> IResult<Input, Cow<str>> {
     let (mut input, mut string) = match string_fragment(input) {
         Ok((input, fragment)) => match fragment {
             StringFragment::Literal(s) => (input, Cow::Borrowed(s)),
@@ -38,7 +39,7 @@ pub(super) fn build_string(input: Input) -> IResult<Input, String> {
                 };
                 input = rest;
             }
-            Err(_) => return Ok((input, string.into())),
+            Err(_) => return Ok((input, string)),
         }
     }
 }
diff --git a/crates/hcl-edit/src/parser/template.rs b/crates/hcl-edit/src/parser/template.rs
index 0a5eae1..26fe307 100644
--- a/crates/hcl-edit/src/parser/template.rs
+++ b/crates/hcl-edit/src/parser/template.rs
@@ -15,6 +15,8 @@ use crate::{
     },
     SetSpan, Span, Spanned,
 };
+use hcl_primitives::template::unescape_markers;
+use std::borrow::Cow;
 use winnow::{
     ascii::{line_ending, space0},
     combinator::{alt, delimited, opt, preceded, repeat, separated_pair, terminated},
@@ -79,12 +81,12 @@ pub(super) fn heredoc_template<'a>(
 
 fn elements<'a, P>(literal: P) -> impl Parser<Input<'a>, Vec<Element>, ParseError<Input<'a>>>
 where
-    P: Parser<Input<'a>, String, ParseError<Input<'a>>>,
+    P: Parser<Input<'a>, Cow<'a, str>, ParseError<Input<'a>>>,
 {
     repeat(
         0..,
         spanned(alt((
-            literal.map(|s| Element::Literal(Spanned::new(s))),
+            literal.map(|s| Element::Literal(Spanned::new(unescape_markers(&s).into()))),
             interpolation.map(Element::Interpolation),
             directive.map(Element::Directive),
         ))),
diff --git a/crates/hcl-rs/src/eval/mod.rs b/crates/hcl-rs/src/eval/mod.rs
index 44734ad..b5d3bba 100644
--- a/crates/hcl-rs/src/eval/mod.rs
+++ b/crates/hcl-rs/src/eval/mod.rs
@@ -67,6 +67,25 @@
 //! # }
 //! ```
 //!
+//! If you need to include the literal representation of variable reference, you can escape `${`
+//! with `$${`:
+//!
+//! ```
+//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! use hcl::eval::{Context, Evaluate};
+//! use hcl::Template;
+//! use std::str::FromStr;
+//!
+//! let template = Template::from_str("Value: ${value}, escaped: $${value}")?;
+//! let mut ctx = Context::new();
+//! ctx.declare_var("value", 1);
+//!
+//! let evaluated = "Value: 1, escaped: ${value}";
+//! assert_eq!(template.evaluate(&ctx)?, evaluated);
+//! #   Ok(())
+//! # }
+//! ```
+//!
 //! Here's another example which evaluates some attribute expressions using [`from_str`] as
 //! described in the [deserialization
 //! example][crate::eval#expression-evaluation-during-de-serialization] below:
diff --git a/crates/hcl-rs/src/format/impls.rs b/crates/hcl-rs/src/format/impls.rs
index af70474..55d2610 100644
--- a/crates/hcl-rs/src/format/impls.rs
+++ b/crates/hcl-rs/src/format/impls.rs
@@ -10,6 +10,7 @@ use crate::template::{
 use crate::util::is_templated;
 use crate::{Identifier, Number, Result, Value};
 use hcl_primitives::ident::is_ident;
+use hcl_primitives::template::escape_markers;
 use std::io;
 
 impl<T> private::Sealed for &T where T: Format {}
@@ -466,7 +467,10 @@ impl Format for Element {
         W: io::Write,
     {
         match self {
-            Element::Literal(lit) => fmt.write_string_fragment(lit),
+            Element::Literal(lit) => {
+                let escaped = escape_markers(lit);
+                fmt.write_string_fragment(&escaped)
+            }
             Element::Interpolation(interp) => interp.format(fmt),
             Element::Directive(dir) => dir.format(fmt),
         }
diff --git a/crates/hcl-rs/src/parser/template.rs b/crates/hcl-rs/src/parser/template.rs
index c9987e0..7e5b796 100644
--- a/crates/hcl-rs/src/parser/template.rs
+++ b/crates/hcl-rs/src/parser/template.rs
@@ -2,6 +2,7 @@ use super::*;
 use crate::template::{
     Directive, Element, ForDirective, IfDirective, Interpolation, Strip, Template,
 };
+use hcl_primitives::template::unescape_markers;
 
 pub fn template(pair: Pair<Rule>) -> Result<Template> {
     pair.into_inner().map(element).collect()
@@ -9,7 +10,7 @@ pub fn template(pair: Pair<Rule>) -> Result<Template> {
 
 fn element(pair: Pair<Rule>) -> Result<Element> {
     match pair.as_rule() {
-        Rule::TemplateLiteral => Ok(Element::Literal(string(pair))),
+        Rule::TemplateLiteral => Ok(Element::Literal(unescape_markers(pair.as_str()).into())),
         Rule::TemplateInterpolation => interpolation(pair).map(Element::Interpolation),
         Rule::TemplateDirective => directive(inner(pair)).map(Element::Directive),
         rule => unexpected_rule(rule),


Test patch:
diff --git a/crates/hcl-edit/src/parser/tests.rs b/crates/hcl-edit/src/parser/tests.rs
index 502c6c4..7fd5f8d 100644
--- a/crates/hcl-edit/src/parser/tests.rs
+++ b/crates/hcl-edit/src/parser/tests.rs
@@ -2,7 +2,11 @@ use super::expr::expr;
 use super::parse_complete;
 use super::structure::body;
 use super::template::template;
-use crate::{expr::Expression, Formatted, Number};
+use crate::{
+    expr::Expression,
+    template::{Element, Interpolation, StringTemplate},
+    Formatted, Ident, Number,
+};
 use indoc::indoc;
 use pretty_assertions::assert_eq;
 
@@ -21,6 +25,17 @@ fn number_expr() {
     assert_eq!(parsed, expected);
 }
 
+#[test]
+fn escaped_string_template() {
+    let parsed = parse_complete(r#""$${escaped} ${unescaped}""#, expr).unwrap();
+    let template = StringTemplate::from_iter([
+        Element::Literal(String::from("${escaped} ").into()),
+        Element::Interpolation(Interpolation::new(Ident::new("unescaped"))),
+    ]);
+    let expected = Expression::Template(template);
+    assert_eq!(parsed, expected);
+}
+
 #[test]
 fn roundtrip_expr() {
     let inputs = [
@@ -120,6 +135,7 @@ fn roundtrip_template() {
             - ${item}
             %{ endfor ~}
         "#},
+        "literal $${escaped} ${value}",
     ];
 
     for input in inputs {
diff --git a/crates/hcl-rs/tests/regressions.rs b/crates/hcl-rs/tests/regressions.rs
index 5d8e459..59ae134 100644
--- a/crates/hcl-rs/tests/regressions.rs
+++ b/crates/hcl-rs/tests/regressions.rs
@@ -1,7 +1,8 @@
 mod common;
 
 use common::{assert_deserialize, assert_format};
-use hcl::{expr::*, Identifier};
+use hcl::eval::{Context, Evaluate};
+use hcl::{expr::*, Body, Identifier, Value};
 use indoc::indoc;
 use serde::Deserialize;
 use std::collections::HashMap;
@@ -201,3 +202,26 @@ fn issue_131() {
         .trim_end(),
     );
 }
+
+// https://github.com/martinohmann/hcl-rs/issues/242
+#[test]
+fn issue_242() {
+    let body: Body = hcl::from_str(indoc! {r#"
+      b = <<-EOF
+        make TARGET=$${GIT_BRANCH}
+      EOF
+    "#
+    })
+    .unwrap();
+
+    let context = Context::new();
+    let value = body
+        .into_attributes()
+        .find(|attr| attr.key == Identifier::from("b"))
+        .expect("key not found")
+        .expr
+        .evaluate(&context)
+        .unwrap();
+
+    assert_eq!(value, Value::from("make TARGET=${GIT_BRANCH}\n"));
+}