Repository: ruby/prism
Pull request #1742 (2023-10-26 20:19:22)

Fix constant path full name when parent is not a constant
When we introduced `full_name`, we forgot that Ruby allows you to do `self::Foo::Bar` or even `var::Foo::Bar`. The code currently breaks in those scenarios when trying to invoke `name`, since the nodes that represent the beginning of the path don't implement it (like `SelfNode`).

There are a few options on how we might fix this. This PR is just using `node.slice` if the node is not a constant read. We can omit the first part when it's not a constant. Or we can add a `name` field to the nodes that don't have it, just returning the slice.

Let me know how you prefer to deal with this scenario.

kddnewton: @vinistock do you have time to come back to this? If not that's okay, I can take over if you'd prefer.
kddnewton: @vinistock ping
vinistock: Sorry! Got back to this today. I changed it so that it raises if any of the parts can't be made into a string name.

I created a custom error class for this and put it under the node itself - since it's only relevant to that particular node. Let me know if you prefer to move that somewhere else.
kddnewton: @vinistock documentation check is failing because the error class is undocumented.

Patch:
diff --git a/config.yml b/config.yml
index ef12f7a7..6cd98b52 100644
--- a/config.yml
+++ b/config.yml
@@ -2370,17 +2370,6 @@ nodes:
 
           foo; bar; baz
           ^^^^^^^^^^^^^
-  - name: StringConcatNode
-    fields:
-      - name: left
-        type: node
-      - name: right
-        type: node
-    comment: |
-      Represents the use of compile-time string concatenation.
-
-          "foo" "bar"
-          ^^^^^^^^^^^
   - name: StringNode
     fields:
       - name: flags
diff --git a/include/prism/diagnostic.h b/include/prism/diagnostic.h
index 33a70229..68e50792 100644
--- a/include/prism/diagnostic.h
+++ b/include/prism/diagnostic.h
@@ -250,6 +250,7 @@ typedef enum {
     PM_WARN_AMBIGUOUS_FIRST_ARGUMENT_PLUS,
     PM_WARN_AMBIGUOUS_PREFIX_STAR,
     PM_WARN_AMBIGUOUS_SLASH,
+    PM_WARN_END_IN_METHOD,
 
     /* This must be the last member. */
     PM_DIAGNOSTIC_ID_LEN,
diff --git a/lib/prism/ffi.rb b/lib/prism/ffi.rb
index c910fd3a..847990ed 100644
--- a/lib/prism/ffi.rb
+++ b/lib/prism/ffi.rb
@@ -299,7 +299,7 @@ module Prism
       values << (options.fetch(:frozen_string_literal, false) ? 1 : 0)
 
       template << "C"
-      values << (options[:verbose] ? 0 : 1)
+      values << (options.fetch(:verbose, true) ? 0 : 1)
 
       template << "L"
       if (scopes = options[:scopes])
diff --git a/lib/prism/node_ext.rb b/lib/prism/node_ext.rb
index 4febc615..5fc13eb0 100644
--- a/lib/prism/node_ext.rb
+++ b/lib/prism/node_ext.rb
@@ -74,6 +74,14 @@ module Prism
   end
 
   class ConstantPathNode < Node
+    # An error class raised when dynamic parts are found while computing a
+    # constant path's full name. For example:
+    # Foo::Bar::Baz -> does not raise because all parts of the constant path are
+    # simple constants
+    # var::Bar::Baz -> raises because the first part of the constant path is a
+    # local variable
+    class DynamicPartsInConstantPathError < StandardError; end
+
     # Returns the list of parts for the full name of this constant path.
     # For example: [:Foo, :Bar]
     def full_name_parts
@@ -85,6 +93,10 @@ module Prism
         current = current.parent
       end
 
+      unless current.is_a?(ConstantReadNode)
+        raise DynamicPartsInConstantPathError, "Constant path contains dynamic parts. Cannot compute full name"
+      end
+
       parts.unshift(current&.name || :"")
     end
 
diff --git a/src/diagnostic.c b/src/diagnostic.c
index 7da8806b..473960e3 100644
--- a/src/diagnostic.c
+++ b/src/diagnostic.c
@@ -256,6 +256,7 @@ static const char* const diagnostic_messages[PM_DIAGNOSTIC_ID_LEN] = {
     [PM_WARN_AMBIGUOUS_FIRST_ARGUMENT_PLUS]     = "Ambiguous first argument; put parentheses or a space even after `+` operator",
     [PM_WARN_AMBIGUOUS_PREFIX_STAR]             = "Ambiguous `*` has been interpreted as an argument prefix",
     [PM_WARN_AMBIGUOUS_SLASH]                   = "Ambiguous `/`; wrap regexp in parentheses or add a space after `/` operator",
+    [PM_WARN_END_IN_METHOD]                     = "END in method; use at_exit",
 };
 
 static const char*
diff --git a/src/prism.c b/src/prism.c
index 09ccaf70..bf048f18 100644
--- a/src/prism.c
+++ b/src/prism.c
@@ -5127,28 +5127,6 @@ pm_statements_node_body_append(pm_statements_node_t *node, pm_node_t *statement)
     statement->flags |= PM_NODE_FLAG_NEWLINE;
 }
 
-/**
- * Allocate a new StringConcatNode node.
- */
-static pm_string_concat_node_t *
-pm_string_concat_node_create(pm_parser_t *parser, pm_node_t *left, pm_node_t *right) {
-    pm_string_concat_node_t *node = PM_ALLOC_NODE(parser, pm_string_concat_node_t);
-
-    *node = (pm_string_concat_node_t) {
-        {
-            .type = PM_STRING_CONCAT_NODE,
-            .location = {
-                .start = left->location.start,
-                .end = right->location.end
-            }
-        },
-        .left = left,
-        .right = right
-    };
-
-    return node;
-}
-
 /**
  * Allocate a new StringNode node with the current string on the parser.
  */
