Repository: ruby/yarp
Pull request #1093 (2023-06-29 02:41:43)

Initial yarp-sys (Rust bindings)
Per [discussion 1068](https://github.com/ruby/yarp/discussions/1068), here's my first attempt at a `yarp-sys` crate: Rust bindings to the YARP C API. It's my first time using the [`bindgen`](https://github.com/rust-lang/rust-bindgen) crate (I've only ever hand-rolled my own Rust FFI stuff), so it's possible I've missed a thing or two there. I've added tests to `rust/yarp-sys/tests` that just attempt to validate that the bindings are getting generated ok. Outside of those tests, there's not really much to the crate.

I've started using these in a separate, higher level crate, and so far they seem to be working alright. Let me know if you'd like more docs or tests or anything else.

---

Questions:

- [x] Add a CI job for running tests

kddnewton: Thanks @turboladen! I've asked a couple of folks from our team to take a look at this PR. It's going to be in limbo for a bit because I'm going on paternity leave for 4 weeks, but it's going to stay open because once we discuss everything I think it'd be good to merge.
turboladen: > Thanks @turboladen! I've asked a couple of folks from our team to take a look at this PR. It's going to be in limbo for a bit because I'm going on paternity leave for 4 weeks, but it's going to stay open because once we discuss everything I think it'd be good to merge.

Sounds good! Enjoy your time off!
ianks: @turboladen I totally missed this PR when I implemented https://github.com/ruby/yarp/pull/1104

Would prefer if yours got merged though since you put in in some great effort here.

Would you be OK with making the output types, etc match the format from #1104?
turboladen: > Would you be OK with making the output types, etc match the format from #1104?

Hm... could you be a bit more specific? Are you talking about _all_ the structs, enums, functions, typedefs, etc? If that's what you mean, I'd think it'd be less effort to just take your PR instead of this one (since you put a lot off effort into that!).

--- 

It looks like #1104 does work to remove things like `yp_` and `_t` and to change the cases of enums and such. ...which I get, but in my experience with doing FFI things, if I'm a consumer of the FFI library (i.e. a consumer of `yarp-sys`), I find it a lot easier to use if the bindings exactly/closely match the C API. If/when you have to look at the C source or docs to figure out some behavior or something, there's mental overhead to translate, say `parser`, to `yp_parser_t`, as opposed to if the FFI type is exactly the same as the C type; which it's maybe no big deal if you're just looking for 1 thing, but if you get down a rabbit hole, there just becomes more you have to keep on that stack in your brain. 

Sure, the `bindgen` generated stuff here isn't Rust-y, but I think the whole point in having the bindings is to take them and put a nice Rust API on top of those, and anyone who's doing that is going to need to refer to the C code to make sure they're doing the right thing. ...and IMHO, having those closer to the C code is a plus, not a minus.

I suppose another upside in the unaltered bindings is that there's almost nothing to maintain? IDK how much of an argument that is though since the main point is to get them right.

Thoughts?
ianks: > It looks like #1104 does work to remove things like `yp_` and `_t` and to change the cases of enums and such. ...which I get, but in my experience with doing FFI things, if I'm a consumer of the FFI library (i.e. a consumer of `yarp-sys`), I find it a lot easier to use if the bindings exactly/closely match the C API. If/when you have to look at the C source or docs to figure out some behavior or something, there's mental overhead to translate, say `parser`, to `yp_parser_t`, as opposed to if the FFI type is exactly the same as the C type; which it's maybe no big deal if you're just looking for 1 thing, but if you get down a rabbit hole, there just becomes more you have to keep on that stack in your brain.
> 
> Sure, the `bindgen` generated stuff here isn't Rust-y, but I think the whole point in having the bindings is to take them and put a nice Rust API on top of those, and anyone who's doing that is going to need to refer to the C code to make sure they're doing the right thing. ...and IMHO, having those closer to the C code is a plus, not a minus.
> 
> I suppose another upside in the unaltered bindings is that there's almost nothing to maintain? IDK how much of an argument that is though since the main point is to get them right.
> 
> Thoughts?

I think your logic makes perfect sense _when there is a higher level wrapper library_ which users consume. However, I wasn't confident that someone would end up maintaining that library in the long-term so it seemed like generating something close would be a good middle-ground. The middle ground being:

- Keep all function names the exact same
- Make the struct / enum names Rusty
- Keep original C docs

All of this is to say, if there are plans for a higher level library, then I agree it'd be better to go for the exact C naming like your approach.

Regardless, I trust your call on this one and happy go with whatever way you decide in the end! 

turboladen: > However, I wasn't confident that someone would end up maintaining that library in the long-term so it seemed like generating something close would be a good middle-ground.

Ah, yeah, good point on maintainability. It sounds like (per #1068) @kddnewton still wants to assess things before determining if a higher-level lib would also make sense in this repo, but if not here, then I'd think the main point of the bindings would be to enable others to create their own higher-level lib using the bindings that they'd find here. ...in which case I think I'd still argue my point, but I think yours is also valid and would be curious to hear if anyone else had thoughts on the matter.

FWIW, I started plugging away over at [turboladen/yarp-rs](https://github.com/turboladen/yarp-rs), where I'd initially planned to just do a straight, general purpose wrapper, but I've kinda paused to think a bit more about my use case (an LSP implementation) in that I might want to wrap `yarp-sys` in such a way that mixes in some async stuff. I guess I only mention this to say that it's been helpful to have the bindings match the C stuff in that work.

In any case, thanks for your feedback!
ianks: Alright, my opinion is now: let's go with the raw naming (`yp_`) and get this thing shipped! ❤️ 

I think some of the bindgen config I had would be good here (namely the derives). Want to do a quick pass over the options to see what you think? Other than that it all LGTM:

https://github.com/ianks/yarp/blob/be76362aa4803bb02f420be5e8e3130a72402496/rust/yarp-bindgen/src/main.rs#L126-L146
turboladen: > Alright, my opinion is now: let's go with the raw naming (`yp_`) and get this thing shipped! ❤️
> 
> I think some of the bindgen config I had would be good here (namely the derives). Want to do a quick pass over the options to see what you think? Other than that it all LGTM:
> 
> https://github.com/ianks/yarp/blob/be76362aa4803bb02f420be5e8e3130a72402496/rust/yarp-bindgen/src/main.rs#L126-L146

Most of those look good to me; a couple questions though:

1. Why `.disable_header_comment()`? Having the `bindgen` version in the bindings seems benign, and maybe even helpful to someone down the road (idk). I don't think I care personally, just curious.
2. These are on by default--any objections to leaving them out?
   - `size_t_is_usize(true)`
   - `layout_tests(true)`
   - `generate_comments(true)`
3. `.generate_cstr(true)`: this is cool--I hadn't seen it before! It requires rust 1.59.0, so I set `rust-version = "1.59.0"` in `Cargo.toml`.
4. Why `.derive_copy(false)`? I tend to like having this turned on...
5. `.clang_arg("-fparse-all-comments")`: This generates warnings that point to the C docs; I'll leave those C comments alone, but just thought I'd point that out.
ianks: > 1. Why `.disable_header_comment()`? Having the `bindgen` version in the bindings seems benign, and maybe even helpful to someone down the road (idk). I don't think I care personally, just curious.

I have the same opinion. Don't care either way.

> 2. These are on by default--any objections to leaving them out?
>    
>    * `size_t_is_usize(true)`
>    * `layout_tests(true)`
>    * `generate_comments(true)`

I tend to prefer keeping them in, since it makes understanding-at-a-glance a bit easier. Up to you though.

> 3. `.generate_cstr(true)`: this is cool--I hadn't seen it before! It requires rust 1.59.0, so I set `rust-version = "1.59.0"` in `Cargo.toml`.

Yeah, we dont need this...

> 4. Why `.derive_copy(false)`? I tend to like having this turned on...

I think for certain types we should derive Copy, but I was more concerned about "freeable" types, such as `Parser` and `Buffer`, etc. Making copy easy on these types could make double frees too easy.

> 5. `.clang_arg("-fparse-all-comments")`: This generates warnings that point to the C docs; I'll leave those C comments alone, but just thought I'd point that out.
> 
> ![image](https://user-images.githubusercontent.com/142010/251571489-3e5fae9a-0b71-47e3-aa14-79ddc300e3a9.png)

Yeah... ideally we wouldn't need this flag, but the comments use `//` comments instead of doxygen style (`/**`)... @kddnewton would be could to convert these at some point.


turboladen: On the bindings builder comment...

1. I left out `.disable_header_comment()`.
2. I added the 3 methods in that are on by default (your point was well taken).
3. I wasn't sure exactly what you mean about the `generate_cstr()` comment--were you saying to remove the `generate_cstr()` call or remove the `ruby-version` setting in `Cargo.toml`?
4. I added the `derive_copy(false)` call', makes sense.
5. Good to know about the docs generating.
ianks: I think this is in a good enough state to ship and start messing around with, thanks so much @turboladen!

@kddnewton Whenever you are ready, free free to hit that :shipit: button!

eregon: This most likely needs a CI check to avoid breaking unknowingly.
turboladen: > This most likely needs a CI check to avoid breaking unknowingly.

Sorry I missed this comment! I just added a new `rust-bindings` workflow.
kddnewton: @turboladen looks like it might just be needing to ignore the files in the manifest check.
turboladen: > @turboladen looks like it might just be needing to ignore the files in the manifest check.

@kddnewton I'm having a bit of a time trying to track this down. I see that the log has:

```
(see /home/runner/work/yarp/yarp/tasks/check_manifest.rake)
```

...but I a) don't see this file in the repo, and b) am not able to grep for anything that looks relevant. Could you point me in the right direction (a bit more :D)? Sorry, I'm not super familiar with Ruby<->C build things. 👀 
kddnewton: Hey @turboladen, no problem, it's not exactly intuitive. 

For this, you should add `rust` to the `ignore_directories` array here: https://github.com/ruby/yarp/blob/main/rakelib/check_manifest.rake#L5.
kddnewton: Also if you wouldn't mind squashing the commits, then I think this will basically be good to go!
turboladen: > For this, you should add `rust` to the `ignore_directories` array here: https://github.com/ruby/yarp/blob/main/rakelib/check_manifest.rake#L5.

Interesting... I don't have `rakelib/` in my branch. I suppose that means I need to pull in some changes. :) Thanks--I'll get that in.
turboladen: > Also if you wouldn't mind squashing the commits, then I think this will basically be good to go!

You betcha--once the CI stuff is sorted, I'll squash away and post back.
turboladen: > Also if you wouldn't mind squashing the commits, then I think this will basically be good to go!

Ok, all updated and squashed. If any builds fail, I'll do my best to get those fixed this weekend.
turboladen: > I'd like to see the CI on this crate expanded to:
> 
>     * Deny warnings.
> 
I get it, but I really hate working in crates that are set up like this; doing active development is such a pain.

>     * Run clippy.
> 
I'd already done this as part of my initial PR, but will add a GH job for this.

>     * Run the tests under leak sanitizer using `rustc`'s `-Z sanitzer=leak` option.
> 
>     * Run the tests under address sanitizer using `rustc`'s `-Z sanitizer=address` option.

This doesn't seem to me to add much value, and will add to the GH action time. I'd say, 100% do this in a crate that consumes these bindings, but just in the Rust tests here? 

I mean, sanitizers aside, what value do the tests provide? They're certainly not validating the C API (and IMO, they shouldn't--that should be done by C/Ruby tests); I'd say they're just validating we can call `yp_` functions as we'd expect, and thus don't actually really provide much value outside maybe serving as examples of how to string together some related `yp_` functions. And in fact, they're probably not even great at that since I don't think any of them are actually realistic, so to speak.
lopopolo: > > I'd like to see the CI on this crate expanded to:
> > ```
> > * Deny warnings.
> > ```
> 
> I get it, but I really hate working in crates that are set up like this; doing active development is such a pain.

I don't like doing local development with deny warnings either. One is able to deny warnings in CI without adding `#![deny(warnings)]` to `lib.rs`.

This configuration can be applied only in CI by setting the `RUSTFLAGS` env variable to `-D warnings` in CI. GitHub Actions makes it easy to set environment configuration like this for an entire workflow.


lopopolo: > I'd already done this as part of my initial PR, but will add a GH job for this.
> 
> > ```
> > * Run the tests under leak sanitizer using `rustc`'s `-Z sanitzer=leak` option.
> > 
> > * Run the tests under address sanitizer using `rustc`'s `-Z sanitizer=address` option.
> > ```
> 
> This doesn't seem to me to add much value, and will add to the GH action time. I'd say, 100% do this in a crate that consumes these bindings, but just in the Rust tests here?
> 
> I mean, sanitizers aside, what value do the tests provide?

There is a non trivial amount of unsafe Rust code in the tests for this crate and the sanitizers I mentioned above would have caught several misuses of `from_raw_parts` present in previous iterations of this PR.

I personally would prefer all the help I can get when writing unsafe Rust.

My understanding is that GitHub Actions is free for open source organizations like @ruby. If blocking CI time is a concern, including the sanitizer jobs as additional jobs in the `rust` workflow should allow the checks to be non-blocking by not marking them as required.
turboladen: > One is able to deny warnings in CI without adding `#![deny(warnings)]` to `lib.rs`.

Ah, I misunderstood your comment. Good call. I'll change.
kddnewton: This thread is getting difficult to parse through because of the number of comments. I think at this point I'm pretty satisfied with the work that has been done, and I'm happy to merge this as is and then iterate on the `main` branch going forward before we release to cargo. @turboladen if you don't mind just checking out the CI failures, once this is passing I'm happy to merge.
turboladen: I think this latest push should fix all the tests. Sorry for the churn. There are still 3 failures total in the sanitizer jobs, pointing to memory leaks in the tests. Normally I'd want to fix those before merging, but given a) the length this PR has been open, and b) the fact that I'm on a Mac and can't easily run tests using the sanitizers (normally I set up a small docker container and run inside those; IMO it'd be nice to commit that setup for other devs on Mac that end up here), personally I'd like to merge this, then do a subsequent PR for addressing the leaks in the tests. If, of course, you'd like me to address those before merging, that's totally understandable, just let me know.
turboladen: > I don't want to merge if the CI is red and it's leaking. I went through the leaks and I believe I've found them all. I've added a patch file here you can apply that should fix them all

Thanks @kddnewton! Patch applied, rebased, and pushed.


kddnewton: @turboladen I think it may have lost some of your more recent changes, like where you caught it up to `0.7.0`.
kddnewton: Ahh man, yeah that function doesn't exist anymore. It's a macro that's `YP_LIST_EMPTY` that just sets everything to 0. If you can calloc it should be fine.
kddnewton: @turboladen sorry, another function signature changed out from under you. I'll get you a patch ready for that.

@eregon I agree this exposes a lot of stuff, but in the interest of getting this PR merged, I'm overlooking that in the hopes to change it on `main`. It's already very difficult to read through this whole thread.
eregon: Yeah I'm OK if this is cleaned up after merge. It should be done somewhat soon, because I don't think we want to spend much effort updating Rust files if some of it goes away like the unescape test.
eregon: I think the one thing I'd like done in this PR is removing `rust/yarp-sys/tests/unescape_tests.rs`, there is no point to try to fix it if the corresponding API is internal and there is no real value for it to be public or exposed to Rust.
eregon: And the fact the CI specifically fails on that is good coincidence, relying on internals is useless churn, better remove that test file now than fix it for nothing.
kddnewton: Actually yeah @turboladen if you want to just remove `unescape_tests.rs` it should be passing and then let's merge.
turboladen: > Actually yeah @turboladen if you want to just remove `unescape_tests.rs` it should be passing and then let's merge.

Will do.

Patch:
diff --git a/.github/workflows/rust-bindings.yml b/.github/workflows/rust-bindings.yml
new file mode 100644
index 00000000..80fa75bc
--- /dev/null
+++ b/.github/workflows/rust-bindings.yml
@@ -0,0 +1,101 @@
+---
+name: Rust Bindings
+
+on:
+  push:
+    paths:
+      - ".github/workflows/rust-bindings.yml"
+      - "include/"
+      - "src/"
+      - "rust/"
+      - "*akefile*"
+    branches:
+      - main
+  pull_request:
+
+env:
+  RUSTFLAGS: "-D warnings"
+
+jobs:
+  cargo-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: head
+          bundler-cache: true
+      - uses: actions/cache@v3
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/git
+            target
+          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }}
+          restore-keys: |
+            ${{ runner.os }}-cargo-${{ hashFiles('Cargo.toml') }}
+            ${{ runner.os }}-cargo
+      - name: rake compile
+        run: bundle exec rake compile
+      - name: cargo test
+        working-directory: rust/yarp-sys
+        run: cargo test
+
+  cargo-clippy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: head
+          bundler-cache: true
+      - uses: actions/cache@v3
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/git
+            target
+          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }}
+          restore-keys: |
+            ${{ runner.os }}-cargo-${{ hashFiles('Cargo.toml') }}
+            ${{ runner.os }}-cargo
+      - name: rake compile
+        run: bundle exec rake compile
+      - name: cargo clippy
+        working-directory: rust/yarp-sys
+        run: cargo clippy --tests -- -W "clippy::pedantic"
+
+  sanitizer-test:
+    name: Test with -Zsanitizer=${{ matrix.sanitizer }}
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sanitizer: [address, leak]
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: head
+          bundler-cache: true
+      - name: rake compile
+        run: bundle exec rake compile
+      - uses: dtolnay/rust-toolchain@nightly
+        with:
+          target: "x86_64-unknown-linux-gnu"
+          components: "rust-src"
+      - name: Test with sanitizer
+        env:
+          RUSTFLAGS: -Zsanitizer=${{ matrix.sanitizer }}
+          RUSTDOCFLAGS: -Zsanitizer=${{ matrix.sanitizer }}
+          # only needed by asan
+          ASAN_OPTIONS: detect_stack_use_after_return=1
+          # Asan's leak detection occasionally complains
+          # about some small leaks if backtraces are captured,
+          # so ensure they're not
+          RUST_BACKTRACE: 0
+        working-directory: rust/yarp-sys
+        run: cargo test -Zbuild-std --verbose --target=x86_64-unknown-linux-gnu
diff --git a/README.md b/README.md
index 884bd702..dfb1f29c 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,8 @@ The repository contains the infrastructure for both a shared library (librubypar
 │   ├── yarp              Ruby library files
 │   └── yarp.rb           main entrypoint for the Ruby library
 ├── rakelib               various Rake tasks for the project
+├── rust
+│   └── yarp-sys          FFI binding for Rust
 ├── src
 │   ├── enc               various encoding files
 │   ├── util              various utility files
@@ -45,7 +47,7 @@ The repository contains the infrastructure for both a shared library (librubypar
 To compile the shared library, you will need:
 
 * A C99 compiler
-* autotools (autoconf, automake, libtool)
+* autotools autoconf, automake, libtool)
 * make
 * Ruby 3.3.0-preview1 or later
 
@@ -84,3 +86,4 @@ See the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information. We additio
 * [Ripper](docs/ripper.md)
 * [Serialization](docs/serialization.md)
 * [Testing](docs/testing.md)
+
diff --git a/ext/yarp/extension.c b/ext/yarp/extension.c
index 455cdcad..119c2800 100644
--- a/ext/yarp/extension.c
+++ b/ext/yarp/extension.c
@@ -66,7 +66,7 @@ dump_input(yp_string_t *input, const char *filepath) {
     yp_node_t *node = yp_parse(&parser);
     yp_serialize(&parser, node, &buffer);
 
-    VALUE result = rb_str_new(yp_buffer_value(&buffer), yp_buffer_length(&buffer));
+    VALUE result = rb_str_new(buffer.value, buffer.length);
     yp_node_destroy(&parser, node);
     yp_buffer_free(&buffer);
     yp_parser_free(&parser);
@@ -483,7 +483,7 @@ parse_serialize_file_metadata(VALUE self, VALUE filepath, VALUE metadata) {
     if (!yp_string_mapped_init(&input, checked)) return Qnil;
 
     yp_parse_serialize(yp_string_source(&input), yp_string_length(&input), &buffer, check_string(metadata));
-    VALUE result = rb_str_new(yp_buffer_value(&buffer), yp_buffer_length(&buffer));
+    VALUE result = rb_str_new(buffer.value, buffer.length);
 
     yp_buffer_free(&buffer);
     return result;
diff --git a/include/yarp/unescape.h b/include/yarp/unescape.h
index d3c68fb0..b76ac9f7 100644
--- a/include/yarp/unescape.h
+++ b/include/yarp/unescape.h
@@ -31,7 +31,7 @@ typedef enum {
 
 // Unescape the contents of the given token into the given string using the
 // given unescape mode.
-YP_EXPORTED_FUNCTION void yp_unescape_manipulate_string(yp_parser_t *parser, yp_string_t *string, yp_unescape_type_t unescape_type, yp_list_t *error_list);
+YP_EXPORTED_FUNCTION void yp_unescape_manipulate_string(yp_parser_t *parser, const char *value, size_t length, yp_string_t *string, yp_unescape_type_t unescape_type, yp_list_t *error_list);
 
 // Accepts a source string and a type of unescaping and returns the unescaped version.
 // The caller must yp_string_free(result); after calling this function.
diff --git a/include/yarp/util/yp_buffer.h b/include/yarp/util/yp_buffer.h
index 095f62a8..722315b9 100644
--- a/include/yarp/util/yp_buffer.h
+++ b/include/yarp/util/yp_buffer.h
@@ -12,24 +12,16 @@
 // A yp_buffer_t is a simple memory buffer that stores data in a contiguous
 // block of memory. It is used to store the serialized representation of a
 // YARP tree.
+// NOTE: keep in sync with YARP::LibRubyParser::Buffer in lib/yarp.rb
 typedef struct {
     char *value;
     size_t length;
     size_t capacity;
 } yp_buffer_t;
 
-// Return the size of the yp_buffer_t struct.
-YP_EXPORTED_FUNCTION size_t yp_buffer_sizeof(void);
-
 // Initialize a yp_buffer_t with its default values.
 YP_EXPORTED_FUNCTION bool yp_buffer_init(yp_buffer_t *buffer);
 
-// Return the value of the buffer.
-YP_EXPORTED_FUNCTION char * yp_buffer_value(yp_buffer_t *buffer);
-
-// Return the length of the buffer.
-YP_EXPORTED_FUNCTION size_t yp_buffer_length(yp_buffer_t *buffer);
-
 // Append the given amount of space as zeroes to the buffer.
 void yp_buffer_append_zeroes(yp_buffer_t *buffer, size_t length);
 
diff --git a/lib/yarp/ffi.rb b/lib/yarp/ffi.rb
index a547d506..293958dd 100644
--- a/lib/yarp/ffi.rb
+++ b/lib/yarp/ffi.rb
@@ -75,10 +75,7 @@ module YARP
 
     load_exported_functions_from(
       "yarp/util/yp_buffer.h",
-      "yp_buffer_sizeof",
       "yp_buffer_init",
-      "yp_buffer_value",
-      "yp_buffer_length",
       "yp_buffer_free"
     )
 
@@ -91,49 +88,34 @@ module YARP
       "yp_string_sizeof"
     )
 
-    # This object represents a yp_buffer_t. We only use it as an opaque pointer,
-    # so it doesn't need to know the fields of yp_buffer_t.
-    class YPBuffer
-      SIZEOF = LibRubyParser.yp_buffer_sizeof
+    # This object represents a yp_buffer_t. Its structure must be kept in sync
+    # with the C version.
+    class YPBuffer < FFI::Struct
+      layout value: :pointer, length: :size_t, capacity: :size_t
 
-      attr_reader :pointer
-
-      def initialize(pointer)
-        @pointer = pointer
-      end
-
-      def value
-        LibRubyParser.yp_buffer_value(pointer)
-      end
-
-      def length
-        LibRubyParser.yp_buffer_length(pointer)
-      end
-
-      def read
-        value.read_string(length)
+      # Read the contents of the buffer into a String object and return it.
+      def to_ruby_string
+        self[:value].read_string(self[:length])
       end
+    end
 
-      # Initialize a new buffer and yield it to the block. The buffer will be
-      # automatically freed when the block returns.
-      def self.with(&block)
-        pointer = FFI::MemoryPointer.new(SIZEOF)
-
-        begin
-          raise unless LibRubyParser.yp_buffer_init(pointer)
-          yield new(pointer)
-        ensure
-          LibRubyParser.yp_buffer_free(pointer)
-          pointer.free
-        end
+    # Initialize a new buffer and yield it to the block. The buffer will be
+    # automatically freed when the block returns.
+    def self.with_buffer(&block)
+      buffer = YPBuffer.new
+
+      begin
+        raise unless yp_buffer_init(buffer)
+        yield buffer
+      ensure
+        yp_buffer_free(buffer)
+        buffer.pointer.free
       end
     end
 
     # This object represents a yp_string_t. We only use it as an opaque pointer,
     # so it doesn't have to be an FFI::Struct.
     class YPString
-      SIZEOF = LibRubyParser.yp_string_sizeof
-
       attr_reader :pointer
 
       def initialize(pointer)
@@ -151,18 +133,23 @@ module YARP
       def read
         source.read_string(length)
       end
+    end
 
-      # Yields a yp_string_t pointer to the given block.
-      def self.with(filepath, &block)
-        pointer = FFI::MemoryPointer.new(SIZEOF)
-
-        begin
-          raise unless LibRubyParser.yp_string_mapped_init(pointer, filepath)
-          yield new(pointer)
-        ensure
-          LibRubyParser.yp_string_free(pointer)
-          pointer.free
-        end
+    # This is the size of a yp_string_t. It is returned by the yp_string_sizeof
+    # function which we call once to ensure we have sufficient space for the
+    # yp_string_t FFI pointer.
+    SIZEOF_YP_STRING = yp_string_sizeof
+
+    # Yields a yp_string_t pointer to the given block.
+    def self.with_string(filepath, &block)
+      string = FFI::MemoryPointer.new(SIZEOF_YP_STRING)
+
+      begin
+        raise unless yp_string_mapped_init(string, filepath)
+        yield YPString.new(string)
+      ensure
+        yp_string_free(string)
+        string.free
       end
     end
   end
@@ -175,10 +162,10 @@ module YARP
   VERSION = LibRubyParser.yp_version.read_string
 
   def self.dump_internal(source, source_size, filepath)
-    LibRubyParser::YPBuffer.with do |buffer|
+    LibRubyParser.with_buffer do |buffer|
       metadata = [filepath.bytesize, filepath.b, 0].pack("LA*L") if filepath
-      LibRubyParser.yp_parse_serialize(source, source_size, buffer.pointer, metadata)
-      buffer.read
+      LibRubyParser.yp_parse_serialize(source, source_size, buffer, metadata)
+      buffer.to_ruby_string
     end
   end
   private_class_method :dump_internal
@@ -190,24 +177,24 @@ module YARP
 
   # Mirror the YARP.dump_file API by using the serialization API.
   def self.dump_file(filepath)
-    LibRubyParser::YPString.with(filepath) do |string|
+    LibRubyParser.with_string(filepath) do |string|
       dump_internal(string.source, string.length, filepath)
     end
   end
 
   # Mirror the YARP.lex API by using the serialization API.
   def self.lex(code, filepath = nil)
-    LibRubyParser::YPBuffer.with do |buffer|
-      LibRubyParser.yp_lex_serialize(code, code.bytesize, filepath, buffer.pointer)
-      Serialize.load_tokens(Source.new(code), buffer.read)
+    LibRubyParser.with_buffer do |buffer|
+      LibRubyParser.yp_lex_serialize(code, code.bytesize, filepath, buffer)
+
+      source = Source.new(code)
+      Serialize.load_tokens(source, buffer.to_ruby_string)
     end
   end
 
   # Mirror the YARP.lex_file API by using the serialization API.
   def self.lex_file(filepath)
-    LibRubyParser::YPString.with(filepath) do |string|
-      lex(string.read, filepath)
-    end
+    LibRubyParser.with_string(filepath) { |string| lex(string.read, filepath) }
   end
 
   # Mirror the YARP.parse API by using the serialization API.
@@ -219,8 +206,6 @@ module YARP
   # native strings instead of Ruby strings because it allows us to use mmap when
   # it is available.
   def self.parse_file(filepath)
-    LibRubyParser::YPString.with(filepath) do |string|
-      parse(string.read, filepath)
-    end
+    LibRubyParser.with_string(filepath) { |string| parse(string.read, filepath) }
   end
 end
diff --git a/rakelib/check_manifest.rake b/rakelib/check_manifest.rake
index 94928c45..4db93278 100644
--- a/rakelib/check_manifest.rake
+++ b/rakelib/check_manifest.rake
@@ -16,6 +16,7 @@ task :check_manifest => [:templates] do
     java
     pkg
     rakelib
+    rust
     templates
     test
     tmp
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 00000000..b83d2226
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1,1 @@
+/target/
diff --git a/rust/yarp-sys/.gitignore b/rust/yarp-sys/.gitignore
new file mode 100644
index 00000000..7f88f54a
--- /dev/null
+++ b/rust/yarp-sys/.gitignore
@@ -0,0 +1,9 @@
+# will have compiled files and executables
+debug/
+target/
+
+# These are backup files generated by rustfmt
+**/*.rs.bk
+
+# MSVC Windows builds of rustc generate these, which store debugging information
+*.pdb
diff --git a/rust/yarp-sys/Cargo.lock b/rust/yarp-sys/Cargo.lock
new file mode 100644
index 00000000..2fa042d4
--- /dev/null
+++ b/rust/yarp-sys/Cargo.lock
@@ -0,0 +1,273 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b8f9420f797f2d9e935edf629310eb938a0d839f984e25327f3c7eed22300c"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.66.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "lazy_static",
+ "lazycell",
+ "log",
+ "peeking_take_while",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+ "which",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clang-sys"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
+[[package]]
+name = "libc"
+version = "0.2.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "log"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+
+[[package]]
+name = "memchr"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+
+[[package]]
+name = "peeking_take_while"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c64d9ba0963cdcea2e1b2230fbae2bab30eb25a174be395c41e764bfb65dd62"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "shlex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
+
+[[package]]
+name = "syn"
+version = "2.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
+
+[[package]]
+name = "which"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269"
+dependencies = [
+ "either",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "yarp-sys"
+version = "0.1.0"
+dependencies = [
+ "bindgen",
+]
diff --git a/rust/yarp-sys/Cargo.toml b/rust/yarp-sys/Cargo.toml
new file mode 100644
index 00000000..91bfe0ec
--- /dev/null
+++ b/rust/yarp-sys/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "yarp-sys"
+version = "0.1.0"
+edition = "2021"
+license-file = "../../LICENSE.md"
+repository = "https://github.com/ruby/yarp"
+description = "Rust bindings to Ruby's YARP parsing library"
+links = "yarp"
+authors = [
+    "Steve Loveless <steve.loveless@gmail.com>",
+    "Ian Ker-Seymer <hello@ianks.com>",
+]
+keywords = ["ruby", "parser", "ffi", "bindings"]
+categories = [
+    "api-bindings",
+    "development-tools::ffi",
+    "external-ffi-bindings",
+    "parsing",
+]
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[build-dependencies]
+bindgen = "0.66"
diff --git a/rust/yarp-sys/README.md b/rust/yarp-sys/README.md
new file mode 100644
index 00000000..b72f7dd4
--- /dev/null
+++ b/rust/yarp-sys/README.md
@@ -0,0 +1,40 @@
+# yarp-sys
+
+Rust bindings to [ruby/yarp](https://github.com/ruby/yarp)'s C API.
+
+## Examples
+
+Currently the best examples are found in the integration tests (in `tests/`).
+
+## Documentation
+
+Since this crate has not been released, docs are not yet online anywhere. You can generate them,
+however, from this directory in this repo by running `cargo doc`, then opening
+`target/doc/yarp_sys/index.html` in your browser. (You could, instead, combine those two steps by
+doing `cargo doc --open`!)
+
+## Development
+
+### Dependencies
+
+In addition to the Ruby YARP depedencies, you shouldn't need anything else besides Rust.
+
+### Updating bindings
+
+`build.rs` (which gets called as part of running `cargo build`, `cargo test`, etc) is where we tell
+`bindgen` which types, functions, etc. that we want it to generate for us. It's smart enough to know
+to generate dependencies for items we specify in there (ex. `yp_parser_t` has fields of type
+`yp_token_t`, but we don't need to tell `bindgen` about `yp_token_t`--it'll figure it out and
+generate bindings for that type too).
+
+If you want to generate new bindings, update `build.rs` accordingly, then run `cargo doc` and check
+the docs; that should tell you if `bindgen` generated all the things you need or not.
+
+### Testing
+
+Since almost all of the code is generated by the well-tested
+[`bindgen`](https://github.com/rust-lang/rust-bindgen) crate, we only have some cursory integration
+tests in `tests/`, really just validating types and functions got generated appropriately. (They
+also give some hints about how to use the API from Rust!) To run the tests, run `cargo test`.
+
+Any new publicly exposed C API additions should get a test or two.
diff --git a/rust/yarp-sys/build.rs b/rust/yarp-sys/build.rs
new file mode 100644
index 00000000..96e0da54
--- /dev/null
+++ b/rust/yarp-sys/build.rs
@@ -0,0 +1,146 @@
+use std::path::{Path, PathBuf};
+
+fn main() {
+    let ruby_build_path = ruby_build_path();
+    let ruby_include_path = ruby_include_path();
+
+    // Tell cargo/rustc that we want to link against `librubyparser.a`.
+    println!("cargo:rustc-link-lib=static=rubyparser");
+
+    // Add `[root]/build/` to the search paths, so it can find `librubyparser.a`.
+    println!(
+        "cargo:rustc-link-search=native={}",
+        ruby_build_path.to_str().unwrap()
+    );
+
+    // This is where the magic happens.
+    let bindings = generate_bindings(&ruby_include_path);
+
+    // Write the bindings to file.
+    write_bindngs(&bindings);
+}
+
+/// Gets the path to project files (`librubyparser*`) at `[root]/build/`.
+///
+fn ruby_build_path() -> PathBuf {
+    cargo_manifest_path()
+        .join("../../build/")
+        .canonicalize()
+        .unwrap()
+}
+
+/// Gets the path to the header files that `bindgen` needs for doing code generation.
+///
+fn ruby_include_path() -> PathBuf {
+    cargo_manifest_path()
+        .join("../../include/")
+        .canonicalize()
+        .unwrap()
+}
+
+fn cargo_manifest_path() -> PathBuf {
+    PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap())
+}
+
+/// Uses `bindgen` to generate bindings to the C API. Update this to allow new types/functions/etc
+/// to be generated (it's allowlisted to only expose functions that'd make sense for public
+/// consumption).
+///
+/// This method only generates code in memory here--it doesn't write it to file.
+///
+fn generate_bindings(ruby_include_path: &Path) -> bindgen::Bindings {
+    bindgen::Builder::default()
+        .derive_default(true)
+        .generate_block(true)
+        .generate_comments(true)
+        .header(ruby_include_path.join("yarp/defines.h").to_str().unwrap())
+        .header(ruby_include_path.join("yarp.h").to_str().unwrap())
+        .clang_arg(format!("-I{}", ruby_include_path.to_str().unwrap()))
+        .clang_arg("-fparse-all-comments")
+        .impl_debug(true)
+        .layout_tests(true)
+        .merge_extern_blocks(true)
+        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
+        .prepend_enum_name(false)
+        .size_t_is_usize(true)
+        .sort_semantically(true)
+        // Structs
+        .allowlist_type(r#"^yp_\w+_node_t"#)
+        .allowlist_type("yp_buffer_t")
+        .allowlist_type("yp_comment_t")
+        .allowlist_type("yp_diagnostic_t")
+        .allowlist_type("yp_encoding_changed_callback_t")
+        .allowlist_type("yp_encoding_decode_callback_t")
+        .allowlist_type("yp_memsize_t")
+        .allowlist_type("yp_list_t")
+        .allowlist_type("yp_node_t")
+        .allowlist_type("yp_node_type")
+        .allowlist_type("yp_parser_t")
+        .allowlist_type("yp_pack_size")
+        // TODO: Commenting this because I can't figure out how to get bindgen to generate the
+        // inner-unions nicely. Hand-rolling this in src/lib.rs for now.
+        // .allowlist_type("yp_string_t")
+        .blocklist_type(r#"^yp_string_t\S*"#)
+        .allowlist_type("yp_string_list_t")
+        .allowlist_type("yp_token_type_t")
+        // Enums
+        .rustified_non_exhaustive_enum("yp_comment_type_t")
+        .rustified_non_exhaustive_enum("yp_context_t")
+        .rustified_non_exhaustive_enum("yp_heredoc_indent_t")
+        .rustified_non_exhaustive_enum("yp_heredoc_quote_t")
+        .rustified_non_exhaustive_enum("yp_lex_mode_t")
+        .rustified_non_exhaustive_enum("yp_lex_state_t")
+        .rustified_non_exhaustive_enum("yp_node_type")
+        .rustified_non_exhaustive_enum("yp_pack_encoding")
+        .rustified_non_exhaustive_enum("yp_pack_endian")
+        .rustified_non_exhaustive_enum("yp_pack_length_type")
+        .rustified_non_exhaustive_enum("yp_pack_result")
+        .rustified_non_exhaustive_enum("yp_pack_signed")
+        .rustified_non_exhaustive_enum("yp_pack_size")
+        .rustified_non_exhaustive_enum("yp_pack_type")
+        .rustified_non_exhaustive_enum("yp_pack_variant")
+        .rustified_non_exhaustive_enum("yp_token_type")
+        .rustified_non_exhaustive_enum("yp_unescape_type_t")
+        // Functions
+        .allowlist_function("yp_buffer_init")
+        .allowlist_function("yp_buffer_free")
+        .allowlist_function("yp_node_destroy")
+        .allowlist_function("yp_list_empty_p")
+        .allowlist_function("yp_list_free")
+        .allowlist_function("yp_list_init")
+        .allowlist_function("yp_node_memsize")
+        .allowlist_function("yp_pack_parse")
+        .allowlist_function("yp_parse")
+        .allowlist_function("yp_parse_serialize")
+        .allowlist_function("yp_parser_free")
+        .allowlist_function("yp_parser_init")
+        .allowlist_function("yp_parser_register_encoding_changed_callback")
+        .allowlist_function("yp_parser_register_encoding_decode_callback")
+        .allowlist_function("yp_prettyprint")
+        .allowlist_function("yp_regexp_named_capture_group_names")
+        .allowlist_function("yp_serialize")
+        .allowlist_function("yp_size_to_native")
+        .allowlist_function("yp_string_free")
+        .allowlist_function("yp_string_length")
+        .allowlist_function("yp_string_source")
+        .allowlist_function("yp_string_list_init")
+        .allowlist_function("yp_string_list_free")
+        .allowlist_function("yp_token_type_to_str")
+        .allowlist_function("yp_unescape_calculate_difference")
+        .allowlist_function("yp_unescape_manipulate_string")
+        .allowlist_function("yp_version")
+        // Vars
+        .allowlist_var(r#"^yp_encoding\S+"#)
+        .generate()
+        .expect("Unable to generate yarp bindings")
+}
+
+/// Write the bindings to the `$OUT_DIR/bindings.rs` file. We'll pull these into the actual library
+/// in `src/lib.rs`.
+fn write_bindngs(bindings: &bindgen::Bindings) {
+    let out_path = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
+
+    bindings
+        .write_to_file(out_path.join("bindings.rs"))
+        .expect("Couldn't write bindings!");
+}
diff --git a/rust/yarp-sys/src/lib.rs b/rust/yarp-sys/src/lib.rs
new file mode 100644
index 00000000..d0d0da82
--- /dev/null
+++ b/rust/yarp-sys/src/lib.rs
@@ -0,0 +1,71 @@
+//! # yarp-sys
+//!
+//! FFI-bindings for `yarp`.
+//!
+#![deny(unused_extern_crates)]
+#![warn(
+    box_pointers,
+    clippy::all,
+    clippy::nursery,
+    clippy::pedantic,
+    future_incompatible,
+    missing_copy_implementations,
+    missing_docs,
+    nonstandard_style,
+    rust_2018_idioms,
+    trivial_casts,
+    trivial_numeric_casts,
+    unreachable_pub,
+    unused_qualifications
+)]
+
+// Allowing because we're not manually defining anything that would cause this, and
+// the bindgen-generated `bindgen_test_layout_yp_parser()` triggers this.
+#[allow(clippy::cognitive_complexity)]
+// Allowing because we're not manually defining anything that would cause this, and
+// the following bindgen-generated functions triggers this:
+// - `bindgen_test_layout_yp_call_node()`
+// - `bindgen_test_layout_yp_def_node()`
+// - `bindgen_test_layout_yp_parser()`
+#[allow(clippy::too_many_lines)]
+#[allow(missing_copy_implementations)]
+#[allow(non_upper_case_globals)]
+#[allow(non_camel_case_types)]
+#[allow(non_snake_case)]
+#[allow(missing_docs)]
+mod bindings {
+    use std::ffi::c_char;
+
+    // In `build.rs`, we use `bindgen` to generate bindings based on C headers and `librubyparser`. Here
+    // is where we pull in those bindings and make them part of our library.
+    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
+
+    pub type yp_parser_t_lex_modes = yp_parser__bindgen_ty_1;
+
+    //  ╭──────────────────────────────────────────────────────────────────────────────╮
+    //  │ Hand-rolling `yp_string_t` things                                            │
+    //  │                                                                              │
+    //  │ `bindgen` can generate these, but because of the nested `union`s, the        │
+    //  │ types end up with weird names. I'm sure there's a way to fix that, but I     │
+    //  │ haven't been able to. So I'm hand-rolling these for now.                     │
+    //  ╰──────────────────────────────────────────────────────────────────────────────╯
+    #[repr(C)]
+    #[derive(Clone, Copy, Debug)]
+    pub struct yp_string_t {
+        pub type_: yp_string_t_type,
+        pub source: *const c_char,
+        pub length: usize,
+    }
+
+    #[repr(u32)]
+    #[non_exhaustive]
+    #[derive(Clone, Copy, Debug)]
+    pub enum yp_string_t_type {
+        YP_STRING_SHARED,
+        YP_STRING_OWNED,
+        YP_STRING_CONSTANT,
+        YP_STRING_MAPPED,
+    }
+}
+
+pub use self::bindings::*;
diff --git a/src/unescape.c b/src/unescape.c
index f1c40347..670145ac 100644
--- a/src/unescape.c
+++ b/src/unescape.c
@@ -180,6 +180,22 @@ unescape_char(const unsigned char value, const unsigned char flags) {
 static const char *
 unescape(char *dest, size_t *dest_length, const char *backslash, const char *end, yp_list_t *error_list, const unsigned char flags, bool write_to_str) {
     switch (backslash[1]) {
+        // \a \b \e \f \n \r \s \t \v
+        case '\r': {
+            // if this is an \r\n we need to escape both
+            if (write_to_str) {
+                dest[(*dest_length)++] = (char) unescape_char(unescape_chars[(unsigned char) backslash[1]], flags);
+            }
+
+            if (backslash + 2 < end && backslash[2] == '\n') {
+                if (write_to_str) {
+                    dest[(*dest_length)++] = (char) unescape_char(unescape_chars[(unsigned char) backslash[2]], flags);
+                }
+                return backslash + 3;
+            }
+
+            return backslash + 2;
+        }
         case 'a':
         case 'b':
         case 'e':
@@ -382,23 +398,14 @@ unescape(char *dest, size_t *dest_length, const char *backslash, const char *end
             yp_diagnostic_list_append(error_list, backslash, backslash + 2, "Invalid meta escape sequence");
             return backslash + 3;
         }
-        // \n
-        case '\n':
-            return backslash + 2;
-        // \r
-        case '\r':
-            if (backslash + 2 < end && backslash[2] == '\n') {
-                return backslash + 3;
-            }
-
-            /* fallthrough */
         // In this case we're escaping something that doesn't need escaping.
-        default: {
-            if (write_to_str) {
-                dest[(*dest_length)++] = backslash[1];
+        default:
+            {
+                if (write_to_str) {
+                    dest[(*dest_length)++] = backslash[1];
+                }
+                return backslash + 2;
             }
-            return backslash + 2;
-        }
     }
 }
 
@@ -431,24 +438,26 @@ unescape(char *dest, size_t *dest_length, const char *backslash, const char *end
 // \c? or \C-?    delete, ASCII 7Fh (DEL)
 //
 YP_EXPORTED_FUNCTION void
-yp_unescape_manipulate_string(yp_parser_t *parser, yp_string_t *string, yp_unescape_type_t unescape_type, yp_list_t *error_list) {
+yp_unescape_manipulate_string(yp_parser_t *parser, const char *value, size_t length, yp_string_t *string, yp_unescape_type_t unescape_type, yp_list_t *error_list) {
     if (unescape_type == YP_UNESCAPE_NONE) {
         // If we're not unescaping then we can reference the source directly.
+        yp_string_shared_init(string, value, value + length);
         return;
     }
 
-    const char *backslash = yp_memchr(string->source, '\\', string->length, parser->encoding_changed, &parser->encoding);
+    const char *backslash = yp_memchr(value, '\\', length, parser->encoding_changed, &parser->encoding);
 
     if (backslash == NULL) {
         // Here there are no escapes, so we can reference the source directly.
+        yp_string_shared_init(string, value, value + length);
         return;
     }
 
     // Here we have found an escape character, so we need to handle all escapes
     // within the string.
-    char *allocated = malloc(string->length);
+    char *allocated = malloc(length);
     if (allocated == NULL) {
-        yp_diagnostic_list_append(error_list, string->source, string->source + string->length, "Failed to allocate memory for unescaping.");
+        yp_diagnostic_list_append(error_list, value, value + length, "Failed to allocate memory for unescaping.");
         return;
     }
 
@@ -459,13 +468,13 @@ yp_unescape_manipulate_string(yp_parser_t *parser, yp_string_t *string, yp_unesc
     // This is the current position in the source string that we're looking at.
     // It's going to move along behind the backslash so that we can copy each
     // segment of the string that doesn't contain an escape.
-    const char *cursor = string->source;
-    const char *end = string->source + string->length;
+    const char *cursor = value;
+    const char *end = value + length;
 
     // For each escape found in the source string, we will handle it and update
     // the moving cursor->backslash window.
     while (backslash != NULL && backslash + 1 < end) {
-        assert(dest_length < string->length);
+        assert(dest_length < length);
 
         // This is the size of the segment of the string from the previous escape
         // or the start of the string to the current escape.
@@ -511,10 +520,6 @@ yp_unescape_manipulate_string(yp_parser_t *parser, yp_string_t *string, yp_unesc
         cursor = end;
     }
 
-    // If the string was already allocated, then we need to free that memory
-    // here. That's because we're about to override it with the escaped string.
-    yp_string_free(string);
-
     // We also need to update the length at the end. This is because every escape
     // reduces the length of the final string, and we don't want garbage at the
     // end.
@@ -529,8 +534,7 @@ yp_unescape_string(const char *start, size_t length, yp_unescape_type_t unescape
     yp_parser_init(&parser, start, length, "");
 
     yp_list_t error_list = YP_LIST_EMPTY;
-    yp_string_shared_init(result, start, start + length);
-    yp_unescape_manipulate_string(&parser, result, unescape_type, &error_list);
+    yp_unescape_manipulate_string(&parser, start, length, result, unescape_type, &error_list);
     success = yp_list_empty_p(&error_list);
 
     yp_list_free(&error_list);
diff --git a/src/util/yp_buffer.c b/src/util/yp_buffer.c
index c9f06ae1..df339040 100644
--- a/src/util/yp_buffer.c
+++ b/src/util/yp_buffer.c
@@ -2,12 +2,6 @@
 
 #define YP_BUFFER_INITIAL_SIZE 1024
 
-// Return the size of the yp_buffer_t struct.
-size_t
-yp_buffer_sizeof(void) {
-    return sizeof(yp_buffer_t);
-}
-
 // Initialize a yp_buffer_t with its default values.
 bool
 yp_buffer_init(yp_buffer_t *buffer) {
@@ -18,18 +12,6 @@ yp_buffer_init(yp_buffer_t *buffer) {
     return buffer->value != NULL;
 }
 
-// Return the value of the buffer.
-char *
-yp_buffer_value(yp_buffer_t *buffer) {
-    return buffer->value;
-}
-
-// Return the length of the buffer.
-size_t
-yp_buffer_length(yp_buffer_t *buffer) {
-    return buffer->length;
-}
-
 // Append the given amount of space to the buffer.
 static inline void
 yp_buffer_append_length(yp_buffer_t *buffer, size_t length) {
diff --git a/src/yarp.c b/src/yarp.c
index 4b1e7a3c..0afceac4 100644
--- a/src/yarp.c
+++ b/src/yarp.c
@@ -3934,10 +3934,10 @@ yp_symbol_node_label_create(yp_parser_t *parser, const yp_token_t *token) {
             yp_token_t label = { .type = YP_TOKEN_LABEL, .start = token->start, .end = token->end - 1 };
             node = yp_symbol_node_create(parser, &opening, &label, &closing);
 
-            assert((label.end - label.start) >= 0);
-            yp_string_shared_init(&node->unescaped, label.start, label.end);
+            ptrdiff_t length = label.end - label.start;
+            assert(length >= 0);
 
-            yp_unescape_manipulate_string(parser, &node->unescaped, YP_UNESCAPE_ALL, &parser->error_list);
+            yp_unescape_manipulate_string(parser, label.start, (size_t) length, &node->unescaped, YP_UNESCAPE_ALL, &parser->error_list);
             break;
         }
         case YP_TOKEN_MISSING: {
@@ -7203,16 +7203,11 @@ parser_lex(yp_parser_t *parser) {
                         break;
                     }
                     case '\\': {
-                        // If we hit an escape, then we need to skip past
-                        // however many characters the escape takes up. However
-                        // it's important that if \n or \r\n are escaped that we
-                        // stop looping before the newline and not after the
-                        // newline so that we can still potentially find the
-                        // terminator of the heredoc.
-                        if (breakpoint + 1 < parser->end && breakpoint[1] == '\n') {
+                        // If we hit escapes, then we need to treat the next token
+                        // literally. In this case we'll skip past the next character and
+                        // find the next breakpoint.
+                        if (breakpoint[1] == '\n') {
                             breakpoint++;
-                        } else if (breakpoint + 2 < parser->end && breakpoint[1] == '\r' && breakpoint[2] == '\n') {
-                            breakpoint += 2;
                         } else {
                             yp_unescape_type_t unescape_type = (quote == YP_HEREDOC_QUOTE_SINGLE) ? YP_UNESCAPE_MINIMAL : YP_UNESCAPE_ALL;
                             size_t difference = yp_unescape_calculate_difference(breakpoint, parser->end, unescape_type, false, &parser->error_list);
@@ -7223,7 +7218,6 @@ parser_lex(yp_parser_t *parser) {
 
                             breakpoint = yp_strpbrk(parser, breakpoint + difference, breakpoints, parser->end - (breakpoint + difference));
                         }
-
                         break;
                     }
                     case '#': {
@@ -7270,10 +7264,10 @@ static yp_regular_expression_node_t *
 yp_regular_expression_node_create_and_unescape(yp_parser_t *parser, const yp_token_t *opening, const yp_token_t *content, const yp_token_t *closing, yp_unescape_type_t unescape_type) {
     yp_regular_expression_node_t *node = yp_regular_expression_node_create(parser, opening, content, closing);
 
-    assert((content->end - content->start) >= 0);
-    yp_string_shared_init(&node->unescaped, content->start, content->end);
+    ptrdiff_t length = content->end - content->start;
+    assert(length >= 0);
 
-    yp_unescape_manipulate_string(parser, &node->unescaped, unescape_type, &parser->error_list);
+    yp_unescape_manipulate_string(parser, content->start, (size_t) length, &node->unescaped, unescape_type, &parser->error_list);
     return node;
 }
 
@@ -7281,10 +7275,10 @@ static yp_symbol_node_t *
 yp_symbol_node_create_and_unescape(yp_parser_t *parser, const yp_token_t *opening, const yp_token_t *content, const yp_token_t *closing, yp_unescape_type_t unescape_type) {
     yp_symbol_node_t *node = yp_symbol_node_create(parser, opening, content, closing);
 
-    assert((content->end - content->start) >= 0);
-    yp_string_shared_init(&node->unescaped, content->start, content->end);
+    ptrdiff_t length = content->end - content->start;
+    assert(length >= 0);
 
-    yp_unescape_manipulate_string(parser, &node->unescaped, unescape_type, &parser->error_list);
+    yp_unescape_manipulate_string(parser, content->start, (size_t) length, &node->unescaped, unescape_type, &parser->error_list);
     return node;
 }
 
@@ -7292,10 +7286,10 @@ static yp_string_node_t *
 yp_string_node_create_and_unescape(yp_parser_t *parser, const yp_token_t *opening, const yp_token_t *content, const yp_token_t *closing, yp_unescape_type_t unescape_type) {
     yp_string_node_t *node = yp_string_node_create(parser, opening, content, closing);
 
-    assert((content->end - content->start) >= 0);
-    yp_string_shared_init(&node->unescaped, content->start, content->end);
+    ptrdiff_t length = content->end - content->start;
+    assert(length >= 0);
 
-    yp_unescape_manipulate_string(parser, &node->unescaped, unescape_type, &parser->error_list);
+    yp_unescape_manipulate_string(parser, content->start, (size_t) length, &node->unescaped, unescape_type, &parser->error_list);
     return node;
 }
 
@@ -7303,10 +7297,10 @@ static yp_x_string_node_t *
 yp_xstring_node_create_and_unescape(yp_parser_t *parser, const yp_token_t *opening, const yp_token_t *content, const yp_token_t *closing) {
     yp_x_string_node_t *node = yp_xstring_node_create(parser, opening, content, closing);
 
-    assert((content->end - content->start) >= 0);
-    yp_string_shared_init(&node->unescaped, content->start, content->end);
+    ptrdiff_t length = content->end - content->start;
+    assert(length >= 0);
 
-    yp_unescape_manipulate_string(parser, &node->unescaped, YP_UNESCAPE_ALL, &parser->error_list);
+    yp_unescape_manipulate_string(parser, content->start, (size_t) length, &node->unescaped, YP_UNESCAPE_ALL, &parser->error_list);
     return node;
 }
 
@@ -9178,12 +9172,7 @@ parse_string_part(yp_parser_t *parser) {
             yp_unescape_type_t unescape_type = YP_UNESCAPE_ALL;
 
             if (parser->lex_modes.current->mode == YP_LEX_HEREDOC) {
-                if (parser->lex_modes.current->as.heredoc.indent == YP_HEREDOC_INDENT_TILDE) {
-                    // If we're in a tilde heredoc, we want to unescape it later
-                    // because we don't want unescaped newlines to disappear
-                    // before we handle them in the dedent.
-                    unescape_type = YP_UNESCAPE_NONE;
-                } else if (parser->lex_modes.current->as.heredoc.quote == YP_HEREDOC_QUOTE_SINGLE) {
+                if (parser->lex_modes.current->as.heredoc.quote == YP_HEREDOC_QUOTE_SINGLE) {
                     unescape_type = YP_UNESCAPE_MINIMAL;
                 }
             }
@@ -9563,7 +9552,7 @@ parse_heredoc_dedent(yp_parser_t *parser, yp_node_t *node, yp_heredoc_quote_t qu
 
         // Get a reference to the string struct that is being held by the string
         // node. This is the value we're going to actual manipulate.
-        yp_string_t *string = &(((yp_string_node_t *) node)->unescaped);
+        yp_string_t *string = &((yp_string_node_t *) node)->unescaped;
         yp_string_ensure_owned(string);
 
         // Now get the bounds of the existing string. We'll use this as a
@@ -9625,7 +9614,6 @@ parse_heredoc_dedent(yp_parser_t *parser, yp_node_t *node, yp_heredoc_quote_t qu
         }
 
         string->length = dest_length;
-        yp_unescape_manipulate_string(parser, string, (quote == YP_HEREDOC_QUOTE_SINGLE) ? YP_UNESCAPE_MINIMAL : YP_UNESCAPE_ALL, &parser->error_list);
     }
 }
 


Test patch:
diff --git a/rust/yarp-sys/tests/node_tests.rs b/rust/yarp-sys/tests/node_tests.rs
new file mode 100644
index 00000000..83ef5c81
--- /dev/null
+++ b/rust/yarp-sys/tests/node_tests.rs
@@ -0,0 +1,38 @@
+use std::{ffi::CString, mem::MaybeUninit};
+
+use yarp_sys::{
+    yp_memsize_t, yp_node_destroy, yp_node_memsize, yp_node_type, yp_parse, yp_parser_free,
+    yp_parser_init, yp_parser_t,
+};
+
+#[test]
+fn node_test() {
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+    let code = CString::new("class Foo; end").unwrap();
+    let mut memsize = MaybeUninit::<yp_memsize_t>::uninit();
+
+    unsafe {
+        yp_parser_init(
+            parser.as_mut_ptr(),
+            code.as_ptr(),
+            code.as_bytes().len(),
+            std::ptr::null(),
+        );
+
+        let parser = parser.assume_init_mut();
+        let parsed_node = yp_parse(parser);
+
+        assert_eq!(
+            (*parsed_node).type_,
+            yp_node_type::YP_NODE_PROGRAM_NODE as u16
+        );
+
+        yp_node_memsize(parsed_node, memsize.as_mut_ptr());
+        let memsize = memsize.assume_init();
+        assert_eq!(memsize.memsize, 296);
+        assert_eq!(memsize.node_count, 4);
+
+        yp_node_destroy(parser, parsed_node);
+        yp_parser_free(parser);
+    }
+}
diff --git a/rust/yarp-sys/tests/pack_tests.rs b/rust/yarp-sys/tests/pack_tests.rs
new file mode 100644
index 00000000..13504241
--- /dev/null
+++ b/rust/yarp-sys/tests/pack_tests.rs
@@ -0,0 +1,57 @@
+use std::{ffi::CString, mem::MaybeUninit};
+
+use yarp_sys::{
+    yp_pack_encoding, yp_pack_endian, yp_pack_length_type, yp_pack_parse, yp_pack_result,
+    yp_pack_signed, yp_pack_size, yp_pack_type, yp_pack_variant, yp_size_to_native,
+};
+
+#[test]
+fn pack_parse_test() {
+    let variant_arg = yp_pack_variant::YP_PACK_VARIANT_PACK;
+    let first_format = CString::new("C").unwrap();
+    let end_format = CString::new("").unwrap();
+    let mut format = vec![first_format.as_ptr(), end_format.as_ptr()];
+
+    let mut type_out = MaybeUninit::<yp_pack_type>::uninit();
+    let mut signed_type_out = MaybeUninit::<yp_pack_signed>::uninit();
+    let mut endian_out = MaybeUninit::<yp_pack_endian>::uninit();
+    let mut size_out = MaybeUninit::<yp_pack_size>::uninit();
+    let mut length_type_out = MaybeUninit::<yp_pack_length_type>::uninit();
+    let mut length_out = 0_u64;
+    let mut encoding_out = MaybeUninit::<yp_pack_encoding>::uninit();
+
+    unsafe {
+        let result = yp_pack_parse(
+            variant_arg,
+            format.as_mut_ptr(),
+            end_format.as_ptr(),
+            type_out.as_mut_ptr(),
+            signed_type_out.as_mut_ptr(),
+            endian_out.as_mut_ptr(),
+            size_out.as_mut_ptr(),
+            length_type_out.as_mut_ptr(),
+            &mut length_out,
+            encoding_out.as_mut_ptr(),
+        );
+
+        assert_eq!(result, yp_pack_result::YP_PACK_OK);
+
+        let type_out = type_out.assume_init();
+        let signed_type_out = signed_type_out.assume_init();
+        let endian_out = endian_out.assume_init();
+        let size_out = size_out.assume_init();
+        let length_type_out = length_type_out.assume_init();
+        let encoding_out = encoding_out.assume_init();
+
+        assert_eq!(type_out, yp_pack_type::YP_PACK_INTEGER);
+        assert_eq!(signed_type_out, yp_pack_signed::YP_PACK_UNSIGNED);
+        assert_eq!(endian_out, yp_pack_endian::YP_PACK_AGNOSTIC_ENDIAN);
+        assert_eq!(size_out, yp_pack_size::YP_PACK_SIZE_8);
+        assert_eq!(length_type_out, yp_pack_length_type::YP_PACK_LENGTH_FIXED);
+        assert_eq!(length_out, 1);
+        assert_eq!(encoding_out, yp_pack_encoding::YP_PACK_ENCODING_ASCII_8BIT);
+
+        let native_size = yp_size_to_native(size_out);
+        assert_eq!(native_size, 1);
+    }
+}
diff --git a/rust/yarp-sys/tests/parser_tests.rs b/rust/yarp-sys/tests/parser_tests.rs
new file mode 100644
index 00000000..88996702
--- /dev/null
+++ b/rust/yarp-sys/tests/parser_tests.rs
@@ -0,0 +1,234 @@
+use std::{
+    ffi::{c_char, CStr, CString},
+    mem::MaybeUninit,
+    path::Path,
+    slice, str,
+    sync::OnceLock,
+};
+
+use yarp_sys::{
+    yp_buffer_free, yp_buffer_init, yp_buffer_t, yp_comment_t, yp_comment_type_t, yp_diagnostic_t,
+    yp_encoding_ascii, yp_encoding_t, yp_node_destroy, yp_parse, yp_parser_free, yp_parser_init,
+    yp_parser_register_encoding_changed_callback, yp_parser_register_encoding_decode_callback,
+    yp_parser_t, yp_prettyprint,
+};
+
+fn ruby_file_contents() -> (CString, usize) {
+    let rust_path = Path::new(env!("CARGO_MANIFEST_DIR"));
+    let ruby_file_path = rust_path.join("../../lib/yarp.rb").canonicalize().unwrap();
+    let file_contents = std::fs::read_to_string(ruby_file_path).unwrap();
+    let len = file_contents.len();
+
+    (CString::new(file_contents).unwrap(), len)
+}
+
+#[test]
+fn init_test() {
+    let (ruby_file_contents, len) = ruby_file_contents();
+    let source = ruby_file_contents.as_ptr();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+
+    unsafe {
+        yp_parser_init(parser.as_mut_ptr(), source, len, std::ptr::null());
+        let parser = parser.assume_init_mut();
+
+        yp_parser_free(parser);
+    }
+}
+
+#[test]
+fn parse_and_print_test() {
+    let (ruby_file_contents, len) = ruby_file_contents();
+    let source = ruby_file_contents.as_ptr();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+    let mut buffer = MaybeUninit::<yp_buffer_t>::uninit();
+
+    unsafe {
+        yp_parser_init(parser.as_mut_ptr(), source, len, std::ptr::null());
+        let parser = parser.assume_init_mut();
+        let node = yp_parse(parser);
+
+        assert!(yp_buffer_init(buffer.as_mut_ptr()), "Failed to init buffer");
+
+        let buffer = buffer.assume_init_mut();
+        yp_prettyprint(parser, node, buffer);
+
+        let slice = slice::from_raw_parts(buffer.value.cast::<u8>(), buffer.length);
+        let string = str::from_utf8(slice).unwrap();
+        assert!(string.starts_with("ProgramNode"));
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+        yp_buffer_free(buffer);
+    }
+}
+
+#[test]
+fn comments_test() {
+    let source = CString::new("# Meow!").unwrap();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+
+    unsafe {
+        yp_parser_init(
+            parser.as_mut_ptr(),
+            source.as_ptr(),
+            source.as_bytes().len(),
+            std::ptr::null(),
+        );
+        let parser = parser.assume_init_mut();
+        let node = yp_parse(parser);
+
+        let comment_list = &parser.comment_list;
+        let comment = comment_list.head as *const yp_comment_t;
+        assert_eq!((*comment).type_, yp_comment_type_t::YP_COMMENT_INLINE);
+
+        let location = {
+            let start = (*comment).start.offset_from(parser.start);
+            let end = (*comment).end.offset_from(parser.start);
+            start..end
+        };
+        assert_eq!(location, 0..7);
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+    }
+}
+
+#[test]
+fn diagnostics_test() {
+    let source = CString::new("class Foo;").unwrap();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+
+    unsafe {
+        yp_parser_init(
+            parser.as_mut_ptr(),
+            source.as_ptr(),
+            source.as_bytes().len(),
+            std::ptr::null(),
+        );
+        let parser = parser.assume_init_mut();
+        let node = yp_parse(parser);
+
+        let error_list = &parser.error_list;
+        // TODO: error_list.head used to get set, but after rebasing `87e02c0b`, this behavior changed. (This pointer used to not be null).
+        assert!(!error_list.head.is_null());
+
+        let error = error_list.head as *const yp_diagnostic_t;
+        let message = CStr::from_ptr((*error).message);
+        assert_eq!(
+            message.to_string_lossy(),
+            "Expected to be able to parse an expression."
+        );
+
+        let location = {
+            let start = (*error).start.offset_from(parser.start);
+            let end = (*error).end.offset_from(parser.start);
+            start..end
+        };
+        assert_eq!(location, 10..10);
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+    }
+}
+
+#[test]
+fn encoding_change_test() {
+    unsafe extern "C" fn callback(_parser: *mut yp_parser_t) {
+        let _ = THING.set(42).ok();
+    }
+
+    static THING: OnceLock<u8> = OnceLock::new();
+
+    let source = CString::new(
+        "# encoding: ascii\nclass Foo; end
+    ",
+    )
+    .unwrap();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+
+    unsafe {
+        yp_parser_init(
+            parser.as_mut_ptr(),
+            source.as_ptr(),
+            source.as_bytes().len(),
+            std::ptr::null(),
+        );
+        let parser = parser.assume_init_mut();
+
+        yp_parser_register_encoding_changed_callback(parser, Some(callback));
+
+        let node = yp_parse(parser);
+        // TODO: This used to get set (assumingly from encountering the 'ascii' encoding directive
+        // in `source`), but after rebasing `87e02c0b`, this behavior changed.
+        assert!(parser.encoding_changed);
+
+        // This value should have been mutated inside the callback when the encoding changed.
+        assert_eq!(*THING.get().unwrap(), 42);
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+    }
+}
+
+#[test]
+fn encoding_decode_test() {
+    unsafe extern "C" fn callback(
+        _parser: *mut yp_parser_t,
+        name: *const c_char,
+        width: usize,
+    ) -> *mut yp_encoding_t {
+        let c_name = CStr::from_ptr(name);
+
+        let _ = THING
+            .set(Output {
+                name: c_name.to_string_lossy().to_string(),
+                width,
+            })
+            .ok();
+
+        let encoding = &mut yp_encoding_ascii;
+        let encoding_ptr: *mut yp_encoding_t = encoding;
+
+        encoding_ptr
+    }
+
+    struct Output {
+        name: String,
+        width: usize,
+    }
+
+    static THING: OnceLock<Output> = OnceLock::new();
+
+    let source = CString::new("# encoding: meow").unwrap();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+
+    unsafe {
+        yp_parser_init(
+            parser.as_mut_ptr(),
+            source.as_ptr(),
+            source.as_bytes().len(),
+            std::ptr::null(),
+        );
+        let parser = parser.assume_init_mut();
+
+        yp_parser_register_encoding_decode_callback(parser, Some(callback));
+
+        let node = yp_parse(parser);
+        // TODO: parser.encoding.name used to get set to "ascii" (via the callback),
+        // but stopped after I rebased on `87e02c0b`.
+        assert!(!parser.encoding.name.is_null());
+        assert!(!yp_encoding_ascii.name.is_null());
+        assert_eq!(
+            CStr::from_ptr(parser.encoding.name).to_string_lossy(),
+            CStr::from_ptr(yp_encoding_ascii.name).to_string_lossy()
+        );
+
+        let output = THING.get().unwrap();
+        assert_eq!(&output.name, "meow");
+        assert_eq!(output.width, 4);
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+    }
+}
diff --git a/rust/yarp-sys/tests/serialize_tests.rs b/rust/yarp-sys/tests/serialize_tests.rs
new file mode 100644
index 00000000..5949f218
--- /dev/null
+++ b/rust/yarp-sys/tests/serialize_tests.rs
@@ -0,0 +1,106 @@
+use std::{
+    ffi::{c_char, CString},
+    mem::MaybeUninit,
+    path::Path,
+    slice, str,
+};
+
+use yarp_sys::{
+    yp_buffer_free, yp_buffer_init, yp_buffer_t, yp_node_destroy, yp_parse, yp_parse_serialize,
+    yp_parser_free, yp_parser_init, yp_parser_t, yp_serialize,
+};
+
+fn ruby_file_contents() -> (CString, usize) {
+    let rust_path = Path::new(env!("CARGO_MANIFEST_DIR"));
+    let ruby_file_path = rust_path.join("../../lib/yarp.rb").canonicalize().unwrap();
+    let file_contents = std::fs::read_to_string(ruby_file_path).unwrap();
+    let len = file_contents.len();
+
+    (CString::new(file_contents).unwrap(), len)
+}
+
+#[test]
+fn serialize_test() {
+    let (ruby_file_contents, len) = ruby_file_contents();
+    let source = ruby_file_contents.as_ptr();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+    let mut buffer = MaybeUninit::<yp_buffer_t>::uninit();
+
+    unsafe {
+        yp_parser_init(parser.as_mut_ptr(), source, len, std::ptr::null());
+        let parser = parser.assume_init_mut();
+        let node = yp_parse(parser);
+
+        assert!(yp_buffer_init(buffer.as_mut_ptr()), "Failed to init buffer");
+
+        let buffer = buffer.assume_init_mut();
+        yp_serialize(parser, node, buffer);
+
+        let serialized = std::slice::from_raw_parts(buffer.value.cast::<u8>(), buffer.length);
+
+        assert_eq!(&serialized[0..4], b"YARP");
+        assert_eq!(serialized[4..5][0], 0); // YP_VERSION_MAJOR
+        assert_eq!(serialized[5..6][0], 6); // YP_VERSION_MINOR
+        assert_eq!(serialized[6..7][0], 0); // YP_VERSION_PATCH
+
+        yp_buffer_free(buffer);
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+    }
+}
+
+#[test]
+fn parse_serialize_test() {
+    let (ruby_file_contents, len) = ruby_file_contents();
+    let source = ruby_file_contents.as_ptr();
+    let mut parser = MaybeUninit::<yp_parser_t>::uninit();
+    let mut serialize_buffer = MaybeUninit::<yp_buffer_t>::uninit();
+    let mut parse_serialize_buffer = MaybeUninit::<yp_buffer_t>::uninit();
+
+    let serialized = unsafe {
+        yp_parser_init(parser.as_mut_ptr(), source, len, std::ptr::null());
+        let parser = parser.assume_init_mut();
+        let node = yp_parse(parser);
+
+        assert!(
+            yp_buffer_init(serialize_buffer.as_mut_ptr()),
+            "Failed to init buffer"
+        );
+
+        let serialize_buffer = serialize_buffer.assume_init_mut();
+        yp_serialize(parser, node, serialize_buffer);
+
+        yp_node_destroy(parser, node);
+        yp_parser_free(parser);
+
+        // Can't use String -> CString here because `value` contains nul bytes.
+        slice::from_raw_parts(serialize_buffer.value.cast::<u8>(), serialize_buffer.length)
+    };
+
+    unsafe {
+        assert!(
+            yp_buffer_init(parse_serialize_buffer.as_mut_ptr()),
+            "Failed to init buffer"
+        );
+
+        let parse_serialize_buffer = parse_serialize_buffer.assume_init_mut();
+        let metadata = std::ptr::null();
+
+        yp_parse_serialize(
+            serialized.as_ptr().cast::<c_char>(),
+            serialized.len(),
+            parse_serialize_buffer,
+            metadata,
+        );
+
+        let slice = slice::from_raw_parts(
+            parse_serialize_buffer.value.cast::<u8>(),
+            parse_serialize_buffer.length,
+        );
+        let string = str::from_utf8(slice).unwrap();
+        assert!(string.starts_with("YARP"));
+
+        yp_buffer_free(serialize_buffer.as_mut_ptr());
+        yp_buffer_free(parse_serialize_buffer);
+    }
+}
diff --git a/rust/yarp-sys/tests/utils_tests.rs b/rust/yarp-sys/tests/utils_tests.rs
new file mode 100644
index 00000000..6d0fa144
--- /dev/null
+++ b/rust/yarp-sys/tests/utils_tests.rs
@@ -0,0 +1,138 @@
+use std::{
+    ffi::{CStr, CString},
+    mem::MaybeUninit,
+};
+
+#[test]
+fn version_test() {
+    use yarp_sys::yp_version;
+
+    let cstring = unsafe {
+        let version = yp_version();
+        CStr::from_ptr(version)
+    };
+
+    assert_eq!(&cstring.to_string_lossy(), "0.6.0");
+}
+
+#[test]
+fn list_test() {
+    use yarp_sys::{yp_list_empty_p, yp_list_free, yp_list_t};
+
+    let mut list = MaybeUninit::<yp_list_t>::zeroed();
+
+    unsafe {
+        let list = list.assume_init_mut();
+
+        assert!(yp_list_empty_p(list));
+
+        yp_list_free(list);
+    }
+}
+
+mod string {
+    use std::ffi::c_char;
+
+    use yarp_sys::{
+        yp_string_free, yp_string_length, yp_string_source, yp_string_t, yp_string_t_type,
+    };
+
+    use super::*;
+
+    struct S {
+        c_string: CString,
+        yp_string: yp_string_t,
+    }
+
+    impl S {
+        fn start_ptr(&self) -> *const c_char {
+            self.c_string.as_ptr()
+        }
+    }
+
+    fn make_string(string_type: yp_string_t_type) -> S {
+        let c_string = CString::new("0123456789012345").unwrap();
+
+        let yp_string = yp_string_t {
+            type_: string_type,
+            source: c_string.as_ptr(),
+            length: c_string.as_bytes().len(),
+        };
+
+        S {
+            c_string,
+            yp_string,
+        }
+    }
+
+    #[test]
+    fn shared_string_test() {
+        let mut s = make_string(yp_string_t_type::YP_STRING_SHARED);
+
+        unsafe {
+            let len = yp_string_length(&s.yp_string);
+            assert_eq!(len, 16);
+
+            let result_start = yp_string_source(&s.yp_string);
+            assert_eq!(s.start_ptr(), result_start);
+
+            yp_string_free(&mut s.yp_string);
+        }
+    }
+
+    #[test]
+    fn owned_string_test() {
+        let s = make_string(yp_string_t_type::YP_STRING_OWNED);
+
+        unsafe {
+            let result_len = yp_string_length(&s.yp_string);
+            assert_eq!(result_len, 16);
+
+            let result_start = yp_string_source(&s.yp_string);
+            assert_eq!(s.yp_string.source, result_start);
+
+            // Don't drop the yp_string--we don't own it anymore!
+        }
+    }
+
+    #[test]
+    fn constant_string_test() {
+        let mut s = make_string(yp_string_t_type::YP_STRING_CONSTANT);
+
+        unsafe {
+            let result_len = yp_string_length(&s.yp_string);
+            assert_eq!(result_len, 16);
+
+            let result_start = yp_string_source(&s.yp_string);
+            assert_eq!(s.yp_string.source, result_start);
+
+            yp_string_free(&mut s.yp_string);
+        }
+    }
+
+    #[test]
+    fn mapped_string_test() {
+        let s = make_string(yp_string_t_type::YP_STRING_MAPPED);
+
+        unsafe {
+            let result_len = yp_string_length(&s.yp_string);
+            assert_eq!(result_len, 16);
+
+            let result_start = yp_string_source(&s.yp_string);
+            assert_eq!(s.yp_string.source, result_start);
+        }
+    }
+}
+
+#[test]
+fn string_list_test() {
+    use yarp_sys::{yp_string_list_free, yp_string_list_init, yp_string_list_t};
+
+    let mut list = MaybeUninit::<yp_string_list_t>::uninit();
+
+    unsafe {
+        yp_string_list_init(list.as_mut_ptr());
+        let list = list.assume_init_mut();
+        yp_string_list_free(list);
+    }
+}
diff --git a/test/snapshots/dos_endings.txt b/test/snapshots/dos_endings.txt
index 0d95af52..e1c6c9fe 100644
--- a/test/snapshots/dos_endings.txt
+++ b/test/snapshots/dos_endings.txt
@@ -18,7 +18,7 @@ ProgramNode(0...108)(
        "puts"
      ),
      ArrayNode(28...37)(
-       [SymbolNode(31...36)(nil, (31...36), nil, "ab")],
+       [SymbolNode(31...36)(nil, (31...36), nil, "a\u0000\u0000b")],
        (28...31),
        (36...37)
      ),
@@ -28,7 +28,7 @@ ProgramNode(0...108)(
           nil,
           (47...70),
           nil,
-          "    1     2\r\n" + "    3\r\n"
+          "    1 \u0000\u0000    2\r\n" + "    3\r\n"
         )],
        (70...73)
      ),
diff --git a/test/snapshots/heredocs_with_ignored_newlines.txt b/test/snapshots/heredocs_with_ignored_newlines.txt
index 8be31f9b..2c0638c0 100644
--- a/test/snapshots/heredocs_with_ignored_newlines.txt
+++ b/test/snapshots/heredocs_with_ignored_newlines.txt
@@ -13,7 +13,8 @@ ProgramNode(0...106)(
           "  not here\n" +
           "HERE\n" +
           "\n" +
-          "<<~BUT  but\n" +
+          "<<~BUT\n" +
+          "  but\n" +
           "BUT\n" +
           "  there\n"
         )],
diff --git a/test/snapshots/seattlerb/heredoc__backslash_dos_format.txt b/test/snapshots/seattlerb/heredoc__backslash_dos_format.txt
index 9b08c7e1..36bb254c 100644
--- a/test/snapshots/seattlerb/heredoc__backslash_dos_format.txt
+++ b/test/snapshots/seattlerb/heredoc__backslash_dos_format.txt
@@ -6,7 +6,12 @@ ProgramNode(0...35)(
        0,
        InterpolatedStringNode(6...35)(
          (6...12),
-         [StringNode(14...30)(nil, (14...30), nil, "beforeafter\r\n")],
+         [StringNode(14...30)(
+            nil,
+            (14...30),
+            nil,
+            "before\u0000\u0000after\r\n"
+          )],
          (30...35)
        ),
        (0...3),
diff --git a/test/snapshots/seattlerb/heredoc_backslash_nl.txt b/test/snapshots/seattlerb/heredoc_backslash_nl.txt
index 24ef6e52..36a07422 100644
--- a/test/snapshots/seattlerb/heredoc_backslash_nl.txt
+++ b/test/snapshots/seattlerb/heredoc_backslash_nl.txt
@@ -5,7 +5,7 @@ ProgramNode(0...93)(
        (0...1),
        (1...39),
        (39...40),
-       "  why would someone do this?   blah\n"
+       "  why would someone do this? \n" + "  blah\n"
      ),
      InterpolatedStringNode(42...93)(
        (42...49),
@@ -13,7 +13,7 @@ ProgramNode(0...93)(
           nil,
           (50...88),
           nil,
-          "  why would someone do this?   blah\n"
+          "  why would someone do this? \n" + "  blah\n"
         )],
        (88...93)
      )]
diff --git a/test/snapshots/seattlerb/pctW_lineno.txt b/test/snapshots/seattlerb/pctW_lineno.txt
index d03e41c0..c9ed9930 100644
--- a/test/snapshots/seattlerb/pctW_lineno.txt
+++ b/test/snapshots/seattlerb/pctW_lineno.txt
@@ -5,7 +5,7 @@ ProgramNode(0...28)(
        [StringNode(3...7)(nil, (3...7), nil, "a\n" + "b"),
         StringNode(8...9)(nil, (8...9), nil, "c"),
         StringNode(10...11)(nil, (10...11), nil, "d"),
-        StringNode(12...16)(nil, (12...16), nil, "ef"),
+        StringNode(12...16)(nil, (12...16), nil, "e\n" + "f"),
         StringNode(17...19)(nil, (17...19), nil, "gy"),
         StringNode(20...23)(nil, (20...23), nil, "hy"),
         StringNode(24...27)(nil, (24...27), nil, "iy")],
diff --git a/test/snapshots/seattlerb/pct_Q_backslash_nl.txt b/test/snapshots/seattlerb/pct_Q_backslash_nl.txt
index 69721af3..f8e3421e 100644
--- a/test/snapshots/seattlerb/pct_Q_backslash_nl.txt
+++ b/test/snapshots/seattlerb/pct_Q_backslash_nl.txt
@@ -1,4 +1,4 @@
 ProgramNode(0...7)(
   [],
-  StatementsNode(0...7)([StringNode(0...7)((0...3), (3...6), (6...7), " ")])
+  StatementsNode(0...7)([StringNode(0...7)((0...3), (3...6), (6...7), " \n")])
 )
diff --git a/test/snapshots/seattlerb/slashy_newlines_within_string.txt b/test/snapshots/seattlerb/slashy_newlines_within_string.txt
index 1ef9123f..52bd599c 100644
--- a/test/snapshots/seattlerb/slashy_newlines_within_string.txt
+++ b/test/snapshots/seattlerb/slashy_newlines_within_string.txt
@@ -11,7 +11,7 @@ ProgramNode(0...40)(
             (5...6),
             (6...32),
             (32...33),
-            "hello my dear friend"
+            "hello\n" + " my\n" + " dear\n" + " friend"
           )]
        ),
        nil,
diff --git a/test/snapshots/whitequark/parser_bug_640.txt b/test/snapshots/whitequark/parser_bug_640.txt
index f43631e1..7af6f48f 100644
--- a/test/snapshots/whitequark/parser_bug_640.txt
+++ b/test/snapshots/whitequark/parser_bug_640.txt
@@ -3,7 +3,7 @@ ProgramNode(0...24)(
   StatementsNode(0...24)(
     [InterpolatedStringNode(0...24)(
        (0...6),
-       [StringNode(7...20)(nil, (7...20), nil, "bazqux\n")],
+       [StringNode(7...20)(nil, (7...20), nil, "baz\n" + "qux\n")],
        (20...24)
      )]
   )
diff --git a/test/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt b/test/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
index c5a02593..2802c084 100644
--- a/test/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
+++ b/test/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
@@ -1,15 +1,15 @@
 ProgramNode(0...210)(
   [],
   StatementsNode(0...210)(
-    [StringNode(0...6)((0...1), (1...5), (5...6), "ab"),
+    [StringNode(0...6)((0...1), (1...5), (5...6), "a\n" + "b"),
      ArrayNode(8...16)(
-       [SymbolNode(11...15)(nil, (11...15), nil, "ab")],
+       [SymbolNode(11...15)(nil, (11...15), nil, "a\n" + "b")],
        (8...11),
        (15...16)
      ),
-     StringNode(18...26)((18...21), (21...25), (25...26), "ab"),
+     StringNode(18...26)((18...21), (21...25), (25...26), "a\n" + "b"),
      ArrayNode(28...36)(
-       [StringNode(31...35)(nil, (31...35), nil, "ab")],
+       [StringNode(31...35)(nil, (31...35), nil, "a\n" + "b")],
        (28...31),
        (35...36)
      ),
@@ -19,15 +19,21 @@ ProgramNode(0...210)(
        (45...46)
      ),
      StringNode(48...56)((48...51), (51...55), (55...56), "a\\\n" + "b"),
-     RegularExpressionNode(58...66)((58...61), (61...65), (65...66), "ab", 0),
-     SymbolNode(68...76)((68...71), (71...75), (75...76), "ab"),
+     RegularExpressionNode(58...66)(
+       (58...61),
+       (61...65),
+       (65...66),
+       "a\n" + "b",
+       0
+     ),
+     SymbolNode(68...76)((68...71), (71...75), (75...76), "a\n" + "b"),
      ArrayNode(78...86)(
        [StringNode(81...85)(nil, (81...85), nil, "a\\\n" + "b")],
        (78...81),
        (85...86)
      ),
-     XStringNode(88...96)((88...91), (91...95), (95...96), "ab"),
-     StringNode(98...105)((98...100), (100...104), (104...105), "ab"),
+     XStringNode(88...96)((88...91), (91...95), (95...96), "a\n" + "b"),
+     StringNode(98...105)((98...100), (100...104), (104...105), "a\n" + "b"),
      StringNode(107...113)(
        (107...108),
        (108...112),
@@ -38,14 +44,14 @@ ProgramNode(0...210)(
        (115...116),
        (116...120),
        (120...121),
-       "ab",
+       "a\n" + "b",
        0
      ),
-     SymbolNode(123...130)(nil, (125...129), nil, "ab"),
-     SymbolNode(132...139)((132...134), (134...138), (138...139), "ab"),
+     SymbolNode(123...130)(nil, (125...129), nil, "a\n" + "b"),
+     SymbolNode(132...139)((132...134), (134...138), (138...139), "a\n" + "b"),
      InterpolatedStringNode(141...161)(
        (141...150),
-       [StringNode(151...156)(nil, (151...156), nil, "ab\n")],
+       [StringNode(151...156)(nil, (151...156), nil, "a\n" + "b\n")],
        (156...161)
      ),
      InterpolatedStringNode(162...182)(
@@ -55,9 +61,14 @@ ProgramNode(0...210)(
      ),
      InterpolatedXStringNode(183...203)(
        (183...192),
-       [StringNode(193...198)(nil, (193...198), nil, "ab\n")],
+       [StringNode(193...198)(nil, (193...198), nil, "a\n" + "b\n")],
        (198...203)
      ),
-     XStringNode(204...210)((204...205), (205...209), (209...210), "ab")]
+     XStringNode(204...210)(
+       (204...205),
+       (205...209),
+       (209...210),
+       "a\n" + "b"
+     )]
   )
 )
diff --git a/test/snapshots/whitequark/ruby_bug_11989.txt b/test/snapshots/whitequark/ruby_bug_11989.txt
index 78ae725c..fb5f7330 100644
--- a/test/snapshots/whitequark/ruby_bug_11989.txt
+++ b/test/snapshots/whitequark/ruby_bug_11989.txt
@@ -9,7 +9,7 @@ ProgramNode(0...21)(
        ArgumentsNode(2...21)(
          [InterpolatedStringNode(2...21)(
             (2...8),
-            [StringNode(9...19)(nil, (9...19), nil, "x\n" + "   y\n")],
+            [StringNode(9...19)(nil, (9...19), nil, "x\n" + " y\n")],
             (19...21)
           )]
        ),
diff --git a/test/snapshots/whitequark/slash_newline_in_heredocs.txt b/test/snapshots/whitequark/slash_newline_in_heredocs.txt
index a2dfc292..facffc7a 100644
--- a/test/snapshots/whitequark/slash_newline_in_heredocs.txt
+++ b/test/snapshots/whitequark/slash_newline_in_heredocs.txt
@@ -3,12 +3,17 @@ ProgramNode(0...56)(
   StatementsNode(0...56)(
     [InterpolatedStringNode(0...27)(
        (0...4),
-       [StringNode(5...25)(nil, (5...25), nil, "    1     2\n" + "    3\n")],
+       [StringNode(5...25)(
+          nil,
+          (5...25),
+          nil,
+          "    1 \n" + "    2\n" + "    3\n"
+        )],
        (25...27)
      ),
      InterpolatedStringNode(29...56)(
        (29...33),
-       [StringNode(34...54)(nil, (34...54), nil, "1 2\n" + "3\n")],
+       [StringNode(34...54)(nil, (34...54), nil, "1 \n" + "2\n" + "3\n")],
        (54...56)
      )]
   )