Repository: martinohmann/hcl-rs
Pull request #182 (2023-03-24 21:35:04)

feat: add `hcl-edit` crate
This new crate contains a parser and encoder which preserve whitespace and comments. Additionally, values emitted by the parser have span information attached to them.

The new parser is more than 100% faster than the old `pest` parser in `hcl-rs` and will eventually replace it.

This initial release is functional, but still has a lot of rough edges, misses documentation and could use better test coverage. Some planned features like better control over the encoded output or interop with `hcl-rs` are not part of this yet.

Updates #112 and #158.


Patch:
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 5912cb0..2f964a4 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,5 +1,6 @@
 {
   "crates/benchmarks": "0.0.3",
+  "crates/hcl-edit": "0.1.0",
   "crates/hcl-primitives": "0.0.2",
   "crates/hcl-rs": "0.14.0",
   "crates/specsuite": "0.0.3"
diff --git a/Cargo.toml b/Cargo.toml
index 7d21ec7..e2a1fa6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
     "crates/benchmarks",
+    "crates/hcl-edit",
     "crates/hcl-primitives",
     "crates/hcl-rs",
     "crates/specsuite",
diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml
index 9520b98..7f3f35a 100644
--- a/crates/benchmarks/Cargo.toml
+++ b/crates/benchmarks/Cargo.toml
@@ -17,5 +17,6 @@ harness = false
 
 [dev-dependencies]
 criterion = "0.4"
+hcl-edit = { path = "../hcl-edit" }
 hcl-rs = { path = "../hcl-rs" }
 testdata = { path = "../testdata" }
diff --git a/crates/benchmarks/benches/parse.rs b/crates/benchmarks/benches/parse.rs
index a635f31..69396bc 100644
--- a/crates/benchmarks/benches/parse.rs
+++ b/crates/benchmarks/benches/parse.rs
@@ -12,10 +12,15 @@ fn parse(c: &mut Criterion) {
 
         group.throughput(Throughput::Bytes(len as u64));
 
-        group.bench_function(BenchmarkId::new("simple", test.name()), |b| {
+        group.bench_function(BenchmarkId::new("hcl-rs", test.name()), |b| {
             hcl::parse(&test.input).unwrap();
             b.iter(|| black_box(hcl::parse(&test.input).unwrap()))
         });
+
+        group.bench_function(BenchmarkId::new("hcl-edit", test.name()), |b| {
+            hcl_edit::parser::parse_body(&test.input).unwrap();
+            b.iter(|| black_box(hcl_edit::parser::parse_body(&test.input).unwrap()))
+        });
     });
 
     group.finish();
diff --git a/crates/hcl-edit/Cargo.toml b/crates/hcl-edit/Cargo.toml
new file mode 100644
index 0000000..96e222f
--- /dev/null
+++ b/crates/hcl-edit/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "hcl-edit"
+version = "0.1.0"
+authors = ["Martin Ohmann <martinohmann@gmail.com>"]
+license = "MIT OR Apache-2.0"
+description = "Parse and modify HCL while preserving comments and whitespace"
+repository = "https://github.com/martinohmann/hcl-rs"
+documentation = "https://docs.rs/hcl-edit/"
+keywords = ["hcl"]
+categories = ["encoding"]
+readme = "README.md"
+edition = "2021"
+include = [
+  "CHANGELOG.md",
+  "Cargo.toml",
+  "LICENSE*",
+  "README.md",
+  "benches/**/*",
+  "examples/**/*",
+  "src/**/*",
+  "tests/**/*"
+]
+
+[package.metadata.docs.rs]
+all-features = true
+
+[features]
+default = []
+perf = ["hcl-primitives/perf"]
+
+[dependencies]
+hcl-primitives = { version = "0.0.2", path = "../hcl-primitives" }
+vecmap-rs = "0.1.10"
+winnow = "0.4.1"
+
+[dev-dependencies]
+indoc = "2.0"
+pretty_assertions = "1.3.0"
+testdata = { path = "../testdata" }
diff --git a/crates/hcl-edit/LICENSE-APACHE b/crates/hcl-edit/LICENSE-APACHE
new file mode 120000
index 0000000..1cd601d
--- /dev/null
+++ b/crates/hcl-edit/LICENSE-APACHE
@@ -0,0 +1,1 @@
+../../LICENSE-APACHE
\ No newline at end of file
diff --git a/crates/hcl-edit/LICENSE-MIT b/crates/hcl-edit/LICENSE-MIT
new file mode 120000
index 0000000..b2cfbdc
--- /dev/null
+++ b/crates/hcl-edit/LICENSE-MIT
@@ -0,0 +1,1 @@
+../../LICENSE-MIT
\ No newline at end of file
diff --git a/crates/hcl-edit/README.md b/crates/hcl-edit/README.md
new file mode 100644
index 0000000..d7d24ac
--- /dev/null
+++ b/crates/hcl-edit/README.md
@@ -0,0 +1,34 @@
+# hcl-edit
+
+[![Build Status](https://github.com/martinohmann/hcl-rs/workflows/ci/badge.svg)](https://github.com/martinohmann/hcl-rs/actions?query=workflow%3Aci)
+[![crates.io](https://img.shields.io/crates/v/hcl-edit)](https://crates.io/crates/hcl-edit)
+[![docs.rs](https://img.shields.io/docsrs/hcl-edit)](https://docs.rs/hcl-edit)
+[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+
+Parse and modify HCL documents while preserving whitespace and comments. This
+crate is to HCL what [`toml_edit`](https://docs.rs/toml_edit) is to TOML. In
+fact, its API is heavily inspired by `toml_edit`.
+
+## Notice
+
+**This crate is under heavy development right now** and the documentation as
+well as usage examples are scarce. There's still a lot of planned functionality
+missing and some APIs might even be cumbersome to use at the moment.
+
+This will improve over time as this project evolves.
+
+**Expect breaking changes at any time** until the biggest issues are fleshed out.
+
+## Contributing
+
+Contributions are welcome! Please read
+[`CONTRIBUTING.md`](https://github.com/martinohmann/hcl-rs/blob/main/CONTRIBUTING.md)
+before creating a PR.
+
+## License
+
+The source code of hcl-edit is licensed under either of [Apache License, Version
+2.0](https://github.com/martinohmann/hcl-rs/blob/main/LICENSE-APACHE) or [MIT
+license](https://github.com/martinohmann/hcl-rs/blob/main/LICENSE-MIT) at your
+option.
diff --git a/crates/hcl-edit/src/encode/expr.rs b/crates/hcl-edit/src/encode/expr.rs
new file mode 100644
index 0000000..528c21e
--- /dev/null
+++ b/crates/hcl-edit/src/encode/expr.rs
@@ -0,0 +1,273 @@
+use super::{
+    encode_decorated, encode_quoted_string, Encode, EncodeDecorated, EncodeState, BOTH_SPACE_DECOR,
+    LEADING_SPACE_DECOR, NO_DECOR, TRAILING_SPACE_DECOR,
+};
+use crate::expr::{
+    Array, BinaryOp, Conditional, Expression, ForCond, ForExpr, ForIntro, FuncArgs, FuncCall, Null,
+    Object, ObjectKey, ObjectValue, ObjectValueAssignment, ObjectValueTerminator, Parenthesis,
+    Splat, Traversal, TraversalOperator, UnaryOp,
+};
+use std::fmt::{self, Write};
+
+impl Encode for Null {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        write!(buf, "{self}")
+    }
+}
+
+impl Encode for Splat {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        write!(buf, "{self}")
+    }
+}
+
+impl EncodeDecorated for Expression {
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result {
+        match self {
+            Expression::Null(v) => v.encode_decorated(buf, default_decor),
+            Expression::Bool(v) => v.encode_decorated(buf, default_decor),
+            Expression::Number(v) => v.encode_decorated(buf, default_decor),
+            Expression::String(v) => {
+                encode_decorated(v, buf, default_decor, |buf| encode_quoted_string(buf, v))
+            }
+            Expression::Array(v) => v.encode_decorated(buf, default_decor),
+            Expression::Object(v) => v.encode_decorated(buf, default_decor),
+            Expression::Template(v) => v.encode_decorated(buf, default_decor),
+            Expression::HeredocTemplate(v) => v.encode_decorated(buf, default_decor),
+            Expression::Parenthesis(v) => v.encode_decorated(buf, default_decor),
+            Expression::Variable(v) => v.encode_decorated(buf, default_decor),
+            Expression::ForExpr(v) => v.encode_decorated(buf, default_decor),
+            Expression::Conditional(v) => v.encode_decorated(buf, default_decor),
+            Expression::FuncCall(v) => v.encode_decorated(buf, default_decor),
+            Expression::UnaryOp(v) => v.encode_decorated(buf, default_decor),
+            Expression::BinaryOp(v) => v.encode_decorated(buf, default_decor),
+            Expression::Traversal(v) => v.encode_decorated(buf, default_decor),
+        }
+    }
+}
+
+impl Encode for Parenthesis {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('(')?;
+        self.inner().encode_decorated(buf, NO_DECOR)?;
+        buf.write_char(')')
+    }
+}
+
+impl Encode for Array {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('[')?;
+
+        if !self.is_empty() {
+            for (i, value) in self.iter().enumerate() {
+                let value_decor = if i == 0 {
+                    NO_DECOR
+                } else {
+                    buf.write_char(',')?;
+                    LEADING_SPACE_DECOR
+                };
+                value.encode_decorated(buf, value_decor)?;
+            }
+
+            if self.trailing_comma() {
+                buf.write_char(',')?;
+            }
+        }
+
+        self.trailing().encode_with_default(buf, "")?;
+        buf.write_char(']')
+    }
+}
+
+impl Encode for Object {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('{')?;
+
+        for (key, value) in self.iter() {
+            key.encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+            value.encode(buf)?;
+        }
+
+        self.trailing().encode_with_default(buf, "")?;
+        buf.write_char('}')
+    }
+}
+
+impl EncodeDecorated for ObjectKey {
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result {
+        match self {
+            ObjectKey::Ident(ident) => ident.encode_decorated(buf, default_decor),
+            ObjectKey::Expression(expr) => expr.encode_decorated(buf, default_decor),
+        }
+    }
+}
+
+impl Encode for ObjectValue {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        match self.assignment() {
+            ObjectValueAssignment::Colon => buf.write_char(':')?,
+            ObjectValueAssignment::Equals => buf.write_char('=')?,
+        }
+
+        self.expr().encode_decorated(buf, LEADING_SPACE_DECOR)?;
+
+        match self.terminator() {
+            ObjectValueTerminator::Comma => buf.write_char(',')?,
+            ObjectValueTerminator::Newline => buf.write_char('\n')?,
+            ObjectValueTerminator::None => {}
+        }
+
+        Ok(())
+    }
+}
+
+impl Encode for ForExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        if let Some(key_expr) = self.key_expr() {
+            // object expr
+            buf.write_char('{')?;
+            self.intro().encode_decorated(buf, NO_DECOR)?;
+            key_expr.encode_decorated(buf, BOTH_SPACE_DECOR)?;
+            buf.write_str("=>")?;
+            self.value_expr()
+                .encode_decorated(buf, LEADING_SPACE_DECOR)?;
+            if self.grouping() {
+                buf.write_str("...")?;
+            }
+            if let Some(cond) = self.cond() {
+                cond.encode_decorated(buf, LEADING_SPACE_DECOR)?;
+            }
+            buf.write_char('}')
+        } else {
+            // list expr
+            buf.write_char('[')?;
+            self.intro().encode_decorated(buf, NO_DECOR)?;
+            self.value_expr()
+                .encode_decorated(buf, LEADING_SPACE_DECOR)?;
+            if let Some(cond) = self.cond() {
+                cond.encode_decorated(buf, LEADING_SPACE_DECOR)?;
+            }
+            buf.write_char(']')
+        }
+    }
+}
+
+impl Encode for ForIntro {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_str("for")?;
+        if let Some(key_var) = self.key_var() {
+            key_var.encode_decorated(buf, LEADING_SPACE_DECOR)?;
+            buf.write_char(',')?;
+        }
+        self.value_var()
+            .encode_decorated(buf, LEADING_SPACE_DECOR)?;
+        buf.write_str("in")?;
+        self.collection_expr()
+            .encode_decorated(buf, BOTH_SPACE_DECOR)?;
+        buf.write_char(':')
+    }
+}
+
+impl Encode for ForCond {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_str("if")?;
+        self.expr().encode_decorated(buf, LEADING_SPACE_DECOR)
+    }
+}
+
+impl Encode for Conditional {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.cond_expr()
+            .encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+        buf.write_char('?')?;
+        self.true_expr().encode_decorated(buf, BOTH_SPACE_DECOR)?;
+        buf.write_char(':')?;
+        self.false_expr().encode_decorated(buf, LEADING_SPACE_DECOR)
+    }
+}
+
+impl Encode for FuncCall {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.name().encode_decorated(buf, NO_DECOR)?;
+        self.args().encode_decorated(buf, NO_DECOR)
+    }
+}
+
+impl Encode for FuncArgs {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('(')?;
+
+        for (i, arg) in self.iter().enumerate() {
+            let arg_decor = if i == 0 {
+                NO_DECOR
+            } else {
+                buf.write_char(',')?;
+                LEADING_SPACE_DECOR
+            };
+
+            arg.encode_decorated(buf, arg_decor)?;
+        }
+
+        if !self.is_empty() {
+            if self.expand_final() {
+                buf.write_str("...")?;
+            } else if self.trailing_comma() {
+                buf.write_char(',')?;
+            }
+        }
+
+        self.trailing().encode_with_default(buf, "")?;
+        buf.write_char(')')
+    }
+}
+
+impl Encode for UnaryOp {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_str(self.operator().as_str())?;
+        self.expr().encode_decorated(buf, NO_DECOR)
+    }
+}
+
+impl Encode for BinaryOp {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.lhs_expr()
+            .encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+        buf.write_str(self.operator().as_str())?;
+        self.rhs_expr().encode_decorated(buf, LEADING_SPACE_DECOR)
+    }
+}
+
+impl Encode for Traversal {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.expr().encode_decorated(buf, NO_DECOR)?;
+
+        for operator in self.operators() {
+            operator.encode_decorated(buf, NO_DECOR)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl Encode for TraversalOperator {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        match self {
+            TraversalOperator::FullSplat(_) | TraversalOperator::Index(_) => buf.write_char('[')?,
+            _other => buf.write_char('.')?,
+        }
+
+        match self {
+            TraversalOperator::AttrSplat(splat) | TraversalOperator::FullSplat(splat) => {
+                splat.encode_decorated(buf, NO_DECOR)?
+            }
+            TraversalOperator::GetAttr(ident) => ident.encode_decorated(buf, NO_DECOR)?,
+            TraversalOperator::Index(expr) => expr.encode_decorated(buf, NO_DECOR)?,
+            TraversalOperator::LegacyIndex(index) => index.encode_decorated(buf, NO_DECOR)?,
+        }
+
+        match self {
+            TraversalOperator::FullSplat(_) | TraversalOperator::Index(_) => buf.write_char(']'),
+            _other => Ok(()),
+        }
+    }
+}
diff --git a/crates/hcl-edit/src/encode/mod.rs b/crates/hcl-edit/src/encode/mod.rs
new file mode 100644
index 0000000..5e90012
--- /dev/null
+++ b/crates/hcl-edit/src/encode/mod.rs
@@ -0,0 +1,148 @@
+mod expr;
+mod structure;
+mod template;
+
+use crate::repr::{Decorate, Decorated, Formatted};
+use crate::{Ident, Number};
+use std::fmt::{self, Write};
+
+pub(crate) const NO_DECOR: (&str, &str) = ("", "");
+const LEADING_SPACE_DECOR: (&str, &str) = (" ", "");
+const TRAILING_SPACE_DECOR: (&str, &str) = ("", " ");
+const BOTH_SPACE_DECOR: (&str, &str) = (" ", " ");
+
+pub(crate) trait EncodeDecorated {
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result;
+}
+
+pub(crate) trait Encode {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result;
+}
+
+pub(crate) struct EncodeState<'a> {
+    buf: &'a mut dyn fmt::Write,
+    escape: bool,
+}
+
+impl<'a> EncodeState<'a> {
+    pub fn new(buf: &'a mut dyn fmt::Write) -> EncodeState<'a> {
+        EncodeState { buf, escape: false }
+    }
+
+    pub fn escaped<F>(&mut self, f: F) -> fmt::Result
+    where
+        F: FnOnce(&mut EncodeState) -> fmt::Result,
+    {
+        self.escape = true;
+        let result = f(self);
+        self.escape = false;
+        result
+    }
+
+    pub fn escape(&self) -> bool {
+        self.escape
+    }
+}
+
+impl<'a> fmt::Write for EncodeState<'a> {
+    fn write_str(&mut self, s: &str) -> fmt::Result {
+        self.buf.write_str(s)
+    }
+}
+
+impl<T> EncodeDecorated for T
+where
+    T: Decorate + Encode,
+{
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result {
+        encode_decorated(self, buf, default_decor, |buf| self.encode(buf))
+    }
+}
+
+impl<T> Encode for Decorated<T>
+where
+    T: Encode,
+{
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        (**self).encode(buf)
+    }
+}
+
+impl<T> Encode for Formatted<T>
+where
+    T: Encode + ToString,
+{
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        if let Some(repr) = self.as_repr() {
+            repr.encode_with_default(buf, "")
+        } else {
+            (**self).encode(buf)
+        }
+    }
+}
+
+impl Encode for bool {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        write!(buf, "{self}")
+    }
+}
+
+impl Encode for u64 {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        write!(buf, "{self}")
+    }
+}
+
+impl Encode for Number {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        write!(buf, "{self}")
+    }
+}
+
+impl Encode for Ident {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_str(self.as_str())
+    }
+}
+
+fn encode_decorated<T, F>(
+    item: &T,
+    buf: &mut EncodeState,
+    default_decor: (&str, &str),
+    f: F,
+) -> fmt::Result
+where
+    T: ?Sized + Decorate,
+    F: FnOnce(&mut EncodeState) -> fmt::Result,
+{
+    let decor = item.decor();
+    decor.encode_prefix(buf, default_decor.0)?;
+    f(buf)?;
+    decor.encode_suffix(buf, default_decor.1)
+}
+
+fn encode_quoted_string(buf: &mut dyn fmt::Write, value: &str) -> fmt::Result {
+    buf.write_char('"')?;
+    encode_escaped(buf, value)?;
+    buf.write_char('"')
+}
+
+fn encode_escaped(buf: &mut dyn fmt::Write, value: &str) -> fmt::Result {
+    for ch in value.chars() {
+        match ch {
+            '\u{8}' => buf.write_str("\\b")?,
+            '\u{9}' => buf.write_str("\\t")?,
+            '\u{a}' => buf.write_str("\\n")?,
+            '\u{c}' => buf.write_str("\\f")?,
+            '\u{d}' => buf.write_str("\\r")?,
+            '\u{22}' => buf.write_str("\\\"")?,
+            '\u{5c}' => buf.write_str("\\\\")?,
+            c if c <= '\u{1f}' || c == '\u{7f}' => {
+                write!(buf, "\\u{:04X}", ch as u32)?;
+            }
+            ch => buf.write_char(ch)?,
+        }
+    }
+
+    Ok(())
+}
diff --git a/crates/hcl-edit/src/encode/structure.rs b/crates/hcl-edit/src/encode/structure.rs
new file mode 100644
index 0000000..a226916
--- /dev/null
+++ b/crates/hcl-edit/src/encode/structure.rs
@@ -0,0 +1,83 @@
+use super::{
+    encode_decorated, encode_quoted_string, Encode, EncodeDecorated, EncodeState, BOTH_SPACE_DECOR,
+    LEADING_SPACE_DECOR, NO_DECOR, TRAILING_SPACE_DECOR,
+};
+use crate::structure::{Attribute, Block, BlockBody, BlockLabel, Body, Oneline, Structure};
+use std::fmt::{self, Write};
+
+impl Encode for Body {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        for structure in self.iter() {
+            structure.encode_decorated(buf, NO_DECOR)?;
+            buf.write_char('\n')?;
+        }
+
+        Ok(())
+    }
+}
+
+impl EncodeDecorated for Structure {
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result {
+        match self {
+            Structure::Attribute(attr) => attr.encode_decorated(buf, default_decor),
+            Structure::Block(block) => block.encode_decorated(buf, default_decor),
+        }
+    }
+}
+
+impl Encode for Attribute {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.key().encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+        buf.write_char('=')?;
+        self.expr().encode_decorated(buf, LEADING_SPACE_DECOR)
+    }
+}
+
+impl Encode for Block {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.ident().encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+
+        for label in self.labels() {
+            label.encode_decorated(buf, TRAILING_SPACE_DECOR)?;
+        }
+
+        self.body().encode(buf)
+    }
+}
+
+impl EncodeDecorated for BlockLabel {
+    fn encode_decorated(&self, buf: &mut EncodeState, default_decor: (&str, &str)) -> fmt::Result {
+        match self {
+            BlockLabel::String(string) => encode_decorated(string, buf, default_decor, |buf| {
+                encode_quoted_string(buf, string)
+            }),
+            BlockLabel::Ident(ident) => ident.encode_decorated(buf, default_decor),
+        }
+    }
+}
+
+impl Encode for BlockBody {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('{')?;
+
+        match self {
+            BlockBody::Multiline(body) => encode_decorated(body, buf, NO_DECOR, |buf| {
+                buf.write_char('\n')?;
+                body.encode(buf)
+            })?,
+            BlockBody::Oneline(oneline) => oneline.encode(buf)?,
+        }
+
+        buf.write_char('}')
+    }
+}
+
+impl Encode for Oneline {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        if let Some(attr) = self.as_attribute() {
+            attr.encode_decorated(buf, BOTH_SPACE_DECOR)?;
+        }
+
+        self.trailing().encode_with_default(buf, "")
+    }
+}
diff --git a/crates/hcl-edit/src/encode/template.rs b/crates/hcl-edit/src/encode/template.rs
new file mode 100644
index 0000000..cbec68c
--- /dev/null
+++ b/crates/hcl-edit/src/encode/template.rs
@@ -0,0 +1,195 @@
+use super::{
+    encode_escaped, Encode, EncodeDecorated, EncodeState, BOTH_SPACE_DECOR, LEADING_SPACE_DECOR,
+    TRAILING_SPACE_DECOR,
+};
+use crate::template::{
+    Directive, Element, ElseTemplateExpr, EndforTemplateExpr, EndifTemplateExpr, ForDirective,
+    ForTemplateExpr, HeredocTemplate, IfDirective, IfTemplateExpr, Interpolation, StringTemplate,
+    Strip, Template,
+};
+use crate::util::indent_by;
+use std::fmt::{self, Write};
+
+const INTERPOLATION_START: &str = "${";
+const DIRECTIVE_START: &str = "%{";
+
+impl Encode for StringTemplate {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_char('"')?;
+        buf.escaped(|buf| {
+            for element in self.iter() {
+                element.encode(buf)?;
+            }
+
+            Ok(())
+        })?;
+        buf.write_char('"')
+    }
+}
+
+impl Encode for HeredocTemplate {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        buf.write_str("<<")?;
+
+        if self.indent().is_some() {
+            buf.write_char('-')?;
+        }
+
+        writeln!(buf, "{}", self.delimiter().as_str())?;
+
+        match self.indent() {
+            Some(n) => {
+                let mut indent_buf = String::new();
+                let mut indent_state = EncodeState::new(&mut indent_buf);
+                self.template().encode(&mut indent_state)?;
+                let indented = indent_by(&indent_buf, n, false);
+                buf.write_str(&indented)?;
+            }
+            None => self.template().encode(buf)?,
+        }
+
+        self.trailing().encode_with_default(buf, "")?;
+
+        write!(buf, "{}", self.delimiter().as_str())
+    }
+}
+
+impl Encode for Template {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        for element in self.iter() {
+            element.encode(buf)?;
+        }
+
+        Ok(())
+    }
+}
+
+impl Encode for Element {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        match self {
+            Element::Literal(lit) => {
+                if buf.escape() {
+                    encode_escaped(buf, lit)
+                } else {
+                    buf.write_str(lit)
+                }
+            }
+            Element::Interpolation(interp) => interp.encode(buf),
+            Element::Directive(dir) => dir.encode(buf),
+        }
+    }
+}
+
+impl Encode for Interpolation {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, INTERPOLATION_START, self.strip(), |buf| {
+            self.expr().encode_decorated(buf, BOTH_SPACE_DECOR)
+        })
+    }
+}
+
+impl Encode for Directive {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        match self {
+            Directive::If(dir) => dir.encode(buf),
+            Directive::For(dir) => dir.encode(buf),
+        }
+    }
+}
+
+impl Encode for IfDirective {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.if_expr().encode(buf)?;
+        if let Some(else_expr) = self.else_expr() {
+            else_expr.encode(buf)?;
+        }
+        self.endif_expr().encode(buf)
+    }
+}
+
+impl Encode for IfTemplateExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, DIRECTIVE_START, self.strip(), |buf| {
+            self.preamble().encode_with_default(buf, " ")?;
+            buf.write_str("if")?;
+            self.cond_expr().encode_decorated(buf, TRAILING_SPACE_DECOR)
+        })?;
+        self.template().encode(buf)
+    }
+}
+
+impl Encode for ElseTemplateExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, DIRECTIVE_START, self.strip(), |buf| {
+            self.preamble().encode_with_default(buf, " ")?;
+            buf.write_str("else")?;
+            self.trailing().encode_with_default(buf, " ")
+        })?;
+        self.template().encode(buf)
+    }
+}
+
+impl Encode for EndifTemplateExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, DIRECTIVE_START, self.strip(), |buf| {
+            self.preamble().encode_with_default(buf, " ")?;
+            buf.write_str("endif")?;
+            self.trailing().encode_with_default(buf, " ")
+        })
+    }
+}
+
+impl Encode for ForDirective {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        self.for_expr().encode(buf)?;
+        self.endfor_expr().encode(buf)
+    }
+}
+
+impl Encode for ForTemplateExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, DIRECTIVE_START, self.strip(), |buf| {
+            self.preamble().encode_with_default(buf, " ")?;
+            buf.write_str("for")?;
+
+            if let Some(key_var) = self.key_var() {
+                key_var.encode_decorated(buf, LEADING_SPACE_DECOR)?;
+                buf.write_char(',')?;
+            }
+
+            self.value_var().encode_decorated(buf, BOTH_SPACE_DECOR)?;
+            buf.write_str("in")?;
+            self.collection_expr()
+                .encode_decorated(buf, BOTH_SPACE_DECOR)
+        })?;
+        self.template().encode(buf)
+    }
+}
+
+impl Encode for EndforTemplateExpr {
+    fn encode(&self, buf: &mut EncodeState) -> fmt::Result {
+        encode_strip(buf, DIRECTIVE_START, self.strip(), |buf| {
+            self.preamble().encode_with_default(buf, " ")?;
+            buf.write_str("endfor")?;
+            self.trailing().encode_with_default(buf, " ")
+        })
+    }
+}
+
+fn encode_strip<F>(buf: &mut EncodeState, start_marker: &str, strip: Strip, f: F) -> fmt::Result
+where
+    F: FnOnce(&mut EncodeState) -> fmt::Result,
+{
+    buf.write_str(start_marker)?;
+    if strip.strip_start() {
+        buf.write_char('~')?;
+    }
+
+    f(buf)?;
+
+    if strip.strip_end() {
+        buf.write_char('~')?;
+    }
+
+    buf.write_char('}')
+}
diff --git a/crates/hcl-edit/src/expr/mod.rs b/crates/hcl-edit/src/expr/mod.rs
new file mode 100644
index 0000000..2366e04
--- /dev/null
+++ b/crates/hcl-edit/src/expr/mod.rs
@@ -0,0 +1,1053 @@
+//! Types to represent the HCL expression sub-language.
+
+#![allow(missing_docs)]
+
+use crate::encode::{EncodeDecorated, EncodeState, NO_DECOR};
+use crate::repr::{Decor, Decorate, Decorated, Formatted, SetSpan, Span, Spanned};
+use crate::template::{HeredocTemplate, StringTemplate};
+use crate::{parser, Ident, Number, RawString};
+use std::fmt;
+use std::ops::Range;
+use std::str::FromStr;
+use vecmap::map::{MutableKeys, VecMap};
+
+// Re-exported for convenience.
+#[doc(inline)]
+pub use hcl_primitives::expr::{BinaryOperator, UnaryOperator};
+
+pub type Iter<'a> = Box<dyn Iterator<Item = &'a Expression> + 'a>;
+
+pub type IterMut<'a> = Box<dyn Iterator<Item = &'a mut Expression> + 'a>;
+
+pub type ObjectIter<'a> = Box<dyn Iterator<Item = (&'a ObjectKey, &'a ObjectValue)> + 'a>;
+
+pub type ObjectIterMut<'a> = Box<dyn Iterator<Item = (&'a ObjectKey, &'a mut ObjectValue)> + 'a>;
+
+pub type TraversalIter<'a> = Box<dyn Iterator<Item = &'a Decorated<TraversalOperator>> + 'a>;
+
+pub type TraversalIterMut<'a> = Box<dyn Iterator<Item = &'a mut Decorated<TraversalOperator>> + 'a>;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Expression {
+    Null(Decorated<Null>),
+    Bool(Decorated<bool>),
+    Number(Formatted<Number>),
+    String(Decorated<String>),
+    Array(Array),
+    Object(Object),
+    Template(StringTemplate),
+    HeredocTemplate(Box<HeredocTemplate>),
+    Parenthesis(Box<Parenthesis>),
+    Variable(Decorated<Ident>),
+    Conditional(Box<Conditional>),
+    FuncCall(Box<FuncCall>),
+    Traversal(Box<Traversal>),
+    UnaryOp(Box<UnaryOp>),
+    BinaryOp(Box<BinaryOp>),
+    ForExpr(Box<ForExpr>),
+}
+
+impl Expression {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            Expression::Null(n) => n.decor_mut().despan(input),
+            Expression::Bool(b) => b.decor_mut().despan(input),
+            Expression::Number(n) => n.decor_mut().despan(input),
+            Expression::String(s) => s.decor_mut().despan(input),
+            Expression::Array(array) => array.despan(input),
+            Expression::Object(object) => object.despan(input),
+            Expression::Template(template) => template.despan(input),
+            Expression::HeredocTemplate(heredoc) => heredoc.despan(input),
+            Expression::Parenthesis(expr) => expr.despan(input),
+            Expression::Variable(var) => var.decor_mut().despan(input),
+            Expression::ForExpr(expr) => expr.despan(input),
+            Expression::Conditional(cond) => cond.despan(input),
+            Expression::FuncCall(call) => call.despan(input),
+            Expression::UnaryOp(op) => op.despan(input),
+            Expression::BinaryOp(op) => op.despan(input),
+            Expression::Traversal(traversal) => traversal.despan(input),
+        }
+    }
+}
+
+impl FromStr for Expression {
+    type Err = parser::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        parser::parse_expr(s)
+    }
+}
+
+impl From<&str> for Expression {
+    fn from(s: &str) -> Self {
+        Expression::from(String::from(s))
+    }
+}
+
+impl From<String> for Expression {
+    fn from(s: String) -> Self {
+        Expression::from(Decorated::new(s))
+    }
+}
+
+impl From<Decorated<String>> for Expression {
+    fn from(s: Decorated<String>) -> Self {
+        Expression::String(s)
+    }
+}
+
+impl From<Array> for Expression {
+    fn from(array: Array) -> Self {
+        Expression::Array(array)
+    }
+}
+
+impl From<Object> for Expression {
+    fn from(object: Object) -> Self {
+        Expression::Object(object)
+    }
+}
+
+impl From<Parenthesis> for Expression {
+    fn from(value: Parenthesis) -> Self {
+        Expression::Parenthesis(Box::new(value))
+    }
+}
+
+impl fmt::Display for Expression {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode_decorated(&mut state, NO_DECOR)
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Parenthesis {
+    inner: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Parenthesis {
+    pub fn new(inner: Expression) -> Parenthesis {
+        Parenthesis {
+            inner,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn inner(&self) -> &Expression {
+        &self.inner
+    }
+
+    pub fn inner_mut(&mut self) -> &mut Expression {
+        &mut self.inner
+    }
+
+    pub fn into_inner(self) -> Expression {
+        self.inner
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.inner.despan(input);
+    }
+}
+
+impl PartialEq for Parenthesis {
+    fn eq(&self, other: &Self) -> bool {
+        self.inner == other.inner
+    }
+}
+
+#[derive(Debug, Clone, Eq, Default)]
+pub struct Array {
+    values: Vec<Expression>,
+    trailing: RawString,
+    trailing_comma: bool,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Array {
+    pub fn new() -> Array {
+        Array {
+            values: Vec::new(),
+            trailing: RawString::default(),
+            trailing_comma: false,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        self.values.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.values.is_empty()
+    }
+
+    pub fn get(&self, index: usize) -> Option<&Expression> {
+        self.values.get(index)
+    }
+
+    pub fn insert(&mut self, index: usize, value: impl Into<Expression>) {
+        self.values.insert(index, value.into());
+    }
+
+    pub fn push(&mut self, value: impl Into<Expression>) {
+        self.values.push(value.into());
+    }
+
+    pub fn remove(&mut self, index: usize) -> Expression {
+        self.values.remove(index)
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        Box::new(self.values.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        Box::new(self.values.iter_mut())
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub fn trailing_comma(&self) -> bool {
+        self.trailing_comma
+    }
+
+    pub fn set_trailing_comma(&mut self, yes: bool) {
+        self.trailing_comma = yes;
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.trailing.despan(input);
+
+        for value in &mut self.values {
+            value.despan(input);
+        }
+    }
+}
+
+impl PartialEq for Array {
+    fn eq(&self, other: &Self) -> bool {
+        self.values == other.values
+            && self.trailing_comma == other.trailing_comma
+            && self.trailing == other.trailing
+    }
+}
+
+impl From<Vec<Expression>> for Array {
+    fn from(values: Vec<Expression>) -> Self {
+        Array {
+            values,
+            trailing: RawString::default(),
+            trailing_comma: false,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Eq, Default)]
+pub struct Object {
+    items: VecMap<ObjectKey, ObjectValue>,
+    trailing: RawString,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Object {
+    pub fn new() -> Object {
+        Object {
+            items: VecMap::new(),
+            trailing: RawString::default(),
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        self.items.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.items.is_empty()
+    }
+
+    pub fn contains_key(&self, key: &ObjectKey) -> bool {
+        self.items.contains_key(key)
+    }
+
+    pub fn get(&self, key: &ObjectKey) -> Option<&ObjectValue> {
+        self.items.get(key)
+    }
+
+    pub fn insert(
+        &mut self,
+        key: impl Into<ObjectKey>,
+        value: impl Into<ObjectValue>,
+    ) -> Option<ObjectValue> {
+        self.items.insert(key.into(), value.into())
+    }
+
+    pub fn remove(&mut self, key: &ObjectKey) -> Option<ObjectValue> {
+        self.items.remove(key)
+    }
+
+    pub fn remove_entry(&mut self, key: &ObjectKey) -> Option<(ObjectKey, ObjectValue)> {
+        self.items.remove_entry(key)
+    }
+
+    pub fn iter(&self) -> ObjectIter<'_> {
+        Box::new(self.items.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> ObjectIterMut<'_> {
+        Box::new(self.items.iter_mut())
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.trailing.despan(input);
+
+        for (key, value) in self.items.iter_mut2() {
+            key.despan(input);
+            value.despan(input);
+        }
+    }
+}
+
+impl PartialEq for Object {
+    fn eq(&self, other: &Self) -> bool {
+        self.items == other.items && self.trailing == other.trailing
+    }
+}
+
+impl From<VecMap<ObjectKey, ObjectValue>> for Object {
+    fn from(items: VecMap<ObjectKey, ObjectValue>) -> Self {
+        Object {
+            items,
+            trailing: RawString::default(),
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ObjectKey {
+    Ident(Decorated<Ident>),
+    Expression(Expression),
+}
+
+impl ObjectKey {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            ObjectKey::Ident(ident) => ident.decor_mut().despan(input),
+            ObjectKey::Expression(expr) => expr.despan(input),
+        }
+    }
+}
+
+impl From<Decorated<Ident>> for ObjectKey {
+    fn from(ident: Decorated<Ident>) -> Self {
+        ObjectKey::Ident(ident)
+    }
+}
+
+impl From<Ident> for ObjectKey {
+    fn from(ident: Ident) -> Self {
+        ObjectKey::from(Decorated::new(ident))
+    }
+}
+
+impl From<Expression> for ObjectKey {
+    fn from(expr: Expression) -> Self {
+        ObjectKey::Expression(expr)
+    }
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum ObjectValueAssignment {
+    Colon,
+    #[default]
+    Equals,
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum ObjectValueTerminator {
+    None,
+    Newline,
+    #[default]
+    Comma,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ObjectValue {
+    expr: Expression,
+    assignment: ObjectValueAssignment,
+    terminator: ObjectValueTerminator,
+}
+
+impl ObjectValue {
+    pub fn new(expr: Expression) -> ObjectValue {
+        ObjectValue {
+            expr,
+            assignment: ObjectValueAssignment::default(),
+            terminator: ObjectValueTerminator::default(),
+        }
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub fn expr_mut(&mut self) -> &mut Expression {
+        &mut self.expr
+    }
+
+    pub fn into_expr(self) -> Expression {
+        self.expr
+    }
+
+    pub fn assignment(&self) -> ObjectValueAssignment {
+        self.assignment
+    }
+
+    pub fn set_assignment(&mut self, sep: ObjectValueAssignment) {
+        self.assignment = sep;
+    }
+
+    pub fn terminator(&self) -> ObjectValueTerminator {
+        self.terminator
+    }
+
+    pub fn set_terminator(&mut self, terminator: ObjectValueTerminator) {
+        self.terminator = terminator;
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.expr.despan(input);
+    }
+}
+
+impl From<Expression> for ObjectValue {
+    fn from(expr: Expression) -> Self {
+        ObjectValue::new(expr)
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Conditional {
+    cond_expr: Expression,
+    true_expr: Expression,
+    false_expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Conditional {
+    pub fn new(
+        cond_expr: Expression,
+        true_expr: Expression,
+        false_expr: Expression,
+    ) -> Conditional {
+        Conditional {
+            cond_expr,
+            true_expr,
+            false_expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn cond_expr(&self) -> &Expression {
+        &self.cond_expr
+    }
+
+    pub fn true_expr(&self) -> &Expression {
+        &self.true_expr
+    }
+
+    pub fn false_expr(&self) -> &Expression {
+        &self.false_expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.cond_expr.despan(input);
+        self.true_expr.despan(input);
+        self.false_expr.despan(input);
+    }
+}
+
+impl PartialEq for Conditional {
+    fn eq(&self, other: &Self) -> bool {
+        self.cond_expr == other.cond_expr
+            && self.true_expr == other.true_expr
+            && self.false_expr == other.false_expr
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct FuncCall {
+    name: Decorated<Ident>,
+    args: FuncArgs,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl FuncCall {
+    pub fn new(name: Decorated<Ident>, args: FuncArgs) -> FuncCall {
+        FuncCall {
+            name,
+            args,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn name(&self) -> &Decorated<Ident> {
+        &self.name
+    }
+
+    pub fn args(&self) -> &FuncArgs {
+        &self.args
+    }
+
+    pub fn args_mut(&mut self) -> &mut FuncArgs {
+        &mut self.args
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.name.decor_mut().despan(input);
+        self.args.despan(input);
+    }
+}
+
+impl PartialEq for FuncCall {
+    fn eq(&self, other: &Self) -> bool {
+        self.name == other.name && self.args == other.args
+    }
+}
+
+#[derive(Debug, Clone, Eq, Default)]
+pub struct FuncArgs {
+    args: Vec<Expression>,
+    expand_final: bool,
+    trailing: RawString,
+    trailing_comma: bool,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl FuncArgs {
+    pub fn new(args: Vec<Expression>) -> FuncArgs {
+        FuncArgs {
+            args,
+            expand_final: false,
+            trailing: RawString::default(),
+            trailing_comma: false,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.args.is_empty()
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        Box::new(self.args.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        Box::new(self.args.iter_mut())
+    }
+
+    pub fn expand_final(&self) -> bool {
+        self.expand_final
+    }
+
+    pub fn set_expand_final(&mut self, yes: bool) {
+        self.expand_final = yes;
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub fn trailing_comma(&self) -> bool {
+        self.trailing_comma
+    }
+
+    pub fn set_trailing_comma(&mut self, yes: bool) {
+        self.trailing_comma = yes;
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        for arg in &mut self.args {
+            arg.despan(input);
+        }
+
+        self.trailing.despan(input);
+    }
+}
+
+impl PartialEq for FuncArgs {
+    fn eq(&self, other: &Self) -> bool {
+        self.args == other.args
+            && self.trailing_comma == other.trailing_comma
+            && self.trailing == other.trailing
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Traversal {
+    expr: Expression,
+    operators: Vec<Decorated<TraversalOperator>>,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Traversal {
+    pub fn new(expr: Expression, operators: Vec<Decorated<TraversalOperator>>) -> Traversal {
+        Traversal {
+            expr,
+            operators,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub fn operators(&self) -> TraversalIter<'_> {
+        Box::new(self.operators.iter())
+    }
+
+    pub fn operators_mut(&mut self) -> TraversalIterMut<'_> {
+        Box::new(self.operators.iter_mut())
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.expr.despan(input);
+
+        for operator in &mut self.operators {
+            operator.despan(input);
+        }
+    }
+}
+
+impl PartialEq for Traversal {
+    fn eq(&self, other: &Self) -> bool {
+        self.expr == other.expr && self.operators == other.operators
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Null;
+
+impl fmt::Display for Null {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "null")
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Splat;
+
+impl fmt::Display for Splat {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "*")
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TraversalOperator {
+    AttrSplat(Decorated<Splat>),
+    FullSplat(Decorated<Splat>),
+    GetAttr(Decorated<Ident>),
+    Index(Expression),
+    LegacyIndex(Decorated<u64>),
+}
+
+impl TraversalOperator {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            TraversalOperator::AttrSplat(splat) | TraversalOperator::FullSplat(splat) => {
+                splat.decor_mut().despan(input);
+            }
+            TraversalOperator::GetAttr(ident) => ident.decor_mut().despan(input),
+            TraversalOperator::Index(expr) => expr.despan(input),
+            TraversalOperator::LegacyIndex(index) => index.decor_mut().despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct UnaryOp {
+    operator: Spanned<UnaryOperator>,
+    expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl UnaryOp {
+    pub fn new(operator: Spanned<UnaryOperator>, expr: Expression) -> UnaryOp {
+        UnaryOp {
+            operator,
+            expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn operator(&self) -> &Spanned<UnaryOperator> {
+        &self.operator
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.expr.despan(input);
+    }
+}
+
+impl PartialEq for UnaryOp {
+    fn eq(&self, other: &Self) -> bool {
+        self.operator == other.operator && self.expr == other.expr
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct BinaryOp {
+    lhs_expr: Expression,
+    operator: Spanned<BinaryOperator>,
+    rhs_expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl BinaryOp {
+    pub fn new(
+        lhs_expr: Expression,
+        operator: Spanned<BinaryOperator>,
+        rhs_expr: Expression,
+    ) -> BinaryOp {
+        BinaryOp {
+            lhs_expr,
+            operator,
+            rhs_expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn lhs_expr(&self) -> &Expression {
+        &self.lhs_expr
+    }
+
+    pub fn operator(&self) -> &Spanned<BinaryOperator> {
+        &self.operator
+    }
+
+    pub fn rhs_expr(&self) -> &Expression {
+        &self.rhs_expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.lhs_expr.despan(input);
+        self.rhs_expr.despan(input);
+    }
+}
+
+impl PartialEq for BinaryOp {
+    fn eq(&self, other: &Self) -> bool {
+        self.lhs_expr == other.lhs_expr
+            && self.operator == other.operator
+            && self.rhs_expr == other.rhs_expr
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct ForExpr {
+    intro: ForIntro,
+    key_expr: Option<Expression>,
+    value_expr: Expression,
+    grouping: bool,
+    cond: Option<ForCond>,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl ForExpr {
+    pub fn new(intro: ForIntro, value_expr: Expression) -> ForExpr {
+        ForExpr {
+            intro,
+            key_expr: None,
+            value_expr,
+            grouping: false,
+            cond: None,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn intro(&self) -> &ForIntro {
+        &self.intro
+    }
+
+    pub fn key_expr(&self) -> Option<&Expression> {
+        self.key_expr.as_ref()
+    }
+
+    pub fn set_key_expr(&mut self, key_expr: Expression) {
+        self.key_expr = Some(key_expr);
+    }
+
+    pub fn value_expr(&self) -> &Expression {
+        &self.value_expr
+    }
+
+    pub fn grouping(&self) -> bool {
+        self.grouping
+    }
+
+    pub fn set_grouping(&mut self, yes: bool) {
+        self.grouping = yes;
+    }
+
+    pub fn cond(&self) -> Option<&ForCond> {
+        self.cond.as_ref()
+    }
+
+    pub fn set_cond(&mut self, cond: ForCond) {
+        self.cond = Some(cond);
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.intro.despan(input);
+
+        if let Some(key_expr) = &mut self.key_expr {
+            key_expr.despan(input);
+        }
+
+        self.value_expr.despan(input);
+
+        if let Some(cond) = &mut self.cond {
+            cond.despan(input);
+        }
+    }
+}
+
+impl PartialEq for ForExpr {
+    fn eq(&self, other: &Self) -> bool {
+        self.intro == other.intro
+            && self.key_expr == other.key_expr
+            && self.value_expr == other.value_expr
+            && self.grouping == other.grouping
+            && self.cond == other.cond
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct ForIntro {
+    key_var: Option<Decorated<Ident>>,
+    value_var: Decorated<Ident>,
+    collection_expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl ForIntro {
+    pub fn new(value_var: Decorated<Ident>, collection_expr: Expression) -> ForIntro {
+        ForIntro {
+            key_var: None,
+            value_var,
+            collection_expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn key_var(&self) -> Option<&Decorated<Ident>> {
+        self.key_var.as_ref()
+    }
+
+    pub fn set_key_var(&mut self, key_var: Decorated<Ident>) {
+        self.key_var = Some(key_var);
+    }
+
+    pub fn value_var(&self) -> &Decorated<Ident> {
+        &self.value_var
+    }
+
+    pub fn collection_expr(&self) -> &Expression {
+        &self.collection_expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        if let Some(key_var) = &mut self.key_var {
+            key_var.decor_mut().despan(input);
+        }
+
+        self.value_var.decor_mut().despan(input);
+        self.collection_expr.despan(input);
+    }
+}
+
+impl PartialEq for ForIntro {
+    fn eq(&self, other: &Self) -> bool {
+        self.key_var == other.key_var
+            && self.value_var == other.value_var
+            && self.collection_expr == other.collection_expr
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct ForCond {
+    expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl ForCond {
+    pub fn new(expr: Expression) -> ForCond {
+        ForCond {
+            expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.expr.despan(input);
+    }
+}
+
+impl PartialEq for ForCond {
+    fn eq(&self, other: &Self) -> bool {
+        self.expr == other.expr
+    }
+}
+
+impl From<Expression> for ForCond {
+    fn from(value: Expression) -> Self {
+        ForCond::new(value)
+    }
+}
+
+decorate_impl! {
+    Array, Object, Parenthesis, FuncCall, FuncArgs, Conditional,
+    Traversal, UnaryOp, BinaryOp, ForExpr, ForIntro, ForCond
+}
+
+span_impl! {
+    Array, Object, Parenthesis, FuncCall, FuncArgs, Conditional,
+    Traversal, UnaryOp, BinaryOp, ForExpr, ForIntro, ForCond
+}
+
+forward_decorate_impl! {
+    Expression => {
+        Null, Bool, Number, String, Array, Object, Template, HeredocTemplate, Parenthesis,
+        Variable, ForExpr, Conditional, FuncCall, UnaryOp, BinaryOp, Traversal
+    },
+    TraversalOperator => { AttrSplat, FullSplat, GetAttr, Index, LegacyIndex },
+    ObjectKey => { Ident, Expression }
+}
+
+forward_span_impl! {
+    Expression => {
+        Null, Bool, Number, String, Array, Object, Template, HeredocTemplate, Parenthesis,
+        Variable, ForExpr, Conditional, FuncCall, UnaryOp, BinaryOp, Traversal
+    },
+    TraversalOperator => { AttrSplat, FullSplat, GetAttr, Index, LegacyIndex },
+    ObjectKey => { Ident, Expression }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    #[test]
+    fn object_access() {
+        // Ident key.
+        let mut obj = Object::new();
+        let mut key = ObjectKey::from(Ident::new_unchecked("foo"));
+        key.decorate(("/* prefix */", "/* suffix */"));
+
+        let value = ObjectValue::from(Expression::from("bar"));
+
+        obj.insert(key.clone(), value.clone());
+
+        assert_eq!(obj.get(&key), Some(&value));
+
+        key.decor_mut().clear();
+
+        assert_eq!(obj.get(&key), Some(&value));
+
+        let (key, _) = obj.remove_entry(&key).unwrap();
+        assert_eq!(key.decor().prefix(), Some(&RawString::from("/* prefix */")));
+        assert_eq!(key.decor().suffix(), Some(&RawString::from("/* suffix */")));
+
+        // Expression key.
+        let mut array = Array::new();
+        array.push("foo");
+        let mut key = ObjectKey::from(Expression::from(array));
+        key.decorate(("/* prefix */", "/* suffix */"));
+
+        let value = ObjectValue::from(Expression::from("bar"));
+
+        obj.insert(key.clone(), value.clone());
+
+        assert_eq!(obj.get(&key), Some(&value));
+
+        key.decor_mut().clear();
+
+        assert_eq!(obj.get(&key), Some(&value));
+
+        let (key, _) = obj.remove_entry(&key).unwrap();
+        assert_eq!(key.decor().prefix(), Some(&RawString::from("/* prefix */")));
+        assert_eq!(key.decor().suffix(), Some(&RawString::from("/* suffix */")));
+    }
+}
diff --git a/crates/hcl-edit/src/lib.rs b/crates/hcl-edit/src/lib.rs
new file mode 100644
index 0000000..1ff4ab6
--- /dev/null
+++ b/crates/hcl-edit/src/lib.rs
@@ -0,0 +1,27 @@
+#![doc = include_str!("../README.md")]
+#![warn(missing_docs)]
+
+extern crate alloc;
+
+#[macro_use]
+mod macros;
+
+pub(crate) mod encode;
+pub mod expr;
+pub mod parser;
+mod raw_string;
+pub mod repr;
+pub mod structure;
+pub mod template;
+mod util;
+
+#[doc(inline)]
+pub use self::raw_string::RawString;
+
+// Re-exported for convenience.
+#[doc(inline)]
+pub use hcl_primitives::{Ident, Number};
+
+mod private {
+    pub trait Sealed {}
+}
diff --git a/crates/hcl-edit/src/macros.rs b/crates/hcl-edit/src/macros.rs
new file mode 100644
index 0000000..fa03141
--- /dev/null
+++ b/crates/hcl-edit/src/macros.rs
@@ -0,0 +1,83 @@
+macro_rules! forward_decorate_impl {
+    ($($ty:ident => { $($variant:ident),+ }),+ $(,)?) => {
+        $(
+            impl Decorate for $ty {
+                fn decor(&self) -> &Decor {
+                    match self {
+                        $(
+                            $ty::$variant(v) => v.decor(),
+                        )*
+                    }
+                }
+
+                fn decor_mut(&mut self) -> &mut Decor {
+                    match self {
+                        $(
+                            $ty::$variant(v) => v.decor_mut(),
+                        )*
+                    }
+                }
+            }
+        )+
+    };
+}
+
+macro_rules! forward_span_impl {
+    ($($ty:ident => { $($variant:ident),+ }),+ $(,)?) => {
+        $(
+            impl Span for $ty {
+                fn span(&self) -> Option<Range<usize>> {
+                    match self {
+                        $(
+                            $ty::$variant(v) => v.span(),
+                        )*
+                    }
+                }
+            }
+
+            impl SetSpan for $ty {
+                fn set_span(&mut self, span: Range<usize>) {
+                    match self {
+                        $(
+                            $ty::$variant(v) => v.set_span(span),
+                        )*
+                    }
+                }
+            }
+        )+
+    };
+}
+
+macro_rules! decorate_impl {
+    ($($ty:ident),+ $(,)?) => {
+        $(
+            impl Decorate for $ty {
+                fn decor(&self) -> &Decor {
+                    &self.decor
+                }
+
+                fn decor_mut(&mut self) -> &mut Decor {
+                    &mut self.decor
+                }
+            }
+        )+
+    };
+}
+
+macro_rules! span_impl {
+    ($($ty:ident),+ $(,)?) => {
+        $(
+            impl Span for $ty {
+                fn span(&self) -> Option<Range<usize>> {
+                    self.span.clone()
+                }
+            }
+
+            impl SetSpan for $ty {
+                fn set_span(&mut self, span: Range<usize>) {
+                    self.span = Some(span);
+                }
+            }
+        )+
+    };
+}
diff --git a/crates/hcl-edit/src/parser/context.rs b/crates/hcl-edit/src/parser/context.rs
new file mode 100644
index 0000000..10a7846
--- /dev/null
+++ b/crates/hcl-edit/src/parser/context.rs
@@ -0,0 +1,49 @@
+use super::{error::ParseError, string::ident, IResult, Input};
+use crate::{repr::Decorated, Ident};
+use std::fmt;
+use winnow::{combinator::cut_err, stream::AsChar, Parser};
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub(super) enum Context {
+    Expression(&'static str),
+    Expected(Expected),
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub(super) enum Expected {
+    Char(char),
+    Literal(&'static str),
+    Description(&'static str),
+}
+
+impl fmt::Display for Expected {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Expected::Char('\n') => write!(f, "newline"),
+            Expected::Char(c) if c.is_ascii_control() => {
+                write!(f, "`{}`", c.escape_debug())
+            }
+            Expected::Char(c) => write!(f, "`{c}`"),
+            Expected::Literal(l) => write!(f, "`{l}`"),
+            Expected::Description(d) => write!(f, "{d}"),
+        }
+    }
+}
+
+pub(super) fn cut_char<'a>(c: char) -> impl Parser<Input<'a>, char, ParseError<Input<'a>>> {
+    cut_err(c)
+        .map(AsChar::as_char)
+        .context(Context::Expected(Expected::Char(c)))
+}
+
+pub(super) fn cut_tag<'a>(
+    tag: &'static str,
+) -> impl Parser<Input<'a>, &'a [u8], ParseError<Input<'a>>> {
+    cut_err(tag).context(Context::Expected(Expected::Literal(tag)))
+}
+
+pub(super) fn cut_ident(input: Input) -> IResult<Input, Decorated<Ident>> {
+    cut_err(ident)
+        .context(Context::Expected(Expected::Description("identifier")))
+        .parse_next(input)
+}
diff --git a/crates/hcl-edit/src/parser/error.rs b/crates/hcl-edit/src/parser/error.rs
new file mode 100644
index 0000000..abfd177
--- /dev/null
+++ b/crates/hcl-edit/src/parser/error.rs
@@ -0,0 +1,271 @@
+use super::context::Context;
+use std::fmt;
+use winnow::{
+    error::{ContextError, FromExternalError},
+    stream::{AsBytes, Offset},
+};
+
+/// Error type returned when the parser encountered an error.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Error {
+    inner: Box<ErrorInner>,
+}
+
+impl Error {
+    pub(super) fn from_parse_error<I>(input: I, err: ParseError<I>) -> Error
+    where
+        I: AsBytes + Offset,
+    {
+        Error::new(ErrorInner::from_parse_error(input, err))
+    }
+
+    fn new(inner: ErrorInner) -> Error {
+        Error {
+            inner: Box::new(inner),
+        }
+    }
+
+    /// Returns the line from the input where the error occurred.
+    ///
+    /// Note that this returns the full line containing the invalid input. Use
+    /// [`.location()`][Error::location] to obtain the column in which the error starts.
+    pub fn line(&self) -> &str {
+        &self.inner.line
+    }
+
+    /// Returns the location in the input at which the error occurred.
+    pub fn location(&self) -> &Location {
+        &self.inner.location
+    }
+}
+
+impl std::error::Error for Error {}
+
+impl fmt::Display for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(&self.inner, f)
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct ErrorInner {
+    message: String,
+    line: String,
+    location: Location,
+}
+
+impl ErrorInner {
+    fn from_parse_error<I>(input: I, err: ParseError<I>) -> ErrorInner
+    where
+        I: AsBytes + Offset,
+    {
+        let (line, location) = locate_error(input.as_bytes(), err.input.as_bytes());
+
+        ErrorInner {
+            message: err.to_string(),
+            line: String::from_utf8_lossy(line).to_string(),
+            location,
+        }
+    }
+
+    fn spacing(&self) -> String {
+        " ".repeat(self.location.line.to_string().len())
+    }
+}
+
+impl fmt::Display for ErrorInner {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{s}--> HCL parse error in line {l}, column {c}\n\
+                 {s} |\n\
+                 {l} | {line}\n\
+                 {s} | {caret:>c$}---\n\
+                 {s} |\n\
+                 {s} = {message}",
+            s = self.spacing(),
+            l = self.location.line,
+            c = self.location.column,
+            line = self.line,
+            caret = '^',
+            message = self.message,
+        )
+    }
+}
+
+/// Represents a location in the parser input.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Location {
+    line: usize,
+    column: usize,
+    offset: usize,
+}
+
+impl Location {
+    /// Returns the line number (one-based) in the parser input.
+    pub fn line(&self) -> usize {
+        self.line
+    }
+
+    /// Returns the column number (one-based) in the parser input.
+    pub fn column(&self) -> usize {
+        self.column
+    }
+
+    /// Returns the byte offset (zero-based) in the parser input.
+    pub fn offset(&self) -> usize {
+        self.offset
+    }
+}
+
+fn locate_error<'a>(input: &'a [u8], remaining_input: &'a [u8]) -> (&'a [u8], Location) {
+    let offset = input.offset_to(remaining_input);
+    let consumed_input = &input[..offset];
+
+    // Find the line that includes the subslice:
+    // Find the *last* newline before the remaining input starts
+    let line_begin = consumed_input
+        .iter()
+        .rev()
+        .position(|&b| b == b'\n')
+        .map_or(0, |pos| offset - pos);
+
+    // Find the full line after that newline
+    let line_context = input[line_begin..]
+        .iter()
+        .position(|&b| b == b'\n')
+        .map_or(&input[line_begin..], |pos| {
+            &input[line_begin..line_begin + pos]
+        });
+
+    // Count the number of newlines in the first `offset` bytes of input
+    let line = consumed_input.iter().filter(|&&b| b == b'\n').count() + 1;
+
+    // The (1-indexed) column number is the offset of the remaining input into that line.
+    let column = line_context.offset_to(remaining_input) + 1;
+
+    (
+        line_context,
+        Location {
+            line,
+            column,
+            offset,
+        },
+    )
+}
+
+#[derive(Debug)]
+pub(super) struct ParseError<I> {
+    input: I,
+    context: Vec<Context>,
+    cause: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
+}
+
+impl<I> ParseError<I> {
+    #[inline]
+    pub(super) fn new(input: I) -> ParseError<I> {
+        ParseError {
+            input,
+            context: Vec::new(),
+            cause: None,
+        }
+    }
+}
+
+impl<I> PartialEq for ParseError<I>
+where
+    I: PartialEq,
+{
+    fn eq(&self, other: &Self) -> bool {
+        self.input == other.input
+            && self.context == other.context
+            && self.cause.as_ref().map(ToString::to_string)
+                == other.cause.as_ref().map(ToString::to_string)
+    }
+}
+
+impl<I> winnow::error::ParseError<I> for ParseError<I> {
+    #[inline]
+    fn from_error_kind(input: I, _kind: winnow::error::ErrorKind) -> Self {
+        ParseError::new(input)
+    }
+
+    #[inline]
+    fn append(self, _input: I, _kind: winnow::error::ErrorKind) -> Self {
+        self
+    }
+}
+
+impl<I> ContextError<I, Context> for ParseError<I> {
+    #[inline]
+    fn add_context(mut self, _input: I, ctx: Context) -> Self {
+        self.context.push(ctx);
+        self
+    }
+}
+
+impl<I, E> FromExternalError<I, E> for ParseError<I>
+where
+    E: std::error::Error + Send + Sync + 'static,
+{
+    #[inline]
+    fn from_external_error(input: I, _kind: winnow::error::ErrorKind, err: E) -> Self {
+        ParseError {
+            input,
+            context: Vec::new(),
+            cause: Some(Box::new(err)),
+        }
+    }
+}
+
+impl<I> fmt::Display for ParseError<I> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let expression = self.context.iter().find_map(|c| match c {
+            Context::Expression(c) => Some(c),
+            _ => None,
+        });
+
+        let expected = self
+            .context
+            .iter()
+            .filter_map(|c| match c {
+                Context::Expected(c) => Some(c),
+                _ => None,
+            })
+            .collect::<Vec<_>>();
+
+        if let Some(expression) = expression {
+            write!(f, "invalid {expression}; ")?;
+        }
+
+        if expected.is_empty() {
+            f.write_str("unexpected token")?;
+        } else {
+            write!(f, "expected ")?;
+
+            match expected.len() {
+                0 => {}
+                1 => write!(f, "{}", &expected[0])?,
+                n => {
+                    for (i, expected) in expected.iter().enumerate() {
+                        if i == n - 1 {
+                            f.write_str(" or ")?;
+                        } else if i > 0 {
+                            f.write_str(", ")?;
+                        }
+
+                        write!(f, "{expected}")?;
+                    }
+                }
+            }
+        }
+
+        if let Some(cause) = &self.cause {
+            write!(f, "; {cause}")?;
+        }
+
+        Ok(())
+    }
+}
+
+impl<I> std::error::Error for ParseError<I> where I: fmt::Debug + fmt::Display {}
diff --git a/crates/hcl-edit/src/parser/expr.rs b/crates/hcl-edit/src/parser/expr.rs
new file mode 100644
index 0000000..11cd652
--- /dev/null
+++ b/crates/hcl-edit/src/parser/expr.rs
@@ -0,0 +1,662 @@
+use super::{
+    context::{cut_char, cut_ident, cut_tag, Context, Expected},
+    error::ParseError,
+    number::number as num,
+    repr::{decorated, prefix_decorated, spanned, suffix_decorated},
+    state::ExprParseState,
+    string::{from_utf8_unchecked, ident, is_id_start, raw_string, str_ident, string},
+    template::{heredoc_template, string_template},
+    trivia::{line_comment, sp, ws},
+    IResult, Input,
+};
+use crate::{
+    expr::*,
+    repr::{Decorate, Decorated, Formatted, SetSpan, Spanned},
+    template::HeredocTemplate,
+    Ident, RawString,
+};
+use std::cell::RefCell;
+use winnow::{
+    branch::alt,
+    bytes::{any, none_of, one_of, take},
+    character::{crlf, dec_uint, line_ending, newline, space0},
+    combinator::{cut_err, fail, not, opt, peek, success},
+    dispatch,
+    multi::{many1, separated0, separated1},
+    sequence::{delimited, preceded, separated_pair, terminated},
+    Parser,
+};
+
+pub(super) fn expr(input: Input) -> IResult<Input, Expression> {
+    let state = RefCell::new(ExprParseState::default());
+    let (input, _) = expr_inner(&state).parse_next(input)?;
+    let expr = state.into_inner().into_expr();
+    Ok((input, expr))
+}
+
+pub(super) fn expr_inner<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        let (mut input, span) = expr_term(state).span().parse_next(input)?;
+        state.borrow_mut().on_span(span);
+
+        loop {
+            // Parse the next whitespace sequence and only add it as decor suffix to the expression if
+            // we actually encounter a traversal, conditional or binary operation. We'll rewind the
+            // parser if none of these follow.
+            let (remaining_input, suffix) = sp.span().parse_next(input)?;
+
+            // This is essentially a `peek` for the next two bytes to identify the following operation.
+            if let Ok((_, peek)) = take::<_, _, ParseError<_>>(2usize).parse_next(remaining_input) {
+                match peek {
+                    // This might be a `...` operator within a for object expr or after the last
+                    // argument of a function call, do not mistakenly parse it as a traversal
+                    // operator.
+                    b".." => return Ok((input, ())),
+                    // Traversal operator.
+                    //
+                    // Note: after the traversal is consumed, the loop is entered again to consume
+                    // a potentially following conditional or binary operation.
+                    [b'.' | b'[', _] => {
+                        state.borrow_mut().on_ws(suffix);
+                        (input, _) = traversal(state).parse_next(remaining_input)?;
+                        continue;
+                    }
+                    // Conditional.
+                    [b'?', _] => {
+                        state.borrow_mut().on_ws(suffix);
+                        return conditional(state).parse_next(remaining_input);
+                    }
+                    // Binary operation.
+                    //
+                    // Note: matching a single `=` is ambiguous as it could also be an object
+                    // key-value separator, so we'll need to match on `==`.
+                    b"=="
+                    | [b'!' | b'<' | b'>' | b'+' | b'-' | b'*' | b'/' | b'%' | b'&' | b'|', _] => {
+                        state.borrow_mut().on_ws(suffix);
+                        return binary_op(state).parse_next(remaining_input);
+                    }
+                    // None of the above matched.
+                    _ => return Ok((input, ())),
+                }
+            }
+
+            // We hit the end of input.
+            return Ok((input, ()));
+        }
+    }
+}
+
+fn expr_term<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        dispatch! {peek(any);
+            b'"' => stringlike(state),
+            b'[' => array(state),
+            b'{' => object(state),
+            b'0'..=b'9' => number(state),
+            b'<' => heredoc(state),
+            b'-' => alt((neg_number(state), unary_op(state))),
+            b'!' => unary_op(state),
+            b'(' => parenthesis(state),
+            b if is_id_start(b) => identlike(state),
+            _ => cut_err(fail)
+                .context(Context::Expression("expression"))
+                .context(Context::Expected(Expected::Char('"')))
+                .context(Context::Expected(Expected::Char('[')))
+                .context(Context::Expected(Expected::Char('{')))
+                .context(Context::Expected(Expected::Char('-')))
+                .context(Context::Expected(Expected::Char('!')))
+                .context(Context::Expected(Expected::Char('(')))
+                .context(Context::Expected(Expected::Char('_')))
+                .context(Context::Expected(Expected::Char('<')))
+                .context(Context::Expected(Expected::Description("letter")))
+                .context(Context::Expected(Expected::Description("digit"))),
+        }
+        .parse_next(input)
+    }
+}
+
+fn stringlike<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        alt((
+            string.map(|string| {
+                state
+                    .borrow_mut()
+                    .on_expr_term(Expression::String(Decorated::new(string)))
+            }),
+            string_template.map(|template| {
+                state
+                    .borrow_mut()
+                    .on_expr_term(Expression::Template(template))
+            }),
+        ))
+        .parse_next(input)
+    }
+}
+
+fn number<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        num.with_recognized()
+            .map(|(num, repr)| {
+                let mut num = Formatted::new(-num);
+                num.set_repr(unsafe { from_utf8_unchecked(repr, "`num` filters out non-ascii") });
+                state.borrow_mut().on_expr_term(Expression::Number(num))
+            })
+            .parse_next(input)
+    }
+}
+
+fn neg_number<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        preceded((b'-', sp), num)
+            .with_recognized()
+            .map_res(|(num, repr)| {
+                std::str::from_utf8(repr).map(|repr| {
+                    let mut num = Formatted::new(-num);
+                    num.set_repr(repr);
+                    state.borrow_mut().on_expr_term(Expression::Number(num))
+                })
+            })
+            .parse_next(input)
+    }
+}
+
+fn traversal<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        many1(prefix_decorated(sp, traversal_operator.map(Decorated::new)))
+            .map(|operators| state.borrow_mut().on_traversal(operators))
+            .parse_next(input)
+    }
+}
+
+fn traversal_operator(input: Input) -> IResult<Input, TraversalOperator> {
+    dispatch! {any;
+        b'.' => prefix_decorated(
+            ws,
+            dispatch! {peek(any);
+                b'*' => b'*'.value(TraversalOperator::AttrSplat(Decorated::new(Splat))),
+                b'0'..=b'9' => dec_uint.map(|index: u64| TraversalOperator::LegacyIndex(index.into())),
+                b if is_id_start(b) => ident.map(TraversalOperator::GetAttr),
+                _ => cut_err(fail)
+                    .context(Context::Expression("traversal operator"))
+                    .context(Context::Expected(Expected::Char('*')))
+                    .context(Context::Expected(Expected::Description("identifier")))
+                    .context(Context::Expected(Expected::Description("unsigned integer"))),
+            },
+        ),
+        b'[' => terminated(
+            decorated(
+                ws,
+                dispatch! {peek(any);
+                    b'*' => b'*'.value(TraversalOperator::FullSplat(Decorated::new(Splat))),
+                    _ => expr.map(TraversalOperator::Index),
+                },
+                ws,
+            ),
+            cut_char(']'),
+        ),
+        _ => fail,
+    }
+    .parse_next(input)
+}
+
+fn unary_op<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        preceded(
+            (spanned(unary_operator.map(Spanned::new)), sp.span())
+                .map(|(operator, span)| state.borrow_mut().on_unary_op(operator, span)),
+            expr_term(state),
+        )
+        .void()
+        .parse_next(input)
+    }
+}
+
+fn unary_operator(input: Input) -> IResult<Input, UnaryOperator> {
+    dispatch! {any;
+        b'-' => success(UnaryOperator::Neg),
+        b'!' => success(UnaryOperator::Not),
+        _ => fail,
+    }
+    .parse_next(input)
+}
+
+fn binary_op<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        (
+            spanned(binary_operator.map(Spanned::new)),
+            prefix_decorated(sp, expr),
+        )
+            .map(|(operator, rhs_expr)| state.borrow_mut().on_binary_op(operator, rhs_expr))
+            .parse_next(input)
+    }
+}
+
+fn binary_operator(input: Input) -> IResult<Input, BinaryOperator> {
+    dispatch! {any;
+        b'=' => b'='.value(BinaryOperator::Eq),
+        b'!' => b'='.value(BinaryOperator::NotEq),
+        b'<' => alt((
+            b'='.value(BinaryOperator::LessEq),
+            success(BinaryOperator::Less),
+        )),
+        b'>' => alt((
+            b'='.value(BinaryOperator::GreaterEq),
+            success(BinaryOperator::Greater),
+        )),
+        b'+' => success(BinaryOperator::Plus),
+        b'-' => success(BinaryOperator::Minus),
+        b'*' => success(BinaryOperator::Mul),
+        b'/' => success(BinaryOperator::Div),
+        b'%' => success(BinaryOperator::Mod),
+        b'&' => b'&'.value(BinaryOperator::And),
+        b'|' => b'|'.value(BinaryOperator::Or),
+        _ => fail,
+    }
+    .parse_next(input)
+}
+
+fn conditional<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        (
+            preceded(b'?', decorated(sp, expr, sp)),
+            preceded(cut_char(':'), prefix_decorated(sp, expr)),
+        )
+            .map(|(true_expr, false_expr)| state.borrow_mut().on_conditional(true_expr, false_expr))
+            .parse_next(input)
+    }
+}
+
+fn array<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        delimited(
+            b'[',
+            alt((for_list_expr(state), array_items(state))),
+            cut_char(']'),
+        )
+        .parse_next(input)
+    }
+}
+
+fn for_list_expr<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        (for_intro, decorated(ws, expr, ws), opt(for_cond))
+            .map(|(intro, value_expr, cond)| {
+                let mut expr = ForExpr::new(intro, value_expr);
+
+                if let Some(cond) = cond {
+                    expr.set_cond(cond);
+                }
+
+                state
+                    .borrow_mut()
+                    .on_expr_term(Expression::ForExpr(Box::new(expr)));
+            })
+            .parse_next(input)
+    }
+}
+
+fn array_items<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        let values = separated0(decorated(ws, preceded(not(b']'), expr), ws), b',');
+
+        (values, opt(b','), raw_string(ws))
+            .map(|(values, comma, trailing)| {
+                let values: Vec<_> = values;
+                let mut array = Array::from(values);
+                if comma.is_some() {
+                    array.set_trailing_comma(true);
+                }
+                array.set_trailing(trailing);
+                state.borrow_mut().on_expr_term(Expression::Array(array));
+            })
+            .parse_next(input)
+    }
+}
+
+fn object<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        delimited(
+            b'{',
+            alt((for_object_expr(state), object_items(state))),
+            cut_char('}'),
+        )
+        .parse_next(input)
+    }
+}
+
+fn for_object_expr<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        (
+            for_intro,
+            separated_pair(
+                decorated(ws, expr, ws),
+                cut_tag("=>"),
+                decorated(ws, expr, ws),
+            ),
+            opt(b"..."),
+            opt(for_cond),
+        )
+            .map(|(intro, (key_expr, value_expr), grouping, cond)| {
+                let mut expr = ForExpr::new(intro, value_expr);
+                expr.set_key_expr(key_expr);
+                expr.set_grouping(grouping.is_some());
+
+                if let Some(cond) = cond {
+                    expr.set_cond(cond);
+                }
+
+                state
+                    .borrow_mut()
+                    .on_expr_term(Expression::ForExpr(Box::new(expr)));
+            })
+            .parse_next(input)
+    }
+}
+
+fn object_items<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        let mut remaining_input = input;
+        let mut object = Object::new();
+
+        loop {
+            let (input, trailing) = raw_string(ws).parse_next(remaining_input)?;
+            let (input, ch) = peek(any).parse_next(input)?;
+
+            if ch == b'}' {
+                object.set_trailing(trailing);
+                state.borrow_mut().on_expr_term(Expression::Object(object));
+                return Ok((input, ()));
+            }
+
+            let (input, mut key) = object_key(input)?;
+            let (input, mut value) = object_value(input)?;
+            key.decor_mut().set_prefix(trailing);
+
+            // Look for the closing brace and return or consume the object item separator and proceed
+            // with the next object item, if any.
+            let (input, ch) = peek(any).parse_next(input)?;
+
+            let (input, value_terminator) = match ch {
+                b'}' => {
+                    value.set_terminator(ObjectValueTerminator::None);
+                    object.insert(key, value);
+                    state.borrow_mut().on_expr_term(Expression::Object(object));
+                    return Ok((input, ()));
+                }
+                b'\r' => crlf
+                    .value(ObjectValueTerminator::Newline)
+                    .parse_next(input)?,
+                b'\n' => newline
+                    .value(ObjectValueTerminator::Newline)
+                    .parse_next(input)?,
+                b',' => take(1usize)
+                    .value(ObjectValueTerminator::Comma)
+                    .parse_next(input)?,
+                b'#' | b'/' => {
+                    let (input, comment_span) = line_comment.span().parse_next(input)?;
+
+                    let decor = value.expr_mut().decor_mut();
+
+                    // Associate the trailing comment with the item value, updating the span if it
+                    // already has a decor suffix.
+                    let suffix_start = match decor.suffix() {
+                        Some(suffix) => suffix.span().unwrap().start,
+                        None => comment_span.start,
+                    };
+
+                    decor.set_suffix(RawString::from_span(suffix_start..comment_span.end));
+
+                    line_ending
+                        .value(ObjectValueTerminator::Newline)
+                        .parse_next(input)?
+                }
+                _ => {
+                    return cut_err(fail)
+                        .context(Context::Expression("object item"))
+                        .context(Context::Expected(Expected::Char('}')))
+                        .context(Context::Expected(Expected::Char(',')))
+                        .context(Context::Expected(Expected::Char('\n')))
+                        .parse_next(input)
+                }
+            };
+
+            value.set_terminator(value_terminator);
+            object.insert(key, value);
+            remaining_input = input;
+        }
+    }
+}
+
+fn object_key(input: Input) -> IResult<Input, ObjectKey> {
+    suffix_decorated(
+        expr.map(|expr| {
+            // Variable identifiers without traversal are treated as identifier object keys.
+            //
+            // Handle this case here by converting the variable into an identifier. This
+            // avoids re-parsing the whole key-value pair when an identifier followed by a
+            // traversal operator is encountered.
+            if let Expression::Variable(variable) = expr {
+                ObjectKey::Ident(Decorated::new(variable.into_value()))
+            } else {
+                ObjectKey::Expression(expr)
+            }
+        }),
+        sp,
+    )
+    .parse_next(input)
+}
+
+fn object_value(input: Input) -> IResult<Input, ObjectValue> {
+    (object_value_assignment, decorated(sp, expr, sp))
+        .map(|(assignment, expr)| {
+            let mut value = ObjectValue::new(expr);
+            value.set_assignment(assignment);
+            value
+        })
+        .parse_next(input)
+}
+
+fn object_value_assignment(input: Input) -> IResult<Input, ObjectValueAssignment> {
+    dispatch! {any;
+        b'=' => success(ObjectValueAssignment::Equals),
+        b':' => success(ObjectValueAssignment::Colon),
+        _ => cut_err(fail)
+            .context(Context::Expression("object value assignment"))
+            .context(Context::Expected(Expected::Char('=')))
+            .context(Context::Expected(Expected::Char(':'))),
+    }
+    .parse_next(input)
+}
+
+fn for_intro(input: Input) -> IResult<Input, ForIntro> {
+    prefix_decorated(
+        ws,
+        delimited(
+            // The `for` tag needs to be followed by either a space character or a comment start to
+            // disambiguate. Otherwise an identifier like `format` will match both the `for` tag
+            // and the following identifier which would fail parsing of arrays with identifier/func
+            // call elements and objects with those as keys.
+            (b"for", peek(one_of(" \t#/"))),
+            (
+                decorated(ws, cut_ident, ws),
+                opt(preceded(b',', decorated(ws, cut_ident, ws))),
+                preceded(cut_tag("in"), decorated(ws, expr, ws)),
+            ),
+            cut_char(':'),
+        )
+        .map(|(first, second, expr)| match second {
+            Some(second) => {
+                let mut intro = ForIntro::new(second, expr);
+                intro.set_key_var(first);
+                intro
+            }
+            None => ForIntro::new(first, expr),
+        }),
+    )
+    .parse_next(input)
+}
+
+fn for_cond(input: Input) -> IResult<Input, ForCond> {
+    prefix_decorated(
+        ws,
+        preceded(b"if", decorated(ws, expr, ws)).map(ForCond::new),
+    )
+    .parse_next(input)
+}
+
+fn parenthesis<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        delimited(
+            cut_char('('),
+            decorated(ws, expr, ws)
+                .map(|expr| state.borrow_mut().on_expr_term(Parenthesis::new(expr))),
+            cut_char(')'),
+        )
+        .parse_next(input)
+    }
+}
+
+fn heredoc<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        let (input, (indented, delim)) = heredoc_start(input)?;
+
+        let (input, (template, trailing)) = (
+            spanned(heredoc_template(delim)),
+            terminated(
+                raw_string(space0),
+                cut_err(delim).context(Context::Expected(Expected::Description(
+                    "heredoc end delimiter",
+                ))),
+            ),
+        )
+            .parse_next(input)?;
+
+        let mut heredoc = HeredocTemplate::new(Ident::new_unchecked(delim), template);
+
+        if indented {
+            heredoc.dedent();
+        }
+
+        heredoc.set_trailing(trailing);
+
+        state
+            .borrow_mut()
+            .on_expr_term(Expression::HeredocTemplate(Box::new(heredoc)));
+        Ok((input, ()))
+    }
+}
+
+fn heredoc_start(input: Input) -> IResult<Input, (bool, &str)> {
+    terminated(
+        (
+            preceded(b"<<", opt(b'-')).map(|indent| indent.is_some()),
+            cut_err(str_ident).context(Context::Expected(Expected::Description("identifier"))),
+        ),
+        cut_err(line_ending).context(Context::Expected(Expected::Char('\n'))),
+    )
+    .parse_next(input)
+}
+
+fn identlike<'i, 's>(
+    state: &'s RefCell<ExprParseState>,
+) -> impl Parser<Input<'i>, (), ParseError<Input<'i>>> + 's {
+    move |input: Input<'i>| {
+        (str_ident.with_span(), opt(prefix_decorated(ws, func_args)))
+            .map(|((ident, span), func_args)| {
+                let expr = match func_args {
+                    Some(func_args) => {
+                        let mut name = Decorated::new(Ident::new_unchecked(ident));
+                        name.set_span(span);
+                        let func_call = FuncCall::new(name, func_args);
+                        Expression::FuncCall(Box::new(func_call))
+                    }
+                    None => match ident {
+                        "null" => Expression::Null(Null.into()),
+                        "true" => Expression::Bool(true.into()),
+                        "false" => Expression::Bool(false.into()),
+                        var => Expression::Variable(Ident::new_unchecked(var).into()),
+                    },
+                };
+
+                state.borrow_mut().on_expr_term(expr)
+            })
+            .parse_next(input)
+    }
+}
+
+fn func_args(input: Input) -> IResult<Input, FuncArgs> {
+    let args = separated1(
+        decorated(ws, preceded(peek(none_of(",.)")), expr), ws),
+        b',',
+    );
+
+    #[derive(Copy, Clone)]
+    enum Trailer {
+        Comma,
+        Ellipsis,
+    }
+
+    let trailer = dispatch! {any;
+        b',' => success(Trailer::Comma),
+        b'.' => cut_tag("..").value(Trailer::Ellipsis),
+        _ => fail,
+    };
+
+    delimited(
+        b'(',
+        (opt((args, opt(trailer))), raw_string(ws)).map(|(args, trailing)| {
+            let mut args = match args {
+                Some((args, Some(trailer))) => {
+                    let mut args = FuncArgs::new(args);
+                    if let Trailer::Ellipsis = trailer {
+                        args.set_expand_final(true);
+                    } else {
+                        args.set_trailing_comma(true);
+                    }
+                    args
+                }
+                Some((args, None)) => FuncArgs::new(args),
+                None => FuncArgs::default(),
+            };
+
+            args.set_trailing(trailing);
+            args
+        }),
+        cut_char(')'),
+    )
+    .parse_next(input)
+}
diff --git a/crates/hcl-edit/src/parser/mod.rs b/crates/hcl-edit/src/parser/mod.rs
new file mode 100644
index 0000000..7f06f81
--- /dev/null
+++ b/crates/hcl-edit/src/parser/mod.rs
@@ -0,0 +1,55 @@
+//! An HCL parser which keeps track of whitespace, comments and span information.
+
+mod context;
+mod error;
+mod expr;
+mod number;
+mod repr;
+mod state;
+mod string;
+mod structure;
+mod template;
+#[cfg(test)]
+mod tests;
+mod trivia;
+
+pub use self::error::{Error, Location};
+use self::{error::ParseError, expr::expr, structure::body, template::template};
+use crate::{expr::Expression, structure::Body, template::Template};
+use winnow::{stream::Located, Parser};
+
+type Input<'a> = Located<&'a [u8]>;
+
+type IResult<I, O, E = ParseError<I>> = winnow::IResult<I, O, E>;
+
+/// Parse an input into a [`Body`](crate::structure::Body).
+pub fn parse_body(input: &str) -> Result<Body, Error> {
+    let mut body = parse_complete(input, body)?;
+    body.despan(input);
+    Ok(body)
+}
+
+/// Parse an input into an [`Expression`](crate::expr::Expression).
+pub fn parse_expr(input: &str) -> Result<Expression, Error> {
+    let mut expr = parse_complete(input, expr)?;
+    expr.despan(input);
+    Ok(expr)
+}
+
+/// Parse an input into a [`Template`](crate::template::Template).
+pub fn parse_template(input: &str) -> Result<Template, Error> {
+    let mut template = parse_complete(input, template)?;
+    template.despan(input);
+    Ok(template)
+}
+
+fn parse_complete<'a, P, O>(input: &'a str, mut parser: P) -> Result<O, Error>
+where
+    P: Parser<Input<'a>, O, ParseError<Input<'a>>>,
+{
+    let input = Input::new(input.as_bytes());
+
+    parser
+        .parse(input)
+        .map_err(|err| Error::from_parse_error(input, err))
+}
diff --git a/crates/hcl-edit/src/parser/number.rs b/crates/hcl-edit/src/parser/number.rs
new file mode 100644
index 0000000..b24f944
--- /dev/null
+++ b/crates/hcl-edit/src/parser/number.rs
@@ -0,0 +1,94 @@
+use super::{
+    context::{Context, Expected},
+    string::from_utf8_unchecked,
+    IResult, Input,
+};
+use crate::Number;
+use std::str::FromStr;
+use winnow::{
+    branch::alt,
+    bytes::one_of,
+    character::digit1,
+    combinator::{cut_err, opt},
+    sequence::{preceded, terminated},
+    Parser,
+};
+
+pub(super) fn number(input: Input) -> IResult<Input, Number> {
+    alt((
+        float.verify_map(Number::from_f64),
+        integer.map(Number::from),
+    ))
+    .parse_next(input)
+}
+
+fn integer(input: Input) -> IResult<Input, u64> {
+    digit1
+        .map_res(|s: &[u8]| {
+            u64::from_str(unsafe { from_utf8_unchecked(s, "`digit1` filters out non-ascii") })
+        })
+        .parse_next(input)
+}
+
+fn float(input: Input) -> IResult<Input, f64> {
+    let fraction = preceded(b'.', digit1);
+
+    terminated(digit1, alt((terminated(fraction, opt(exponent)), exponent)))
+        .recognize()
+        .map_res(|s: &[u8]| {
+            f64::from_str(unsafe {
+                from_utf8_unchecked(s, "`digit1` and `exponent` filter out non-ascii")
+            })
+        })
+        .parse_next(input)
+}
+
+fn exponent(input: Input) -> IResult<Input, &[u8]> {
+    (
+        one_of("eE"),
+        opt(one_of("+-")),
+        cut_err(digit1).context(Context::Expected(Expected::Description("digit"))),
+    )
+        .recognize()
+        .parse_next(input)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pretty_assertions::assert_eq;
+
+    #[test]
+    fn parse_integer() {
+        let tests: &[(&str, u64)] = &[
+            ("1", 1),
+            ("99", 99),
+            ("0", 0),
+            ("18446744073709551615", u64::MAX),
+        ];
+
+        for (input, expected) in tests {
+            let parsed = integer.parse(Input::new(input.as_bytes()));
+            assert!(parsed.is_ok(), "expected `{input}` to parse correctly");
+            assert_eq!(parsed.unwrap(), *expected);
+        }
+    }
+
+    #[test]
+    fn parse_float() {
+        let tests: &[(&str, f64)] = &[
+            ("1.0", 1.0),
+            ("1e10", 10000000000.0),
+            ("2.5E3", 2500.0),
+            ("42e-3", 0.042),
+            ("0.1E-4", 0.00001),
+            ("1.7976931348623157e308", f64::MAX),
+        ];
+
+        for (input, expected) in tests {
+            let parsed = float.parse(Input::new(input.as_bytes()));
+            assert!(parsed.is_ok(), "expected `{input}` to parse correctly");
+            assert_eq!(parsed.unwrap(), *expected);
+        }
+    }
+}
diff --git a/crates/hcl-edit/src/parser/repr.rs b/crates/hcl-edit/src/parser/repr.rs
new file mode 100644
index 0000000..c71e6de
--- /dev/null
+++ b/crates/hcl-edit/src/parser/repr.rs
@@ -0,0 +1,66 @@
+use super::{error::ParseError, Input};
+use crate::{
+    repr::{Decorate, SetSpan},
+    RawString,
+};
+use winnow::Parser;
+
+pub(super) fn spanned<'a, F, O>(inner: F) -> impl Parser<Input<'a>, O, ParseError<Input<'a>>>
+where
+    F: Parser<Input<'a>, O, ParseError<Input<'a>>>,
+    O: SetSpan,
+{
+    inner.with_span().map(|(mut value, span)| {
+        value.set_span(span);
+        value
+    })
+}
+
+pub(super) fn decorated<'a, F, G, H, O1, O2, O3>(
+    prefix: F,
+    inner: G,
+    suffix: H,
+) -> impl Parser<Input<'a>, O2, ParseError<Input<'a>>>
+where
+    F: Parser<Input<'a>, O1, ParseError<Input<'a>>>,
+    G: Parser<Input<'a>, O2, ParseError<Input<'a>>>,
+    H: Parser<Input<'a>, O3, ParseError<Input<'a>>>,
+    O2: Decorate + SetSpan,
+{
+    (prefix.span(), spanned(inner), suffix.span()).map(|(prefix, mut value, suffix)| {
+        let decor = value.decor_mut();
+        decor.set_prefix(RawString::from_span(prefix));
+        decor.set_suffix(RawString::from_span(suffix));
+        value
+    })
+}
+
+pub(super) fn prefix_decorated<'a, F, G, O1, O2>(
+    prefix: F,
+    inner: G,
+) -> impl Parser<Input<'a>, O2, ParseError<Input<'a>>>
+where
+    F: Parser<Input<'a>, O1, ParseError<Input<'a>>>,
+    G: Parser<Input<'a>, O2, ParseError<Input<'a>>>,
+    O2: Decorate + SetSpan,
+{
+    (prefix.span(), spanned(inner)).map(|(prefix, mut value)| {
+        value.decor_mut().set_prefix(RawString::from_span(prefix));
+        value
+    })
+}
+
+pub(super) fn suffix_decorated<'a, F, G, O1, O2>(
+    inner: F,
+    suffix: G,
+) -> impl Parser<Input<'a>, O1, ParseError<Input<'a>>>
+where
+    F: Parser<Input<'a>, O1, ParseError<Input<'a>>>,
+    G: Parser<Input<'a>, O2, ParseError<Input<'a>>>,
+    O1: Decorate + SetSpan,
+{
+    (spanned(inner), suffix.span()).map(|(mut value, suffix)| {
+        value.decor_mut().set_suffix(RawString::from_span(suffix));
+        value
+    })
+}
diff --git a/crates/hcl-edit/src/parser/state.rs b/crates/hcl-edit/src/parser/state.rs
new file mode 100644
index 0000000..74827c8
--- /dev/null
+++ b/crates/hcl-edit/src/parser/state.rs
@@ -0,0 +1,138 @@
+use crate::{
+    expr::{
+        BinaryOp, BinaryOperator, Conditional, Expression, Traversal, TraversalOperator, UnaryOp,
+        UnaryOperator,
+    },
+    repr::{Decorate, Decorated, SetSpan, Spanned},
+    structure::{Body, Structure},
+    RawString,
+};
+use std::ops::Range;
+
+#[derive(Debug, Default)]
+pub(super) struct BodyParseState {
+    current: Option<Structure>,
+    structures: Vec<Structure>,
+    ws: Option<Range<usize>>,
+}
+
+impl BodyParseState {
+    pub(super) fn on_ws(&mut self, span: Range<usize>) {
+        if let Some(existing) = &self.ws {
+            self.ws = Some(existing.start..span.end);
+        } else {
+            self.ws = Some(span);
+        }
+    }
+
+    pub(super) fn on_structure(&mut self, mut structure: Structure) {
+        if let Some(prefix) = self.ws.take() {
+            structure
+                .decor_mut()
+                .set_prefix(RawString::from_span(prefix));
+        }
+
+        self.current = Some(structure);
+    }
+
+    pub(super) fn on_line_ending(&mut self) {
+        let mut current = self.current.take().unwrap();
+
+        if let Some(suffix) = self.ws.take() {
+            current.decor_mut().set_suffix(RawString::from_span(suffix));
+        }
+
+        self.structures.push(current);
+    }
+
+    pub(super) fn into_body(self) -> Body {
+        Body::from(self.structures)
+    }
+}
+
+#[derive(Debug, Default)]
+pub(super) struct ExprParseState {
+    unary: Option<Spanned<UnaryOperator>>,
+    current: Option<Expression>,
+    ws: Option<Range<usize>>,
+}
+
+impl ExprParseState {
+    pub(super) fn on_ws(&mut self, span: Range<usize>) {
+        if let Some(existing) = &self.ws {
+            self.ws = Some(existing.start..span.end);
+        } else {
+            self.ws = Some(span);
+        }
+    }
+
+    pub(super) fn on_span(&mut self, span: Range<usize>) {
+        if let Some(expr) = &mut self.current {
+            expr.set_span(span);
+        }
+    }
+
+    pub(super) fn on_expr_term(&mut self, expr: impl Into<Expression>) {
+        let mut expr = expr.into();
+        if let Some(operator) = self.unary.take() {
+            expr = Expression::UnaryOp(Box::new(UnaryOp::new(operator, expr)));
+        }
+
+        if let Some(prefix) = self.ws.take() {
+            expr.decor_mut().set_prefix(RawString::from_span(prefix));
+        }
+
+        self.current = Some(expr);
+    }
+
+    pub(super) fn on_unary_op(&mut self, operator: Spanned<UnaryOperator>, ws_span: Range<usize>) {
+        self.unary = Some(operator);
+        self.on_ws(ws_span);
+    }
+
+    pub(super) fn on_traversal(&mut self, operators: Vec<Decorated<TraversalOperator>>) {
+        let mut expr_term = self.current.take().unwrap();
+
+        if let Some(suffix) = self.ws.take() {
+            expr_term
+                .decor_mut()
+                .set_suffix(RawString::from_span(suffix));
+        }
+
+        let traversal = Traversal::new(expr_term, operators);
+        let expr = Expression::Traversal(Box::new(traversal));
+        self.current = Some(expr);
+    }
+
+    pub(super) fn on_conditional(&mut self, true_expr: Expression, false_expr: Expression) {
+        let mut cond_expr = self.current.take().unwrap();
+
+        if let Some(suffix) = self.ws.take() {
+            cond_expr
+                .decor_mut()
+                .set_suffix(RawString::from_span(suffix));
+        }
+
+        let conditional = Conditional::new(cond_expr, true_expr, false_expr);
+        let expr = Expression::Conditional(Box::new(conditional));
+        self.current = Some(expr);
+    }
+
+    pub(super) fn on_binary_op(&mut self, operator: Spanned<BinaryOperator>, rhs_expr: Expression) {
+        let mut lhs_expr = self.current.take().unwrap();
+
+        if let Some(suffix) = self.ws.take() {
+            lhs_expr
+                .decor_mut()
+                .set_suffix(RawString::from_span(suffix));
+        }
+
+        let binary_op = BinaryOp::new(lhs_expr, operator, rhs_expr);
+        let expr = Expression::BinaryOp(Box::new(binary_op));
+        self.current = Some(expr);
+    }
+
+    pub(super) fn into_expr(self) -> Expression {
+        self.current.unwrap()
+    }
+}
diff --git a/crates/hcl-edit/src/parser/string.rs b/crates/hcl-edit/src/parser/string.rs
new file mode 100644
index 0000000..b23f8a8
--- /dev/null
+++ b/crates/hcl-edit/src/parser/string.rs
@@ -0,0 +1,181 @@
+use super::{
+    context::{Context, Expected},
+    error::ParseError,
+    trivia::void,
+    IResult, Input,
+};
+use crate::{repr::Decorated, Ident, RawString};
+use std::borrow::Cow;
+use winnow::{
+    branch::alt,
+    bytes::{any, one_of, tag, take_while0, take_while_m_n},
+    combinator::{cut_err, fail, not, opt, success},
+    dispatch,
+    multi::many1,
+    sequence::{delimited, preceded},
+    stream::AsChar,
+    Parser,
+};
+
+pub(super) fn string(input: Input) -> IResult<Input, String> {
+    delimited(b'"', opt(build_string), b'"')
+        .map(Option::unwrap_or_default)
+        .parse_next(input)
+}
+
+pub(super) fn build_string(input: Input) -> IResult<Input, String> {
+    let (mut input, mut string) = match string_fragment(input) {
+        Ok((input, fragment)) => match fragment {
+            StringFragment::Literal(s) => (input, Cow::Borrowed(s)),
+            StringFragment::EscapedChar(c) => (input, Cow::Owned(String::from(c))),
+        },
+        Err(err) => return Err(err),
+    };
+
+    loop {
+        match string_fragment(input) {
+            Ok((rest, fragment)) => {
+                match fragment {
+                    StringFragment::Literal(s) => string.to_mut().push_str(s),
+                    StringFragment::EscapedChar(c) => string.to_mut().push(c),
+                };
+                input = rest;
+            }
+            Err(_) => return Ok((input, string.into())),
+        }
+    }
+}
+
+/// A string fragment contains a fragment of a string being parsed: either
+/// a non-empty Literal (a series of non-escaped characters) or a single
+/// parsed escaped character.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum StringFragment<'a> {
+    Literal(&'a str),
+    EscapedChar(char),
+}
+
+fn string_fragment(input: Input) -> IResult<Input, StringFragment> {
+    alt((
+        string_literal.map(StringFragment::Literal),
+        escaped_char.map(StringFragment::EscapedChar),
+    ))
+    .parse_next(input)
+}
+
+/// Parse a non-empty block of text that doesn't include `\`,  `"` or non-escaped template
+/// interpolation/directive start markers.
+fn string_literal(input: Input) -> IResult<Input, &str> {
+    let literal_end = dispatch! {any;
+        b'\"' | b'\\' => success(true),
+        b'$' | b'%' => b'{'.value(true),
+        _ => fail,
+    };
+    literal_until(literal_end).parse_next(input)
+}
+
+pub(super) fn literal_until<'a, F, T>(
+    literal_end: F,
+) -> impl Parser<Input<'a>, &'a str, ParseError<Input<'a>>>
+where
+    F: Parser<Input<'a>, T, ParseError<Input<'a>>>,
+{
+    void(many1(alt((
+        tag("$${"),
+        tag("%%{"),
+        preceded(not(literal_end), any).recognize(),
+    ))))
+    .recognize()
+    .map_res(std::str::from_utf8)
+}
+
+/// Parse an escaped character: `\n`, `\t`, `\r`, `\u00AC`, etc.
+fn escaped_char(input: Input) -> IResult<Input, char> {
+    let (input, _) = b'\\'.parse_next(input)?;
+
+    dispatch! {any;
+        b'n' => success('\n'),
+        b'r' => success('\r'),
+        b't' => success('\t'),
+        b'\\' => success('\\'),
+        b'"' => success('"'),
+        b'/' => success('/'),
+        b'b' => success('\u{08}'),
+        b'f' => success('\u{0C}'),
+        b'u' => cut_err(hexescape::<4>)
+            .context(Context::Expression("unicode 4-digit hex code")),
+        b'U' => cut_err(hexescape::<8>)
+            .context(Context::Expression("unicode 8-digit hex code")),
+        _ => cut_err(fail)
+            .context(Context::Expression("escape sequence"))
+            .context(Context::Expected(Expected::Char('b')))
+            .context(Context::Expected(Expected::Char('f')))
+            .context(Context::Expected(Expected::Char('n')))
+            .context(Context::Expected(Expected::Char('r')))
+            .context(Context::Expected(Expected::Char('t')))
+            .context(Context::Expected(Expected::Char('u')))
+            .context(Context::Expected(Expected::Char('U')))
+            .context(Context::Expected(Expected::Char('\\')))
+            .context(Context::Expected(Expected::Char('"'))),
+    }
+    .parse_next(input)
+}
+
+fn hexescape<const N: usize>(input: Input) -> IResult<Input, char> {
+    let parse_hex =
+        take_while_m_n(1, N, |c: u8| c.is_ascii_hexdigit()).verify(|hex: &[u8]| hex.len() == N);
+
+    let parse_u32 = parse_hex.map_res(|hex: &[u8]| {
+        u32::from_str_radix(
+            unsafe { from_utf8_unchecked(hex, "`is_ascii_hexdigit` filters out non-ascii") },
+            16,
+        )
+    });
+
+    parse_u32.verify_map(std::char::from_u32).parse_next(input)
+}
+
+pub(super) fn raw_string<'a, P, O>(
+    inner: P,
+) -> impl Parser<Input<'a>, RawString, ParseError<Input<'a>>>
+where
+    P: Parser<Input<'a>, O, ParseError<Input<'a>>>,
+{
+    inner.span().map(RawString::from_span)
+}
+
+pub(super) fn ident(input: Input) -> IResult<Input, Decorated<Ident>> {
+    str_ident
+        .map(|ident| Decorated::new(Ident::new_unchecked(ident)))
+        .parse_next(input)
+}
+
+pub(super) fn str_ident(input: Input) -> IResult<Input, &str> {
+    (one_of(is_id_start), take_while0(is_id_continue))
+        .recognize()
+        .map(|s: &[u8]| unsafe {
+            from_utf8_unchecked(s, "`is_id_start` and `is_id_continue` filter out non-utf8")
+        })
+        .parse_next(input)
+}
+
+#[inline]
+pub(super) fn is_id_start(b: u8) -> bool {
+    hcl_primitives::ident::is_id_start(b.as_char())
+}
+
+#[inline]
+fn is_id_continue(b: u8) -> bool {
+    hcl_primitives::ident::is_id_continue(b.as_char())
+}
+
+pub(super) unsafe fn from_utf8_unchecked<'b>(
+    bytes: &'b [u8],
+    safety_justification: &'static str,
+) -> &'b str {
+    if cfg!(debug_assertions) {
+        std::str::from_utf8(bytes).expect(safety_justification)
+    } else {
+        std::str::from_utf8_unchecked(bytes)
+    }
+}
diff --git a/crates/hcl-edit/src/parser/structure.rs b/crates/hcl-edit/src/parser/structure.rs
new file mode 100644
index 0000000..b2d7b87
--- /dev/null
+++ b/crates/hcl-edit/src/parser/structure.rs
@@ -0,0 +1,145 @@
+use super::{
+    context::{cut_char, cut_ident, Context, Expected},
+    expr::expr,
+    repr::{decorated, prefix_decorated, suffix_decorated},
+    state::BodyParseState,
+    string::{ident, is_id_start, raw_string, string},
+    trivia::{line_comment, sp, void, ws},
+    IResult, Input,
+};
+use crate::{
+    expr::Expression,
+    repr::{Decorate, Decorated, SetSpan},
+    structure::{Attribute, Block, BlockBody, BlockLabel, Body, Oneline, Structure},
+};
+use std::cell::RefCell;
+use winnow::{
+    branch::alt,
+    bytes::{any, one_of},
+    character::line_ending,
+    combinator::{cut_err, eof, fail, opt, peek},
+    multi::many0,
+    prelude::*,
+    sequence::{delimited, preceded, terminated},
+    stream::Location,
+};
+
+pub(super) fn body(input: Input) -> IResult<Input, Body> {
+    let state = RefCell::new(BodyParseState::default());
+
+    let (input, (span, suffix)) = (
+        void(many0(terminated(
+            (
+                ws.span().map(|span| state.borrow_mut().on_ws(span)),
+                structure(&state),
+                (sp, opt(line_comment))
+                    .span()
+                    .map(|span| state.borrow_mut().on_ws(span)),
+            ),
+            cut_err(alt((line_ending, eof)).map(|_| state.borrow_mut().on_line_ending()))
+                .context(Context::Expected(Expected::Description("newline")))
+                .context(Context::Expected(Expected::Description("eof"))),
+        )))
+        .span(),
+        raw_string(ws),
+    )
+        .parse_next(input)?;
+
+    let mut body = state.into_inner().into_body();
+    body.set_span(span);
+    body.decor_mut().set_suffix(suffix);
+    Ok((input, body))
+}
+
+fn structure<'i, 's>(
+    state: &'s RefCell<BodyParseState>,
+) -> impl FnMut(Input<'i>) -> IResult<Input<'i>, ()> + 's {
+    move |input: Input<'i>| {
+        let start = input.location();
+        let (input, _) = peek(one_of(is_id_start)).parse_next(input)?;
+        let (input, ident) = suffix_decorated(cut_ident, sp).parse_next(input)?;
+        let (input, ch) = peek(any).parse_next(input)?;
+
+        let (input, mut structure) = match ch {
+            b'=' => {
+                let (input, expr) = attribute_expr(input)?;
+                let attr = Attribute::new(ident, expr);
+                (input, Structure::Attribute(attr))
+            }
+            b'{' => {
+                let (input, body) = block_body(input)?;
+                let block = Block::new(ident, body);
+                (input, Structure::Block(block))
+            }
+            ch if ch == b'"' || is_id_start(ch) => {
+                let (input, labels) = block_labels(input)?;
+                let (input, body) = block_body(input)?;
+                let mut block = Block::new(ident, body);
+                block.set_labels(labels);
+                (input, Structure::Block(block))
+            }
+            _ => {
+                return cut_err(fail)
+                    .context(Context::Expression("structure"))
+                    .context(Context::Expected(Expected::Char('{')))
+                    .context(Context::Expected(Expected::Char('=')))
+                    .context(Context::Expected(Expected::Char('"')))
+                    .context(Context::Expected(Expected::Description("identifier")))
+                    .parse_next(input)
+            }
+        };
+
+        let end = input.location();
+        structure.set_span(start..end);
+        state.borrow_mut().on_structure(structure);
+        Ok((input, ()))
+    }
+}
+
+fn attribute_expr(input: Input) -> IResult<Input, Expression> {
+    preceded(
+        cut_char('=').context(Context::Expression("attribute")),
+        prefix_decorated(sp, expr),
+    )
+    .parse_next(input)
+}
+
+fn block_labels(input: Input) -> IResult<Input, Vec<BlockLabel>> {
+    many0(suffix_decorated(block_label, sp)).parse_next(input)
+}
+
+fn block_label(input: Input) -> IResult<Input, BlockLabel> {
+    alt((
+        string.map(|string| BlockLabel::String(Decorated::new(string))),
+        ident.map(BlockLabel::Ident),
+    ))
+    .parse_next(input)
+}
+
+fn block_body(input: Input) -> IResult<Input, BlockBody> {
+    let attribute =
+        (suffix_decorated(ident, sp), attribute_expr).map(|(key, expr)| Attribute::new(key, expr));
+
+    delimited(
+        cut_char('{'),
+        alt((
+            // Multiline block.
+            prefix_decorated((sp, opt(line_comment)), preceded(line_ending, body))
+                .map(BlockBody::Multiline),
+            // One-line block.
+            (opt(decorated(sp, attribute, sp)), raw_string(sp)).map(|(attr, trailing)| {
+                let mut oneline = Oneline::new();
+                if let Some(attr) = attr {
+                    oneline.set_attribute(attr);
+                }
+                oneline.set_trailing(trailing);
+                BlockBody::Oneline(Box::new(oneline))
+            }),
+        )),
+        cut_char('}')
+            .context(Context::Expression("block body"))
+            .context(Context::Expected(Expected::Char('\n')))
+            .context(Context::Expected(Expected::Description("identifier"))),
+    )
+    .parse_next(input)
+}
diff --git a/crates/hcl-edit/src/parser/template.rs b/crates/hcl-edit/src/parser/template.rs
new file mode 100644
index 0000000..9d3b8f0
--- /dev/null
+++ b/crates/hcl-edit/src/parser/template.rs
@@ -0,0 +1,218 @@
+use super::{
+    context::{cut_char, cut_ident, cut_tag},
+    error::ParseError,
+    expr::expr,
+    repr::{decorated, spanned},
+    string::{build_string, from_utf8_unchecked, literal_until, raw_string},
+    trivia::ws,
+    IResult, Input,
+};
+use crate::{
+    repr::{SetSpan, Span, Spanned},
+    template::*,
+};
+use winnow::{
+    branch::alt,
+    character::{line_ending, space0},
+    combinator::opt,
+    multi::many0,
+    sequence::{delimited, preceded, separated_pair, terminated},
+    Parser,
+};
+
+pub(super) fn string_template(input: Input) -> IResult<Input, StringTemplate> {
+    delimited(b'"', elements(build_string), b'"')
+        .output_into()
+        .parse_next(input)
+}
+
+pub(super) fn template(input: Input) -> IResult<Input, Template> {
+    let literal_end = alt((b"${", b"%{"));
+    let literal = literal_until(literal_end).output_into();
+    elements(literal).output_into().parse_next(input)
+}
+
+pub(super) fn heredoc_template<'a>(
+    delim: &'a str,
+) -> impl Parser<Input<'a>, Template, ParseError<Input<'a>>> {
+    move |input: Input<'a>| {
+        // We'll need to look for a line ending followed by optional space and the heredoc
+        // delimiter.
+        //
+        // Here's the catch though: the line ending has to be treated as part of the last template
+        // literal, but is still required to be matched when detecting the heredoc end. Matching
+        // only `(space0, delim)` is not sufficient, because heredocs can contain their delimiter
+        // as long as it's not preceeded by a line ending.
+        //
+        // Handling this case via parser combinators is quite tricky and thus we'll manually add
+        // the line ending to the last template element below.
+        let heredoc_end = (line_ending, space0, delim).recognize();
+        let literal_end = alt((b"${", b"%{", heredoc_end));
+        let literal = literal_until(literal_end).output_into();
+
+        // Use `opt` to handle an empty template.
+        opt((elements(literal), line_ending.with_span()).map(
+            |(mut elements, (line_ending, line_ending_span))| {
+                let line_ending = unsafe {
+                    from_utf8_unchecked(line_ending, "`line_ending` filters out non-ascii")
+                };
+                // If there is a trailing literal, update its span and append the line ending to
+                // it. Otherwise just add a new literal containing only the line ending.
+                if let Some(Element::Literal(lit)) = elements.last_mut() {
+                    let existing_span = lit.span().unwrap();
+                    lit.push_str(line_ending);
+                    lit.set_span(existing_span.start..line_ending_span.end);
+                } else {
+                    let mut lit = Spanned::new(String::from(line_ending));
+                    lit.set_span(line_ending_span);
+                    elements.push(Element::Literal(lit));
+                }
+
+                Template::from(elements)
+            },
+        ))
+        .map(Option::unwrap_or_default)
+        .parse_next(input)
+    }
+}
+
+fn elements<'a, P>(literal: P) -> impl Parser<Input<'a>, Vec<Element>, ParseError<Input<'a>>>
+where
+    P: Parser<Input<'a>, String, ParseError<Input<'a>>>,
+{
+    many0(spanned(alt((
+        literal.map(|s| Element::Literal(Spanned::new(s))),
+        interpolation.map(Element::Interpolation),
+        directive.map(Element::Directive),
+    ))))
+}
+
+fn interpolation(input: Input) -> IResult<Input, Interpolation> {
+    control(b"${", decorated(ws, expr, ws))
+        .map(|(expr, strip)| {
+            let mut interp = Interpolation::new(expr);
+            interp.set_strip(strip);
+            interp
+        })
+        .parse_next(input)
+}
+
+fn directive(input: Input) -> IResult<Input, Directive> {
+    alt((
+        if_directive.map(Directive::If),
+        for_directive.map(Directive::For),
+    ))
+    .parse_next(input)
+}
+
+fn if_directive(input: Input) -> IResult<Input, IfDirective> {
+    let if_expr = (
+        control(
+            b"%{",
+            (terminated(raw_string(ws), b"if"), decorated(ws, expr, ws)),
+        ),
+        spanned(template),
+    )
+        .map(|(((preamble, cond_expr), strip), template)| {
+            let mut expr = IfTemplateExpr::new(cond_expr, template);
+            expr.set_strip(strip);
+            expr.set_preamble(preamble);
+            expr
+        });
+
+    let else_expr = (
+        control(
+            b"%{",
+            separated_pair(raw_string(ws), b"else", raw_string(ws)),
+        ),
+        spanned(template),
+    )
+        .map(|(((preamble, trailing), strip), template)| {
+            let mut expr = ElseTemplateExpr::new(template);
+            expr.set_strip(strip);
+            expr.set_preamble(preamble);
+            expr.set_trailing(trailing);
+            expr
+        });
+
+    let endif_expr = control(
+        b"%{",
+        separated_pair(raw_string(ws), cut_tag("endif"), raw_string(ws)),
+    )
+    .map(|((preamble, trailing), strip)| {
+        let mut expr = EndifTemplateExpr::new();
+        expr.set_strip(strip);
+        expr.set_preamble(preamble);
+        expr.set_trailing(trailing);
+        expr
+    });
+
+    (if_expr, opt(else_expr), endif_expr)
+        .map(|(if_expr, else_expr, endif_expr)| IfDirective::new(if_expr, else_expr, endif_expr))
+        .parse_next(input)
+}
+
+fn for_directive(input: Input) -> IResult<Input, ForDirective> {
+    let for_expr = (
+        control(
+            b"%{",
+            (
+                terminated(raw_string(ws), b"for"),
+                decorated(ws, cut_ident, ws),
+                opt(preceded(b',', decorated(ws, cut_ident, ws))),
+                preceded(cut_tag("in"), decorated(ws, expr, ws)),
+            ),
+        ),
+        spanned(template),
+    )
+        .map(
+            |(((preamble, key_var, value_var, collection_expr), strip), template)| {
+                let (key_var, value_var) = match value_var {
+                    Some(value_var) => (Some(key_var), value_var),
+                    None => (None, key_var),
+                };
+
+                let mut expr = ForTemplateExpr::new(key_var, value_var, collection_expr, template);
+                expr.set_strip(strip);
+                expr.set_preamble(preamble);
+                expr
+            },
+        );
+
+    let endfor_expr = control(
+        b"%{",
+        separated_pair(raw_string(ws), cut_tag("endfor"), raw_string(ws)),
+    )
+    .map(|((preamble, trailing), strip)| {
+        let mut expr = EndforTemplateExpr::new();
+        expr.set_strip(strip);
+        expr.set_preamble(preamble);
+        expr.set_trailing(trailing);
+        expr
+    });
+
+    (for_expr, endfor_expr)
+        .map(|(for_expr, endfor_expr)| ForDirective::new(for_expr, endfor_expr))
+        .parse_next(input)
+}
+
+fn control<'a, S, P, O1, O2>(
+    intro: S,
+    inner: P,
+) -> impl Parser<Input<'a>, (O2, Strip), ParseError<Input<'a>>>
+where
+    S: Parser<Input<'a>, O1, ParseError<Input<'a>>>,
+    P: Parser<Input<'a>, O2, ParseError<Input<'a>>>,
+{
+    (
+        preceded(intro, opt(b'~')),
+        inner,
+        terminated(opt(b'~'), cut_char('}')),
+    )
+        .map(|(strip_start, output, strip_end)| {
+            (
+                output,
+                Strip::from((strip_start.is_some(), strip_end.is_some())),
+            )
+        })
+}
diff --git a/crates/hcl-edit/src/parser/trivia.rs b/crates/hcl-edit/src/parser/trivia.rs
new file mode 100644
index 0000000..62f8076
--- /dev/null
+++ b/crates/hcl-edit/src/parser/trivia.rs
@@ -0,0 +1,112 @@
+use super::{IResult, Input};
+use winnow::{
+    branch::alt,
+    bytes::{any, take_until0},
+    character::{multispace0, not_line_ending, space0},
+    combinator::{fail, peek},
+    dispatch,
+    multi::many0,
+    sequence::{delimited, preceded},
+    Parser,
+};
+
+pub(super) fn ws(input: Input) -> IResult<Input, ()> {
+    (
+        multispace0.void(),
+        void(many0((comment, multispace0.void()))),
+    )
+        .void()
+        .parse_next(input)
+}
+
+pub(super) fn sp(input: Input) -> IResult<Input, ()> {
+    (space0.void(), void(many0((inline_comment, space0.void()))))
+        .void()
+        .parse_next(input)
+}
+
+fn comment(input: Input) -> IResult<Input, ()> {
+    dispatch! {peek(any);
+        b'#' => hash_line_comment,
+        b'/' => alt((double_slash_line_comment, inline_comment)),
+        _ => fail,
+    }
+    .parse_next(input)
+}
+
+pub(super) fn line_comment(input: Input) -> IResult<Input, ()> {
+    dispatch! {peek(any);
+        b'#' => hash_line_comment,
+        b'/' => double_slash_line_comment,
+        _ => fail,
+    }
+    .parse_next(input)
+}
+
+fn hash_line_comment(input: Input) -> IResult<Input, ()> {
+    preceded(b'#', not_line_ending).void().parse_next(input)
+}
+
+fn double_slash_line_comment(input: Input) -> IResult<Input, ()> {
+    preceded(b"//", not_line_ending).void().parse_next(input)
+}
+
+fn inline_comment(input: Input) -> IResult<Input, ()> {
+    delimited(b"/*", take_until0("*/"), b"*/")
+        .void()
+        .parse_next(input)
+}
+
+#[inline]
+pub(super) fn void<P, I, E>(inner: P) -> impl Parser<I, (), E>
+where
+    P: Parser<I, (), E>,
+{
+    inner
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_comments() {
+        let inline_comments = [
+            "",
+            " ",
+            "/**/  ",
+            "/*foo*/",
+            " /* foo
+                bar
+                */  /* baz */\t",
+        ];
+
+        let multiline_comments = [
+            "# foo
+                # bar",
+            "
+            /* foo
+                bar
+                */  # baz */",
+            "
+                // foo #
+                // bar /*
+                # baz",
+        ];
+
+        for input in inline_comments {
+            let parsed = sp.parse(Input::new(input.as_bytes()));
+            assert!(parsed.is_ok(), "expected `{input}` to parse correctly");
+        }
+
+        for input in multiline_comments {
+            let parsed = sp.parse(Input::new(input.as_bytes()));
+            assert!(parsed.is_err(), "expected parse error for `{input}`");
+        }
+
+        for input in inline_comments.iter().chain(multiline_comments.iter()) {
+            let parsed = ws.parse(Input::new(input.as_bytes()));
+            assert!(parsed.is_ok(), "expected `{input}` to parse correctly");
+        }
+    }
+}
diff --git a/crates/hcl-edit/src/raw_string.rs b/crates/hcl-edit/src/raw_string.rs
new file mode 100644
index 0000000..b46cd9b
--- /dev/null
+++ b/crates/hcl-edit/src/raw_string.rs
@@ -0,0 +1,98 @@
+use crate::encode::EncodeState;
+use hcl_primitives::InternalString;
+use std::fmt::Write;
+use std::ops::Range;
+
+/// Opaque string storage for raw HCL.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RawString(RawStringInner);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RawStringInner {
+    Empty,
+    Spanned(Range<usize>),
+    Explicit(InternalString),
+}
+
+impl RawString {
+    pub(crate) fn from_span(span: Range<usize>) -> Self {
+        if span.is_empty() {
+            RawString(RawStringInner::Empty)
+        } else {
+            RawString(RawStringInner::Spanned(span))
+        }
+    }
+
+    pub(crate) fn span(&self) -> Option<Range<usize>> {
+        match &self.0 {
+            RawStringInner::Empty | RawStringInner::Explicit(_) => None,
+            RawStringInner::Spanned(span) => Some(span.clone()),
+        }
+    }
+
+    /// Returns the `RawString` as a `&str`.
+    pub(crate) fn as_str(&self) -> &str {
+        match &self.0 {
+            RawStringInner::Empty | RawStringInner::Spanned(_) => "",
+            RawStringInner::Explicit(s) => s.as_str(),
+        }
+    }
+
+    pub(crate) fn encode_with_default(
+        &self,
+        buf: &mut EncodeState,
+        default: &str,
+    ) -> std::fmt::Result {
+        if let RawStringInner::Spanned(_) = self.0 {
+            buf.write_str(default)
+        } else {
+            buf.write_str(self.as_str())
+        }
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        match &self.0 {
+            RawStringInner::Empty | RawStringInner::Explicit(_) => {}
+            RawStringInner::Spanned(span) => {
+                *self = RawString::from(input.get(span.clone()).unwrap_or_else(|| {
+                    panic!("span {span:?} should be in input:\n```\n{input}\n```")
+                }));
+            }
+        }
+    }
+}
+
+impl Default for RawString {
+    fn default() -> Self {
+        RawString(RawStringInner::Empty)
+    }
+}
+
+impl From<&str> for RawString {
+    #[inline]
+    fn from(s: &str) -> Self {
+        if s.is_empty() {
+            RawString(RawStringInner::Empty)
+        } else {
+            RawString::from(InternalString::from(s))
+        }
+    }
+}
+
+impl From<String> for RawString {
+    #[inline]
+    fn from(s: String) -> Self {
+        if s.is_empty() {
+            RawString(RawStringInner::Empty)
+        } else {
+            RawString::from(InternalString::from(s))
+        }
+    }
+}
+
+impl From<InternalString> for RawString {
+    #[inline]
+    fn from(inner: InternalString) -> Self {
+        RawString(RawStringInner::Explicit(inner))
+    }
+}
diff --git a/crates/hcl-edit/src/repr.rs b/crates/hcl-edit/src/repr.rs
new file mode 100644
index 0000000..b8226d4
--- /dev/null
+++ b/crates/hcl-edit/src/repr.rs
@@ -0,0 +1,465 @@
+//! Representations of values within a HCL document.
+
+use crate::encode::{Encode, EncodeState};
+use crate::raw_string::RawString;
+use std::fmt::{self, Write};
+use std::ops::{Deref, DerefMut, Range};
+
+/// Represents the whitespace and comments before (the "prefix") or after (the "suffix") a HCL value.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct Decor {
+    prefix: Option<RawString>,
+    suffix: Option<RawString>,
+}
+
+impl Decor {
+    /// Creates a new `Decor` for a prefix and a suffix.
+    pub fn new(prefix: impl Into<RawString>, suffix: impl Into<RawString>) -> Decor {
+        Decor {
+            prefix: Some(prefix.into()),
+            suffix: Some(suffix.into()),
+        }
+    }
+
+    /// Sets the decor prefix.
+    pub fn set_prefix(&mut self, prefix: impl Into<RawString>) {
+        self.prefix = Some(prefix.into());
+    }
+
+    /// Sets the decor suffix.
+    pub fn set_suffix(&mut self, suffix: impl Into<RawString>) {
+        self.suffix = Some(suffix.into());
+    }
+
+    /// Returns a reference to the decor prefix, if one is present, `None` otherwise.
+    pub fn prefix(&self) -> Option<&RawString> {
+        self.prefix.as_ref()
+    }
+
+    /// Returns a reference to the decor suffix, if one is present, `None` otherwise.
+    pub fn suffix(&self) -> Option<&RawString> {
+        self.suffix.as_ref()
+    }
+
+    /// Clears the decor prefix and suffix.
+    pub fn clear(&mut self) {
+        self.prefix = None;
+        self.suffix = None;
+    }
+
+    pub(crate) fn encode_prefix(&self, buf: &mut EncodeState, default: &str) -> fmt::Result {
+        if let Some(prefix) = self.prefix() {
+            prefix.encode_with_default(buf, default)
+        } else {
+            buf.write_str(default)
+        }
+    }
+
+    pub(crate) fn encode_suffix(&self, buf: &mut EncodeState, default: &str) -> fmt::Result {
+        if let Some(suffix) = self.suffix() {
+            suffix.encode_with_default(buf, default)
+        } else {
+            buf.write_str(default)
+        }
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        if let Some(prefix) = &mut self.prefix {
+            prefix.despan(input);
+        }
+
+        if let Some(suffix) = &mut self.suffix {
+            suffix.despan(input);
+        }
+    }
+}
+
+impl<P, S> From<(P, S)> for Decor
+where
+    P: Into<RawString>,
+    S: Into<RawString>,
+{
+    fn from((prefix, suffix): (P, S)) -> Self {
+        Decor::new(prefix, suffix)
+    }
+}
+
+/// A trait for objects which carry span information.
+pub trait Span {
+    /// Obtains the span information. This only returns `Some` if the value was emitted by the
+    /// parser.
+    ///
+    /// The returned range represents a zero-based start and end byte offset in the input from
+    /// which this object was parsed.
+    fn span(&self) -> Option<Range<usize>>;
+}
+
+pub(crate) trait SetSpan {
+    fn set_span(&mut self, span: Range<usize>);
+}
+
+/// A trait for objects which can be decorated with whitespace and comments.
+pub trait Decorate {
+    /// Returns a reference to the object's [`Decor`].
+    fn decor(&self) -> &Decor;
+
+    /// Returns a mutable reference to the object's [`Decor`].
+    fn decor_mut(&mut self) -> &mut Decor;
+
+    /// Decorate the object with `decor` in-place.
+    fn decorate(&mut self, decor: impl Into<Decor>) {
+        *self.decor_mut() = decor.into();
+    }
+
+    /// Decorate the object with `decor` and return the modified value.
+    fn decorated(mut self, decor: impl Into<Decor>) -> Self
+    where
+        Self: Sized,
+    {
+        self.decorate(decor);
+        self
+    }
+}
+
+/// A wrapper type for attaching span information to a value.
+#[derive(Debug, Clone, Eq)]
+pub struct Spanned<T> {
+    value: T,
+    span: Option<Range<usize>>,
+}
+
+impl<T> Spanned<T> {
+    /// Creates a new `Spanned<T>` from a `T`.
+    pub fn new(value: T) -> Spanned<T> {
+        Spanned { value, span: None }
+    }
+
+    /// Consumes the `Spanned<T>` and returns the wrapped value.
+    pub fn into_value(self) -> T {
+        self.value
+    }
+
+    /// Returns a reference to the wrapped value.
+    pub fn value(&self) -> &T {
+        &self.value
+    }
+
+    /// Returns a mutable reference to the wrapped value.
+    pub fn value_mut(&mut self) -> &mut T {
+        &mut self.value
+    }
+}
+
+impl<T> PartialEq for Spanned<T>
+where
+    T: PartialEq,
+{
+    fn eq(&self, other: &Self) -> bool {
+        self.value == other.value
+    }
+}
+
+impl<T> AsRef<T> for Spanned<T> {
+    #[inline]
+    fn as_ref(&self) -> &T {
+        self.value()
+    }
+}
+
+impl<T> AsMut<T> for Spanned<T> {
+    #[inline]
+    fn as_mut(&mut self) -> &mut T {
+        self.value_mut()
+    }
+}
+
+impl<T> Deref for Spanned<T> {
+    type Target = T;
+
+    #[inline]
+    fn deref(&self) -> &T {
+        self.as_ref()
+    }
+}
+
+impl<T> DerefMut for Spanned<T> {
+    #[inline]
+    fn deref_mut(&mut self) -> &mut T {
+        self.as_mut()
+    }
+}
+
+impl<T> From<T> for Spanned<T> {
+    fn from(value: T) -> Self {
+        Spanned::new(value)
+    }
+}
+
+impl<T> Span for Spanned<T> {
+    fn span(&self) -> Option<Range<usize>> {
+        self.span.clone()
+    }
+}
+
+impl<T> SetSpan for Spanned<T> {
+    fn set_span(&mut self, span: Range<usize>) {
+        self.span = Some(span);
+    }
+}
+
+impl<T> fmt::Display for Spanned<T>
+where
+    T: Encode,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode(&mut state)
+    }
+}
+
+/// A wrapper type for attaching a [`Decor`] and span information to a value.
+#[derive(Debug, Clone, Eq)]
+pub struct Decorated<T> {
+    value: T,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl<T> Decorated<T> {
+    /// Creates a new `Decorated<T>` from a `T`.
+    pub fn new(value: T) -> Decorated<T> {
+        Decorated {
+            value,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    /// Consumes the `Decorated<T>` and returns the wrapped value.
+    pub fn into_value(self) -> T {
+        self.value
+    }
+
+    /// Returns a reference to the wrapped value.
+    pub fn value(&self) -> &T {
+        &self.value
+    }
+
+    /// Returns a mutable reference to the wrapped value.
+    pub fn value_mut(&mut self) -> &mut T {
+        &mut self.value
+    }
+}
+
+impl<T> PartialEq for Decorated<T>
+where
+    T: PartialEq,
+{
+    fn eq(&self, other: &Self) -> bool {
+        self.value == other.value
+    }
+}
+
+impl<T> AsRef<T> for Decorated<T> {
+    #[inline]
+    fn as_ref(&self) -> &T {
+        self.value()
+    }
+}
+
+impl<T> AsMut<T> for Decorated<T> {
+    #[inline]
+    fn as_mut(&mut self) -> &mut T {
+        self.value_mut()
+    }
+}
+
+impl<T> Deref for Decorated<T> {
+    type Target = T;
+
+    #[inline]
+    fn deref(&self) -> &T {
+        self.as_ref()
+    }
+}
+
+impl<T> DerefMut for Decorated<T> {
+    #[inline]
+    fn deref_mut(&mut self) -> &mut T {
+        self.as_mut()
+    }
+}
+
+impl<T> From<T> for Decorated<T> {
+    fn from(value: T) -> Self {
+        Decorated::new(value)
+    }
+}
+
+impl<T> Decorate for Decorated<T> {
+    fn decor(&self) -> &Decor {
+        &self.decor
+    }
+
+    fn decor_mut(&mut self) -> &mut Decor {
+        &mut self.decor
+    }
+}
+
+impl<T> Span for Decorated<T> {
+    fn span(&self) -> Option<Range<usize>> {
+        self.span.clone()
+    }
+}
+
+impl<T> SetSpan for Decorated<T> {
+    fn set_span(&mut self, span: Range<usize>) {
+        self.span = Some(span);
+    }
+}
+
+impl<T> fmt::Display for Decorated<T>
+where
+    T: Encode,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode(&mut state)
+    }
+}
+
+/// A wrapper type for a value together with its `ToString` representation with additional
+/// [`Decor`] and span infromation attached.
+#[derive(Debug, Clone, Eq)]
+pub struct Formatted<T> {
+    value: T,
+    decor: Decor,
+    repr: Option<RawString>,
+    span: Option<Range<usize>>,
+}
+
+impl<T> Formatted<T>
+where
+    T: ToString,
+{
+    /// Creates a new `Formatted<T>` from a `T`.
+    pub fn new(value: T) -> Formatted<T> {
+        Formatted {
+            value,
+            decor: Decor::default(),
+            repr: None,
+            span: None,
+        }
+    }
+
+    /// Returns the raw string representation of the value, if any.
+    pub fn as_repr(&self) -> Option<&RawString> {
+        self.repr.as_ref()
+    }
+
+    pub(crate) fn set_repr(&mut self, repr: impl Into<RawString>) {
+        self.repr = Some(repr.into());
+    }
+
+    /// Formats the value using its `ToString` representation.
+    pub fn format(&mut self) {
+        self.set_repr(self.value.to_string())
+    }
+}
+
+impl<T> Formatted<T> {
+    /// Consumes the `Formatted<T>` and returns the wrapped value.
+    pub fn into_value(self) -> T {
+        self.value
+    }
+
+    /// Returns a reference to the wrapped value.
+    pub fn value(&self) -> &T {
+        &self.value
+    }
+
+    /// Returns a mutable reference to the wrapped value.
+    pub fn value_mut(&mut self) -> &mut T {
+        &mut self.value
+    }
+}
+
+impl<T> PartialEq for Formatted<T>
+where
+    T: PartialEq,
+{
+    fn eq(&self, other: &Self) -> bool {
+        self.value == other.value
+    }
+}
+
+impl<T> AsRef<T> for Formatted<T> {
+    #[inline]
+    fn as_ref(&self) -> &T {
+        self.value()
+    }
+}
+
+impl<T> AsMut<T> for Formatted<T> {
+    #[inline]
+    fn as_mut(&mut self) -> &mut T {
+        self.value_mut()
+    }
+}
+
+impl<T> Deref for Formatted<T> {
+    type Target = T;
+
+    #[inline]
+    fn deref(&self) -> &T {
+        self.as_ref()
+    }
+}
+
+impl<T> DerefMut for Formatted<T> {
+    #[inline]
+    fn deref_mut(&mut self) -> &mut T {
+        self.as_mut()
+    }
+}
+
+impl<T> From<T> for Formatted<T>
+where
+    T: ToString,
+{
+    fn from(value: T) -> Self {
+        Formatted::new(value)
+    }
+}
+
+impl<T> Decorate for Formatted<T> {
+    fn decor(&self) -> &Decor {
+        &self.decor
+    }
+
+    fn decor_mut(&mut self) -> &mut Decor {
+        &mut self.decor
+    }
+}
+
+impl<T> Span for Formatted<T> {
+    fn span(&self) -> Option<Range<usize>> {
+        self.span.clone()
+    }
+}
+
+impl<T> SetSpan for Formatted<T> {
+    fn set_span(&mut self, span: Range<usize>) {
+        self.span = Some(span);
+    }
+}
+
+impl<T> fmt::Display for Formatted<T>
+where
+    T: Encode,
+{
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode(&mut state)
+    }
+}
diff --git a/crates/hcl-edit/src/structure/mod.rs b/crates/hcl-edit/src/structure/mod.rs
new file mode 100644
index 0000000..9001ab1
--- /dev/null
+++ b/crates/hcl-edit/src/structure/mod.rs
@@ -0,0 +1,405 @@
+//! Types to represent the HCL structural sub-language.
+
+#![allow(missing_docs)]
+
+use crate::encode::{Encode, EncodeState};
+use crate::expr::Expression;
+use crate::repr::{Decor, Decorate, Decorated, SetSpan, Span};
+use crate::{parser, Ident, RawString};
+use std::fmt;
+use std::ops::Range;
+use std::str::FromStr;
+
+pub type Iter<'a> = Box<dyn Iterator<Item = &'a Structure> + 'a>;
+
+pub type IterMut<'a> = Box<dyn Iterator<Item = &'a mut Structure> + 'a>;
+
+pub type LabelIter<'a> = Box<dyn Iterator<Item = &'a BlockLabel> + 'a>;
+
+pub type LabelIterMut<'a> = Box<dyn Iterator<Item = &'a mut BlockLabel> + 'a>;
+
+#[derive(Debug, Clone, Default, Eq)]
+pub struct Body {
+    structures: Vec<Structure>,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Body {
+    pub fn new() -> Body {
+        Body::default()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.structures.is_empty()
+    }
+
+    pub fn len(&self) -> usize {
+        self.structures.len()
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        Box::new(self.structures.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        Box::new(self.structures.iter_mut())
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        for structure in &mut self.structures {
+            structure.despan(input);
+        }
+    }
+}
+
+impl PartialEq for Body {
+    fn eq(&self, other: &Self) -> bool {
+        self.structures == other.structures
+    }
+}
+
+impl fmt::Display for Body {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode(&mut state)
+    }
+}
+
+impl FromStr for Body {
+    type Err = parser::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        parser::parse_body(s)
+    }
+}
+
+impl From<Vec<Structure>> for Body {
+    fn from(structures: Vec<Structure>) -> Self {
+        Body {
+            structures,
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Structure {
+    Attribute(Attribute),
+    Block(Block),
+}
+
+impl Structure {
+    pub fn is_attribute(&self) -> bool {
+        self.as_attribute().is_some()
+    }
+
+    pub fn is_block(&self) -> bool {
+        self.as_block().is_some()
+    }
+
+    pub fn as_attribute(&self) -> Option<&Attribute> {
+        match self {
+            Structure::Attribute(attr) => Some(attr),
+            Structure::Block(_) => None,
+        }
+    }
+
+    pub fn as_attribute_mut(&mut self) -> Option<&mut Attribute> {
+        match self {
+            Structure::Attribute(attr) => Some(attr),
+            Structure::Block(_) => None,
+        }
+    }
+
+    pub fn as_block(&self) -> Option<&Block> {
+        match self {
+            Structure::Block(block) => Some(block),
+            Structure::Attribute(_) => None,
+        }
+    }
+
+    pub fn as_block_mut(&mut self) -> Option<&mut Block> {
+        match self {
+            Structure::Block(block) => Some(block),
+            Structure::Attribute(_) => None,
+        }
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            Structure::Attribute(attr) => attr.despan(input),
+            Structure::Block(block) => block.despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Attribute {
+    key: Decorated<Ident>,
+    expr: Expression,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Attribute {
+    pub fn new(key: Decorated<Ident>, expr: Expression) -> Attribute {
+        Attribute {
+            key,
+            expr,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn key(&self) -> &Decorated<Ident> {
+        &self.key
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.key.decor_mut().despan(input);
+        self.expr.despan(input);
+    }
+}
+
+impl PartialEq for Attribute {
+    fn eq(&self, other: &Self) -> bool {
+        self.key == other.key && self.expr == other.expr
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Block {
+    identifier: Decorated<Ident>,
+    labels: Vec<BlockLabel>,
+    body: BlockBody,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl Block {
+    pub fn new(ident: Decorated<Ident>, body: BlockBody) -> Block {
+        Block {
+            identifier: ident,
+            labels: Vec::new(),
+            body,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn ident(&self) -> &Decorated<Ident> {
+        &self.identifier
+    }
+
+    pub fn labels(&self) -> LabelIter<'_> {
+        Box::new(self.labels.iter())
+    }
+
+    pub fn labels_mut(&mut self) -> LabelIterMut<'_> {
+        Box::new(self.labels.iter_mut())
+    }
+
+    pub fn set_labels(&mut self, labels: Vec<BlockLabel>) {
+        self.labels = labels;
+    }
+
+    pub fn body(&self) -> &BlockBody {
+        &self.body
+    }
+
+    pub fn body_mut(&mut self) -> &mut BlockBody {
+        &mut self.body
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.identifier.decor_mut().despan(input);
+        for label in &mut self.labels {
+            label.despan(input);
+        }
+        self.body.despan(input);
+    }
+}
+
+impl PartialEq for Block {
+    fn eq(&self, other: &Self) -> bool {
+        self.identifier == other.identifier
+            && self.labels == other.labels
+            && self.body == other.body
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum BlockLabel {
+    Ident(Decorated<Ident>),
+    String(Decorated<String>),
+}
+
+impl BlockLabel {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            BlockLabel::Ident(ident) => ident.decor_mut().despan(input),
+            BlockLabel::String(expr) => expr.decor_mut().despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum BlockBody {
+    Multiline(Body),
+    Oneline(Box<Oneline>),
+}
+
+impl BlockBody {
+    pub fn is_empty(&self) -> bool {
+        match self {
+            BlockBody::Multiline(body) => body.is_empty(),
+            BlockBody::Oneline(oneline) => oneline.is_empty(),
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        match self {
+            BlockBody::Multiline(body) => body.len(),
+            BlockBody::Oneline(oneline) => {
+                if oneline.is_empty() {
+                    0
+                } else {
+                    1
+                }
+            }
+        }
+    }
+
+    pub fn is_multiline(&self) -> bool {
+        self.as_multiline().is_some()
+    }
+
+    pub fn is_oneline(&self) -> bool {
+        self.as_oneline().is_some()
+    }
+
+    pub fn as_multiline(&self) -> Option<&Body> {
+        match self {
+            BlockBody::Multiline(body) => Some(body),
+            BlockBody::Oneline(_) => None,
+        }
+    }
+
+    pub fn as_multiline_mut(&mut self) -> Option<&mut Body> {
+        match self {
+            BlockBody::Multiline(body) => Some(body),
+            BlockBody::Oneline(_) => None,
+        }
+    }
+
+    pub fn as_oneline(&self) -> Option<&Oneline> {
+        match self {
+            BlockBody::Multiline(_) => None,
+            BlockBody::Oneline(oneline) => Some(oneline),
+        }
+    }
+
+    pub fn as_oneline_mut(&mut self) -> Option<&mut Oneline> {
+        match self {
+            BlockBody::Multiline(_) => None,
+            BlockBody::Oneline(oneline) => Some(oneline),
+        }
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        match self {
+            BlockBody::Multiline(body) => body.iter(),
+            BlockBody::Oneline(oneline) => match &oneline.attr {
+                Some(attr) => Box::new(std::iter::once(attr)),
+                None => Box::new(std::iter::empty()),
+            },
+        }
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        match self {
+            BlockBody::Multiline(body) => body.iter_mut(),
+            BlockBody::Oneline(oneline) => match &mut oneline.attr {
+                Some(attr) => Box::new(std::iter::once(attr)),
+                None => Box::new(std::iter::empty()),
+            },
+        }
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            BlockBody::Multiline(body) => body.despan(input),
+            BlockBody::Oneline(oneline) => oneline.despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct Oneline {
+    attr: Option<Structure>,
+    trailing: RawString,
+}
+
+impl Oneline {
+    pub fn new() -> Oneline {
+        Oneline::default()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.attr.is_none()
+    }
+
+    pub fn set_attribute(&mut self, attr: Attribute) {
+        self.attr = Some(Structure::Attribute(attr))
+    }
+
+    pub fn as_attribute(&self) -> Option<&Attribute> {
+        self.attr.as_ref().and_then(|s| s.as_attribute())
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        if let Some(attr) = &mut self.attr {
+            attr.despan(input);
+        }
+        self.trailing.despan(input);
+    }
+}
+
+impl From<Attribute> for Oneline {
+    fn from(attr: Attribute) -> Self {
+        Oneline {
+            attr: Some(Structure::Attribute(attr)),
+            trailing: RawString::default(),
+        }
+    }
+}
+
+decorate_impl! { Body, Attribute, Block }
+
+span_impl! { Body, Attribute, Block }
+
+forward_decorate_impl! {
+    Structure => { Attribute, Block },
+    BlockLabel => { Ident, String },
+}
+
+forward_span_impl! {
+    Structure => { Attribute, Block },
+    BlockLabel => { Ident, String }
+}
diff --git a/crates/hcl-edit/src/template/mod.rs b/crates/hcl-edit/src/template/mod.rs
new file mode 100644
index 0000000..b707736
--- /dev/null
+++ b/crates/hcl-edit/src/template/mod.rs
@@ -0,0 +1,643 @@
+//! Types to represent the HCL template sub-language.
+
+#![allow(missing_docs)]
+
+use crate::encode::{Encode, EncodeState};
+use crate::expr::Expression;
+use crate::repr::{Decor, Decorate, Decorated, SetSpan, Span, Spanned};
+use crate::util::{dedent_by, min_leading_whitespace};
+use crate::{parser, Ident, RawString};
+use std::fmt;
+use std::ops::Range;
+use std::str::FromStr;
+
+// Re-exported for convenience.
+#[doc(inline)]
+pub use hcl_primitives::template::Strip;
+
+pub type Iter<'a> = Box<dyn Iterator<Item = &'a Element> + 'a>;
+
+pub type IterMut<'a> = Box<dyn Iterator<Item = &'a mut Element> + 'a>;
+
+#[derive(Debug, Clone, Eq, Default)]
+pub struct StringTemplate {
+    elements: Vec<Element>,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl StringTemplate {
+    pub fn new() -> StringTemplate {
+        StringTemplate::default()
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        Box::new(self.elements.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        Box::new(self.elements.iter_mut())
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        for element in &mut self.elements {
+            element.despan(input);
+        }
+    }
+}
+
+impl From<Vec<Element>> for StringTemplate {
+    fn from(elements: Vec<Element>) -> Self {
+        StringTemplate {
+            elements,
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+}
+
+impl PartialEq for StringTemplate {
+    fn eq(&self, other: &Self) -> bool {
+        self.elements == other.elements
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct HeredocTemplate {
+    delimiter: Ident,
+    template: Template,
+    indent: Option<usize>,
+    trailing: RawString,
+    decor: Decor,
+    span: Option<Range<usize>>,
+}
+
+impl HeredocTemplate {
+    pub fn new(delimiter: Ident, template: Template) -> HeredocTemplate {
+        HeredocTemplate {
+            delimiter,
+            template,
+            indent: None,
+            trailing: RawString::default(),
+            decor: Decor::default(),
+            span: None,
+        }
+    }
+
+    pub fn delimiter(&self) -> &Ident {
+        &self.delimiter
+    }
+
+    pub fn template(&self) -> &Template {
+        &self.template
+    }
+
+    pub fn indent(&self) -> Option<usize> {
+        self.indent
+    }
+
+    pub fn set_indent(&mut self, indent: usize) {
+        self.indent = Some(indent);
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub fn dedent(&mut self) {
+        let mut indent: Option<usize> = None;
+        let mut skip_first = false;
+
+        for element in self.template.iter() {
+            if let Element::Literal(literal) = element {
+                let leading_ws = min_leading_whitespace(literal, skip_first);
+                indent = Some(indent.map_or(leading_ws, |indent| indent.min(leading_ws)));
+                skip_first = !literal.ends_with('\n');
+            } else {
+                skip_first = true;
+            }
+        }
+
+        if let Some(indent) = indent {
+            skip_first = false;
+
+            for element in self.template.iter_mut() {
+                if let Element::Literal(literal) = element {
+                    let dedented = dedent_by(literal, indent, skip_first);
+                    *literal.as_mut() = dedented.into();
+                    skip_first = !literal.ends_with('\n');
+                } else {
+                    skip_first = true;
+                }
+            }
+
+            self.set_indent(indent);
+        }
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.decor.despan(input);
+        self.template.despan(input);
+        self.trailing.despan(input);
+    }
+}
+
+impl PartialEq for HeredocTemplate {
+    fn eq(&self, other: &Self) -> bool {
+        self.delimiter == other.delimiter
+            && self.template == other.template
+            && self.indent == other.indent
+            && self.trailing == other.trailing
+    }
+}
+
+#[derive(Debug, Clone, Eq, Default)]
+pub struct Template {
+    elements: Vec<Element>,
+    span: Option<Range<usize>>,
+}
+
+impl Template {
+    pub fn new() -> Template {
+        Template::default()
+    }
+
+    pub fn iter(&self) -> Iter<'_> {
+        Box::new(self.elements.iter())
+    }
+
+    pub fn iter_mut(&mut self) -> IterMut<'_> {
+        Box::new(self.elements.iter_mut())
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        for element in &mut self.elements {
+            element.despan(input);
+        }
+    }
+}
+
+impl PartialEq for Template {
+    fn eq(&self, other: &Self) -> bool {
+        self.elements == other.elements
+    }
+}
+
+impl fmt::Display for Template {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut state = EncodeState::new(f);
+        self.encode(&mut state)
+    }
+}
+
+impl FromStr for Template {
+    type Err = parser::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        parser::parse_template(s)
+    }
+}
+
+impl From<Vec<Element>> for Template {
+    fn from(elements: Vec<Element>) -> Self {
+        Template {
+            elements,
+            span: None,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Element {
+    Literal(Spanned<String>),
+    Interpolation(Interpolation),
+    Directive(Directive),
+}
+
+impl Element {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            Element::Literal(_) => {}
+            Element::Interpolation(interp) => interp.despan(input),
+            Element::Directive(dir) => dir.despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct Interpolation {
+    expr: Expression,
+    strip: Strip,
+    span: Option<Range<usize>>,
+}
+
+impl Interpolation {
+    pub fn new(expr: Expression) -> Interpolation {
+        Interpolation {
+            expr,
+            strip: Strip::default(),
+            span: None,
+        }
+    }
+
+    pub fn expr(&self) -> &Expression {
+        &self.expr
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.expr.despan(input);
+    }
+}
+
+impl PartialEq for Interpolation {
+    fn eq(&self, other: &Self) -> bool {
+        self.expr == other.expr && self.strip == other.strip
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Directive {
+    If(IfDirective),
+    For(ForDirective),
+}
+
+impl Directive {
+    pub(crate) fn despan(&mut self, input: &str) {
+        match self {
+            Directive::If(dir) => dir.despan(input),
+            Directive::For(dir) => dir.despan(input),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct IfDirective {
+    if_expr: IfTemplateExpr,
+    else_expr: Option<ElseTemplateExpr>,
+    endif_expr: EndifTemplateExpr,
+    span: Option<Range<usize>>,
+}
+
+impl IfDirective {
+    pub fn new(
+        if_expr: IfTemplateExpr,
+        else_expr: Option<ElseTemplateExpr>,
+        endif_expr: EndifTemplateExpr,
+    ) -> IfDirective {
+        IfDirective {
+            if_expr,
+            else_expr,
+            endif_expr,
+            span: None,
+        }
+    }
+
+    pub fn if_expr(&self) -> &IfTemplateExpr {
+        &self.if_expr
+    }
+
+    pub fn else_expr(&self) -> Option<&ElseTemplateExpr> {
+        self.else_expr.as_ref()
+    }
+
+    pub fn endif_expr(&self) -> &EndifTemplateExpr {
+        &self.endif_expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.if_expr.despan(input);
+
+        if let Some(else_expr) = &mut self.else_expr {
+            else_expr.despan(input);
+        }
+
+        self.endif_expr.despan(input);
+    }
+}
+
+impl PartialEq for IfDirective {
+    fn eq(&self, other: &Self) -> bool {
+        self.if_expr == other.if_expr
+            && self.else_expr == other.else_expr
+            && self.endif_expr == other.endif_expr
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IfTemplateExpr {
+    preamble: RawString,
+    cond_expr: Expression,
+    template: Template,
+    strip: Strip,
+}
+
+impl IfTemplateExpr {
+    pub fn new(cond_expr: Expression, template: Template) -> IfTemplateExpr {
+        IfTemplateExpr {
+            preamble: RawString::default(),
+            cond_expr,
+            template,
+            strip: Strip::default(),
+        }
+    }
+
+    pub fn cond_expr(&self) -> &Expression {
+        &self.cond_expr
+    }
+
+    pub fn template(&self) -> &Template {
+        &self.template
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub fn preamble(&self) -> &RawString {
+        &self.preamble
+    }
+
+    pub fn set_preamble(&mut self, preamble: impl Into<RawString>) {
+        self.preamble = preamble.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.preamble.despan(input);
+        self.cond_expr.despan(input);
+        self.template.despan(input);
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ElseTemplateExpr {
+    preamble: RawString,
+    trailing: RawString,
+    template: Template,
+    strip: Strip,
+}
+
+impl ElseTemplateExpr {
+    pub fn new(template: Template) -> ElseTemplateExpr {
+        ElseTemplateExpr {
+            preamble: RawString::default(),
+            trailing: RawString::default(),
+            template,
+            strip: Strip::default(),
+        }
+    }
+
+    pub fn template(&self) -> &Template {
+        &self.template
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub fn preamble(&self) -> &RawString {
+        &self.preamble
+    }
+
+    pub fn set_preamble(&mut self, preamble: impl Into<RawString>) {
+        self.preamble = preamble.into();
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.preamble.despan(input);
+        self.template.despan(input);
+        self.trailing.despan(input);
+    }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct EndifTemplateExpr {
+    preamble: RawString,
+    trailing: RawString,
+    strip: Strip,
+}
+
+impl EndifTemplateExpr {
+    pub fn new() -> EndifTemplateExpr {
+        EndifTemplateExpr::default()
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub fn preamble(&self) -> &RawString {
+        &self.preamble
+    }
+
+    pub fn set_preamble(&mut self, preamble: impl Into<RawString>) {
+        self.preamble = preamble.into();
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.preamble.despan(input);
+        self.trailing.despan(input);
+    }
+}
+
+#[derive(Debug, Clone, Eq)]
+pub struct ForDirective {
+    for_expr: ForTemplateExpr,
+    endfor_expr: EndforTemplateExpr,
+    span: Option<Range<usize>>,
+}
+
+impl ForDirective {
+    pub fn new(for_expr: ForTemplateExpr, endfor_expr: EndforTemplateExpr) -> ForDirective {
+        ForDirective {
+            for_expr,
+            endfor_expr,
+            span: None,
+        }
+    }
+
+    pub fn for_expr(&self) -> &ForTemplateExpr {
+        &self.for_expr
+    }
+
+    pub fn endfor_expr(&self) -> &EndforTemplateExpr {
+        &self.endfor_expr
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.for_expr.despan(input);
+        self.endfor_expr.despan(input);
+    }
+}
+
+impl PartialEq for ForDirective {
+    fn eq(&self, other: &Self) -> bool {
+        self.for_expr == other.for_expr && self.endfor_expr == other.endfor_expr
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ForTemplateExpr {
+    preamble: RawString,
+    key_var: Option<Decorated<Ident>>,
+    value_var: Decorated<Ident>,
+    collection_expr: Expression,
+    template: Template,
+    strip: Strip,
+}
+
+impl ForTemplateExpr {
+    pub fn new(
+        key_var: Option<Decorated<Ident>>,
+        value_var: Decorated<Ident>,
+        collection_expr: Expression,
+        template: Template,
+    ) -> ForTemplateExpr {
+        ForTemplateExpr {
+            preamble: RawString::default(),
+            key_var,
+            value_var,
+            collection_expr,
+            template,
+            strip: Strip::default(),
+        }
+    }
+
+    pub fn key_var(&self) -> Option<&Decorated<Ident>> {
+        self.key_var.as_ref()
+    }
+
+    pub fn value_var(&self) -> &Decorated<Ident> {
+        &self.value_var
+    }
+
+    pub fn collection_expr(&self) -> &Expression {
+        &self.collection_expr
+    }
+
+    pub fn template(&self) -> &Template {
+        &self.template
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub fn preamble(&self) -> &RawString {
+        &self.preamble
+    }
+
+    pub fn set_preamble(&mut self, preamble: impl Into<RawString>) {
+        self.preamble = preamble.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.preamble.despan(input);
+
+        if let Some(key_var) = &mut self.key_var {
+            key_var.decor_mut().despan(input);
+        }
+
+        self.value_var.decor_mut().despan(input);
+        self.collection_expr.despan(input);
+        self.template.despan(input);
+    }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct EndforTemplateExpr {
+    preamble: RawString,
+    trailing: RawString,
+    strip: Strip,
+}
+
+impl EndforTemplateExpr {
+    pub fn new() -> EndforTemplateExpr {
+        EndforTemplateExpr::default()
+    }
+
+    pub fn strip(&self) -> Strip {
+        self.strip
+    }
+
+    pub fn set_strip(&mut self, strip: Strip) {
+        self.strip = strip;
+    }
+
+    pub fn preamble(&self) -> &RawString {
+        &self.preamble
+    }
+
+    pub fn set_preamble(&mut self, preamble: impl Into<RawString>) {
+        self.preamble = preamble.into();
+    }
+
+    pub fn trailing(&self) -> &RawString {
+        &self.trailing
+    }
+
+    pub fn set_trailing(&mut self, trailing: impl Into<RawString>) {
+        self.trailing = trailing.into();
+    }
+
+    pub(crate) fn despan(&mut self, input: &str) {
+        self.preamble.despan(input);
+        self.trailing.despan(input);
+    }
+}
+
+decorate_impl! { StringTemplate, HeredocTemplate }
+
+span_impl! {
+    StringTemplate, HeredocTemplate, Template,
+    Interpolation, IfDirective, ForDirective
+}
+
+forward_span_impl! {
+    Element => { Literal, Interpolation, Directive },
+    Directive => { If, For }
+}
diff --git a/crates/hcl-edit/src/util.rs b/crates/hcl-edit/src/util.rs
new file mode 100644
index 0000000..da44742
--- /dev/null
+++ b/crates/hcl-edit/src/util.rs
@@ -0,0 +1,80 @@
+use std::borrow::Cow;
+
+pub(crate) fn dedent_by(s: &str, n: usize, skip_first: bool) -> Cow<str> {
+    let mut dedented = String::with_capacity(s.len());
+
+    for (i, line) in s.lines().enumerate() {
+        if i == 0 && skip_first {
+            dedented.push_str(line);
+        } else if !line.is_empty() {
+            dedented.extend(line.chars().skip(n));
+        }
+
+        dedented.push('\n');
+    }
+
+    if dedented.ends_with('\n') && !s.ends_with('\n') {
+        let new_len = dedented.len() - 1;
+        dedented.truncate(new_len);
+    }
+
+    dedented.shrink_to_fit();
+
+    Cow::Owned(dedented)
+}
+
+pub(crate) fn min_leading_whitespace(s: &str, skip_first: bool) -> usize {
+    if s.is_empty() {
+        return 0;
+    }
+
+    let mut leading_ws: Option<usize> = None;
+
+    // Find the minimum number of possible leading units of whitespace that can be be stripped off
+    // of each non-empty line.
+    for (i, line) in s.lines().enumerate() {
+        if (i == 0 && skip_first) || line.is_empty() {
+            continue;
+        }
+
+        let line_leading_ws = line.chars().take_while(|ch| ch.is_whitespace()).count();
+
+        if line_leading_ws == 0 {
+            // Fast path: no dedent needed if we encounter a non-empty line which starts with a
+            // non-whitespace character.
+            return 0;
+        }
+
+        leading_ws = Some(leading_ws.map_or(line_leading_ws, |leading_ws| {
+            leading_ws.min(line_leading_ws)
+        }));
+    }
+
+    leading_ws.unwrap_or(0)
+}
+
+pub(crate) fn indent_by(s: &str, n: usize, skip_first: bool) -> String {
+    let prefix = " ".repeat(n);
+    let length = s.len();
+    let mut output = String::with_capacity(length + length / 2);
+
+    for (i, line) in s.lines().enumerate() {
+        if i > 0 {
+            output.push('\n');
+
+            if !line.is_empty() {
+                output.push_str(&prefix);
+            }
+        } else if !skip_first && !line.is_empty() {
+            output.push_str(&prefix);
+        }
+
+        output.push_str(line);
+    }
+
+    if s.ends_with('\n') {
+        output.push('\n');
+    }
+
+    output
+}
diff --git a/release-please-config.json b/release-please-config.json
index 8cb506e..e64493b 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -17,6 +17,9 @@
   "plugins": ["cargo-workspace"],
   "release-type": "rust",
   "packages": {
+    "crates/hcl-edit": {
+      "component": "hcl-edit"
+    },
     "crates/hcl-primitives": {
       "component": "hcl-primitives"
     },


Test patch:
diff --git a/crates/hcl-edit/src/parser/tests.rs b/crates/hcl-edit/src/parser/tests.rs
new file mode 100644
index 0000000..66185c5
--- /dev/null
+++ b/crates/hcl-edit/src/parser/tests.rs
@@ -0,0 +1,134 @@
+use super::expr::expr;
+use super::parse_complete;
+use super::structure::body;
+use super::template::template;
+use indoc::indoc;
+use pretty_assertions::assert_eq;
+
+macro_rules! assert_roundtrip {
+    ($input:expr, $parser:expr) => {
+        let mut parsed = parse_complete($input, $parser).unwrap();
+        parsed.despan($input);
+        assert_eq!(&parsed.to_string(), $input);
+    };
+}
+
+#[test]
+fn roundtrip_expr() {
+    let inputs = [
+        "_an-id3nt1fieR",
+        r#""a string""#,
+        r#""\\""#,
+        "12e+10",
+        "- 12e+10",
+        "-34.0012e+10",
+        "-1.0000",
+        "1.0000E10",
+        "42",
+        "var.enabled ? 1 : 0",
+        r#"["bar", ["baz"]]"#,
+        "[1,]",
+        r#"[format("prefix-%s", var.foo)]"#,
+        r#"{"bar" = "baz","qux" = ident }"#,
+        "{\"bar\" : \"baz\", \"qux\"= ident # a comment\n }",
+        "{ #comment\n }",
+        "{  }",
+        "{ /*comment*/ }",
+        "{ foo = 1, }",
+        "{ foo = 1, bar = 1 }",
+        "{ foo = 1 /*comment*/ }",
+        "{ foo = 1 #comment\n }",
+        "{ foo = 1, #comment\n bar = 1 }",
+        "<<HEREDOC\nHEREDOC",
+        indoc! {r#"
+            <<HEREDOC
+            ${foo}
+            %{if asdf}qux%{endif}
+            heredoc
+            HEREDOC"#},
+        r#""foo ${bar} $${baz}, %{if cond ~} qux %{~ endif}""#,
+        r#""${var.l ? "us-east-1." : ""}""#,
+        "element(concat(aws_kms_key.key-one.*.arn, aws_kms_key.key-two.*.arn), 0)",
+        "foo(bar...)",
+        "foo(bar,)",
+        "foo( )",
+    ];
+
+    for input in inputs {
+        assert_roundtrip!(input, expr);
+    }
+}
+
+#[test]
+fn roundtrip_body() {
+    let mut inputs = vec![
+        indoc! {r#"
+            // comment
+            block {
+              foo = "bar"
+            }
+
+            oneline { bar="baz"} # comment
+
+            array = [
+              1, 2,
+              3
+            ]
+        "#},
+        "block { attr = 1 }\n",
+        "foo = \"bar\"\nbar = 2\n",
+        indoc! {r#"
+            indented_heredoc = <<-EOT
+                ${foo}
+              %{if asdf}qux%{endif}bar
+                  heredoc
+                EOT
+        "#},
+    ];
+
+    let tests = testdata::load().unwrap();
+    assert!(tests.len() > 0);
+
+    for test in &tests {
+        inputs.push(&test.input);
+    }
+
+    for input in inputs {
+        assert_roundtrip!(input, body);
+    }
+}
+
+#[test]
+fn roundtrip_template() {
+    let inputs = [
+        "foo $${baz} ${bar}, %{if cond ~} qux %{~ endif}",
+        indoc! {r#"
+            Bill of materials:
+            %{ for item in items ~}
+            - ${item}
+            %{ endfor ~}
+        "#},
+    ];
+
+    for input in inputs {
+        assert_roundtrip!(input, template);
+    }
+}
+
+#[test]
+fn invalid_exprs() {
+    let inputs = [
+        "{ , }",
+        "[ , ]",
+        "{ foo = 1 bar = 1 }",
+        "foo(...)",
+        "foo(,)",
+    ];
+
+    for input in inputs {
+        assert!(
+            parse_complete(input, expr).is_err(),
+            "expected expression to be invalid: `{input}`",
+        );
+    }
+}
diff --git a/crates/hcl-edit/tests/parse.rs b/crates/hcl-edit/tests/parse.rs
new file mode 100644
index 0000000..4cd3e9c
--- /dev/null
+++ b/crates/hcl-edit/tests/parse.rs
@@ -0,0 +1,109 @@
+use hcl_edit::parser::parse_body;
+use indoc::indoc;
+use pretty_assertions::assert_eq;
+
+macro_rules! assert_error {
+    ($hcl:expr, $msg:expr) => {
+        match parse_body($hcl) {
+            Ok(s) => panic!("parsed to: {:#?}", s),
+            Err(e) => assert_eq!($msg, e.to_string()),
+        }
+    };
+}
+
+#[test]
+fn invalid_structures() {
+    assert_error!(
+        "foo = 1\nbar [",
+        indoc! {r#"
+             --> HCL parse error in line 2, column 5
+              |
+            2 | bar [
+              |     ^---
+              |
+              = invalid structure; expected `{`, `=`, `"` or identifier"#}
+    );
+}
+
+#[test]
+fn invalid_blocks() {
+    assert_error!(
+        "ident {",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 8
+              |
+            1 | ident {
+              |        ^---
+              |
+              = invalid block body; expected `}`, newline or identifier"#}
+    );
+
+    assert_error!(
+        "ident \"label\" {",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 16
+              |
+            1 | ident "label" {
+              |                ^---
+              |
+              = invalid block body; expected `}`, newline or identifier"#}
+    );
+
+    assert_error!(
+        "ident { foo }",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 13
+              |
+            1 | ident { foo }
+              |             ^---
+              |
+              = invalid attribute; expected `=`"#}
+    );
+
+    assert_error!(
+        "ident { [ }",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 9
+              |
+            1 | ident { [ }
+              |         ^---
+              |
+              = invalid block body; expected `}`, newline or identifier"#}
+    );
+}
+
+#[test]
+fn invalid_exprs() {
+    assert_error!(
+        "ident = ''",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 9
+              |
+            1 | ident = ''
+              |         ^---
+              |
+              = invalid expression; expected `"`, `[`, `{`, `-`, `!`, `(`, `_`, `<`, letter or digit"#}
+    );
+
+    assert_error!(
+        "ident = var.%",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 13
+              |
+            1 | ident = var.%
+              |             ^---
+              |
+              = invalid traversal operator; expected `*`, identifier or unsigned integer"#}
+    );
+
+    assert_error!(
+        "ident = { foo = \"\"\" }",
+        indoc! {r#"
+             --> HCL parse error in line 1, column 19
+              |
+            1 | ident = { foo = """ }
+              |                   ^---
+              |
+              = invalid object item; expected `}`, `,` or newline"#}
+    );
+}