@@ -13470,9 +13448,10 @@ parse_strings_empty_content(const uint8_t *location) {
  * Parse a set of strings that could be concatenated together.
  */
 static inline pm_node_t *
-parse_strings(pm_parser_t *parser) {
+parse_strings(pm_parser_t *parser, pm_node_t *current) {
     assert(parser->current.type == PM_TOKEN_STRING_BEGIN);
-    pm_node_t *result = NULL;
+
+    bool concating = false;
     bool state_is_arg_labeled = lex_state_p(parser, PM_LEX_STATE_ARG | PM_LEX_STATE_LABELED);
 
     while (match1(parser, PM_TOKEN_STRING_BEGIN)) {
@@ -13608,7 +13587,7 @@ parse_strings(pm_parser_t *parser) {
             }
         }
 
-        if (result == NULL) {
+        if (current == NULL) {
             // If the node we just parsed is a symbol node, then we can't
             // concatenate it with anything else, so we can now return that
             // node.
@@ -13618,7 +13597,7 @@ parse_strings(pm_parser_t *parser) {
 
             // If we don't already have a node, then it's fine and we can just
             // set the result to be the node we just parsed.
-            result = node;
+            current = node;
         } else {
             // Otherwise we need to check the type of the node we just parsed.
             // If it cannot be concatenated with the previous node, then we'll
@@ -13627,13 +13606,22 @@ parse_strings(pm_parser_t *parser) {
                 pm_parser_err_node(parser, node, PM_ERR_STRING_CONCATENATION);
             }
 
-            // Either way we will create a concat node to hold the strings
-            // together.
-            result = (pm_node_t *) pm_string_concat_node_create(parser, result, node);
+            // If we haven't already created our container for concatenation,
+            // we'll do that now.
+            if (!concating) {
+                concating = true;
+                pm_token_t bounds = not_provided(parser);
+
+                pm_interpolated_string_node_t *container = pm_interpolated_string_node_create(parser, &bounds, NULL, &bounds);
+                pm_interpolated_string_node_append(container, current);
+                current = (pm_node_t *) container;
+            }
+
+            pm_interpolated_string_node_append((pm_interpolated_string_node_t *) current, node);
         }
     }
 
-    return result;
+    return current;
 }
 
 /**
@@ -13894,8 +13882,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power) {
             // Characters can be followed by strings in which case they are
             // automatically concatenated.
             if (match1(parser, PM_TOKEN_STRING_BEGIN)) {
-                pm_node_t *concat = parse_strings(parser);
-                return (pm_node_t *) pm_string_concat_node_create(parser, node, concat);
+                return parse_strings(parser, node);
             }
 
             return node;
@@ -14169,8 +14156,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power) {
             }
 
             if (match1(parser, PM_TOKEN_STRING_BEGIN)) {
-                pm_node_t *concat = parse_strings(parser);
-                return (pm_node_t *) pm_string_concat_node_create(parser, node, concat);
+                return parse_strings(parser, node);
             }
 
             return node;
@@ -14905,6 +14891,10 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power) {
             parser_lex(parser);
             pm_token_t keyword = parser->previous;
 
+            if (context_def_p(parser)) {
+                pm_parser_warn_token(parser, &keyword, PM_WARN_END_IN_METHOD);
+            }
+
             expect1(parser, PM_TOKEN_BRACE_LEFT, PM_ERR_END_UPCASE_BRACE);
             pm_token_t opening = parser->previous;
             pm_statements_node_t *statements = parse_statements(parser, PM_CONTEXT_POSTEXE);
@@ -15773,7 +15763,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power) {
             return (pm_node_t *) node;
         }
         case PM_TOKEN_STRING_BEGIN:
-            return parse_strings(parser);
+            return parse_strings(parser, NULL);
         case PM_TOKEN_SYMBOL_BEGIN: {
             pm_lex_mode_t lex_mode = *parser->lex_modes.current;
             parser_lex(parser);


Test patch:
diff --git a/test/prism/constant_path_node_test.rb b/test/prism/constant_path_node_test.rb
index 5bb98306..9842be0c 100644
--- a/test/prism/constant_path_node_test.rb
+++ b/test/prism/constant_path_node_test.rb
@@ -15,6 +15,33 @@ module Prism
       assert_equal("Foo::Bar::Baz::Qux", constant_path.full_name)
     end
 
+    def test_full_name_for_constant_path_with_self
+      source = <<~RUBY
+        self:: # comment
+          Bar::Baz::
+            Qux
+      RUBY
+
+      constant_path = Prism.parse(source).value.statements.body.first
+      assert_raise(ConstantPathNode::DynamicPartsInConstantPathError) do
+        constant_path.full_name
+      end
+    end
+
+    def test_full_name_for_constant_path_with_variable
+      source = <<~RUBY
+        foo:: # comment
+          Bar::Baz::
+            Qux
+      RUBY
+
+      constant_path = Prism.parse(source).value.statements.body.first
+
+      assert_raise(ConstantPathNode::DynamicPartsInConstantPathError) do
+        constant_path.full_name
+      end
+    end
+
     def test_full_name_for_constant_path_target
       source = <<~RUBY
         Foo:: # comment
diff --git a/test/prism/errors_test.rb b/test/prism/errors_test.rb
index cf7330df..9362032f 100644
--- a/test/prism/errors_test.rb
+++ b/test/prism/errors_test.rb
@@ -1708,6 +1708,12 @@ module Prism
       ]
     end
 
+    def test_upcase_end_in_def
+      assert_warning_messages "def foo; END { }; end", [
+        "END in method; use at_exit"
+      ]
+    end
+
     private
 
     def assert_errors(expected, source, errors, compare_ripper: RUBY_ENGINE == "ruby")
@@ -1727,6 +1733,11 @@ module Prism
       assert_equal(errors, result.errors.map(&:message))
     end
 
+    def assert_warning_messages(source, warnings)
+      result = Prism.parse(source)
+      assert_equal(warnings, result.warnings.map(&:message))
+    end
+
     def expression(source)
       Prism.parse(source).value.statements.body.last
     end
diff --git a/test/prism/location_test.rb b/test/prism/location_test.rb
index 02a577a7..302e6bd1 100644
--- a/test/prism/location_test.rb
+++ b/test/prism/location_test.rb
@@ -495,6 +495,7 @@ module Prism
     def test_InterpolatedStringNode
       assert_location(InterpolatedStringNode, "\"foo \#@bar baz\"")
       assert_location(InterpolatedStringNode, "<<~A\nhello \#{1} world\nA", 0...4)
+      assert_location(InterpolatedStringNode, '"foo" "bar"')
     end
 
     def test_InterpolatedSymbolNode
@@ -789,10 +790,6 @@ module Prism
       assert_location(StatementsNode, "\"\#{foo}\"", 3...6) { |node| node.parts.first.statements }
     end
 
-    def test_StringConcatNode
-      assert_location(StringConcatNode, '"foo" "bar"')
-    end
-
     def test_StringNode
       assert_location(StringNode, '"foo"')
       assert_location(StringNode, '%q[foo]')
diff --git a/test/prism/snapshots/dos_endings.txt b/test/prism/snapshots/dos_endings.txt
index 75833413..3097a361 100644
--- a/test/prism/snapshots/dos_endings.txt
+++ b/test/prism/snapshots/dos_endings.txt
@@ -11,21 +11,22 @@
         │   ├── arguments:
         │   │   @ ArgumentsNode (location: (1,5)-(2,12))
         │   │   ├── arguments: (length: 1)
-        │   │   │   └── @ StringConcatNode (location: (1,5)-(2,12))
-        │   │   │       ├── left:
-        │   │   │       │   @ StringNode (location: (1,5)-(1,9))
-        │   │   │       │   ├── flags: ∅
-        │   │   │       │   ├── opening_loc: (1,5)-(1,6) = "\""
-        │   │   │       │   ├── content_loc: (1,6)-(1,8) = "hi"
-        │   │   │       │   ├── closing_loc: (1,8)-(1,9) = "\""
-        │   │   │       │   └── unescaped: "hi"
-        │   │   │       └── right:
-        │   │   │           @ StringNode (location: (2,5)-(2,12))
-        │   │   │           ├── flags: ∅
-        │   │   │           ├── opening_loc: (2,5)-(2,6) = "\""
-        │   │   │           ├── content_loc: (2,6)-(2,11) = "there"
-        │   │   │           ├── closing_loc: (2,11)-(2,12) = "\""
-        │   │   │           └── unescaped: "there"
+        │   │   │   └── @ InterpolatedStringNode (location: (1,5)-(2,12))
+        │   │   │       ├── opening_loc: ∅
+        │   │   │       ├── parts: (length: 2)
+        │   │   │       │   ├── @ StringNode (location: (1,5)-(1,9))
+        │   │   │       │   │   ├── flags: ∅
+        │   │   │       │   │   ├── opening_loc: (1,5)-(1,6) = "\""
+        │   │   │       │   │   ├── content_loc: (1,6)-(1,8) = "hi"
+        │   │   │       │   │   ├── closing_loc: (1,8)-(1,9) = "\""
+        │   │   │       │   │   └── unescaped: "hi"
+        │   │   │       │   └── @ StringNode (location: (2,5)-(2,12))
+        │   │   │       │       ├── flags: ∅
+        │   │   │       │       ├── opening_loc: (2,5)-(2,6) = "\""
+        │   │   │       │       ├── content_loc: (2,6)-(2,11) = "there"
+        │   │   │       │       ├── closing_loc: (2,11)-(2,12) = "\""
+        │   │   │       │       └── unescaped: "there"
+        │   │   │       └── closing_loc: ∅
         │   │   └── flags: ∅
         │   ├── closing_loc: ∅
         │   ├── block: ∅
diff --git a/test/prism/snapshots/seattlerb/parse_line_evstr_after_break.txt b/test/prism/snapshots/seattlerb/parse_line_evstr_after_break.txt
index 662bd86c..145da454 100644
--- a/test/prism/snapshots/seattlerb/parse_line_evstr_after_break.txt
+++ b/test/prism/snapshots/seattlerb/parse_line_evstr_after_break.txt
@@ -3,32 +3,33 @@
 └── statements:
     @ StatementsNode (location: (1,0)-(2,6))
     └── body: (length: 1)
-        └── @ StringConcatNode (location: (1,0)-(2,6))
-            ├── left:
-            │   @ StringNode (location: (1,0)-(1,3))
-            │   ├── flags: ∅
-            │   ├── opening_loc: (1,0)-(1,1) = "\""
-            │   ├── content_loc: (1,1)-(1,2) = "a"
-            │   ├── closing_loc: (1,2)-(1,3) = "\""
-            │   └── unescaped: "a"
-            └── right:
-                @ InterpolatedStringNode (location: (2,0)-(2,6))
-                ├── opening_loc: (2,0)-(2,1) = "\""
-                ├── parts: (length: 1)
-                │   └── @ EmbeddedStatementsNode (location: (2,1)-(2,5))
-                │       ├── opening_loc: (2,1)-(2,3) = "\#{"
-                │       ├── statements:
-                │       │   @ StatementsNode (location: (2,3)-(2,4))
-                │       │   └── body: (length: 1)
-                │       │       └── @ CallNode (location: (2,3)-(2,4))
-                │       │           ├── receiver: ∅
-                │       │           ├── call_operator_loc: ∅
-                │       │           ├── message_loc: (2,3)-(2,4) = "b"
-                │       │           ├── opening_loc: ∅
-                │       │           ├── arguments: ∅
-                │       │           ├── closing_loc: ∅
-                │       │           ├── block: ∅
-                │       │           ├── flags: variable_call
-                │       │           └── name: :b
-                │       └── closing_loc: (2,4)-(2,5) = "}"
-                └── closing_loc: (2,5)-(2,6) = "\""
+        └── @ InterpolatedStringNode (location: (1,0)-(2,6))
+            ├── opening_loc: ∅
+            ├── parts: (length: 2)
+            │   ├── @ StringNode (location: (1,0)-(1,3))
+            │   │   ├── flags: ∅
+            │   │   ├── opening_loc: (1,0)-(1,1) = "\""
+            │   │   ├── content_loc: (1,1)-(1,2) = "a"
+            │   │   ├── closing_loc: (1,2)-(1,3) = "\""
+            │   │   └── unescaped: "a"
+            │   └── @ InterpolatedStringNode (location: (2,0)-(2,6))
+            │       ├── opening_loc: (2,0)-(2,1) = "\""
+            │       ├── parts: (length: 1)
+            │       │   └── @ EmbeddedStatementsNode (location: (2,1)-(2,5))
+            │       │       ├── opening_loc: (2,1)-(2,3) = "\#{"
+            │       │       ├── statements:
+            │       │       │   @ StatementsNode (location: (2,3)-(2,4))
+            │       │       │   └── body: (length: 1)
+            │       │       │       └── @ CallNode (location: (2,3)-(2,4))
+            │       │       │           ├── receiver: ∅
+            │       │       │           ├── call_operator_loc: ∅
+            │       │       │           ├── message_loc: (2,3)-(2,4) = "b"
+            │       │       │           ├── opening_loc: ∅
+            │       │       │           ├── arguments: ∅
+            │       │       │           ├── closing_loc: ∅
+            │       │       │           ├── block: ∅
+            │       │       │           ├── flags: variable_call
+            │       │       │           └── name: :b
+            │       │       └── closing_loc: (2,4)-(2,5) = "}"
+            │       └── closing_loc: (2,5)-(2,6) = "\""
+            └── closing_loc: ∅
diff --git a/test/prism/snapshots/seattlerb/str_lit_concat_bad_encodings.txt b/test/prism/snapshots/seattlerb/str_lit_concat_bad_encodings.txt
index 7bf71b78..f1226f34 100644
--- a/test/prism/snapshots/seattlerb/str_lit_concat_bad_encodings.txt
+++ b/test/prism/snapshots/seattlerb/str_lit_concat_bad_encodings.txt
@@ -3,18 +3,19 @@
 └── statements:
     @ StatementsNode (location: (1,0)-(2,66))
     └── body: (length: 1)
-        └── @ StringConcatNode (location: (1,0)-(2,66))
-            ├── left:
-            │   @ StringNode (location: (1,0)-(1,62))
-            │   ├── flags: ∅
-            │   ├── opening_loc: (1,0)-(1,1) = "\""
-            │   ├── content_loc: (1,1)-(1,61) = "\\xE3\\xD3\\x8B\\xE3\\x83\\xBC\\x83\\xE3\\x83\\xE3\\x82\\xB3\\xA3\\x82\\x99"
-            │   ├── closing_loc: (1,61)-(1,62) = "\""
-            │   └── unescaped: "\xE3Ӌー\x83\xE3\x83コ\xA3\x82\x99"
-            └── right:
-                @ StringNode (location: (2,8)-(2,66))
-                ├── flags: ∅
-                ├── opening_loc: (2,8)-(2,9) = "\""
-                ├── content_loc: (2,9)-(2,65) = "\\xE3\\x83\\xB3\\xE3\\x83\\x8F\\xE3\\x82\\x9A\\xC3\\xBD;foo@bar.com"
-                ├── closing_loc: (2,65)-(2,66) = "\""
-                └── unescaped: "ンパý;foo@bar.com"
+        └── @ InterpolatedStringNode (location: (1,0)-(2,66))
+            ├── opening_loc: ∅
+            ├── parts: (length: 2)
+            │   ├── @ StringNode (location: (1,0)-(1,62))
+            │   │   ├── flags: ∅
+            │   │   ├── opening_loc: (1,0)-(1,1) = "\""
+            │   │   ├── content_loc: (1,1)-(1,61) = "\\xE3\\xD3\\x8B\\xE3\\x83\\xBC\\x83\\xE3\\x83\\xE3\\x82\\xB3\\xA3\\x82\\x99"
+            │   │   ├── closing_loc: (1,61)-(1,62) = "\""
+            │   │   └── unescaped: "\xE3Ӌー\x83\xE3\x83コ\xA3\x82\x99"
+            │   └── @ StringNode (location: (2,8)-(2,66))
+            │       ├── flags: ∅
+            │       ├── opening_loc: (2,8)-(2,9) = "\""
+            │       ├── content_loc: (2,9)-(2,65) = "\\xE3\\x83\\xB3\\xE3\\x83\\x8F\\xE3\\x82\\x9A\\xC3\\xBD;foo@bar.com"
+            │       ├── closing_loc: (2,65)-(2,66) = "\""
+            │       └── unescaped: "ンパý;foo@bar.com"
+            └── closing_loc: ∅
diff --git a/test/prism/snapshots/strings.txt b/test/prism/snapshots/strings.txt
index c70e6472..5a031705 100644
--- a/test/prism/snapshots/strings.txt
+++ b/test/prism/snapshots/strings.txt
@@ -482,21 +482,22 @@
         │   ├── content_loc: (97,1)-(97,2) = "a"
         │   ├── closing_loc: ∅
         │   └── unescaped: "a"
-        ├── @ StringConcatNode (location: (99,0)-(99,6))
-        │   ├── left:
-        │   │   @ StringNode (location: (99,0)-(99,2))
-        │   │   ├── flags: ∅
-        │   │   ├── opening_loc: (99,0)-(99,1) = "?"
-        │   │   ├── content_loc: (99,1)-(99,2) = "a"
-        │   │   ├── closing_loc: ∅
-        │   │   └── unescaped: "a"
-        │   └── right:
-        │       @ StringNode (location: (99,3)-(99,6))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (99,3)-(99,4) = "\""
-        │       ├── content_loc: (99,4)-(99,5) = "a"
-        │       ├── closing_loc: (99,5)-(99,6) = "\""
-        │       └── unescaped: "a"
+        ├── @ InterpolatedStringNode (location: (99,0)-(99,6))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ StringNode (location: (99,0)-(99,2))
+        │   │   │   ├── flags: ∅
+        │   │   │   ├── opening_loc: (99,0)-(99,1) = "?"
+        │   │   │   ├── content_loc: (99,1)-(99,2) = "a"
+        │   │   │   ├── closing_loc: ∅
+        │   │   │   └── unescaped: "a"
+        │   │   └── @ StringNode (location: (99,3)-(99,6))
+        │   │       ├── flags: ∅
+        │   │       ├── opening_loc: (99,3)-(99,4) = "\""
+        │   │       ├── content_loc: (99,4)-(99,5) = "a"
+        │   │       ├── closing_loc: (99,5)-(99,6) = "\""
+        │   │       └── unescaped: "a"
+        │   └── closing_loc: ∅
         ├── @ StringNode (location: (101,0)-(101,7))
         │   ├── flags: ∅
         │   ├── opening_loc: (101,0)-(101,3) = "%Q{"
diff --git a/test/prism/snapshots/unparser/corpus/literal/literal.txt b/test/prism/snapshots/unparser/corpus/literal/literal.txt
index abf552b5..3a54d4f9 100644
--- a/test/prism/snapshots/unparser/corpus/literal/literal.txt
+++ b/test/prism/snapshots/unparser/corpus/literal/literal.txt
@@ -338,21 +338,22 @@
         │       └── numeric:
         │           @ IntegerNode (location: (27,0)-(27,1))
         │           └── flags: decimal
-        ├── @ StringConcatNode (location: (28,0)-(28,11))
-        │   ├── left:
-        │   │   @ StringNode (location: (28,0)-(28,5))
-        │   │   ├── flags: ∅
-        │   │   ├── opening_loc: (28,0)-(28,1) = "\""
-        │   │   ├── content_loc: (28,1)-(28,4) = "foo"
-        │   │   ├── closing_loc: (28,4)-(28,5) = "\""
-        │   │   └── unescaped: "foo"
-        │   └── right:
-        │       @ StringNode (location: (28,6)-(28,11))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (28,6)-(28,7) = "\""
-        │       ├── content_loc: (28,7)-(28,10) = "bar"
-        │       ├── closing_loc: (28,10)-(28,11) = "\""
-        │       └── unescaped: "bar"
+        ├── @ InterpolatedStringNode (location: (28,0)-(28,11))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ StringNode (location: (28,0)-(28,5))
+        │   │   │   ├── flags: ∅
+        │   │   │   ├── opening_loc: (28,0)-(28,1) = "\""
+        │   │   │   ├── content_loc: (28,1)-(28,4) = "foo"
+        │   │   │   ├── closing_loc: (28,4)-(28,5) = "\""
+        │   │   │   └── unescaped: "foo"
+        │   │   └── @ StringNode (location: (28,6)-(28,11))
+        │   │       ├── flags: ∅
+        │   │       ├── opening_loc: (28,6)-(28,7) = "\""
+        │   │       ├── content_loc: (28,7)-(28,10) = "bar"
+        │   │       ├── closing_loc: (28,10)-(28,11) = "\""
+        │   │       └── unescaped: "bar"
+        │   └── closing_loc: ∅
         ├── @ InterpolatedStringNode (location: (29,0)-(29,15))
         │   ├── opening_loc: (29,0)-(29,1) = "\""
         │   ├── parts: (length: 2)
diff --git a/test/prism/snapshots/unparser/corpus/semantic/dstr.txt b/test/prism/snapshots/unparser/corpus/semantic/dstr.txt
index bc675a56..5ab954b6 100644
--- a/test/prism/snapshots/unparser/corpus/semantic/dstr.txt
+++ b/test/prism/snapshots/unparser/corpus/semantic/dstr.txt
@@ -425,143 +425,146 @@
         │   │       ├── closing_loc: ∅
         │   │       └── unescaped: "\nb"
         │   └── closing_loc: (117,6)-(117,7) = "\""
-        ├── @ StringConcatNode (location: (119,0)-(120,5))
-        │   ├── left:
-        │   │   @ StringNode (location: (119,0)-(119,3))
-        │   │   ├── flags: ∅
-        │   │   ├── opening_loc: (119,0)-(119,1) = "'"
-        │   │   ├── content_loc: (119,1)-(119,2) = "a"
-        │   │   ├── closing_loc: (119,2)-(119,3) = "'"
-        │   │   └── unescaped: "a"
-        │   └── right:
-        │       @ InterpolatedStringNode (location: (120,0)-(120,5))
-        │       ├── opening_loc: (120,0)-(120,1) = "\""
-        │       ├── parts: (length: 1)
-        │       │   └── @ EmbeddedStatementsNode (location: (120,1)-(120,4))
-        │       │       ├── opening_loc: (120,1)-(120,3) = "\#{"
-        │       │       ├── statements: ∅
-        │       │       └── closing_loc: (120,3)-(120,4) = "}"
-        │       └── closing_loc: (120,4)-(120,5) = "\""
-        ├── @ StringConcatNode (location: (122,0)-(122,8))
-        │   ├── left:
-        │   │   @ StringConcatNode (location: (122,0)-(122,5))
-        │   │   ├── left:
-        │   │   │   @ StringNode (location: (122,0)-(122,2))
+        ├── @ InterpolatedStringNode (location: (119,0)-(120,5))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ StringNode (location: (119,0)-(119,3))
+        │   │   │   ├── flags: ∅
+        │   │   │   ├── opening_loc: (119,0)-(119,1) = "'"
+        │   │   │   ├── content_loc: (119,1)-(119,2) = "a"
+        │   │   │   ├── closing_loc: (119,2)-(119,3) = "'"
+        │   │   │   └── unescaped: "a"
+        │   │   └── @ InterpolatedStringNode (location: (120,0)-(120,5))
+        │   │       ├── opening_loc: (120,0)-(120,1) = "\""
+        │   │       ├── parts: (length: 1)
+        │   │       │   └── @ EmbeddedStatementsNode (location: (120,1)-(120,4))
+        │   │       │       ├── opening_loc: (120,1)-(120,3) = "\#{"
+        │   │       │       ├── statements: ∅
+        │   │       │       └── closing_loc: (120,3)-(120,4) = "}"
+        │   │       └── closing_loc: (120,4)-(120,5) = "\""
+        │   └── closing_loc: ∅
+        ├── @ InterpolatedStringNode (location: (122,0)-(122,8))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 3)
+        │   │   ├── @ StringNode (location: (122,0)-(122,2))
         │   │   │   ├── flags: ∅
         │   │   │   ├── opening_loc: (122,0)-(122,1) = "\""
         │   │   │   ├── content_loc: (122,1)-(122,1) = ""
         │   │   │   ├── closing_loc: (122,1)-(122,2) = "\""
         │   │   │   └── unescaped: ""
-        │   │   └── right:
-        │   │       @ StringNode (location: (122,3)-(122,5))
+        │   │   ├── @ StringNode (location: (122,3)-(122,5))
+        │   │   │   ├── flags: ∅
+        │   │   │   ├── opening_loc: (122,3)-(122,4) = "\""
+        │   │   │   ├── content_loc: (122,4)-(122,4) = ""
+        │   │   │   ├── closing_loc: (122,4)-(122,5) = "\""
+        │   │   │   └── unescaped: ""
+        │   │   └── @ StringNode (location: (122,6)-(122,8))
         │   │       ├── flags: ∅
-        │   │       ├── opening_loc: (122,3)-(122,4) = "\""
-        │   │       ├── content_loc: (122,4)-(122,4) = ""
-        │   │       ├── closing_loc: (122,4)-(122,5) = "\""
+        │   │       ├── opening_loc: (122,6)-(122,7) = "\""
+        │   │       ├── content_loc: (122,7)-(122,7) = ""
+        │   │       ├── closing_loc: (122,7)-(122,8) = "\""
         │   │       └── unescaped: ""
-        │   └── right:
-        │       @ StringNode (location: (122,6)-(122,8))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (122,6)-(122,7) = "\""
-        │       ├── content_loc: (122,7)-(122,7) = ""
-        │       ├── closing_loc: (122,7)-(122,8) = "\""
-        │       └── unescaped: ""
-        ├── @ StringConcatNode (location: (124,0)-(124,12))
-        │   ├── left:
-        │   │   @ InterpolatedStringNode (location: (124,0)-(124,8))
-        │   │   ├── opening_loc: (124,0)-(124,1) = "\""
-        │   │   ├── parts: (length: 2)
-        │   │   │   ├── @ StringNode (location: (124,1)-(124,2))
-        │   │   │   │   ├── flags: ∅
-        │   │   │   │   ├── opening_loc: ∅
-        │   │   │   │   ├── content_loc: (124,1)-(124,2) = "a"
-        │   │   │   │   ├── closing_loc: ∅
-        │   │   │   │   └── unescaped: "a"
-        │   │   │   └── @ EmbeddedStatementsNode (location: (124,2)-(124,7))
-        │   │   │       ├── opening_loc: (124,2)-(124,4) = "\#{"
-        │   │   │       ├── statements:
-        │   │   │       │   @ StatementsNode (location: (124,4)-(124,6))
-        │   │   │       │   └── body: (length: 1)
-        │   │   │       │       └── @ InstanceVariableReadNode (location: (124,4)-(124,6))
-        │   │   │       │           └── name: :@a
-        │   │   │       └── closing_loc: (124,6)-(124,7) = "}"
-        │   │   └── closing_loc: (124,7)-(124,8) = "\""
-        │   └── right:
-        │       @ StringNode (location: (124,9)-(124,12))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (124,9)-(124,10) = "\""
-        │       ├── content_loc: (124,10)-(124,11) = "b"
-        │       ├── closing_loc: (124,11)-(124,12) = "\""
-        │       └── unescaped: "b"
-        ├── @ StringConcatNode (location: (125,0)-(125,10))
-        │   ├── left:
-        │   │   @ InterpolatedStringNode (location: (125,0)-(125,6))
-        │   │   ├── opening_loc: (125,0)-(125,1) = "\""
-        │   │   ├── parts: (length: 2)
-        │   │   │   ├── @ StringNode (location: (125,1)-(125,2))
-        │   │   │   │   ├── flags: ∅
-        │   │   │   │   ├── opening_loc: ∅
-        │   │   │   │   ├── content_loc: (125,1)-(125,2) = "a"
-        │   │   │   │   ├── closing_loc: ∅
-        │   │   │   │   └── unescaped: "a"
-        │   │   │   └── @ EmbeddedVariableNode (location: (125,2)-(125,5))
-        │   │   │       ├── operator_loc: (125,2)-(125,3) = "#"
-        │   │   │       └── variable:
-        │   │   │           @ InstanceVariableReadNode (location: (125,3)-(125,5))
-        │   │   │           └── name: :@a
-        │   │   └── closing_loc: (125,5)-(125,6) = "\""
-        │   └── right:
-        │       @ StringNode (location: (125,7)-(125,10))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (125,7)-(125,8) = "\""
-        │       ├── content_loc: (125,8)-(125,9) = "b"
-        │       ├── closing_loc: (125,9)-(125,10) = "\""
-        │       └── unescaped: "b"
-        ├── @ StringConcatNode (location: (126,0)-(126,10))
-        │   ├── left:
-        │   │   @ InterpolatedStringNode (location: (126,0)-(126,6))
-        │   │   ├── opening_loc: (126,0)-(126,1) = "\""
-        │   │   ├── parts: (length: 2)
-        │   │   │   ├── @ StringNode (location: (126,1)-(126,2))
-        │   │   │   │   ├── flags: ∅
-        │   │   │   │   ├── opening_loc: ∅
-        │   │   │   │   ├── content_loc: (126,1)-(126,2) = "a"
-        │   │   │   │   ├── closing_loc: ∅
-        │   │   │   │   └── unescaped: "a"
-        │   │   │   └── @ EmbeddedVariableNode (location: (126,2)-(126,5))
-        │   │   │       ├── operator_loc: (126,2)-(126,3) = "#"
-        │   │   │       └── variable:
-        │   │   │           @ GlobalVariableReadNode (location: (126,3)-(126,5))
-        │   │   │           └── name: :$a
-        │   │   └── closing_loc: (126,5)-(126,6) = "\""
-        │   └── right:
-        │       @ StringNode (location: (126,7)-(126,10))
-        │       ├── flags: ∅
-        │       ├── opening_loc: (126,7)-(126,8) = "\""
-        │       ├── content_loc: (126,8)-(126,9) = "b"
-        │       ├── closing_loc: (126,9)-(126,10) = "\""
-        │       └── unescaped: "b"
-        └── @ StringConcatNode (location: (127,0)-(127,11))
-            ├── left:
-            │   @ InterpolatedStringNode (location: (127,0)-(127,7))
-            │   ├── opening_loc: (127,0)-(127,1) = "\""
-            │   ├── parts: (length: 2)
-            │   │   ├── @ StringNode (location: (127,1)-(127,2))
-            │   │   │   ├── flags: ∅
-            │   │   │   ├── opening_loc: ∅
-            │   │   │   ├── content_loc: (127,1)-(127,2) = "a"
-            │   │   │   ├── closing_loc: ∅
-            │   │   │   └── unescaped: "a"
-            │   │   └── @ EmbeddedVariableNode (location: (127,2)-(127,6))
-            │   │       ├── operator_loc: (127,2)-(127,3) = "#"
-            │   │       └── variable:
-            │   │           @ ClassVariableReadNode (location: (127,3)-(127,6))
-            │   │           └── name: :@@a
-            │   └── closing_loc: (127,6)-(127,7) = "\""
-            └── right:
-                @ StringNode (location: (127,8)-(127,11))
-                ├── flags: ∅
-                ├── opening_loc: (127,8)-(127,9) = "\""
-                ├── content_loc: (127,9)-(127,10) = "b"
-                ├── closing_loc: (127,10)-(127,11) = "\""
-                └── unescaped: "b"
+        │   └── closing_loc: ∅
+        ├── @ InterpolatedStringNode (location: (124,0)-(124,12))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ InterpolatedStringNode (location: (124,0)-(124,8))
+        │   │   │   ├── opening_loc: (124,0)-(124,1) = "\""
+        │   │   │   ├── parts: (length: 2)
+        │   │   │   │   ├── @ StringNode (location: (124,1)-(124,2))
+        │   │   │   │   │   ├── flags: ∅
+        │   │   │   │   │   ├── opening_loc: ∅
+        │   │   │   │   │   ├── content_loc: (124,1)-(124,2) = "a"
+        │   │   │   │   │   ├── closing_loc: ∅
+        │   │   │   │   │   └── unescaped: "a"
+        │   │   │   │   └── @ EmbeddedStatementsNode (location: (124,2)-(124,7))
+        │   │   │   │       ├── opening_loc: (124,2)-(124,4) = "\#{"
+        │   │   │   │       ├── statements:
+        │   │   │   │       │   @ StatementsNode (location: (124,4)-(124,6))
+        │   │   │   │       │   └── body: (length: 1)
+        │   │   │   │       │       └── @ InstanceVariableReadNode (location: (124,4)-(124,6))
+        │   │   │   │       │           └── name: :@a
+        │   │   │   │       └── closing_loc: (124,6)-(124,7) = "}"
+        │   │   │   └── closing_loc: (124,7)-(124,8) = "\""
+        │   │   └── @ StringNode (location: (124,9)-(124,12))
+        │   │       ├── flags: ∅
+        │   │       ├── opening_loc: (124,9)-(124,10) = "\""
+        │   │       ├── content_loc: (124,10)-(124,11) = "b"
+        │   │       ├── closing_loc: (124,11)-(124,12) = "\""
+        │   │       └── unescaped: "b"
+        │   └── closing_loc: ∅
+        ├── @ InterpolatedStringNode (location: (125,0)-(125,10))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ InterpolatedStringNode (location: (125,0)-(125,6))
+        │   │   │   ├── opening_loc: (125,0)-(125,1) = "\""
+        │   │   │   ├── parts: (length: 2)
+        │   │   │   │   ├── @ StringNode (location: (125,1)-(125,2))
+        │   │   │   │   │   ├── flags: ∅
+        │   │   │   │   │   ├── opening_loc: ∅
+        │   │   │   │   │   ├── content_loc: (125,1)-(125,2) = "a"
+        │   │   │   │   │   ├── closing_loc: ∅
+        │   │   │   │   │   └── unescaped: "a"
+        │   │   │   │   └── @ EmbeddedVariableNode (location: (125,2)-(125,5))
+        │   │   │   │       ├── operator_loc: (125,2)-(125,3) = "#"
+        │   │   │   │       └── variable:
+        │   │   │   │           @ InstanceVariableReadNode (location: (125,3)-(125,5))
+        │   │   │   │           └── name: :@a
+        │   │   │   └── closing_loc: (125,5)-(125,6) = "\""
+        │   │   └── @ StringNode (location: (125,7)-(125,10))
+        │   │       ├── flags: ∅
+        │   │       ├── opening_loc: (125,7)-(125,8) = "\""
+        │   │       ├── content_loc: (125,8)-(125,9) = "b"
+        │   │       ├── closing_loc: (125,9)-(125,10) = "\""
+        │   │       └── unescaped: "b"
+        │   └── closing_loc: ∅
+        ├── @ InterpolatedStringNode (location: (126,0)-(126,10))
+        │   ├── opening_loc: ∅
+        │   ├── parts: (length: 2)
+        │   │   ├── @ InterpolatedStringNode (location: (126,0)-(126,6))
+        │   │   │   ├── opening_loc: (126,0)-(126,1) = "\""
+        │   │   │   ├── parts: (length: 2)
+        │   │   │   │   ├── @ StringNode (location: (126,1)-(126,2))
+        │   │   │   │   │   ├── flags: ∅
+        │   │   │   │   │   ├── opening_loc: ∅
+        │   │   │   │   │   ├── content_loc: (126,1)-(126,2) = "a"
+        │   │   │   │   │   ├── closing_loc: ∅
+        │   │   │   │   │   └── unescaped: "a"
+        │   │   │   │   └── @ EmbeddedVariableNode (location: (126,2)-(126,5))
+        │   │   │   │       ├── operator_loc: (126,2)-(126,3) = "#"
+        │   │   │   │       └── variable:
+        │   │   │   │           @ GlobalVariableReadNode (location: (126,3)-(126,5))
+        │   │   │   │           └── name: :$a
+        │   │   │   └── closing_loc: (126,5)-(126,6) = "\""
+        │   │   └── @ StringNode (location: (126,7)-(126,10))
+        │   │       ├── flags: ∅
+        │   │       ├── opening_loc: (126,7)-(126,8) = "\""
+        │   │       ├── content_loc: (126,8)-(126,9) = "b"
+        │   │       ├── closing_loc: (126,9)-(126,10) = "\""
+        │   │       └── unescaped: "b"
+        │   └── closing_loc: ∅
+        └── @ InterpolatedStringNode (location: (127,0)-(127,11))
+            ├── opening_loc: ∅
+            ├── parts: (length: 2)
+            │   ├── @ InterpolatedStringNode (location: (127,0)-(127,7))
+            │   │   ├── opening_loc: (127,0)-(127,1) = "\""
+            │   │   ├── parts: (length: 2)
+            │   │   │   ├── @ StringNode (location: (127,1)-(127,2))
+            │   │   │   │   ├── flags: ∅
+            │   │   │   │   ├── opening_loc: ∅
+            │   │   │   │   ├── content_loc: (127,1)-(127,2) = "a"
+            │   │   │   │   ├── closing_loc: ∅
+            │   │   │   │   └── unescaped: "a"
+            │   │   │   └── @ EmbeddedVariableNode (location: (127,2)-(127,6))
+            │   │   │       ├── operator_loc: (127,2)-(127,3) = "#"
+            │   │   │       └── variable:
+            │   │   │           @ ClassVariableReadNode (location: (127,3)-(127,6))
+            │   │   │           └── name: :@@a
+            │   │   └── closing_loc: (127,6)-(127,7) = "\""
+            │   └── @ StringNode (location: (127,8)-(127,11))
+            │       ├── flags: ∅
+            │       ├── opening_loc: (127,8)-(127,9) = "\""
+            │       ├── content_loc: (127,9)-(127,10) = "b"
+            │       ├── closing_loc: (127,10)-(127,11) = "\""
+            │       └── unescaped: "b"
+            └── closing_loc: ∅
diff --git a/test/prism/snapshots/whitequark/ruby_bug_11990.txt b/test/prism/snapshots/whitequark/ruby_bug_11990.txt
index 54532e32..1e35d543 100644
--- a/test/prism/snapshots/whitequark/ruby_bug_11990.txt
+++ b/test/prism/snapshots/whitequark/ruby_bug_11990.txt
@@ -11,21 +11,22 @@
             ├── arguments:
             │   @ ArgumentsNode (location: (1,2)-(1,12))
             │   ├── arguments: (length: 1)
-            │   │   └── @ StringConcatNode (location: (1,2)-(1,12))
-            │   │       ├── left:
-            │   │       │   @ StringNode (location: (1,2)-(1,6))
-            │   │       │   ├── flags: ∅
-            │   │       │   ├── opening_loc: (1,2)-(1,6) = "<<~E"
-            │   │       │   ├── content_loc: (2,0)-(3,0) = "  x\n"
-            │   │       │   ├── closing_loc: (3,0)-(4,0) = "E\n"
-            │   │       │   └── unescaped: "x\n"
-            │   │       └── right:
-            │   │           @ StringNode (location: (1,7)-(1,12))
-            │   │           ├── flags: ∅
-            │   │           ├── opening_loc: (1,7)-(1,8) = "\""
-            │   │           ├── content_loc: (1,8)-(1,11) = "  y"
-            │   │           ├── closing_loc: (1,11)-(1,12) = "\""
-            │   │           └── unescaped: "  y"
+            │   │   └── @ InterpolatedStringNode (location: (1,2)-(1,12))
+            │   │       ├── opening_loc: ∅
+            │   │       ├── parts: (length: 2)
+            │   │       │   ├── @ StringNode (location: (1,2)-(1,6))
+            │   │       │   │   ├── flags: ∅
+            │   │       │   │   ├── opening_loc: (1,2)-(1,6) = "<<~E"
+            │   │       │   │   ├── content_loc: (2,0)-(3,0) = "  x\n"
+            │   │       │   │   ├── closing_loc: (3,0)-(4,0) = "E\n"
+            │   │       │   │   └── unescaped: "x\n"
+            │   │       │   └── @ StringNode (location: (1,7)-(1,12))
+            │   │       │       ├── flags: ∅
+            │   │       │       ├── opening_loc: (1,7)-(1,8) = "\""
+            │   │       │       ├── content_loc: (1,8)-(1,11) = "  y"
+            │   │       │       ├── closing_loc: (1,11)-(1,12) = "\""
+            │   │       │       └── unescaped: "  y"
+            │   │       └── closing_loc: ∅
             │   └── flags: ∅
             ├── closing_loc: ∅
             ├── block: ∅
diff --git a/test/prism/snapshots/whitequark/string_concat.txt b/test/prism/snapshots/whitequark/string_concat.txt
index 2c15895d..38ea0c74 100644
--- a/test/prism/snapshots/whitequark/string_concat.txt
+++ b/test/prism/snapshots/whitequark/string_concat.txt
@@ -3,27 +3,28 @@
 └── statements:
     @ StatementsNode (location: (1,0)-(1,14))
     └── body: (length: 1)
-        └── @ StringConcatNode (location: (1,0)-(1,14))
-            ├── left:
-            │   @ InterpolatedStringNode (location: (1,0)-(1,8))
-            │   ├── opening_loc: (1,0)-(1,1) = "\""
-            │   ├── parts: (length: 2)
-            │   │   ├── @ StringNode (location: (1,1)-(1,4))
-            │   │   │   ├── flags: ∅
-            │   │   │   ├── opening_loc: ∅
-            │   │   │   ├── content_loc: (1,1)-(1,4) = "foo"
-            │   │   │   ├── closing_loc: ∅
-            │   │   │   └── unescaped: "foo"
-            │   │   └── @ EmbeddedVariableNode (location: (1,4)-(1,7))
-            │   │       ├── operator_loc: (1,4)-(1,5) = "#"
-            │   │       └── variable:
-            │   │           @ InstanceVariableReadNode (location: (1,5)-(1,7))
-            │   │           └── name: :@a
-            │   └── closing_loc: (1,7)-(1,8) = "\""
-            └── right:
-                @ StringNode (location: (1,9)-(1,14))
-                ├── flags: ∅
-                ├── opening_loc: (1,9)-(1,10) = "\""
-                ├── content_loc: (1,10)-(1,13) = "bar"
-                ├── closing_loc: (1,13)-(1,14) = "\""
-                └── unescaped: "bar"
+        └── @ InterpolatedStringNode (location: (1,0)-(1,14))
+            ├── opening_loc: ∅
+            ├── parts: (length: 2)
+            │   ├── @ InterpolatedStringNode (location: (1,0)-(1,8))
+            │   │   ├── opening_loc: (1,0)-(1,1) = "\""
+            │   │   ├── parts: (length: 2)
+            │   │   │   ├── @ StringNode (location: (1,1)-(1,4))
+            │   │   │   │   ├── flags: ∅
+            │   │   │   │   ├── opening_loc: ∅
+            │   │   │   │   ├── content_loc: (1,1)-(1,4) = "foo"
+            │   │   │   │   ├── closing_loc: ∅
+            │   │   │   │   └── unescaped: "foo"
+            │   │   │   └── @ EmbeddedVariableNode (location: (1,4)-(1,7))
+            │   │   │       ├── operator_loc: (1,4)-(1,5) = "#"
+            │   │   │       └── variable:
+            │   │   │           @ InstanceVariableReadNode (location: (1,5)-(1,7))
+            │   │   │           └── name: :@a
+            │   │   └── closing_loc: (1,7)-(1,8) = "\""
+            │   └── @ StringNode (location: (1,9)-(1,14))
+            │       ├── flags: ∅
+            │       ├── opening_loc: (1,9)-(1,10) = "\""
+            │       ├── content_loc: (1,10)-(1,13) = "bar"
+            │       ├── closing_loc: (1,13)-(1,14) = "\""
+            │       └── unescaped: "bar"
+            └── closing_loc: ∅