Repository: martinohmann/hcl-rs (rust)

Issue:
template expressions are double-escaped
Right now, template expressions are subject to double-escaping which produces invalid HCL when formatted.

The following tests are failing altough they should pass:

```rust
expect_format(
    Attribute::new("a", TemplateExpr::from("${\"b\"}")),
    "a = \"${\"b\"}\"\n",
);

expect_format(value!({ a = "${\"b\"}" }), "{\n  \"a\" = \"${\"b\"}\"\n}");
```

Pull request description:
fix(format): prevent double-escaping of template strings
Fixes #131

Patch:
diff --git a/src/format/impls.rs b/src/format/impls.rs
index c317e2b..82f174c 100644
--- a/src/format/impls.rs
+++ b/src/format/impls.rs
@@ -7,6 +7,7 @@ use crate::structure::{Attribute, Block, BlockLabel, Body, Structure};
 use crate::template::{
     Directive, Element, ForDirective, IfDirective, Interpolation, StripMode, Template,
 };
+use crate::util::is_templated;
 use crate::{Identifier, Number, Result, Value};
 use std::io;
 
@@ -519,7 +520,7 @@ impl Format for String {
     where
         W: io::Write,
     {
-        fmt.write_quoted_string(self)
+        fmt.write_quoted_string(self, !is_templated(self))
     }
 }
 
diff --git a/src/format/mod.rs b/src/format/mod.rs
index 6cdb73f..9de9088 100644
--- a/src/format/mod.rs
+++ b/src/format/mod.rs
@@ -315,10 +315,15 @@ where
         self.write_bytes(s.as_bytes())
     }
 
-    /// Writes a quoted string to the writer. The quoted string will be escaped.
-    fn write_quoted_string(&mut self, s: &str) -> Result<()> {
+    /// Writes a quoted string to the writer. The quoted string will be escaped if `escape` is
+    /// true.
+    fn write_quoted_string(&mut self, s: &str, escape: bool) -> Result<()> {
         self.write_bytes(b"\"")?;
-        self.write_escaped_string(s)?;
+        if escape {
+            self.write_escaped_string(s)?;
+        } else {
+            self.write_string_fragment(s)?;
+        }
         self.write_bytes(b"\"")
     }
 
diff --git a/src/parser/expr.rs b/src/parser/expr.rs
index af7dc1a..ed8c8ed 100644
--- a/src/parser/expr.rs
+++ b/src/parser/expr.rs
@@ -253,9 +253,9 @@ fn heredoc(pair: Pair<Rule>) -> Heredoc {
     template.push('\n');
 
     Heredoc {
-        strip,
         delimiter,
         template,
+        strip,
     }
 }
 
diff --git a/src/util.rs b/src/util.rs
index 4d61ea5..b99114c 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -126,3 +126,55 @@ pub fn try_unescape(s: &str) -> Cow<str> {
         Err(_) => Cow::Borrowed(s),
     }
 }
+
+/// Scan `s` for sequences that introduce a template interpolation or directive. Returns `true`
+/// once it found one of these start markers, `false` otherwise.
+///
+/// This function only looks for start markers and does not check if the template is actually
+/// valid.
+pub fn is_templated(s: &str) -> bool {
+    if s.len() < 3 {
+        return false;
+    }
+
+    let mut skip_next = false;
+
+    // Because calling `s.contains("${")` would also match escaped interpolations (`$${`) a
+    // window iterator is used here to detect and ignore these. The same applies to escaped
+    // directives.
+    for window in s.as_bytes().windows(3) {
+        if skip_next {
+            skip_next = false;
+            continue;
+        }
+
+        match window {
+            [b'$', b'$', b'{'] | [b'%', b'%', b'{'] => {
+                // The next window would incorrectly match the next arm, so it must be
+                // skipped.
+                skip_next = true;
+            }
+            [b'$' | b'%', b'{', _] => return true,
+            _ => {}
+        }
+    }
+
+    false
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_is_templated() {
+        assert!(is_templated("${a}"));
+        assert!(is_templated("${\"a\"}"));
+        assert!(is_templated("%{ if foo }foo%{ else }bar%{ endif }"));
+        assert!(is_templated("$${ introduces an ${\"interpolation\"}"));
+        assert!(!is_templated(
+            "escaped directive %%{ if foo }foo%%{ else }bar%%{ endif }"
+        ));
+        assert!(!is_templated("escaped interpolation $${a}"));
+    }
+}


Test patch:
diff --git a/src/format/tests.rs b/src/format/tests.rs
index 7e31ed8..caf1ed1 100644
--- a/src/format/tests.rs
+++ b/src/format/tests.rs
@@ -1,5 +1,7 @@
 use super::*;
-use crate::expr::{BinaryOp, BinaryOperator, Expression, FuncCall, Operation, Variable};
+use crate::expr::{
+    BinaryOp, BinaryOperator, Expression, FuncCall, Operation, TemplateExpr, Variable,
+};
 use crate::structure::Attribute;
 use crate::template::{ForDirective, IfDirective, StripMode, Template};
 use crate::Identifier;
@@ -119,3 +121,13 @@ Hello %{~ if item == "world" } World! %{~ else ~} ${item}.%{ endif ~}
 
     expect_format(template, expected);
 }
+
+#[test]
+fn issue_131() {
+    expect_format(
+        Attribute::new("a", TemplateExpr::from("${\"b\"}")),
+        "a = \"${\"b\"}\"\n",
+    );
+
+    expect_format(value!({ a = "${\"b\"}" }), "{\n  \"a\" = \"${\"b\"}\"\n}");
+}