Repository: martinohmann/hcl-rs
Pull request #249 (2023-06-16 18:23:47)

fix(string): properly handle escaping of interpolation/directive marker
Closes https://github.com/martinohmann/hcl-rs/issues/248

This is the same fix as made in https://github.com/martinohmann/hcl-rs/pull/247 for template literals, just for
strings.


Patch:
diff --git a/crates/hcl-edit/src/encode/mod.rs b/crates/hcl-edit/src/encode/mod.rs
index 92a378e..b8391c3 100644
--- a/crates/hcl-edit/src/encode/mod.rs
+++ b/crates/hcl-edit/src/encode/mod.rs
@@ -3,6 +3,7 @@ mod structure;
 mod template;
 
 use crate::{Decorate, Decorated, Formatted, Ident, Number};
+use hcl_primitives::template::escape_markers;
 use std::fmt::{self, Write};
 
 pub(crate) const NO_DECOR: (&str, &str) = ("", "");
@@ -122,7 +123,8 @@ where
 
 fn encode_quoted_string(buf: &mut dyn fmt::Write, value: &str) -> fmt::Result {
     buf.write_char('"')?;
-    encode_escaped(buf, value)?;
+    let value = escape_markers(value);
+    encode_escaped(buf, &value)?;
     buf.write_char('"')
 }
 
diff --git a/crates/hcl-edit/src/parser/string.rs b/crates/hcl-edit/src/parser/string.rs
index 9fd049f..6d1c821 100644
--- a/crates/hcl-edit/src/parser/string.rs
+++ b/crates/hcl-edit/src/parser/string.rs
@@ -5,6 +5,7 @@ use super::{
     IResult, Input,
 };
 use crate::{Decorated, Ident, RawString};
