Repository: martinohmann/hcl-rs (rust)

Issue:
hcl-edit: hardcoded newline for Body
`hcl-edit` roundtrip on `Body`:

- "`foo = \"bar\"`" => "`foo = \"bar\"\n`" (0 != 1 newline)
- "`foo = \"bar\"\n`" => "`foo = \"bar\"\n`" (1 == 1 newline)
- "`foo = \"bar\"\n\n`" => "`foo = \"bar\"\n\n`" (2 == 2 newlines)

For the first case I would expect there to be no newline in the result.

There seems to be a static newline here
https://github.com/martinohmann/hcl-rs/blob/b92d3d49920683c83bf70008c8c6f832c0cf5481/crates/hcl-edit/src/encode/structure.rs#L12 and something is eating up a newline in the parser

Pull request description:
fix(structure): handle absence of trailing newline in body
Fixes #270
Closes #271 

Patch:
diff --git a/crates/hcl-edit/src/encode/structure.rs b/crates/hcl-edit/src/encode/structure.rs
index 9c752f1..0f2ccbd 100644
--- a/crates/hcl-edit/src/encode/structure.rs
+++ b/crates/hcl-edit/src/encode/structure.rs
@@ -7,9 +7,12 @@ use std::fmt::{self, Write};
 
 impl Encode for Body {
     fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
-        for structure in self.iter() {
+        for (index, structure) in self.iter().enumerate() {
             structure.encode_decorated(buf, NO_DECOR)?;
-            buf.write_char('\n')?;
+
+            if index < self.len() - 1 || !self.prefer_omit_trailing_newline() {
+                buf.write_char('\n')?;
+            }
         }
 
         Ok(())
@@ -52,7 +55,11 @@ impl Encode for Block {
                 }
             } else {
                 buf.write_char('\n')?;
-                body.encode(buf)?;
+
+                for structure in body {
+                    structure.encode_decorated(buf, NO_DECOR)?;
+                    buf.write_char('\n')?;
+                }
             }
 
             Ok(())
diff --git a/crates/hcl-edit/src/parser/state.rs b/crates/hcl-edit/src/parser/state.rs
index e8de7bf..7742d88 100644
--- a/crates/hcl-edit/src/parser/state.rs
+++ b/crates/hcl-edit/src/parser/state.rs
@@ -13,6 +13,7 @@ pub(super) struct BodyParseState<'a> {
     current: Option<Structure>,
     structures: Vec<Structure>,
     ws: Option<Range<usize>>,
+    eof: bool,
 }
 
 impl<'a> BodyParseState<'a> {
@@ -48,8 +49,15 @@ impl<'a> BodyParseState<'a> {
         self.structures.push(current);
     }
 
+    pub(super) fn on_eof(&mut self) {
+        self.on_line_ending();
+        self.eof = true;
+    }
+
     pub(super) fn into_body(self) -> Body {
-        Body::from_vec_unchecked(self.structures)
+        let mut body = Body::from_vec_unchecked(self.structures);
+        body.set_prefer_omit_trailing_newline(self.eof);
+        body
     }
 }
 
diff --git a/crates/hcl-edit/src/parser/structure.rs b/crates/hcl-edit/src/parser/structure.rs
index f38c2bb..ab3fbb7 100644
--- a/crates/hcl-edit/src/parser/structure.rs
+++ b/crates/hcl-edit/src/parser/structure.rs
@@ -32,11 +32,14 @@ pub(super) fn body(input: &mut Input) -> PResult<Body> {
                         .span()
                         .map(|span| state.borrow_mut().on_ws(span)),
                 ),
-                cut_err(alt((line_ending, eof)).map(|_| state.borrow_mut().on_line_ending()))
-                    .context(StrContext::Expected(StrContextValue::Description(
-                        "newline",
-                    )))
-                    .context(StrContext::Expected(StrContextValue::Description("eof"))),
+                cut_err(alt((
+                    line_ending.map(|_| state.borrow_mut().on_line_ending()),
+                    eof.map(|_| state.borrow_mut().on_eof()),
+                )))
+                .context(StrContext::Expected(StrContextValue::Description(
+                    "newline",
+                )))
+                .context(StrContext::Expected(StrContextValue::Description("eof"))),
             ),
         ))
         .span(),
