Repository: ruby/prism
Pull request #2754 (2024-04-29 21:10:17)

Specialize code unit computing for ASCII only sources
### Motivation

The new code unit APIs work really well and they achieved the behaviour we wanted in the Ruby LSP. Unfortunately, they are significantly slower than the much simpler computation for columns. Invoking `byteslice` repeatedly for large documents slows down the LSP by a lot.

Thankfully, we don't have to pay this price unnecessarily. If we specialize the implementation of the code unit API for ASCII-only sources, then we can take the simpler route which is about 5x faster on my machine.

### Implementation

I created a specialized version of `Source` called `ASCIISource`. Creating an equivalent `MultiByteSource` felt unnecessary, since we can have that as the default behaviour of `Source` (or we can flip if we prefer the faster option as the default).

To decide which kind of source to create, I introduced a `Source.create` method, which I honestly don't love, but also can't think of anything simpler.

kddnewton: Just to confirm you benchmarked this locally and it solves the issue for ascii only?
vinistock: > Just to confirm you benchmarked this locally and it solves the issue for ascii only?

Yes, it does. About 5x faster than the version that handles multibyte.

Patch:
diff --git a/config.yml b/config.yml
index c9045d77..56c95e4e 100644
--- a/config.yml
+++ b/config.yml
@@ -123,13 +123,12 @@ errors:
   - HASH_ROCKET
   - HASH_TERM
   - HASH_VALUE
-  - HEREDOC_IDENTIFIER
   - HEREDOC_TERM
   - INCOMPLETE_QUESTION_MARK
   - INCOMPLETE_VARIABLE_CLASS
-  - INCOMPLETE_VARIABLE_CLASS_3_3
+  - INCOMPLETE_VARIABLE_CLASS_3_3_0
   - INCOMPLETE_VARIABLE_INSTANCE
-  - INCOMPLETE_VARIABLE_INSTANCE_3_3
+  - INCOMPLETE_VARIABLE_INSTANCE_3_3_0
   - INSTANCE_VARIABLE_BARE
   - INVALID_BLOCK_EXIT
   - INVALID_CHARACTER
@@ -144,16 +143,14 @@ errors:
   - INVALID_NUMBER_DECIMAL
   - INVALID_NUMBER_HEXADECIMAL
   - INVALID_NUMBER_OCTAL
-  - INVALID_NUMBER_UNDERSCORE_INNER
-  - INVALID_NUMBER_UNDERSCORE_TRAILING
+  - INVALID_NUMBER_UNDERSCORE
   - INVALID_PERCENT
   - INVALID_PRINTABLE_CHARACTER
   - INVALID_RETRY_AFTER_ELSE
   - INVALID_RETRY_AFTER_ENSURE
   - INVALID_RETRY_WITHOUT_RESCUE
-  - INVALID_SYMBOL
   - INVALID_VARIABLE_GLOBAL
-  - INVALID_VARIABLE_GLOBAL_3_3
+  - INVALID_VARIABLE_GLOBAL_3_3_0
   - INVALID_YIELD
   - IT_NOT_ALLOWED_NUMBERED
   - IT_NOT_ALLOWED_ORDINARY
@@ -269,7 +266,7 @@ warnings:
   - COMPARISON_AFTER_COMPARISON
   - DOT_DOT_DOT_EOL
   - EQUAL_IN_CONDITIONAL
-  - EQUAL_IN_CONDITIONAL_3_3
+  - EQUAL_IN_CONDITIONAL_3_3_0
   - END_IN_METHOD
   - DUPLICATED_HASH_KEY
   - DUPLICATED_WHEN_CLAUSE
diff --git a/ext/prism/extension.c b/ext/prism/extension.c
index 7b3f8944..84872914 100644
--- a/ext/prism/extension.c
+++ b/ext/prism/extension.c
@@ -32,6 +32,7 @@ ID rb_option_id_frozen_string_literal;
 ID rb_option_id_line;
 ID rb_option_id_scopes;
 ID rb_option_id_version;
+ID rb_prism_source_id_for;
 
 /******************************************************************************/
 /* IO of Ruby code                                                            */
@@ -599,8 +600,7 @@ parse_lex_input(pm_string_t *input, const pm_options_t *options, bool return_nod
 
     VALUE source_string = rb_str_new((const char *) pm_string_source(input), pm_string_length(input));
     VALUE offsets = rb_ary_new();
-    VALUE source_argv[] = { source_string, LONG2NUM(parser.start_line), offsets };
-    VALUE source = rb_class_new_instance(3, source_argv, rb_cPrismSource);
+    VALUE source = rb_funcall(rb_cPrismSource, rb_prism_source_id_for, 3, source_string, LONG2NUM(parser.start_line), offsets);
 
     parse_lex_data_t parse_lex_data = {
         .source = source,
@@ -1379,6 +1379,8 @@ Init_prism(void) {
     rb_option_id_scopes = rb_intern_const("scopes");
     rb_option_id_version = rb_intern_const("version");
 
+    rb_prism_source_id_for = rb_intern("for");
+
     /**
      * The version of the prism library.
      */
diff --git a/gemfiles/2.7/Gemfile.lock b/gemfiles/2.7/Gemfile.lock
index 66268cf4..8c510b93 100644
--- a/gemfiles/2.7/Gemfile.lock
+++ b/gemfiles/2.7/Gemfile.lock
@@ -8,7 +8,7 @@ GEM
   specs:
     ast (2.4.2)
     ffi (1.16.3)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/3.0/Gemfile.lock b/gemfiles/3.0/Gemfile.lock
index 6cb63c2b..f0ffb223 100644
--- a/gemfiles/3.0/Gemfile.lock
+++ b/gemfiles/3.0/Gemfile.lock
@@ -9,11 +9,11 @@ GEM
     abbrev (0.1.2)
     ast (2.4.2)
     ffi (1.16.3)
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.4)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/3.1/Gemfile.lock b/gemfiles/3.1/Gemfile.lock
index 967b7ed3..859e940c 100644
--- a/gemfiles/3.1/Gemfile.lock
+++ b/gemfiles/3.1/Gemfile.lock
@@ -9,11 +9,11 @@ GEM
     abbrev (0.1.2)
     ast (2.4.2)
     ffi (1.16.3)
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.4)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/3.2/Gemfile.lock b/gemfiles/3.2/Gemfile.lock
index c47e6498..65de9ae5 100644
--- a/gemfiles/3.2/Gemfile.lock
+++ b/gemfiles/3.2/Gemfile.lock
@@ -9,11 +9,11 @@ GEM
     abbrev (0.1.2)
     ast (2.4.2)
     ffi (1.16.3)
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.4)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/3.3/Gemfile.lock b/gemfiles/3.3/Gemfile.lock
index c93095a3..fb1bb192 100644
--- a/gemfiles/3.3/Gemfile.lock
+++ b/gemfiles/3.3/Gemfile.lock
@@ -9,11 +9,11 @@ GEM
     abbrev (0.1.2)
     ast (2.4.2)
     ffi (1.16.3)
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.4)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/3.4/Gemfile.lock b/gemfiles/3.4/Gemfile.lock
index 22144921..71c89a28 100644
--- a/gemfiles/3.4/Gemfile.lock
+++ b/gemfiles/3.4/Gemfile.lock
@@ -9,11 +9,11 @@ GEM
     abbrev (0.1.2)
     ast (2.4.2)
     ffi (1.16.3)
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.4)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/jruby/Gemfile.lock b/gemfiles/jruby/Gemfile.lock
index 7017f4db..8ce32517 100644
--- a/gemfiles/jruby/Gemfile.lock
+++ b/gemfiles/jruby/Gemfile.lock
@@ -7,7 +7,7 @@ GEM
   remote: https://rubygems.org/
   specs:
     ast (2.4.2)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/truffleruby/Gemfile.lock b/gemfiles/truffleruby/Gemfile.lock
index dd849a94..9fa18a62 100644
--- a/gemfiles/truffleruby/Gemfile.lock
+++ b/gemfiles/truffleruby/Gemfile.lock
@@ -7,7 +7,7 @@ GEM
   remote: https://rubygems.org/
   specs:
     ast (2.4.2)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
diff --git a/gemfiles/typecheck/Gemfile.lock b/gemfiles/typecheck/Gemfile.lock
index b2247e2d..d52d75ed 100644
--- a/gemfiles/typecheck/Gemfile.lock
+++ b/gemfiles/typecheck/Gemfile.lock
@@ -39,7 +39,7 @@ GEM
     mutex_m (0.2.0)
     netrc (0.11.0)
     parallel (1.24.0)
-    parser (3.3.1.0)
+    parser (3.3.0.5)
       ast (~> 2.4.1)
       racc
     power_assert (2.0.3)
@@ -61,15 +61,15 @@ GEM
       sexp_processor (~> 4.16)
     securerandom (0.3.1)
     sexp_processor (4.17.1)
-    sorbet (0.5.11361)
-      sorbet-static (= 0.5.11361)
-    sorbet-runtime (0.5.11361)
-    sorbet-static (0.5.11361-aarch64-linux)
-    sorbet-static (0.5.11361-universal-darwin)
-    sorbet-static (0.5.11361-x86_64-linux)
-    sorbet-static-and-runtime (0.5.11361)
-      sorbet (= 0.5.11361)
-      sorbet-runtime (= 0.5.11361)
+    sorbet (0.5.11353)
+      sorbet-static (= 0.5.11353)
+    sorbet-runtime (0.5.11353)
+    sorbet-static (0.5.11353-aarch64-linux)
+    sorbet-static (0.5.11353-universal-darwin)
+    sorbet-static (0.5.11353-x86_64-linux)
+    sorbet-static-and-runtime (0.5.11353)
+      sorbet (= 0.5.11353)
+      sorbet-runtime (= 0.5.11353)
     spoom (1.3.0)
       erubi (>= 1.10.0)
       prism (>= 0.19.0)