+use hcl_primitives::template::unescape_markers;
 use std::borrow::Cow;
 use winnow::{
     combinator::{alt, cut_err, delimited, fail, not, opt, preceded, repeat, success},
@@ -17,7 +18,7 @@ use winnow::{
 pub(super) fn string(input: Input) -> IResult<Input, String> {
     delimited(b'"', opt(build_string), b'"')
         .map(Option::unwrap_or_default)
-        .output_into()
+        .map(|s| unescape_markers(&s).into())
         .parse_next(input)
 }
 
diff --git a/crates/hcl-rs/src/format/impls.rs b/crates/hcl-rs/src/format/impls.rs
index 55d2610..9421b2d 100644
--- a/crates/hcl-rs/src/format/impls.rs
+++ b/crates/hcl-rs/src/format/impls.rs
@@ -148,7 +148,13 @@ impl Format for Value {
             Value::Null => Ok(fmt.write_null()?),
             Value::Bool(b) => Ok(fmt.write_bool(*b)?),
             Value::Number(num) => num.format(fmt),
-            Value::String(string) => string.format(fmt),
+            Value::String(string) => {
+                if is_templated(string) {
+                    fmt.write_quoted_string(string)
+                } else {
+                    fmt.write_quoted_string_escaped(string)
+                }
+            }
             Value::Array(array) => format_array(fmt, array.iter()),
             Value::Object(object) => format_object(fmt, object.iter().map(|(k, v)| (StrKey(k), v))),
         }
@@ -193,7 +199,7 @@ impl<'a> Format for StrKey<'a> {
         if fmt.config.prefer_ident_keys && is_ident(self.0) {
             fmt.write_string_fragment(self.0)
         } else {
-            fmt.write_quoted_string(self.0, !is_templated(self.0))
+            fmt.write_quoted_string_escaped(self.0)
         }
     }
 }
@@ -217,7 +223,7 @@ impl Format for TemplateExpr {
         W: io::Write,
     {
         match self {
-            TemplateExpr::QuotedString(string) => string.format(fmt),
+            TemplateExpr::QuotedString(string) => fmt.write_quoted_string(string),
             TemplateExpr::Heredoc(heredoc) => heredoc.format(fmt),
         }
     }
@@ -556,7 +562,7 @@ impl Format for String {
     where
         W: io::Write,
     {
-        fmt.write_quoted_string(self, !is_templated(self))
+        fmt.write_quoted_string_escaped(self)
     }
 }
 
diff --git a/crates/hcl-rs/src/format/mod.rs b/crates/hcl-rs/src/format/mod.rs
index ef80bef..35c43da 100644
--- a/crates/hcl-rs/src/format/mod.rs
+++ b/crates/hcl-rs/src/format/mod.rs
@@ -39,6 +39,7 @@ mod impls;
 
 use self::escape::{CharEscape, ESCAPE};
 use crate::Result;
+use hcl_primitives::template::escape_markers;
 use std::io;
 
 mod private {
@@ -425,15 +426,17 @@ where
         self.write_bytes(s.as_bytes())
     }
 
-    /// 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<()> {
+    /// Writes a quoted string to the writer.
+    fn write_quoted_string(&mut self, s: &str) -> Result<()> {
         self.write_bytes(b"\"")?;
-        if escape {
-            self.write_escaped_string(s)?;
-        } else {
-            self.write_string_fragment(s)?;
-        }
+        self.write_string_fragment(s)?;
+        self.write_bytes(b"\"")
+    }
+
+    /// Writes a quoted string to the writer after escaping it.
+    fn write_quoted_string_escaped(&mut self, s: &str) -> Result<()> {
+        self.write_bytes(b"\"")?;
+        self.write_escaped_string(s)?;
         self.write_bytes(b"\"")
     }
 
@@ -445,6 +448,7 @@ where
     /// Writes a string to the writer and escapes control characters and quotes that might be
     /// contained in it.
     fn write_escaped_string(&mut self, value: &str) -> Result<()> {
+        let value = escape_markers(value);
         let bytes = value.as_bytes();
 
         let mut start = 0;
diff --git a/crates/hcl-rs/src/parser/mod.rs b/crates/hcl-rs/src/parser/mod.rs
index cf12f13..e95bde1 100644
--- a/crates/hcl-rs/src/parser/mod.rs
+++ b/crates/hcl-rs/src/parser/mod.rs
@@ -9,6 +9,7 @@ use crate::{
     expr::Expression, structure::Body, template::Template, util::unescape, Identifier, Number,
     Result,
 };
+use hcl_primitives::template::unescape_markers;
 use pest::{
     iterators::{Pair, Pairs},
     Parser as _,
@@ -76,7 +77,7 @@ fn string(pair: Pair<Rule>) -> String {
 }
 
 fn unescape_string(pair: Pair<Rule>) -> Result<String> {
-    unescape(pair.as_str()).map(|c| c.to_string())
+    unescape(pair.as_str()).map(|c| unescape_markers(&c).to_string())
 }
 
 fn ident(pair: Pair<Rule>) -> Identifier {


Test patch:
diff --git a/crates/hcl-edit/tests/regressions.rs b/crates/hcl-edit/tests/regressions.rs
new file mode 100644
index 0000000..c02bdd5
--- /dev/null
+++ b/crates/hcl-edit/tests/regressions.rs
@@ -0,0 +1,13 @@
+use hcl_edit::expr::Expression;
+
+// https://github.com/martinohmann/hcl-rs/issues/248
+#[test]
+fn issue_248() {
+    let expr = Expression::from("${foo}");
+
+    let encoded = expr.to_string();
+    assert_eq!(encoded, "\"$${foo}\"");
+
+    let parsed: Expression = encoded.parse().unwrap();
+    assert_eq!(parsed, expr);
+}
diff --git a/crates/hcl-rs/tests/regressions.rs b/crates/hcl-rs/tests/regressions.rs
index 59ae134..1e15fd0 100644
--- a/crates/hcl-rs/tests/regressions.rs
+++ b/crates/hcl-rs/tests/regressions.rs
@@ -2,7 +2,7 @@ mod common;
 
 use common::{assert_deserialize, assert_format};
 use hcl::eval::{Context, Evaluate};
-use hcl::{expr::*, Body, Identifier, Value};
+use hcl::{expr::*, Attribute, Body, Identifier, Value};
 use indoc::indoc;
 use serde::Deserialize;
 use std::collections::HashMap;
@@ -225,3 +225,17 @@ fn issue_242() {
 
     assert_eq!(value, Value::from("make TARGET=${GIT_BRANCH}\n"));
 }
+
+// https://github.com/martinohmann/hcl-rs/issues/248
+#[test]
+fn issue_248() {
+    let body = Body::builder()
+        .add_attribute(Attribute::new("attr", "${foo}"))
+        .build();
+
+    let formatted = hcl::format::to_string(&body).unwrap();
+    assert_eq!(formatted, "attr = \"$${foo}\"\n");
+
+    let parsed = hcl::parse(&formatted).unwrap();
+    assert_eq!(parsed, body);
+}