diff --git a/crates/hcl-edit/src/structure/body.rs b/crates/hcl-edit/src/structure/body.rs
index 806155e..087e076 100644
--- a/crates/hcl-edit/src/structure/body.rs
+++ b/crates/hcl-edit/src/structure/body.rs
@@ -86,6 +86,7 @@ pub type BlocksMut<'a> = Box<dyn Iterator<Item = &'a mut Block> + 'a>;
 pub struct Body {
     structures: Vec<Structure>,
     prefer_oneline: bool,
+    prefer_omit_trailing_newline: bool,
     decor: Decor,
     span: Option<Range<usize>>,
 }
@@ -644,6 +645,27 @@ impl Body {
         self.prefer_oneline
     }
 
+    /// Configures whether the trailing newline after the last structure in the body should be
+    /// omitted.
+    ///
+    /// This is only a hint which will be applied if this is the top-level `Body` of a HCL
+    /// document and is ignored if the `Body` is part of a [`Block`].
+    ///
+    /// The default is to always emit a trailing newline after the last body structure.
+    #[inline]
+    pub fn set_prefer_omit_trailing_newline(&mut self, yes: bool) {
+        self.prefer_omit_trailing_newline = yes;
+    }
+
+    /// Returns `true` if the trailing newline after the last structure in the body should be
+    /// omitted.
+    ///
+    /// See the documentation of [`Body::set_prefer_omit_trailing_newline`] for more.
+    #[inline]
+    pub fn prefer_omit_trailing_newline(&self) -> bool {
+        self.prefer_omit_trailing_newline
+    }
+
     /// Returns `true` if the body only consist of a single `Attribute`.
     #[inline]
     pub(crate) fn has_single_attribute(&self) -> bool {


Test patch:
diff --git a/crates/hcl-edit/src/parser/tests.rs b/crates/hcl-edit/src/parser/tests.rs
index 2b2d139..42c466d 100644
--- a/crates/hcl-edit/src/parser/tests.rs
+++ b/crates/hcl-edit/src/parser/tests.rs
@@ -85,6 +85,7 @@ fn roundtrip_body() {
         "#},
         "block { attr = 1 }\n",
         "foo = \"bar\"\nbar = 2\n",
+        "foo = \"bar\"\nbar = 3",
         indoc! {r#"
             indented_heredoc = <<-EOT
                 ${foo}
diff --git a/crates/hcl-edit/tests/regressions.rs b/crates/hcl-edit/tests/regressions.rs
index 8c397f3..5d26ce6 100644
--- a/crates/hcl-edit/tests/regressions.rs
+++ b/crates/hcl-edit/tests/regressions.rs
@@ -1,4 +1,5 @@
 use hcl_edit::expr::Expression;
+use hcl_edit::structure::{Attribute, Block, Body};
 use hcl_edit::template::{Element, Interpolation, Template};
 use hcl_edit::Ident;
 use pretty_assertions::assert_eq;
@@ -28,3 +29,31 @@ fn issue_256() {
 
     assert_eq!(parsed, expected);
 }
+
+// https://github.com/martinohmann/hcl-rs/issues/270
+#[test]
+fn issue_270() {
+    let no_trailing_newline = String::from("block {\nfoo = \"bar\"\n}\nbar = \"baz\"");
+    let trailing_newline = format!("{no_trailing_newline}\n");
+
+    // Parsed
+    let parsed: Body = no_trailing_newline.parse().unwrap();
+    assert_eq!(parsed.to_string(), no_trailing_newline);
+
+    let parsed: Body = trailing_newline.parse().unwrap();
+    assert_eq!(parsed.to_string(), trailing_newline);
+
+    // Manually constructed
+    let mut body = Body::builder()
+        .block(
+            Block::builder(Ident::new("block"))
+                .attribute(Attribute::new(Ident::new("foo"), "bar"))
+                .build(),
+        )
+        .attribute(Attribute::new(Ident::new("bar"), "baz"))
+        .build();
+    assert_eq!(body.to_string(), trailing_newline);
+
+    body.set_prefer_omit_trailing_newline(true);
+    assert_eq!(body.to_string(), no_trailing_newline);
+}