diff --git a/include/prism/options.h b/include/prism/options.h
index a623ae0b..d0b46a08 100644
--- a/include/prism/options.h
+++ b/include/prism/options.h
@@ -49,8 +49,8 @@ typedef enum {
     /** The current version of prism. */
     PM_OPTIONS_VERSION_LATEST = 0,
 
-    /** The vendored version of prism in CRuby 3.3.x. */
-    PM_OPTIONS_VERSION_CRUBY_3_3 = 1
+    /** The vendored version of prism in CRuby 3.3.0. */
+    PM_OPTIONS_VERSION_CRUBY_3_3_0 = 1
 } pm_options_version_t;
 
 /**
diff --git a/java/org/prism/ParsingOptions.java b/java/org/prism/ParsingOptions.java
index 598b07a0..839c5b0b 100644
--- a/java/org/prism/ParsingOptions.java
+++ b/java/org/prism/ParsingOptions.java
@@ -13,7 +13,7 @@ public abstract class ParsingOptions {
      */
     public enum SyntaxVersion {
         LATEST(0),
-        V3_3(1);
+        V3_3_0(1);
 
         private final int value;
 
diff --git a/lib/prism/ffi.rb b/lib/prism/ffi.rb
index 2014ccea..cec4b9d6 100644
--- a/lib/prism/ffi.rb
+++ b/lib/prism/ffi.rb
@@ -317,7 +317,7 @@ module Prism
         buffer.read
       end
 
-      Serialize.load_tokens(Source.new(code), serialized)
+      Serialize.load_tokens(Source.for(code), serialized)
     end
 
     def parse_common(string, code, options) # :nodoc:
@@ -329,7 +329,7 @@ module Prism
       LibRubyParser::PrismBuffer.with do |buffer|
         LibRubyParser.pm_serialize_parse_comments(buffer.pointer, string.pointer, string.length, dump_options(options))
 
-        source = Source.new(code)
+        source = Source.for(code)
         loader = Serialize::Loader.new(source, buffer.read)
 
         loader.load_header
@@ -343,7 +343,7 @@ module Prism
       LibRubyParser::PrismBuffer.with do |buffer|
         LibRubyParser.pm_serialize_parse_lex(buffer.pointer, string.pointer, string.length, dump_options(options))
 
-        source = Source.new(code)
+        source = Source.for(code)
         loader = Serialize::Loader.new(source, buffer.read)
 
         tokens = loader.load_tokens
diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb
index f199af18..4f8e443a 100644
--- a/lib/prism/lex_compat.rb
+++ b/lib/prism/lex_compat.rb
@@ -861,7 +861,7 @@ module Prism
       # We sort by location to compare against Ripper's output
       tokens.sort_by!(&:location)
 
-      Result.new(tokens, result.comments, result.magic_comments, result.data_loc, result.errors, result.warnings, Source.new(source))
+      Result.new(tokens, result.comments, result.magic_comments, result.data_loc, result.errors, result.warnings, Source.for(source))
     end
   end
 
diff --git a/lib/prism/parse_result.rb b/lib/prism/parse_result.rb
index ff8b1dc8..817be4f4 100644
--- a/lib/prism/parse_result.rb
+++ b/lib/prism/parse_result.rb
@@ -5,6 +5,14 @@ module Prism
   # conjunction with locations to allow them to resolve line numbers and source
   # ranges.
   class Source
+    # Create a new source object with the given source code. This method should
+    # be used instead of `new` and it will return either a `Source` or a
+    # specialized and more performant `ASCIISource` if no multibyte characters
+    # are present in the source code.
+    def self.for(source, start_line = 1, offsets = [])
+      source.ascii_only? ? ASCIISource.new(source, start_line, offsets): new(source, start_line, offsets)
+    end
+
     # The source code that this source object represents.
     attr_reader :source
 
@@ -27,11 +35,6 @@ module Prism
       source.encoding
     end
 
-    # Returns the lines of the source code as an array of strings.
-    def lines
-      source.lines
-    end
-
     # Perform a byteslice on the source code using the given byte offset and
     # byte length.
     def slice(byte_offset, length)
@@ -111,6 +114,39 @@ module Prism
     end
   end
 
+  # Specialized version of Prism::Source for source code that includes ASCII
+  # characters only. This class is used to apply performance optimizations that
+  # cannot be applied to sources that include multibyte characters. Sources that
+  # include multibyte characters are represented by the Prism::Source class.
+  class ASCIISource < Source
+    # Return the character offset for the given byte offset.
+    def character_offset(byte_offset)
+      byte_offset
+    end
+
+    # Return the column number in characters for the given byte offset.
+    def character_column(byte_offset)
+      byte_offset - line_start(byte_offset)
+    end
+
+    # Returns the offset from the start of the file for the given byte offset
+    # counting in code units for the given encoding.
+    #
+    # This method is tested with UTF-8, UTF-16, and UTF-32. If there is the
+    # concept of code units that differs from the number of characters in other
+    # encodings, it is not captured here.
+    def code_units_offset(byte_offset, encoding)
+      byte_offset
+    end
+
+    # Specialized version of `code_units_column` that does not depend on
+    # `code_units_offset`, which is a more expensive operation. This is
+    # essentialy the same as `Prism::Source#column`.
+    def code_units_column(byte_offset, encoding)
+      byte_offset - line_start(byte_offset)
+    end
+  end
+
   # This represents a location in the source.
   class Location
     # A Source object that is used to determine more information from the given
@@ -182,11 +218,6 @@ module Prism
       "#<Prism::Location @start_offset=#{@start_offset} @length=#{@length} start_line=#{start_line}>"
     end
 
-    # Returns all of the lines of the source code associated with this location.
-    def source_lines
-      source.lines
-    end
-
     # The source code that this location represents.
     def slice
       source.slice(start_offset, length)
diff --git a/lib/prism/translation/parser.rb b/lib/prism/translation/parser.rb
index 2d769302..0d11b8f5 100644
--- a/lib/prism/translation/parser.rb
+++ b/lib/prism/translation/parser.rb
@@ -46,7 +46,7 @@ module Prism
         source = source_buffer.source
 
         offset_cache = build_offset_cache(source)
-        result = unwrap(Prism.parse(source, filepath: source_buffer.name, version: convert_for_prism(version), scopes: [[]]), offset_cache)
+        result = unwrap(Prism.parse(source, filepath: source_buffer.name, version: convert_for_prism(version)), offset_cache)
 
         build_ast(result.value, offset_cache)
       ensure
@@ -59,7 +59,7 @@ module Prism
         source = source_buffer.source
 
         offset_cache = build_offset_cache(source)
-        result = unwrap(Prism.parse(source, filepath: source_buffer.name, version: convert_for_prism(version), scopes: [[]]), offset_cache)
+        result = unwrap(Prism.parse(source, filepath: source_buffer.name, version: convert_for_prism(version)), offset_cache)
 
         [
           build_ast(result.value, offset_cache),
@@ -78,7 +78,7 @@ module Prism
         offset_cache = build_offset_cache(source)
         result =
           begin
-            unwrap(Prism.parse_lex(source, filepath: source_buffer.name, version: convert_for_prism(version), scopes: [[]]), offset_cache)
+            unwrap(Prism.parse_lex(source, filepath: source_buffer.name, version: convert_for_prism(version)), offset_cache)
           rescue ::Parser::SyntaxError
             raise if !recover
           end
@@ -149,17 +149,17 @@ module Prism
           Diagnostic.new(:error, :endless_setter, {}, diagnostic_location, [])
         when :embdoc_term
           Diagnostic.new(:error, :embedded_document, {}, diagnostic_location, [])
-        when :incomplete_variable_class, :incomplete_variable_class_3_3
+        when :incomplete_variable_class, :incomplete_variable_class_3_3_0
           location = location.copy(length: location.length + 1)
           diagnostic_location = build_range(location, offset_cache)
 
           Diagnostic.new(:error, :cvar_name, { name: location.slice }, diagnostic_location, [])
-        when :incomplete_variable_instance, :incomplete_variable_instance_3_3
+        when :incomplete_variable_instance, :incomplete_variable_instance_3_3_0
           location = location.copy(length: location.length + 1)
           diagnostic_location = build_range(location, offset_cache)
 
           Diagnostic.new(:error, :ivar_name, { name: location.slice }, diagnostic_location, [])
-        when :invalid_variable_global, :invalid_variable_global_3_3
+        when :invalid_variable_global, :invalid_variable_global_3_3_0
           Diagnostic.new(:error, :gvar_name, { name: location.slice }, diagnostic_location, [])
         when :module_in_method
           Diagnostic.new(:error, :module_in_def, {}, diagnostic_location, [])
diff --git a/lib/prism/translation/ripper.rb b/lib/prism/translation/ripper.rb
index 4e3b1b74..2c5e4569 100644
--- a/lib/prism/translation/ripper.rb
+++ b/lib/prism/translation/ripper.rb
@@ -3267,11 +3267,7 @@ module Prism
 
       # Lazily initialize the parse result.
       def result
-        @result ||=
-          begin
-            scopes = RUBY_VERSION >= "3.3.0" ? [] : [[]]
-            Prism.parse(source, scopes: scopes)
-          end
+        @result ||= Prism.parse(source)
       end
 
       ##########################################################################
diff --git a/lib/prism/translation/ruby_parser.rb b/lib/prism/translation/ruby_parser.rb
index f0a3bdd6..a8692db5 100644
--- a/lib/prism/translation/ruby_parser.rb
+++ b/lib/prism/translation/ruby_parser.rb
@@ -1536,13 +1536,13 @@ module Prism
       # Parse the given source and translate it into the seattlerb/ruby_parser
       # gem's Sexp format.
       def parse(source, filepath = "(string)")
-        translate(Prism.parse(source, filepath: filepath, scopes: [[]]), filepath)
+        translate(Prism.parse(source, filepath: filepath), filepath)
       end
 
       # Parse the given file and translate it into the seattlerb/ruby_parser
       # gem's Sexp format.
       def parse_file(filepath)
-        translate(Prism.parse_file(filepath, scopes: [[]]), filepath)
+        translate(Prism.parse_file(filepath), filepath)
       end
 
       class << self
diff --git a/prism.gemspec b/prism.gemspec
index 18d0c00a..cfc10c1a 100644
--- a/prism.gemspec
+++ b/prism.gemspec
@@ -104,15 +104,20 @@ Gem::Specification.new do |spec|
     "prism.gemspec",
     "rbi/prism.rbi",
     "rbi/prism/compiler.rbi",
+    "rbi/prism/desugar_compiler.rbi",
     "rbi/prism/inspect_visitor.rbi",
+    "rbi/prism/mutation_compiler.rbi",
     "rbi/prism/node_ext.rbi",
     "rbi/prism/node.rbi",
     "rbi/prism/parse_result.rbi",
     "rbi/prism/reflection.rbi",
     "rbi/prism/translation/parser.rbi",
+    "rbi/prism/translation/parser/compiler.rbi",
     "rbi/prism/translation/parser33.rbi",
     "rbi/prism/translation/parser34.rbi",
     "rbi/prism/translation/ripper.rbi",
+    "rbi/prism/translation/ripper/ripper_compiler.rbi",
+    "rbi/prism/translation/ruby_parser.rbi",
     "rbi/prism/visitor.rbi",
     "sig/prism.rbs",
     "sig/prism/compiler.rbs",
diff --git a/rbi/prism/compiler.rbi b/rbi/prism/compiler.rbi
index c738236d..b37abd54 100644
--- a/rbi/prism/compiler.rbi
+++ b/rbi/prism/compiler.rbi
@@ -1,12 +1,14 @@
 # typed: strict
 
 class Prism::Compiler
-  sig { params(node: T.nilable(Prism::Node)).returns(T.untyped) }
+  Result = type_member
+
+  sig { params(node: T.nilable(Prism::Node)).returns(T.nilable(Result)) }
   def visit(node); end
 
-  sig { params(nodes: T::Array[T.nilable(Prism::Node)]).returns(T::Array[T.untyped]) }
+  sig { params(nodes: T::Array[T.nilable(Prism::Node)]).returns(T::Array[T.nilable(Result)]) }
   def visit_all(nodes); end
 
-  sig { params(node: Prism::Node).returns(T::Array[T.untyped]) }
+  sig { params(node: Prism::Node).returns(T::Array[T.nilable(Result)]) }
   def visit_child_nodes(node); end
 end
diff --git a/rbi/prism/desugar_compiler.rbi b/rbi/prism/desugar_compiler.rbi
new file mode 100644
index 00000000..b7dbc0ff
--- /dev/null
+++ b/rbi/prism/desugar_compiler.rbi
@@ -0,0 +1,5 @@
+# typed: strict
+
+class Prism::DesugarCompiler < Prism::MutationCompiler
+  Result = type_member { { fixed: Prism::Node } }
+end
diff --git a/rbi/prism/mutation_compiler.rbi b/rbi/prism/mutation_compiler.rbi
new file mode 100644
index 00000000..bc1590e1
--- /dev/null
+++ b/rbi/prism/mutation_compiler.rbi
@@ -0,0 +1,5 @@
+# typed: strict
+
+class Prism::MutationCompiler < Prism::Compiler
+  Result = type_member
+end
diff --git a/rbi/prism/parse_result.rbi b/rbi/prism/parse_result.rbi
index 8e9129c2..4ad40510 100644
--- a/rbi/prism/parse_result.rbi
+++ b/rbi/prism/parse_result.rbi
@@ -16,9 +16,6 @@ class Prism::Source
   sig { returns(Encoding) }
   def encoding; end
 
-  sig { returns(T::Array[Integer]) }
-  def lines; end
-
   sig { params(byte_offset: Integer, length: Integer).returns(String) }
   def slice(byte_offset, length); end
 
@@ -44,6 +41,20 @@ class Prism::Source
   def code_units_column(byte_offset, encoding); end
 end
 
+class Prism::ASCIISource < Source
+  sig { params(byte_offset: Integer).returns(Integer) }
+  def character_offset(byte_offset); end
+
+  sig { params(byte_offset: Integer).returns(Integer) }
+  def character_column(byte_offset); end
+
+  sig { params(byte_offset: Integer, encoding: Encoding).returns(Integer) }
+  def code_units_offset(byte_offset, encoding); end
+
+  sig { params(byte_offset: Integer, encoding: Encoding).returns(Integer) }
+  def code_units_column(byte_offset, encoding); end
+end
+
 class Prism::Location
   sig { returns(Prism::Source) }
   def source; end
@@ -81,9 +92,6 @@ class Prism::Location
   sig { returns(String) }
   def inspect; end
 
-  sig { returns(T::Array[String]) }
-  def source_lines; end
-
   sig { returns(String) }
   def slice; end
 
diff --git a/rbi/prism/translation/parser/compiler.rbi b/rbi/prism/translation/parser/compiler.rbi
new file mode 100644
index 00000000..0e3cdf5f
--- /dev/null
+++ b/rbi/prism/translation/parser/compiler.rbi
@@ -0,0 +1,13 @@
+# typed: strict
+
+# We keep these shims in here because our client libraries might not have
+# ast/parser in their bundle.
+module AST; end
+class AST::Node; end
+module Parser; end
+module Parser::AST; end
+class Parser::AST::Node < AST::Node; end
+
+class Prism::Translation::Parser::Compiler < Prism::Compiler
+  Result = type_member { { fixed: Parser::AST::Node } }
+end
diff --git a/rbi/prism/translation/ripper.rbi b/rbi/prism/translation/ripper.rbi
index 4d239705..5173e4c2 100644
--- a/rbi/prism/translation/ripper.rbi
+++ b/rbi/prism/translation/ripper.rbi
@@ -1,10 +1,12 @@
 # typed: strict
 
 class Prism::Translation::Ripper < Prism::Compiler
+  Result = type_member
+
   sig { returns(T::Boolean) }
   def error?; end
 
-  sig { returns(T.untyped) }
+  sig { returns(T.nilable(Result)) }
   def parse; end
 
   sig { params(source: String, filename: String, lineno: Integer, raise_errors: T.untyped).returns(T.untyped) }
@@ -13,3 +15,11 @@ class Prism::Translation::Ripper < Prism::Compiler
   sig { params(source: String, filename: String, lineno: Integer, raise_errors: T.untyped).returns(T.untyped) }
   def self.sexp(source, filename = "-", lineno = 1, raise_errors: false); end
 end
+
+class Prism::Translation::Ripper::SexpBuilder < Prism::Translation::Ripper
+  Result = type_member { { fixed: T::Array[T.untyped] } }
+end
+
+class Prism::Translation::Ripper::SexpBuilderPP < Prism::Translation::Ripper::SexpBuilder
+  Result = type_member { { fixed: T::Array[T.untyped] } }
+end
diff --git a/rbi/prism/translation/ripper/ripper_compiler.rbi b/rbi/prism/translation/ripper/ripper_compiler.rbi
new file mode 100644
index 00000000..ba360674
--- /dev/null
+++ b/rbi/prism/translation/ripper/ripper_compiler.rbi
@@ -0,0 +1,5 @@
+# typed: strict
+
+class Prism::Translation::RipperCompiler < Prism::Compiler
+  Result = type_member
+end
diff --git a/rbi/prism/translation/ruby_parser.rbi b/rbi/prism/translation/ruby_parser.rbi
new file mode 100644
index 00000000..a890898a
--- /dev/null
+++ b/rbi/prism/translation/ruby_parser.rbi
@@ -0,0 +1,11 @@
+# typed: strict
+
+# We keep this shim in here because our client libraries might not have
+# ruby_parser in their bundle.
+class Sexp < ::Array
+  Elem = type_member { { fixed: T.untyped }}
+end
+
+class Prism::Translation::RubyParser::Compiler < Prism::Compiler
+  Result = type_member { { fixed: Sexp } }
+end
diff --git a/sig/prism/parse_result.rbs b/sig/prism/parse_result.rbs
index f520479a..1d42824f 100644
--- a/sig/prism/parse_result.rbs
+++ b/sig/prism/parse_result.rbs
@@ -6,7 +6,6 @@ module Prism
 
     def initialize: (String source, ?Integer start_line, ?Array[Integer] offsets) -> void
     def encoding: () -> Encoding
-    def lines: () -> Array[String]
     def slice: (Integer byte_offset, Integer length) -> String
     def line: (Integer byte_offset) -> Integer
     def line_start: (Integer byte_offset) -> Integer
@@ -19,6 +18,13 @@ module Prism
     def code_units_column: (Integer byte_offset, Encoding encoding) -> Integer
   end
 
+  class ASCIISource < Source
+    def character_offset: (Integer byte_offset) -> Integer
+    def character_column: (Integer byte_offset) -> Integer
+    def code_units_offset: (Integer byte_offset, Encoding encoding) -> Integer
+    def code_units_column: (Integer byte_offset, Encoding encoding) -> Integer
+  end
+
   class Location
     attr_reader source: Source
     attr_reader start_offset: Integer
@@ -32,7 +38,6 @@ module Prism
     def comments: () -> Array[comment]
     def copy: (?source: Source, ?start_offset: Integer, ?length: Integer) -> Location
     def chop: () -> Location
-    def source_lines: () -> Array[String]
     def slice: () -> String
     def slice_lines: () -> String
     def start_character_offset: () -> Integer
diff --git a/src/options.c b/src/options.c
index 664db4f0..4d0d6dbc 100644
--- a/src/options.c
+++ b/src/options.c
@@ -58,8 +58,8 @@ pm_options_version_set(pm_options_t *options, const char *version, size_t length
         case 5:
             assert(version != NULL);
 
-            if ((strncmp(version, "3.3.0", length) == 0) || (strncmp(version, "3.3.1", length) == 0)) {
-                options->version = PM_OPTIONS_VERSION_CRUBY_3_3;
+            if (strncmp(version, "3.3.0", length) == 0) {
+                options->version = PM_OPTIONS_VERSION_CRUBY_3_3_0;
                 return true;
             }
 
diff --git a/src/prism.c b/src/prism.c
index d9c75c31..2815723e 100644
--- a/src/prism.c
+++ b/src/prism.c
@@ -672,26 +672,6 @@ pm_parser_warn_node(pm_parser_t *parser, const pm_node_t *node, pm_diagnostic_id
 #define PM_PARSER_WARN_NODE_FORMAT(parser, node, diag_id, ...) \
     PM_PARSER_WARN_FORMAT(parser, (node)->location.start, (node)->location.end, diag_id, __VA_ARGS__)
 
-/**
- * Add an error for an expected heredoc terminator. This is a special function
- * only because it grabs its location off of a lex mode instead of a node or a
- * token.
- */
-static void
-pm_parser_err_heredoc_term(pm_parser_t *parser, pm_lex_mode_t *lex_mode) {
-    const uint8_t *ident_start = lex_mode->as.heredoc.ident_start;
-    size_t ident_length = lex_mode->as.heredoc.ident_length;
-
-    PM_PARSER_ERR_FORMAT(
-        parser,
-        ident_start,
-        ident_start + ident_length,
-        PM_ERR_HEREDOC_TERM,
-        (int) ident_length,
-        (const char *) ident_start
-    );
-}
-
 /******************************************************************************/
 /* Scope-related functions                                                    */
 /******************************************************************************/
@@ -1425,7 +1405,7 @@ pm_conditional_predicate_warn_write_literal_p(const pm_node_t *node) {
 static inline void
 pm_conditional_predicate_warn_write_literal(pm_parser_t *parser, const pm_node_t *node) {
     if (pm_conditional_predicate_warn_write_literal_p(node)) {
-        pm_parser_warn_node(parser, node, parser->version == PM_OPTIONS_VERSION_CRUBY_3_3 ? PM_WARN_EQUAL_IN_CONDITIONAL_3_3 : PM_WARN_EQUAL_IN_CONDITIONAL);
+        pm_parser_warn_node(parser, node, parser->version == PM_OPTIONS_VERSION_CRUBY_3_3_0 ? PM_WARN_EQUAL_IN_CONDITIONAL_3_3_0 : PM_WARN_EQUAL_IN_CONDITIONAL);
     }
 }
 
@@ -5053,50 +5033,6 @@ pm_instance_variable_write_node_create(pm_parser_t *parser, pm_instance_variable
     return node;
 }
 
-/**
- * Append a part into a list of string parts. Importantly this handles nested
- * interpolated strings by not necessarily removing the marker for static
- * literals.
- */
-static void
-pm_interpolated_node_append(pm_node_t *node, pm_node_list_t *parts, pm_node_t *part) {
-    switch (PM_NODE_TYPE(part)) {
-        case PM_STRING_NODE:
-            pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
-            break;
-        case PM_EMBEDDED_STATEMENTS_NODE: {
-            pm_embedded_statements_node_t *cast = (pm_embedded_statements_node_t *) part;
-            pm_node_t *embedded = (cast->statements != NULL && cast->statements->body.size == 1) ? cast->statements->body.nodes[0] : NULL;
-
-            if (embedded == NULL) {
-                // If there are no statements or more than one statement, then
-                // we lose the static literal flag.
-                pm_node_flag_unset(node, PM_NODE_FLAG_STATIC_LITERAL);
-            } else if (PM_NODE_TYPE_P(embedded, PM_STRING_NODE)) {
-                // If the embedded statement is a string, then we can keep the
-                // static literal flag and mark the string as frozen.
-                pm_node_flag_set(embedded, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
-            } else if (PM_NODE_TYPE_P(embedded, PM_INTERPOLATED_STRING_NODE) && PM_NODE_FLAG_P(embedded, PM_NODE_FLAG_STATIC_LITERAL)) {
-                // If the embedded statement is an interpolated string and it's
-                // a static literal, then we can keep the static literal flag.
-            } else {
-                // Otherwise we lose the static literal flag.
-                pm_node_flag_unset(node, PM_NODE_FLAG_STATIC_LITERAL);
-            }
-
-            break;
-        }
-        case PM_EMBEDDED_VARIABLE_NODE:
-            pm_node_flag_unset((pm_node_t *) node, PM_NODE_FLAG_STATIC_LITERAL);
-            break;
-        default:
-            assert(false && "unexpected node type");
-            break;
-    }
-
-    pm_node_list_append(parts, part);
-}
-
 /**
  * Allocate a new InterpolatedRegularExpressionNode node.
  */
@@ -5130,113 +5066,54 @@ pm_interpolated_regular_expression_node_append(pm_interpolated_regular_expressio
         node->base.location.end = part->location.end;
     }
 
-    pm_interpolated_node_append((pm_node_t *) node, &node->parts, part);
+    if (PM_NODE_TYPE_P(part, PM_STRING_NODE)) {
+        pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
+    }
+
+    if (!PM_NODE_FLAG_P(part, PM_NODE_FLAG_STATIC_LITERAL)) {
+        pm_node_flag_unset((pm_node_t *) node, PM_NODE_FLAG_STATIC_LITERAL);
+    }
+
+    pm_node_list_append(&node->parts, part);
 }
 
 static inline void
 pm_interpolated_regular_expression_node_closing_set(pm_parser_t *parser, pm_interpolated_regular_expression_node_t *node, const pm_token_t *closing) {
     node->closing_loc = PM_LOCATION_TOKEN_VALUE(closing);
     node->base.location.end = closing->end;
-    pm_node_flag_set((pm_node_t *) node, pm_regular_expression_flags_create(parser, closing));
+    pm_node_flag_set((pm_node_t *)node, pm_regular_expression_flags_create(parser, closing));
 }
 
 /**
  * Append a part to an InterpolatedStringNode node.
- *
- * This has some somewhat complicated semantics, because we need to update
- * multiple flags that have somewhat confusing interactions.
- *
- * PM_NODE_FLAG_STATIC_LITERAL indicates that the node should be treated as a
- * single static literal string that can be pushed onto the stack on its own.
- * Note that this doesn't necessarily mean that the string will be frozen or
- * not; the instructions in CRuby will be either putobject or putstring,
- * depending on the combination of `--enable-frozen-string-literal`,
- * `# frozen_string_literal: true`, and whether or not there is interpolation.
- *
- * PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN indicates that the string should be
- * explicitly frozen. This will only happen if the string is comprised entirely
- * of string parts that are themselves static literals and frozen.
- *
- * PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE indicates that the string should
- * be explicitly marked as mutable. This will happen from
- * `--disable-frozen-string-literal` or `# frozen_string_literal: false`. This
- * is necessary to indicate that the string should be left up to the runtime,
- * which could potentially use a chilled string otherwise.
  */
 static inline void
-pm_interpolated_string_node_append(pm_interpolated_string_node_t *node, pm_node_t *part) {
-#define CLEAR_FLAGS(node) \
-    node->base.flags = (pm_node_flags_t) (node->base.flags & ~(PM_NODE_FLAG_STATIC_LITERAL | PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN | PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE))
-
-#define MUTABLE_FLAGS(node) \
-    node->base.flags = (pm_node_flags_t) ((node->base.flags | PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE) & ~PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN);
-
+pm_interpolated_string_node_append(pm_parser_t *parser, pm_interpolated_string_node_t *node, pm_node_t *part) {
     if (node->parts.size == 0 && node->opening_loc.start == NULL) {
         node->base.location.start = part->location.start;
     }
 
-    node->base.location.end = MAX(node->base.location.end, part->location.end);
-
-    switch (PM_NODE_TYPE(part)) {
-        case PM_STRING_NODE:
-            pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
-            break;
-        case PM_INTERPOLATED_STRING_NODE:
-            if (PM_NODE_FLAG_P(part, PM_NODE_FLAG_STATIC_LITERAL)) {
-                // If the string that we're concatenating is a static literal,
-                // then we can keep the static literal flag for this string.
-            } else {
-                // Otherwise, we lose the static literal flag here and we should
-                // also clear the mutability flags.
-                CLEAR_FLAGS(node);
-            }
-            break;
-        case PM_EMBEDDED_STATEMENTS_NODE: {
-            pm_embedded_statements_node_t *cast = (pm_embedded_statements_node_t *) part;
-            pm_node_t *embedded = (cast->statements != NULL && cast->statements->body.size == 1) ? cast->statements->body.nodes[0] : NULL;
-
-            if (embedded == NULL) {
-                // If we're embedding multiple statements or no statements, then
-                // the string is not longer a static literal.
-                CLEAR_FLAGS(node);
-            } else if (PM_NODE_TYPE_P(embedded, PM_STRING_NODE)) {
-                // If the embedded statement is a string, then we can make that
-                // string as frozen and static literal, and not touch the static
-                // literal status of this string.
-                pm_node_flag_set(embedded, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
-
-                if (PM_NODE_FLAG_P(node, PM_NODE_FLAG_STATIC_LITERAL)) {
-                    MUTABLE_FLAGS(node);
-                }
-            } else if (PM_NODE_TYPE_P(embedded, PM_INTERPOLATED_STRING_NODE) && PM_NODE_FLAG_P(embedded, PM_NODE_FLAG_STATIC_LITERAL)) {
-                // If the embedded statement is an interpolated string, but that
-                // string is marked as static literal, then we can keep our
-                // static literal status for this string.
-                if (PM_NODE_FLAG_P(node, PM_NODE_FLAG_STATIC_LITERAL)) {
-                    MUTABLE_FLAGS(node);
-                }
-            } else {
-                // In all other cases, we lose the static literal flag here and
-                // become mutable.
-                CLEAR_FLAGS(node);
-            }
+    if (PM_NODE_TYPE_P(part, PM_STRING_NODE)) {
+        pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
+    }
 
-            break;
-        }
-        case PM_EMBEDDED_VARIABLE_NODE:
-            // Embedded variables clear static literal, which means we also
-            // should clear the mutability flags.
-            CLEAR_FLAGS(node);
-            break;
-        default:
-            assert(false && "unexpected node type");
-            break;
+    if (!PM_NODE_FLAG_P(part, PM_NODE_FLAG_STATIC_LITERAL)) {
+        pm_node_flag_unset((pm_node_t *) node, PM_NODE_FLAG_STATIC_LITERAL | PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN | PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE);
     }
 
     pm_node_list_append(&node->parts, part);
+    node->base.location.end = MAX(node->base.location.end, part->location.end);
 
-#undef CLEAR_FLAGS
-#undef MUTABLE_FLAGS
+    if (PM_NODE_FLAG_P(node, PM_NODE_FLAG_STATIC_LITERAL)) {
+        switch (parser->frozen_string_literal) {
+            case PM_OPTIONS_FROZEN_STRING_LITERAL_DISABLED:
+                pm_node_flag_set((pm_node_t *) node, PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE);
+                break;
+            case PM_OPTIONS_FROZEN_STRING_LITERAL_ENABLED:
+                pm_node_flag_set((pm_node_t *) node, PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN);
+                break;
+        }
+    }
 }
 
 /**
@@ -5245,21 +5122,11 @@ pm_interpolated_string_node_append(pm_interpolated_string_node_t *node, pm_node_
 static pm_interpolated_string_node_t *
 pm_interpolated_string_node_create(pm_parser_t *parser, const pm_token_t *opening, const pm_node_list_t *parts, const pm_token_t *closing) {
     pm_interpolated_string_node_t *node = PM_ALLOC_NODE(parser, pm_interpolated_string_node_t);
-    pm_node_flags_t flags = PM_NODE_FLAG_STATIC_LITERAL;
-
-    switch (parser->frozen_string_literal) {
-        case PM_OPTIONS_FROZEN_STRING_LITERAL_DISABLED:
-            flags |= PM_INTERPOLATED_STRING_NODE_FLAGS_MUTABLE;
-            break;
-        case PM_OPTIONS_FROZEN_STRING_LITERAL_ENABLED:
-            flags |= PM_INTERPOLATED_STRING_NODE_FLAGS_FROZEN;
-            break;
-    }
 
     *node = (pm_interpolated_string_node_t) {
         {
             .type = PM_INTERPOLATED_STRING_NODE,
-            .flags = flags,
+            .flags = PM_NODE_FLAG_STATIC_LITERAL,
             .location = {
                 .start = opening->start,
                 .end = closing->end,
@@ -5273,7 +5140,7 @@ pm_interpolated_string_node_create(pm_parser_t *parser, const pm_token_t *openin
     if (parts != NULL) {
         pm_node_t *part;
         PM_NODE_LIST_FOREACH(parts, index, part) {
-            pm_interpolated_string_node_append(node, part);
+            pm_interpolated_string_node_append(parser, node, part);
         }
     }
 
@@ -5295,7 +5162,15 @@ pm_interpolated_symbol_node_append(pm_interpolated_symbol_node_t *node, pm_node_
         node->base.location.start = part->location.start;
     }
 
-    pm_interpolated_node_append((pm_node_t *) node, &node->parts, part);
+    if (PM_NODE_TYPE_P(part, PM_STRING_NODE)) {
+        pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
+    }
+
+    if (!PM_NODE_FLAG_P(part, PM_NODE_FLAG_STATIC_LITERAL)) {
+        pm_node_flag_unset((pm_node_t *) node, PM_NODE_FLAG_STATIC_LITERAL);
+    }
+
+    pm_node_list_append(&node->parts, part);
     node->base.location.end = MAX(node->base.location.end, part->location.end);
 }
 
@@ -5361,7 +5236,11 @@ pm_interpolated_xstring_node_create(pm_parser_t *parser, const pm_token_t *openi
 
 static inline void
 pm_interpolated_xstring_node_append(pm_interpolated_x_string_node_t *node, pm_node_t *part) {
-    pm_interpolated_node_append((pm_node_t *) node, &node->parts, part);
+    if (PM_NODE_TYPE_P(part, PM_STRING_NODE)) {
+        pm_node_flag_set(part, PM_NODE_FLAG_STATIC_LITERAL | PM_STRING_FLAGS_FROZEN);
+    }
+
+    pm_node_list_append(&node->parts, part);
     node->base.location.end = part->location.end;
 }
 
@@ -6962,8 +6841,7 @@ pm_super_node_create(pm_parser_t *parser, const pm_token_t *keyword, pm_argument
 }
 
 /**
- * Read through the contents of a string and check if it consists solely of
- * US-ASCII code points.
+ * Read through the contents of a string and check if it consists solely of US ASCII code points.
  */
 static bool
 pm_ascii_only_p(const pm_string_t *contents) {
@@ -6977,72 +6855,27 @@ pm_ascii_only_p(const pm_string_t *contents) {
     return true;
 }
 
-/**
- * Validate that the contents of the given symbol are all valid UTF-8.
- */
-static void
-parse_symbol_encoding_validate_utf8(pm_parser_t *parser, const pm_token_t *location, const pm_string_t *contents) {
-    for (const uint8_t *cursor = pm_string_source(contents), *end = cursor + pm_string_length(contents); cursor < end;) {
-        size_t width = pm_encoding_utf_8_char_width(cursor, end - cursor);
-
-        if (width == 0) {
-            pm_parser_err(parser, location->start, location->end, PM_ERR_INVALID_SYMBOL);
-            break;
-        }
-
-        cursor += width;
-    }
-}
-
-/**
- * Validate that the contents of the given symbol are all valid in the encoding
- * of the parser.
- */
-static void
-parse_symbol_encoding_validate_other(pm_parser_t *parser, const pm_token_t *location, const pm_string_t *contents) {
-    const pm_encoding_t *encoding = parser->encoding;
-
-    for (const uint8_t *cursor = pm_string_source(contents), *end = cursor + pm_string_length(contents); cursor < end;) {
-        size_t width = encoding->char_width(cursor, end - cursor);
-
-        if (width == 0) {
-            pm_parser_err(parser, location->start, location->end, PM_ERR_INVALID_SYMBOL);
-            break;
-        }
-
-        cursor += width;
-    }
-}
-
 /**
  * Ruby "downgrades" the encoding of Symbols to US-ASCII if the associated
  * encoding is ASCII-compatible and the Symbol consists only of US-ASCII code
  * points. Otherwise, the encoding may be explicitly set with an escape
  * sequence.
- *
- * If the validate flag is set, then it will check the contents of the symbol
- * to ensure that all characters are valid in the encoding.
  */
 static inline pm_node_flags_t
-parse_symbol_encoding(pm_parser_t *parser, const pm_token_t *location, const pm_string_t *contents, bool validate) {
+parse_symbol_encoding(const pm_parser_t *parser, const pm_string_t *contents) {
     if (parser->explicit_encoding != NULL) {
         // A Symbol may optionally have its encoding explicitly set. This will
         // happen if an escape sequence results in a non-ASCII code point.
         if (parser->explicit_encoding == PM_ENCODING_UTF_8_ENTRY) {
-            if (validate) parse_symbol_encoding_validate_utf8(parser, location, contents);
             return PM_SYMBOL_FLAGS_FORCED_UTF8_ENCODING;
         } else if (parser->encoding == PM_ENCODING_US_ASCII_ENTRY) {
             return PM_SYMBOL_FLAGS_FORCED_BINARY_ENCODING;
-        } else if (validate) {
-            parse_symbol_encoding_validate_other(parser, location, contents);
         }
     } else if (pm_ascii_only_p(contents)) {
         // Ruby stipulates that all source files must use an ASCII-compatible
         // encoding. Thus, all symbols appearing in source are eligible for
         // "downgrading" to US-ASCII.
         return PM_SYMBOL_FLAGS_FORCED_US_ASCII_ENCODING;
-    } else if (validate) {
-        parse_symbol_encoding_validate_other(parser, location, contents);
     }
 
     return 0;
@@ -7210,7 +7043,7 @@ pm_symbol_node_create(pm_parser_t *parser, const pm_token_t *opening, const pm_t
  */
 static pm_symbol_node_t *
 pm_symbol_node_create_current_string(pm_parser_t *parser, const pm_token_t *opening, const pm_token_t *value, const pm_token_t *closing) {
-    pm_symbol_node_t *node = pm_symbol_node_create_unescaped(parser, opening, value, closing, &parser->current_string, parse_symbol_encoding(parser, value, &parser->current_string, false));
+    pm_symbol_node_t *node = pm_symbol_node_create_unescaped(parser, opening, value, closing, &parser->current_string, parse_symbol_encoding(parser, &parser->current_string));
     parser->current_string = PM_STRING_EMPTY;
     return node;
 }
@@ -7232,7 +7065,7 @@ pm_symbol_node_label_create(pm_parser_t *parser, const pm_token_t *token) {
 
             assert((label.end - label.start) >= 0);
             pm_string_shared_init(&node->unescaped, label.start, label.end);
-            pm_node_flag_set((pm_node_t *) node, parse_symbol_encoding(parser, &label, &node->unescaped, false));
+            pm_node_flag_set((pm_node_t *) node, parse_symbol_encoding(parser, &node->unescaped));
 
             break;
         }
@@ -7317,8 +7150,7 @@ pm_string_node_to_symbol_node(pm_parser_t *parser, pm_string_node_t *node, const
         .unescaped = node->unescaped
     };
 
-    pm_token_t content = { .type = PM_TOKEN_IDENTIFIER, .start = node->content_loc.start, .end = node->content_loc.end };
-    pm_node_flag_set((pm_node_t *) new_node, parse_symbol_encoding(parser, &content, &node->unescaped, true));
+    pm_node_flag_set((pm_node_t *)new_node, parse_symbol_encoding(parser, &node->unescaped));
 
     // We are explicitly _not_ using pm_node_destroy here because we don't want
     // to trash the unescaped string. We could instead copy the string if we
@@ -7854,7 +7686,7 @@ pm_local_variable_read_node_create_it(pm_parser_t *parser, const pm_token_t *nam
 static pm_node_t *
 pm_node_check_it(pm_parser_t *parser, pm_node_t *node) {
     if (
-        (parser->version != PM_OPTIONS_VERSION_CRUBY_3_3) &&
+        (parser->version != PM_OPTIONS_VERSION_CRUBY_3_3_0) &&
         !parser->current_scope->closed &&
         (parser->current_scope->numbered_parameters != PM_SCOPE_NUMBERED_PARAMETERS_DISALLOWED) &&
         pm_node_is_it(parser, node)
@@ -8578,11 +8410,10 @@ context_human(pm_context_t context) {
 /* Specific token lexers                                                      */
 /******************************************************************************/
 
-static inline void
-pm_strspn_number_validate(pm_parser_t *parser, const uint8_t *string, size_t length, const uint8_t *invalid) {
+static void
+pm_strspn_number_validate(pm_parser_t *parser, const uint8_t *invalid) {
     if (invalid != NULL) {
-        pm_diagnostic_id_t diag_id = (invalid == (string + length - 1)) ? PM_ERR_INVALID_NUMBER_UNDERSCORE_TRAILING : PM_ERR_INVALID_NUMBER_UNDERSCORE_INNER;
-        pm_parser_err(parser, invalid, invalid + 1, diag_id);
+        pm_parser_err(parser, invalid, invalid + 1, PM_ERR_INVALID_NUMBER_UNDERSCORE);
     }
 }
 
@@ -8590,7 +8421,7 @@ static size_t
 pm_strspn_binary_number_validate(pm_parser_t *parser, const uint8_t *string) {
     const uint8_t *invalid = NULL;
     size_t length = pm_strspn_binary_number(string, parser->end - string, &invalid);
-    pm_strspn_number_validate(parser, string, length, invalid);
+    pm_strspn_number_validate(parser, invalid);
     return length;
 }
 
@@ -8598,7 +8429,7 @@ static size_t
 pm_strspn_octal_number_validate(pm_parser_t *parser, const uint8_t *string) {
     const uint8_t *invalid = NULL;
     size_t length = pm_strspn_octal_number(string, parser->end - string, &invalid);
-    pm_strspn_number_validate(parser, string, length, invalid);
+    pm_strspn_number_validate(parser, invalid);
     return length;
 }
 
@@ -8606,7 +8437,7 @@ static size_t
 pm_strspn_decimal_number_validate(pm_parser_t *parser, const uint8_t *string) {
     const uint8_t *invalid = NULL;
     size_t length = pm_strspn_decimal_number(string, parser->end - string, &invalid);
-    pm_strspn_number_validate(parser, string, length, invalid);
+    pm_strspn_number_validate(parser, invalid);
     return length;
 }
 
@@ -8614,7 +8445,7 @@ static size_t
 pm_strspn_hexadecimal_number_validate(pm_parser_t *parser, const uint8_t *string) {
     const uint8_t *invalid = NULL;
     size_t length = pm_strspn_hexadecimal_number(string, parser->end - string, &invalid);
-    pm_strspn_number_validate(parser, string, length, invalid);
+    pm_strspn_number_validate(parser, invalid);
     return length;
 }
 
@@ -8852,7 +8683,7 @@ lex_global_variable(pm_parser_t *parser) {
                 } while (parser->current.end < parser->end && (width = char_is_identifier(parser, parser->current.end)) > 0);
 
                 // $0 isn't allowed to be followed by anything.
-                pm_diagnostic_id_t diag_id = parser->version == PM_OPTIONS_VERSION_CRUBY_3_3 ? PM_ERR_INVALID_VARIABLE_GLOBAL_3_3 : PM_ERR_INVALID_VARIABLE_GLOBAL;
+                pm_diagnostic_id_t diag_id = parser->version == PM_OPTIONS_VERSION_CRUBY_3_3_0 ? PM_ERR_INVALID_VARIABLE_GLOBAL_3_3_0 : PM_ERR_INVALID_VARIABLE_GLOBAL;
                 PM_PARSER_ERR_TOKEN_FORMAT_CONTENT(parser, parser->current, diag_id);
             }
 
@@ -8888,7 +8719,7 @@ lex_global_variable(pm_parser_t *parser) {
             } else {
                 // If we get here, then we have a $ followed by something that
                 // isn't recognized as a global variable.
-                pm_diagnostic_id_t diag_id = parser->version == PM_OPTIONS_VERSION_CRUBY_3_3 ? PM_ERR_INVALID_VARIABLE_GLOBAL_3_3 : PM_ERR_INVALID_VARIABLE_GLOBAL;
+                pm_diagnostic_id_t diag_id = parser->version == PM_OPTIONS_VERSION_CRUBY_3_3_0 ? PM_ERR_INVALID_VARIABLE_GLOBAL_3_3_0 : PM_ERR_INVALID_VARIABLE_GLOBAL;
                 size_t width = parser->encoding->char_width(parser->current.end, parser->end - parser->current.end);
                 PM_PARSER_ERR_TOKEN_FORMAT(parser, parser->current, diag_id, (int) ((parser->current.end + width) - parser->current.start), (const char *) parser->current.start);
             }
@@ -9526,7 +9357,22 @@ escape_read(pm_parser_t *parser, pm_buffer_t *buffer, pm_buffer_t *regular_expre
             const uint8_t *start = parser->current.end - 1;
             parser->current.end++;
 
-            if (peek(parser) == '{') {
+            if (
+                (parser->current.end + 4 <= parser->end) &&
+                pm_char_is_hexadecimal_digit(parser->current.end[0]) &&
+                pm_char_is_hexadecimal_digit(parser->current.end[1]) &&
+                pm_char_is_hexadecimal_digit(parser->current.end[2]) &&
+                pm_char_is_hexadecimal_digit(parser->current.end[3])
+            ) {
+                uint32_t value = escape_unicode(parser->current.end, 4);
+
+                if (flags & PM_ESCAPE_FLAG_REGEXP) {
+                    pm_buffer_append_bytes(regular_expression_buffer, start, (size_t) (parser->current.end + 4 - start));
+                }
+                escape_write_unicode(parser, buffer, flags, start, parser->current.end + 4, value);
+
+                parser->current.end += 4;
+            } else if (peek(parser) == '{') {
                 const uint8_t *unicode_codepoints_start = parser->current.end - 2;
 
                 parser->current.end++;
@@ -9576,21 +9422,7 @@ escape_read(pm_parser_t *parser, pm_buffer_t *buffer, pm_buffer_t *regular_expre
                     pm_buffer_append_bytes(regular_expression_buffer, unicode_codepoints_start, (size_t) (parser->current.end - unicode_codepoints_start));
                 }
             } else {
-                size_t length = pm_strspn_hexadecimal_digit(parser->current.end, MIN(parser->end - parser->current.end, 4));
-
-                if (length == 4) {
-                    uint32_t value = escape_unicode(parser->current.end, 4);
-
-                    if (flags & PM_ESCAPE_FLAG_REGEXP) {
-                        pm_buffer_append_bytes(regular_expression_buffer, start, (size_t) (parser->current.end + 4 - start));
-                    }
-
-                    escape_write_unicode(parser, buffer, flags, start, parser->current.end + 4, value);
-                    parser->current.end += 4;
-                } else {
-                    parser->current.end += length;
-                    pm_parser_err_current(parser, PM_ERR_ESCAPE_INVALID_UNICODE);
-                }
+                pm_parser_err_current(parser, PM_ERR_ESCAPE_INVALID_UNICODE);
             }
 
             return;
@@ -9844,8 +9676,8 @@ lex_at_variable(pm_parser_t *parser) {
         }
     } else if (parser->current.end < parser->end && pm_char_is_decimal_digit(*parser->current.end)) {
         pm_diagnostic_id_t diag_id = (type == PM_TOKEN_CLASS_VARIABLE) ? PM_ERR_INCOMPLETE_VARIABLE_CLASS : PM_ERR_INCOMPLETE_VARIABLE_INSTANCE;
-        if (parser->version == PM_OPTIONS_VERSION_CRUBY_3_3) {
-            diag_id = (type == PM_TOKEN_CLASS_VARIABLE) ? PM_ERR_INCOMPLETE_VARIABLE_CLASS_3_3 : PM_ERR_INCOMPLETE_VARIABLE_INSTANCE_3_3;
+        if (parser->version == PM_OPTIONS_VERSION_CRUBY_3_3_0) {
+            diag_id = (type == PM_TOKEN_CLASS_VARIABLE) ? PM_ERR_INCOMPLETE_VARIABLE_CLASS_3_3_0 : PM_ERR_INCOMPLETE_VARIABLE_INSTANCE_3_3_0;
         }
 
         size_t width = parser->encoding->char_width(parser->current.end, parser->end - parser->current.end);
@@ -10829,11 +10661,8 @@ parser_lex(pm_parser_t *parser) {
                                 }
 
                                 size_t ident_length = (size_t) (parser->current.end - ident_start);
-                                bool ident_error = false;
-
                                 if (quote != PM_HEREDOC_QUOTE_NONE && !match(parser, (uint8_t) quote)) {
-                                    pm_parser_err(parser, ident_start, ident_start + ident_length, PM_ERR_HEREDOC_IDENTIFIER);
-                                    ident_error = true;
+                                    // TODO: handle unterminated heredoc
                                 }
 
                                 parser->explicit_encoding = NULL;
@@ -10858,7 +10687,7 @@ parser_lex(pm_parser_t *parser) {
                                         // this is not a valid heredoc declaration. In this case we
                                         // will add an error, but we will still return a heredoc
                                         // start.
-                                        if (!ident_error) pm_parser_err_heredoc_term(parser, parser->lex_modes.current);
+                                        pm_parser_err_current(parser, PM_ERR_HEREDOC_TERM);
                                         body_start = parser->end;
                                     } else {
                                         // Otherwise, we want to indicate that the body of the
@@ -12185,7 +12014,7 @@ parser_lex(pm_parser_t *parser) {
             // terminator) but still continue parsing so that content after the
             // declaration of the heredoc can be parsed.
             if (parser->current.end >= parser->end) {
-                pm_parser_err_heredoc_term(parser, lex_mode);
+                pm_parser_err_current(parser, PM_ERR_HEREDOC_TERM);
                 parser->next_start = lex_mode->as.heredoc.next_start;
                 parser->heredoc_end = parser->current.end;
                 lex_state_set(parser, PM_LEX_STATE_END);
@@ -12824,23 +12653,6 @@ expect3(pm_parser_t *parser, pm_token_type_t type1, pm_token_type_t type2, pm_to
     parser->previous.type = PM_TOKEN_MISSING;
 }
 
-/**
- * A special expect1 that expects a heredoc terminator and handles popping the
- * lex mode accordingly.
- */
-static void
-expect1_heredoc_term(pm_parser_t *parser, pm_lex_mode_t *lex_mode) {
-    if (match1(parser, PM_TOKEN_HEREDOC_END)) {
-        lex_mode_pop(parser);
-        parser_lex(parser);
-    } else {
-        pm_parser_err_heredoc_term(parser, lex_mode);
-        lex_mode_pop(parser);
-        parser->previous.start = parser->previous.end;
-        parser->previous.type = PM_TOKEN_MISSING;
-    }
-}
-
 static pm_node_t *
 parse_expression(pm_parser_t *parser, pm_binding_power_t binding_power, bool accepts_command_call, pm_diagnostic_id_t diag_id);
 
@@ -12968,32 +12780,6 @@ parse_write_name(pm_parser_t *parser, pm_constant_id_t *name_field) {
     *name_field = pm_constant_pool_insert_owned(&parser->constant_pool, name, length + 1);
 }
 
-/**
- * Certain expressions are not targetable, but in order to provide a better
- * experience we give a specific error message. In order to maintain as much
- * information in the tree as possible, we replace them with local variable
- * writes.
- */
-static pm_node_t *
-parse_unwriteable_target(pm_parser_t *parser, pm_node_t *target) {
-    switch (PM_NODE_TYPE(target)) {
-        case PM_SOURCE_ENCODING_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_ENCODING); break;
-        case PM_FALSE_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_FALSE); break;
-        case PM_SOURCE_FILE_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_FILE); break;
-        case PM_SOURCE_LINE_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_LINE); break;
-        case PM_NIL_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_NIL); break;
-        case PM_SELF_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_SELF); break;
-        case PM_TRUE_NODE: pm_parser_err_node(parser, target, PM_ERR_EXPRESSION_NOT_WRITABLE_TRUE); break;
-        default: break;
-    }
-
-    pm_constant_id_t name = pm_parser_constant_id_location(parser, target->location.start, target->location.end);
-    pm_local_variable_target_node_t *result = pm_local_variable_target_node_create(parser, &target->location, name, 0);
-
-    pm_node_destroy(parser, target);
-    return (pm_node_t *) result;
-}
-
 /**
  * Convert the given node into a valid target node.
  */
@@ -13002,38 +12788,17 @@ parse_target(pm_parser_t *parser, pm_node_t *target) {
     switch (PM_NODE_TYPE(target)) {
         case PM_MISSING_NODE:
             return target;
-        case PM_SOURCE_ENCODING_NODE:
-        case PM_FALSE_NODE:
-        case PM_SOURCE_FILE_NODE:
-        case PM_SOURCE_LINE_NODE:
-        case PM_NIL_NODE:
-        case PM_SELF_NODE:
-        case PM_TRUE_NODE: {
-            // In these special cases, we have specific error messages and we
-            // will replace them with local variable writes.
-            return parse_unwriteable_target(parser, target);
-        }
         case PM_CLASS_VARIABLE_READ_NODE:
             assert(sizeof(pm_class_variable_target_node_t) == sizeof(pm_class_variable_read_node_t));
             target->type = PM_CLASS_VARIABLE_TARGET_NODE;
             return target;
         case PM_CONSTANT_PATH_NODE:
-            if (context_def_p(parser)) {
-                pm_parser_err_node(parser, target, PM_ERR_WRITE_TARGET_IN_METHOD);
-            }
-
             assert(sizeof(pm_constant_path_target_node_t) == sizeof(pm_constant_path_node_t));
             target->type = PM_CONSTANT_PATH_TARGET_NODE;
-
             return target;
         case PM_CONSTANT_READ_NODE:
-            if (context_def_p(parser)) {
-                pm_parser_err_node(parser, target, PM_ERR_WRITE_TARGET_IN_METHOD);
-            }
-
             assert(sizeof(pm_constant_target_node_t) == sizeof(pm_constant_read_node_t));
             target->type = PM_CONSTANT_TARGET_NODE;
-
             return target;
         case PM_BACK_REFERENCE_READ_NODE:
         case PM_NUMBERED_REFERENCE_READ_NODE:
@@ -13177,20 +12942,13 @@ parse_write(pm_parser_t *parser, pm_node_t *target, pm_token_t *operator, pm_nod
         }
         case PM_CONSTANT_PATH_NODE: {
             pm_node_t *node = (pm_node_t *) pm_constant_path_write_node_create(parser, (pm_constant_path_node_t *) target, operator, value);
-
-            if (context_def_p(parser)) {
-                pm_parser_err_node(parser, node, PM_ERR_WRITE_TARGET_IN_METHOD);
-            }
-
             return parse_shareable_constant_write(parser, node);
         }
         case PM_CONSTANT_READ_NODE: {
             pm_node_t *node = (pm_node_t *) pm_constant_write_node_create(parser, (pm_constant_read_node_t *) target, operator, value);
-
             if (context_def_p(parser)) {
                 pm_parser_err_node(parser, node, PM_ERR_WRITE_TARGET_IN_METHOD);
             }
-
             pm_node_destroy(parser, target);
             return parse_shareable_constant_write(parser, node);
         }
@@ -15400,7 +15158,7 @@ parse_symbol(pm_parser_t *parser, pm_lex_mode_t *lex_mode, pm_lex_state_t next_s
         pm_symbol_node_t *symbol = pm_symbol_node_create(parser, &opening, &parser->previous, &closing);
 
         pm_string_shared_init(&symbol->unescaped, parser->previous.start, parser->previous.end);
-        pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &parser->previous, &symbol->unescaped, false));
+        pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &symbol->unescaped));
 
         return (pm_node_t *) symbol;
     }
@@ -15500,7 +15258,7 @@ parse_symbol(pm_parser_t *parser, pm_lex_mode_t *lex_mode, pm_lex_state_t next_s
         expect1(parser, PM_TOKEN_STRING_END, PM_ERR_SYMBOL_TERM_DYNAMIC);
     }
 
-    return (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &content, &unescaped, false));
+    return (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &unescaped));
 }
 
 /**
@@ -15525,7 +15283,7 @@ parse_undef_argument(pm_parser_t *parser) {
             pm_symbol_node_t *symbol = pm_symbol_node_create(parser, &opening, &parser->previous, &closing);
 
             pm_string_shared_init(&symbol->unescaped, parser->previous.start, parser->previous.end);
-            pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &parser->previous, &symbol->unescaped, false));
+            pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &symbol->unescaped));
 
             return (pm_node_t *) symbol;
         }
@@ -15566,7 +15324,7 @@ parse_alias_argument(pm_parser_t *parser, bool first) {
             pm_symbol_node_t *symbol = pm_symbol_node_create(parser, &opening, &parser->previous, &closing);
 
             pm_string_shared_init(&symbol->unescaped, parser->previous.start, parser->previous.end);
-            pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &parser->previous, &symbol->unescaped, false));
+            pm_node_flag_set((pm_node_t *) symbol, parse_symbol_encoding(parser, &symbol->unescaped));
 
             return (pm_node_t *) symbol;
         }
@@ -16330,7 +16088,7 @@ parse_pattern_primitive(pm_parser_t *parser, pm_constant_id_list_t *captures, pm
 
                     if (variable == NULL) {
                         if (
-                            (parser->version != PM_OPTIONS_VERSION_CRUBY_3_3) &&
+                            (parser->version != PM_OPTIONS_VERSION_CRUBY_3_3_0) &&
                             !parser->current_scope->closed &&
                             (parser->current_scope->numbered_parameters != PM_SCOPE_NUMBERED_PARAMETERS_DISALLOWED) &&
                             pm_token_is_it(parser->previous.start, parser->previous.end)
@@ -16731,7 +16489,7 @@ parse_strings(pm_parser_t *parser, pm_node_t *current) {
 
                 pm_node_list_free(&parts);
             } else if (accept1(parser, PM_TOKEN_LABEL_END) && !state_is_arg_labeled) {
-                node = (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &content, &unescaped, true));
+                node = (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &unescaped));
             } else if (match1(parser, PM_TOKEN_EOF)) {
                 pm_parser_err_token(parser, &opening, PM_ERR_STRING_LITERAL_EOF);
                 node = (pm_node_t *) pm_string_node_create_unescaped(parser, &opening, &content, &parser->current, &unescaped);
@@ -16757,7 +16515,7 @@ parse_strings(pm_parser_t *parser, pm_node_t *current) {
                 pm_node_flag_set(node, parse_unescaped_encoding(parser));
                 expect1(parser, PM_TOKEN_STRING_END, PM_ERR_STRING_LITERAL_EOF);
             } else if (accept1(parser, PM_TOKEN_LABEL_END)) {
-                node = (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &content, &unescaped, true));
+                node = (pm_node_t *) pm_symbol_node_create_unescaped(parser, &opening, &content, &parser->previous, &unescaped, parse_symbol_encoding(parser, &unescaped));
             } else {
                 // If we get here, then we have interpolation so we'll need
                 // to create a string or symbol node with interpolation.
@@ -16839,11 +16597,11 @@ parse_strings(pm_parser_t *parser, pm_node_t *current) {
                 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);
+                pm_interpolated_string_node_append(parser, container, current);
                 current = (pm_node_t *) container;
             }
 
-            pm_interpolated_string_node_append((pm_interpolated_string_node_t *) current, node);
+            pm_interpolated_string_node_append(parser, (pm_interpolated_string_node_t *) current, node);
         }
     }
 
@@ -17544,7 +17302,8 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
             if (match2(parser, PM_TOKEN_HEREDOC_END, PM_TOKEN_EOF)) {
                 // If we get here, then we have an empty heredoc. We'll create
                 // an empty content token and return an empty string node.
-                expect1_heredoc_term(parser, lex_mode);
+                lex_mode_pop(parser);
+                expect1(parser, PM_TOKEN_HEREDOC_END, PM_ERR_HEREDOC_TERM);
                 pm_token_t content = parse_strings_empty_content(parser->previous.start);
 
                 if (quote == PM_HEREDOC_QUOTE_BACKTICK) {
@@ -17585,7 +17344,8 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                 }
 
                 node = (pm_node_t *) cast;
-                expect1_heredoc_term(parser, lex_mode);
+                lex_mode_pop(parser);
+                expect1(parser, PM_TOKEN_HEREDOC_END, PM_ERR_HEREDOC_TERM);
             } else {
                 // If we get here, then we have multiple parts in the heredoc,
                 // so we'll need to create an interpolated string node to hold
@@ -17607,18 +17367,20 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                     pm_interpolated_x_string_node_t *cast = pm_interpolated_xstring_node_create(parser, &opening, &opening);
                     cast->parts = parts;
 
-                    expect1_heredoc_term(parser, lex_mode);
-                    pm_interpolated_xstring_node_closing_set(cast, &parser->previous);
+                    lex_mode_pop(parser);
+                    expect1(parser, PM_TOKEN_HEREDOC_END, PM_ERR_HEREDOC_TERM);
 
+                    pm_interpolated_xstring_node_closing_set(cast, &parser->previous);
                     cast->base.location = cast->opening_loc;
                     node = (pm_node_t *) cast;
                 } else {
                     pm_interpolated_string_node_t *cast = pm_interpolated_string_node_create(parser, &opening, &parts, &opening);
                     pm_node_list_free(&parts);
 
-                    expect1_heredoc_term(parser, lex_mode);
-                    pm_interpolated_string_node_closing_set(cast, &parser->previous);
+                    lex_mode_pop(parser);
+                    expect1(parser, PM_TOKEN_HEREDOC_END, PM_ERR_HEREDOC_TERM);
 
+                    pm_interpolated_string_node_closing_set(cast, &parser->previous);
                     cast->base.location = cast->opening_loc;
                     node = (pm_node_t *) cast;
                 }
@@ -19024,15 +18786,15 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                             // If we hit string content and the current node is
                             // an interpolated string, then we need to append
                             // the string content to the list of child nodes.
-                            pm_interpolated_string_node_append((pm_interpolated_string_node_t *) current, string);
+                            pm_interpolated_string_node_append(parser, (pm_interpolated_string_node_t *) current, string);
                         } else if (PM_NODE_TYPE_P(current, PM_STRING_NODE)) {
                             // If we hit string content and the current node is
                             // a string node, then we need to convert the
                             // current node into an interpolated string and add
                             // the string content to the list of child nodes.
                             pm_interpolated_string_node_t *interpolated = pm_interpolated_string_node_create(parser, &opening, NULL, &closing);
-                            pm_interpolated_string_node_append(interpolated, current);
-                            pm_interpolated_string_node_append(interpolated, string);
+                            pm_interpolated_string_node_append(parser, interpolated, current);
+                            pm_interpolated_string_node_append(parser, interpolated, string);
                             current = (pm_node_t *) interpolated;
                         } else {
                             assert(false && "unreachable");
@@ -19057,7 +18819,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                             pm_token_t opening = not_provided(parser);
                             pm_token_t closing = not_provided(parser);
                             pm_interpolated_string_node_t *interpolated = pm_interpolated_string_node_create(parser, &opening, NULL, &closing);
-                            pm_interpolated_string_node_append(interpolated, current);
+                            pm_interpolated_string_node_append(parser, interpolated, current);
                             current = (pm_node_t *) interpolated;
                         } else {
                             // If we hit an embedded variable and the current
@@ -19066,7 +18828,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                         }
 
                         pm_node_t *part = parse_string_part(parser);
-                        pm_interpolated_string_node_append((pm_interpolated_string_node_t *) current, part);
+                        pm_interpolated_string_node_append(parser, (pm_interpolated_string_node_t *) current, part);
                         break;
                     }
                     case PM_TOKEN_EMBEXPR_BEGIN: {
@@ -19086,7 +18848,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                             pm_token_t opening = not_provided(parser);
                             pm_token_t closing = not_provided(parser);
                             pm_interpolated_string_node_t *interpolated = pm_interpolated_string_node_create(parser, &opening, NULL, &closing);
-                            pm_interpolated_string_node_append(interpolated, current);
+                            pm_interpolated_string_node_append(parser, interpolated, current);
                             current = (pm_node_t *) interpolated;
                         } else if (PM_NODE_TYPE_P(current, PM_INTERPOLATED_STRING_NODE)) {
                             // If we hit an embedded expression and the current
@@ -19097,7 +18859,7 @@ parse_expression_prefix(pm_parser_t *parser, pm_binding_power_t binding_power, b
                         }
 
                         pm_node_t *part = parse_string_part(parser);
-                        pm_interpolated_string_node_append((pm_interpolated_string_node_t *) current, part);
+                        pm_interpolated_string_node_append(parser, (pm_interpolated_string_node_t *) current, part);
                         break;
                     }
                     default:
@@ -21621,28 +21383,25 @@ pm_parser_errors_format(const pm_parser_t *parser, const pm_list_t *error_list,
         pm_buffer_append_string(buffer, error_format.blank_prefix, error_format.blank_prefix_length);
 
         size_t column = 0;
-        while (column < error->column_start) {
-            pm_buffer_append_byte(buffer, ' ');
-
-            size_t char_width = encoding->char_width(start + column, parser->end - (start + column));
-            column += (char_width == 0 ? 1 : char_width);
-        }
-
-        if (colorize) pm_buffer_append_string(buffer, PM_COLOR_RED, 7);
-        pm_buffer_append_byte(buffer, '^');
-
-        size_t char_width = encoding->char_width(start + column, parser->end - (start + column));
-        column += (char_width == 0 ? 1 : char_width);
-
         while (column < error->column_end) {
-            pm_buffer_append_byte(buffer, '~');
+            if (column < error->column_start) {
+                pm_buffer_append_byte(buffer, ' ');
+            } else {
+                const uint8_t caret = column == error->column_start ? '^' : '~';
+
+                if (colorize) {
+                    pm_buffer_append_string(buffer, PM_COLOR_RED, 7);
+                    pm_buffer_append_byte(buffer, caret);
+                    pm_buffer_append_string(buffer, PM_COLOR_RESET, 3);
+                } else {
+                    pm_buffer_append_byte(buffer, caret);
+                }
+            }
 
             size_t char_width = encoding->char_width(start + column, parser->end - (start + column));
             column += (char_width == 0 ? 1 : char_width);
         }
 
-        if (colorize) pm_buffer_append_string(buffer, PM_COLOR_RESET, 3);
-
         if (inline_messages) {
             pm_buffer_append_byte(buffer, ' ');
             assert(error->error != NULL);
diff --git a/templates/ext/prism/api_node.c.erb b/templates/ext/prism/api_node.c.erb
index 419236ef..0e3e4d63 100644
--- a/templates/ext/prism/api_node.c.erb
+++ b/templates/ext/prism/api_node.c.erb
@@ -76,8 +76,7 @@ pm_source_new(const pm_parser_t *parser, rb_encoding *encoding) {
         rb_ary_push(offsets, ULONG2NUM(parser->newline_list.offsets[index]));
     }
 
-    VALUE source_argv[] = { source_string, LONG2NUM(parser->start_line), offsets };
-    return rb_class_new_instance(3, source_argv, rb_cPrismSource);
+    return rb_funcall(rb_cPrismSource, rb_intern("for"), 3, source_string, LONG2NUM(parser->start_line), offsets);
 }
 
 typedef struct pm_node_stack_node {
diff --git a/templates/lib/prism/dsl.rb.erb b/templates/lib/prism/dsl.rb.erb
index 8dbb5409..eff0d1c4 100644
--- a/templates/lib/prism/dsl.rb.erb
+++ b/templates/lib/prism/dsl.rb.erb
@@ -2,7 +2,7 @@ module Prism
   # The DSL module provides a set of methods that can be used to create prism
   # nodes in a more concise manner. For example, instead of writing:
   #
-  #     source = Prism::Source.new("[1]")
+  #     source = Prism::Source.for("[1]")
   #
   #     Prism::ArrayNode.new(
   #       [
@@ -20,7 +20,7 @@ module Prism
   #
   # you could instead write:
   #
-  #     source = Prism::Source.new("[1]")
+  #     source = Prism::Source.for("[1]")
   #
   #     ArrayNode(
   #       IntegerNode(Prism::IntegerBaseFlags::DECIMAL, 1, Location(source, 1, 1)), source),
diff --git a/templates/lib/prism/node.rb.erb b/templates/lib/prism/node.rb.erb
index 6ffbf7b1..ddbe53e5 100644
--- a/templates/lib/prism/node.rb.erb
+++ b/templates/lib/prism/node.rb.erb
@@ -40,15 +40,6 @@ module Prism
       end
     end
 
-    # Returns all of the lines of the source code associated with this node.
-    def source_lines
-      location.source_lines
-    end
-
-    # An alias for source_lines, used to mimic the API from
-    # RubyVM::AbstractSyntaxTree to make it easier to migrate.
-    alias script_lines source_lines
-
     # Slice the location of the node from the source.
     def slice
       location.slice
diff --git a/templates/lib/prism/serialize.rb.erb b/templates/lib/prism/serialize.rb.erb
index c31a319e..29ae5356 100644
--- a/templates/lib/prism/serialize.rb.erb
+++ b/templates/lib/prism/serialize.rb.erb
@@ -19,7 +19,7 @@ module Prism
     # Deserialize the AST represented by the given string into a parse result.
     def self.load(input, serialized)
       input = input.dup
-      source = Source.new(input)
+      source = Source.for(input)
       loader = Loader.new(source, serialized)
       result = loader.load_result
 
diff --git a/templates/rbi/prism/node.rbi.erb b/templates/rbi/prism/node.rbi.erb
index 9184ac64..8451301f 100644
--- a/templates/rbi/prism/node.rbi.erb
+++ b/templates/rbi/prism/node.rbi.erb
@@ -13,12 +13,6 @@ class Prism::Node
   sig { returns(Integer) }
   def end_offset; end
 
-  sig { returns(T::Array[String]) }
-  def source_lines; end
-
-  sig { returns(T::Array[String]) }
-  def script_lines; end
-
   sig { returns(String) }
   def slice; end
 
diff --git a/templates/sig/prism/node.rbs.erb b/templates/sig/prism/node.rbs.erb
index 30c91eef..75cfc6e9 100644
--- a/templates/sig/prism/node.rbs.erb
+++ b/templates/sig/prism/node.rbs.erb
@@ -12,8 +12,6 @@ module Prism
 
     def start_offset: () -> Integer
     def end_offset: () -> Integer
-    def source_lines: () -> Array[String]
-    alias script_lines source_lines
     def slice: () -> String
     def slice_lines: () -> String
     def pretty_print: (untyped q) -> untyped
diff --git a/templates/src/diagnostic.c.erb b/templates/src/diagnostic.c.erb
index 6a93ba58..83901371 100644
--- a/templates/src/diagnostic.c.erb
+++ b/templates/src/diagnostic.c.erb
@@ -158,12 +158,12 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_END_UPCASE_TERM]                    = { "expected a `}` to close the `END` statement", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_CONTROL]             = { "invalid control escape sequence", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_CONTROL_REPEAT]      = { "invalid control escape sequence; control cannot be repeated", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_ESCAPE_INVALID_HEXADECIMAL]         = { "invalid hex escape sequence", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_ESCAPE_INVALID_HEXADECIMAL]         = { "invalid hexadecimal escape sequence", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_META]                = { "invalid meta escape sequence", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_META_REPEAT]         = { "invalid meta escape sequence; meta cannot be repeated", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_UNICODE]             = { "invalid Unicode escape sequence", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_UNICODE_CM_FLAGS]    = { "invalid Unicode escape sequence; Unicode cannot be combined with control or meta flags", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_ESCAPE_INVALID_UNICODE_LITERAL]     = { "invalid Unicode escape sequence; Multiple codepoints at single character literal are disallowed", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_ESCAPE_INVALID_UNICODE_LITERAL]     = { "invalid Unicode escape sequence; multiple codepoints are not allowed in a character literal", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_UNICODE_LONG]        = { "invalid Unicode escape sequence; maximum length is 6 digits", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_ESCAPE_INVALID_UNICODE_TERM]        = { "invalid Unicode escape sequence; needs closing `}`", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_EXPECT_ARGUMENT]                    = { "expected an argument", PM_ERROR_LEVEL_SYNTAX },
@@ -207,12 +207,11 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_HASH_ROCKET]                        = { "expected a `=>` between the hash key and value", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_HASH_TERM]                          = { "expected a `}` to close the hash literal", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_HASH_VALUE]                         = { "expected a value in the hash literal", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_HEREDOC_IDENTIFIER]                 = { "unterminated here document identifier", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_HEREDOC_TERM]                       = { "unterminated heredoc; can't find string \"%.*s\"", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_HEREDOC_TERM]                       = { "could not find a terminator for the heredoc", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INCOMPLETE_QUESTION_MARK]           = { "incomplete expression at `?`", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INCOMPLETE_VARIABLE_CLASS_3_3]      = { "`%.*s' is not allowed as a class variable name", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_INCOMPLETE_VARIABLE_CLASS_3_3_0]    = { "`%.*s' is not allowed as a class variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INCOMPLETE_VARIABLE_CLASS]          = { "'%.*s' is not allowed as a class variable name", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INCOMPLETE_VARIABLE_INSTANCE_3_3]   = { "`%.*s' is not allowed as an instance variable name", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_INCOMPLETE_VARIABLE_INSTANCE_3_3_0] = { "`%.*s' is not allowed as an instance variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INCOMPLETE_VARIABLE_INSTANCE]       = { "'%.*s' is not allowed as an instance variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INSTANCE_VARIABLE_BARE]             = { "'@' without identifiers is not allowed as an instance variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_BLOCK_EXIT]                 = { "Invalid %s", PM_ERROR_LEVEL_SYNTAX },
@@ -223,8 +222,7 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_INVALID_NUMBER_DECIMAL]             = { "invalid decimal number; numeric literal without digits", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_NUMBER_HEXADECIMAL]         = { "invalid hexadecimal number; numeric literal without digits", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_NUMBER_OCTAL]               = { "invalid octal number; numeric literal without digits", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INVALID_NUMBER_UNDERSCORE_INNER]    = { "invalid underscore placement in number", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INVALID_NUMBER_UNDERSCORE_TRAILING] = { "trailing '_' in number", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_INVALID_NUMBER_UNDERSCORE]          = { "invalid underscore placement in number", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_CHARACTER]                  = { "invalid character 0x%X", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_MULTIBYTE_CHAR]             = { "invalid multibyte char (%s)", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_MULTIBYTE_CHARACTER]        = { "invalid multibyte character 0x%X", PM_ERROR_LEVEL_SYNTAX },
@@ -234,8 +232,7 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_INVALID_RETRY_AFTER_ELSE]           = { "Invalid retry after else", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_RETRY_AFTER_ENSURE]         = { "Invalid retry after ensure", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_RETRY_WITHOUT_RESCUE]       = { "Invalid retry without rescue", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INVALID_SYMBOL]                     = { "invalid symbol", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_INVALID_VARIABLE_GLOBAL_3_3]        = { "`%.*s' is not allowed as a global variable name", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_INVALID_VARIABLE_GLOBAL_3_3_0]      = { "`%.*s' is not allowed as a global variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_VARIABLE_GLOBAL]            = { "'%.*s' is not allowed as a global variable name", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_INVALID_YIELD]                      = { "Invalid yield", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_IT_NOT_ALLOWED_NUMBERED]            = { "`it` is not allowed when an numbered parameter is defined", PM_ERROR_LEVEL_SYNTAX },
@@ -244,13 +241,13 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_LAMBDA_TERM_BRACE]                  = { "expected a lambda block beginning with `{` to end with `}`", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_LAMBDA_TERM_END]                    = { "expected a lambda block beginning with `do` to end with `end`", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_LIST_I_LOWER_ELEMENT]               = { "expected a symbol in a `%i` list", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_LIST_I_LOWER_TERM]                  = { "unterminated list; expected a closing delimiter for the `%i`", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_LIST_I_LOWER_TERM]                  = { "expected a closing delimiter for the `%i` list", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_LIST_I_UPPER_ELEMENT]               = { "expected a symbol in a `%I` list", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_LIST_I_UPPER_TERM]                  = { "unterminated list; expected a closing delimiter for the `%I`", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_LIST_I_UPPER_TERM]                  = { "expected a closing delimiter for the `%I` list", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_LIST_W_LOWER_ELEMENT]               = { "expected a string in a `%w` list", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_LIST_W_LOWER_TERM]                  = { "unterminated list; expected a closing delimiter for the `%w`", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_LIST_W_LOWER_TERM]                  = { "expected a closing delimiter for the `%w` list", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_LIST_W_UPPER_ELEMENT]               = { "expected a string in a `%W` list", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_LIST_W_UPPER_TERM]                  = { "unterminated list; expected a closing delimiter for the `%W`", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_LIST_W_UPPER_TERM]                  = { "expected a closing delimiter for the `%W` list", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_MALLOC_FAILED]                      = { "failed to allocate memory", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_MIXED_ENCODING]                     = { "UTF-8 mixed within %s source", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_MODULE_IN_METHOD]                   = { "unexpected module definition in method body", PM_ERROR_LEVEL_SYNTAX },
@@ -307,7 +304,7 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_REGEXP_NON_ESCAPED_MBC]             = { "/.../n has a non escaped non ASCII character in non ASCII-8BIT script: /%.*s/", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_REGEXP_INVALID_UNICODE_RANGE]       = { "invalid Unicode range: /%.*s/", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_REGEXP_UNKNOWN_OPTIONS]             = { "unknown regexp %s: %.*s", PM_ERROR_LEVEL_SYNTAX },
-    [PM_ERR_REGEXP_TERM]                        = { "unterminated regexp meets end of file; expected a closing delimiter", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_REGEXP_TERM]                        = { "expected a closing delimiter for the regular expression", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_REGEXP_UTF8_CHAR_NON_UTF8_REGEXP]   = { "UTF-8 character in non UTF-8 regexp: /%s/", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_RESCUE_EXPRESSION]                  = { "expected a rescued expression", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_RESCUE_MODIFIER_VALUE]              = { "expected a value after the `rescue` modifier", PM_ERROR_LEVEL_SYNTAX },
@@ -324,7 +321,7 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_ERR_STRING_LITERAL_EOF]                 = { "unterminated string meets end of file", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_STRING_LITERAL_TERM]                = { "unexpected %s, expected a string literal terminator", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_SYMBOL_INVALID]                     = { "invalid symbol", PM_ERROR_LEVEL_SYNTAX }, // TODO expected symbol? prism.c ~9719
-    [PM_ERR_SYMBOL_TERM_DYNAMIC]                = { "unterminated quoted string; expected a closing delimiter for the dynamic symbol", PM_ERROR_LEVEL_SYNTAX },
+    [PM_ERR_SYMBOL_TERM_DYNAMIC]                = { "expected a closing delimiter for the dynamic symbol", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_SYMBOL_TERM_INTERPOLATED]           = { "expected a closing delimiter for the interpolated symbol", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_TERNARY_COLON]                      = { "expected a `:` after the true expression of a ternary operator", PM_ERROR_LEVEL_SYNTAX },
     [PM_ERR_TERNARY_EXPRESSION_FALSE]           = { "expected an expression after `:` in the ternary operator", PM_ERROR_LEVEL_SYNTAX },
@@ -353,7 +350,7 @@ static const pm_diagnostic_data_t diagnostic_messages[PM_DIAGNOSTIC_ID_MAX] = {
     [PM_WARN_DOT_DOT_DOT_EOL]                   = { "... at EOL, should be parenthesized?", PM_WARNING_LEVEL_DEFAULT },
     [PM_WARN_DUPLICATED_HASH_KEY]               = { "key %.*s is duplicated and overwritten on line %" PRIi32, PM_WARNING_LEVEL_DEFAULT },
     [PM_WARN_DUPLICATED_WHEN_CLAUSE]            = { "duplicated 'when' clause with line %" PRIi32 " is ignored", PM_WARNING_LEVEL_VERBOSE },
-    [PM_WARN_EQUAL_IN_CONDITIONAL_3_3]          = { "found `= literal' in conditional, should be ==", PM_WARNING_LEVEL_DEFAULT },
+    [PM_WARN_EQUAL_IN_CONDITIONAL_3_3_0]        = { "found `= literal' in conditional, should be ==", PM_WARNING_LEVEL_DEFAULT },
     [PM_WARN_EQUAL_IN_CONDITIONAL]              = { "found '= literal' in conditional, should be ==", PM_WARNING_LEVEL_DEFAULT },
     [PM_WARN_END_IN_METHOD]                     = { "END in method; use at_exit", PM_WARNING_LEVEL_DEFAULT },
     [PM_WARN_FLOAT_OUT_OF_RANGE]                = { "Float %.*s%s out of range", PM_WARNING_LEVEL_VERBOSE },
diff --git a/templates/src/token_type.c.erb b/templates/src/token_type.c.erb
index 0587fc6c..1aeecd72 100644
--- a/templates/src/token_type.c.erb
+++ b/templates/src/token_type.c.erb
@@ -130,9 +130,9 @@ pm_token_type_human(pm_token_type_t token_type) {
         case PM_TOKEN_GREATER_EQUAL:
             return "'>='";
         case PM_TOKEN_GREATER_GREATER:
-            return ">>";
+            return "'>>'";
         case PM_TOKEN_GREATER_GREATER_EQUAL:
-            return ">>=";
+            return "'>>='";
         case PM_TOKEN_HEREDOC_END:
             return "heredoc ending";
         case PM_TOKEN_HEREDOC_START:
@@ -258,9 +258,9 @@ pm_token_type_human(pm_token_type_t token_type) {
         case PM_TOKEN_LESS_EQUAL_GREATER:
             return "'<=>'";
         case PM_TOKEN_LESS_LESS:
-            return "<<";
+            return "'<<'";
         case PM_TOKEN_LESS_LESS_EQUAL:
-            return "<<=";
+            return "'<<='";
         case PM_TOKEN_METHOD_NAME:
             return "method name";
         case PM_TOKEN_MINUS:


Test patch:
diff --git a/test/prism/errors_test.rb b/test/prism/errors_test.rb
index 4d35d057..f989daa3 100644
--- a/test/prism/errors_test.rb
+++ b/test/prism/errors_test.rb
@@ -117,25 +117,25 @@ module Prism
 
     def test_unterminated_i_list
       assert_errors expression("%i["), "%i[", [
-        ["unterminated list; expected a closing delimiter for the `%i`", 0..3]
+        ["expected a closing delimiter for the `%i` list", 0..3]
       ]
     end
 
     def test_unterminated_w_list
       assert_errors expression("%w["), "%w[", [
-        ["unterminated list; expected a closing delimiter for the `%w`", 0..3]
+        ["expected a closing delimiter for the `%w` list", 0..3]
       ]
     end
 
     def test_unterminated_W_list
       assert_errors expression("%W["), "%W[", [
-        ["unterminated list; expected a closing delimiter for the `%W`", 0..3]
+        ["expected a closing delimiter for the `%W` list", 0..3]
       ]
     end
 
     def test_unterminated_regular_expression
       assert_errors expression("/hello"), "/hello", [
-        ["unterminated regexp meets end of file; expected a closing delimiter", 0..1]
+        ["expected a closing delimiter for the regular expression", 0..1]
       ]
     end
 
@@ -143,7 +143,7 @@ module Prism
       source = "<<-END + /b\nEND\n"
 
       assert_errors expression(source), source, [
-        ["unterminated regexp meets end of file; expected a closing delimiter", 9..10]
+        ["expected a closing delimiter for the regular expression", 9..10]
       ]
     end
 
@@ -189,7 +189,7 @@ module Prism
 
     def test_unterminated_s_symbol
       assert_errors expression("%s[abc"), "%s[abc", [
-        ["unterminated quoted string; expected a closing delimiter for the dynamic symbol", 0..3]
+        ["expected a closing delimiter for the dynamic symbol", 0..3]
       ]
     end
 
@@ -690,13 +690,13 @@ module Prism
       expected = StringNode(StringFlags::FORCED_UTF8_ENCODING, Location(), Location(), nil, "\u0001\u0002")
 
       assert_errors expected, '?\u{0001 0002}', [
-        ["invalid Unicode escape sequence; Multiple codepoints at single character literal are disallowed", 9..12]
+        ["invalid Unicode escape sequence; multiple codepoints are not allowed in a character literal", 9..12]
       ]
     end
 
     def test_invalid_hex_escape
       assert_errors expression('"\\xx"'), '"\\xx"', [
-        ["invalid hex escape sequence", 1..3],
+        ["invalid hexadecimal escape sequence", 1..3],
       ]
     end
 
@@ -1238,7 +1238,7 @@ module Prism
       expected = CallNode(0, receiver, Location(), :foo, Location(), nil, nil, nil, nil)
 
       assert_errors expected, "<<~FOO.foo\n", [
-        ["unterminated heredoc; can't find string \"FOO\"", 3..6]
+        ["could not find a terminator for the heredoc", 11..11]
       ]
     end
 
@@ -1381,6 +1381,7 @@ module Prism
 
     def test_invalid_number_underscores
       error_messages = ["invalid underscore placement in number"]
+
       assert_error_messages "1__1", error_messages
       assert_error_messages "0b1__1", error_messages
       assert_error_messages "0o1__1", error_messages
@@ -1388,7 +1389,6 @@ module Prism
       assert_error_messages "0d1__1", error_messages
       assert_error_messages "0x1__1", error_messages
 
-      error_messages = ["trailing '_' in number"]
       assert_error_messages "1_1_", error_messages
       assert_error_messages "0b1_1_", error_messages
       assert_error_messages "0o1_1_", error_messages
diff --git a/test/prism/index_write_test.rb b/test/prism/index_write_test.rb
index 4c387da1..1c6f7bce 100644
--- a/test/prism/index_write_test.rb
+++ b/test/prism/index_write_test.rb
@@ -4,7 +4,7 @@ require_relative "test_helper"
 
 module Prism
   class IndexWriteTest < TestCase
-    def test_keywords_3_3
+    def test_keywords_3_3_0
       assert_parse_success(<<~RUBY, "3.3.0")
         foo[bar: 1] = 1
         foo[bar: 1] &&= 1
@@ -22,7 +22,7 @@ module Prism
       RUBY
     end
 
-    def test_block_3_3
+    def test_block_3_3_0
       assert_parse_success(<<~RUBY, "3.3.0")
         foo[&bar] = 1
         foo[&bar] &&= 1
diff --git a/test/prism/snapshots/heredocs_nested.txt b/test/prism/snapshots/heredocs_nested.txt
index da13e48c..f830b028 100644
--- a/test/prism/snapshots/heredocs_nested.txt
+++ b/test/prism/snapshots/heredocs_nested.txt
@@ -4,7 +4,7 @@
     @ StatementsNode (location: (1,0)-(12,4))
     └── body: (length: 2)
         ├── @ InterpolatedStringNode (location: (1,0)-(1,7))
-        │   ├── flags: mutable
+        │   ├── flags: ∅
         │   ├── opening_loc: (1,0)-(1,7) = "<<~RUBY"
         │   ├── parts: (length: 4)
         │   │   ├── @ StringNode (location: (2,0)-(3,0))
@@ -19,7 +19,7 @@
         │   │   │   │   @ StatementsNode (location: (4,0)-(4,6))
         │   │   │   │   └── body: (length: 1)
         │   │   │   │       └── @ StringNode (location: (4,0)-(4,6))
-        │   │   │   │           ├── flags: frozen
+        │   │   │   │           ├── flags: ∅
         │   │   │   │           ├── opening_loc: (4,0)-(4,6) = "<<RUBY"
         │   │   │   │           ├── content_loc: (5,0)-(6,0) = "  hello\n"
         │   │   │   │           ├── closing_loc: (6,0)-(7,0) = "RUBY\n"
diff --git a/test/prism/snapshots/seattlerb/dstr_evstr.txt b/test/prism/snapshots/seattlerb/dstr_evstr.txt
index add8ad6f..8d771e88 100644
--- a/test/prism/snapshots/seattlerb/dstr_evstr.txt
+++ b/test/prism/snapshots/seattlerb/dstr_evstr.txt
@@ -13,7 +13,7 @@
             │   │   │   @ StatementsNode (location: (1,3)-(1,6))
             │   │   │   └── body: (length: 1)
             │   │   │       └── @ StringNode (location: (1,3)-(1,6))
-            │   │   │           ├── flags: frozen
+            │   │   │           ├── flags: ∅
             │   │   │           ├── opening_loc: (1,3)-(1,4) = "'"
             │   │   │           ├── content_loc: (1,4)-(1,5) = "a"
             │   │   │           ├── closing_loc: (1,5)-(1,6) = "'"
diff --git a/test/prism/snapshots/seattlerb/dstr_str.txt b/test/prism/snapshots/seattlerb/dstr_str.txt
index 6fe07818..70b5752c 100644
--- a/test/prism/snapshots/seattlerb/dstr_str.txt
+++ b/test/prism/snapshots/seattlerb/dstr_str.txt
@@ -4,7 +4,7 @@
     @ StatementsNode (location: (1,0)-(1,10))
     └── body: (length: 1)
         └── @ InterpolatedStringNode (location: (1,0)-(1,10))
-            ├── flags: mutable
+            ├── flags: ∅
             ├── opening_loc: (1,0)-(1,1) = "\""
             ├── parts: (length: 2)
             │   ├── @ EmbeddedStatementsNode (location: (1,1)-(1,7))
@@ -13,7 +13,7 @@
             │   │   │   @ StatementsNode (location: (1,3)-(1,6))
             │   │   │   └── body: (length: 1)
             │   │   │       └── @ StringNode (location: (1,3)-(1,6))
-            │   │   │           ├── flags: frozen
+            │   │   │           ├── flags: ∅
             │   │   │           ├── opening_loc: (1,3)-(1,4) = "'"
             │   │   │           ├── content_loc: (1,4)-(1,5) = "a"
             │   │   │           ├── closing_loc: (1,5)-(1,6) = "'"
diff --git a/test/prism/snapshots/seattlerb/heredoc_nested.txt b/test/prism/snapshots/seattlerb/heredoc_nested.txt
index a2322b96..26d533a3 100644
--- a/test/prism/snapshots/seattlerb/heredoc_nested.txt
+++ b/test/prism/snapshots/seattlerb/heredoc_nested.txt
@@ -7,7 +7,7 @@
             ├── flags: ∅
             ├── elements: (length: 2)
             │   ├── @ InterpolatedStringNode (location: (1,1)-(1,4))
-            │   │   ├── flags: mutable
+            │   │   ├── flags: ∅
             │   │   ├── opening_loc: (1,1)-(1,4) = "<<A"
             │   │   ├── parts: (length: 3)
             │   │   │   ├── @ EmbeddedStatementsNode (location: (2,0)-(2,6))
@@ -16,7 +16,7 @@
             │   │   │   │   │   @ StatementsNode (location: (2,2)-(2,5))
             │   │   │   │   │   └── body: (length: 1)
             │   │   │   │   │       └── @ StringNode (location: (2,2)-(2,5))
-            │   │   │   │   │           ├── flags: frozen
+            │   │   │   │   │           ├── flags: ∅
             │   │   │   │   │           ├── opening_loc: (2,2)-(2,5) = "<<B"
             │   │   │   │   │           ├── content_loc: (3,0)-(4,0) = "b\n"
             │   │   │   │   │           ├── closing_loc: (4,0)-(5,0) = "B\n"
diff --git a/test/prism/snapshots/seattlerb/pct_w_heredoc_interp_nested.txt b/test/prism/snapshots/seattlerb/pct_w_heredoc_interp_nested.txt
index 1b8ec69b..c4bc53b7 100644
--- a/test/prism/snapshots/seattlerb/pct_w_heredoc_interp_nested.txt
+++ b/test/prism/snapshots/seattlerb/pct_w_heredoc_interp_nested.txt
@@ -13,7 +13,7 @@
             │   │   ├── closing_loc: ∅
             │   │   └── unescaped: "1"
             │   ├── @ InterpolatedStringNode (location: (1,6)-(1,12))
-            │   │   ├── flags: mutable
+            │   │   ├── flags: ∅
             │   │   ├── opening_loc: ∅
             │   │   ├── parts: (length: 1)
             │   │   │   └── @ EmbeddedStatementsNode (location: (1,6)-(1,12))
@@ -22,7 +22,7 @@
             │   │   │       │   @ StatementsNode (location: (1,8)-(1,11))
             │   │   │       │   └── body: (length: 1)
             │   │   │       │       └── @ StringNode (location: (1,8)-(1,11))
-            │   │   │       │           ├── flags: frozen
+            │   │   │       │           ├── flags: ∅
             │   │   │       │           ├── opening_loc: (1,8)-(1,11) = "<<A"
             │   │   │       │           ├── content_loc: (2,0)-(3,0) = "2\n"
             │   │   │       │           ├── closing_loc: (3,0)-(4,0) = "A\n"
diff --git a/test/prism/snapshots/seattlerb/str_str.txt b/test/prism/snapshots/seattlerb/str_str.txt
index 97031c8a..f3f1213a 100644
--- a/test/prism/snapshots/seattlerb/str_str.txt
+++ b/test/prism/snapshots/seattlerb/str_str.txt
@@ -4,7 +4,7 @@
     @ StatementsNode (location: (1,0)-(1,10))
     └── body: (length: 1)
         └── @ InterpolatedStringNode (location: (1,0)-(1,10))
-            ├── flags: mutable
+            ├── flags: ∅
             ├── opening_loc: (1,0)-(1,1) = "\""
             ├── parts: (length: 2)
             │   ├── @ StringNode (location: (1,1)-(1,3))
@@ -19,7 +19,7 @@
             │       │   @ StatementsNode (location: (1,5)-(1,8))
             │       │   └── body: (length: 1)
             │       │       └── @ StringNode (location: (1,5)-(1,8))
-            │       │           ├── flags: frozen
+            │       │           ├── flags: ∅
             │       │           ├── opening_loc: (1,5)-(1,6) = "'"
             │       │           ├── content_loc: (1,6)-(1,7) = "b"
             │       │           ├── closing_loc: (1,7)-(1,8) = "'"
diff --git a/test/prism/snapshots/seattlerb/str_str_str.txt b/test/prism/snapshots/seattlerb/str_str_str.txt
index b592d380..b01f2b57 100644
--- a/test/prism/snapshots/seattlerb/str_str_str.txt
+++ b/test/prism/snapshots/seattlerb/str_str_str.txt
@@ -4,7 +4,7 @@
     @ StatementsNode (location: (1,0)-(1,12))
     └── body: (length: 1)
         └── @ InterpolatedStringNode (location: (1,0)-(1,12))
-            ├── flags: mutable
+            ├── flags: ∅
             ├── opening_loc: (1,0)-(1,1) = "\""
             ├── parts: (length: 3)
             │   ├── @ StringNode (location: (1,1)-(1,3))
@@ -19,7 +19,7 @@
             │   │   │   @ StatementsNode (location: (1,5)-(1,8))
             │   │   │   └── body: (length: 1)
             │   │   │       └── @ StringNode (location: (1,5)-(1,8))
-            │   │   │           ├── flags: frozen
+            │   │   │           ├── flags: ∅
             │   │   │           ├── opening_loc: (1,5)-(1,6) = "'"
             │   │   │           ├── content_loc: (1,6)-(1,7) = "b"
             │   │   │           ├── closing_loc: (1,7)-(1,8) = "'"
diff --git a/test/prism/snapshots/unparser/corpus/literal/literal.txt b/test/prism/snapshots/unparser/corpus/literal/literal.txt
index 98b88e11..dcf00bf4 100644
--- a/test/prism/snapshots/unparser/corpus/literal/literal.txt
+++ b/test/prism/snapshots/unparser/corpus/literal/literal.txt
@@ -635,7 +635,7 @@
         │   │       │   @ StatementsNode (location: (53,3)-(53,11))
         │   │       │   └── body: (length: 1)
         │   │       │       └── @ StringNode (location: (53,3)-(53,11))
-        │   │       │           ├── flags: frozen
+        │   │       │           ├── flags: ∅
         │   │       │           ├── opening_loc: (53,3)-(53,4) = "\""
         │   │       │           ├── content_loc: (53,4)-(53,10) = "\\u0000"
         │   │       │           ├── closing_loc: (53,10)-(53,11) = "\""
@@ -707,7 +707,7 @@
         │   │       │   @ StatementsNode (location: (59,4)-(59,9))
         │   │       │   └── body: (length: 1)
         │   │       │       └── @ StringNode (location: (59,4)-(59,9))
-        │   │       │           ├── flags: frozen
+        │   │       │           ├── flags: ∅
         │   │       │           ├── opening_loc: (59,4)-(59,5) = "\""
         │   │       │           ├── content_loc: (59,5)-(59,8) = "foo"
         │   │       │           ├── closing_loc: (59,8)-(59,9) = "\""
diff --git a/test/prism/snapshots/whitequark/dedenting_heredoc.txt b/test/prism/snapshots/whitequark/dedenting_heredoc.txt
index 67896b24..acb79e83 100644
--- a/test/prism/snapshots/whitequark/dedenting_heredoc.txt
+++ b/test/prism/snapshots/whitequark/dedenting_heredoc.txt
@@ -15,7 +15,7 @@
         │   │   ├── flags: ∅
         │   │   └── arguments: (length: 1)
         │   │       └── @ InterpolatedStringNode (location: (1,2)-(1,8))
-        │   │           ├── flags: mutable
+        │   │           ├── flags: ∅
         │   │           ├── opening_loc: (1,2)-(1,8) = "<<~\"E\""
         │   │           ├── parts: (length: 3)
         │   │           │   ├── @ StringNode (location: (2,0)-(3,0))
@@ -30,7 +30,7 @@
         │   │           │   │   │   @ StatementsNode (location: (3,4)-(3,9))
         │   │           │   │   │   └── body: (length: 1)
         │   │           │   │   │       └── @ StringNode (location: (3,4)-(3,9))
-        │   │           │   │   │           ├── flags: frozen
+        │   │           │   │   │           ├── flags: ∅
         │   │           │   │   │           ├── opening_loc: (3,4)-(3,5) = "\""
         │   │           │   │   │           ├── content_loc: (3,5)-(3,8) = "  y"
         │   │           │   │   │           ├── closing_loc: (3,8)-(3,9) = "\""
diff --git a/test/prism/unescape_test.rb b/test/prism/unescape_test.rb
index 3f78a59b..2a352c52 100644
--- a/test/prism/unescape_test.rb
+++ b/test/prism/unescape_test.rb
@@ -38,7 +38,7 @@ module Prism
         end
 
         def prism(escape)
-          result = Prism.parse(code(escape), encoding: "binary")
+          result = Prism.parse(code(escape))
 
           if result.success?
             yield result.value.statements.body.first