Repository: ruby/prism
Pull request #3183 (2024-10-16 15:37:18)

Relocation API


Patch:
diff --git a/docs/relocation.md b/docs/relocation.md
new file mode 100644
index 00000000..0515f562
--- /dev/null
+++ b/docs/relocation.md
@@ -0,0 +1,34 @@
+# Relocation
+
+Prism parses deterministically for the same input. This provides a nice property that is exposed through the `#node_id` API on nodes. Effectively this means that for the same input, these values will remain consistent every time the source is parsed. This means we can reparse the source same with a `#node_id` value and find the exact same node again.
+
+The `Relocation` module provides an API around this property. It allows you to "save" nodes and locations using a minimal amount of memory (just the node_id and a field identifier) and then reify them later. This minimizes the amount of memory you need to allocate to store this information because it does not keep around a pointer to the source string.
+
+## Getting started
+
+To get started with the `Relocation` module, you would first instantiate a `Repository` object. You do this through a DSL that chains method calls for configuration. For example, if for every entry in the repository you want to store the start and end lines, the start and end code unit columns for in UTF-16, and the leading comments, you would:
+
+```ruby
+repository = Prism::Relocation.filepath("path/to/file").lines.code_unit_columns(Encoding::UTF_16).leading_comments
+```
+
+Now that you have the repository, you can pass it into any of the `save*` APIs on nodes or locations to create entries in the repository that will be lazily reified.
+
+```ruby
+# assume that node is a Prism::ClassNode object
+entry = node.constant_path.save(repository)
+```
+
+Now that you have the entry object, you do not need to keep around a reference to the repository, it will be cleaned up on its own when the last entry is reified. Now, whenever you need to, you may call the associated field methods on the entry object, as in:
+
+```ruby
+entry.start_line
+entry.end_line
+
+entry.start_code_units_column
+entry.end_code_units_column
+
+entry.leading_comments
+```
+
+Note that if you had configured other fields to be saved, you would be able to access them as well. The first time one of these fields is accessed, the repository will reify every entry it knows about and then clean itself up. In this way, you can effectively treat them as if you had kept around lightweight versions of `Prism::Node` or `Prism::Location` objects.
diff --git a/ext/prism/extension.c b/ext/prism/extension.c
index 26415c2b..47603fd9 100644
--- a/ext/prism/extension.c
+++ b/ext/prism/extension.c
@@ -1153,7 +1153,6 @@ string_query(pm_string_query_t result) {
         case PM_STRING_QUERY_TRUE:
             return Qtrue;
     }
-    return Qfalse;
 }
 
 /**
diff --git a/lib/prism.rb b/lib/prism.rb
index 50b14a54..94f4c8ca 100644
--- a/lib/prism.rb
+++ b/lib/prism.rb
@@ -24,6 +24,7 @@ module Prism
   autoload :Pack, "prism/pack"
   autoload :Pattern, "prism/pattern"
   autoload :Reflection, "prism/reflection"
+  autoload :Relocation, "prism/relocation"
   autoload :Serialize, "prism/serialize"
   autoload :StringQuery, "prism/string_query"
   autoload :Translation, "prism/translation"
diff --git a/lib/prism/relocation.rb b/lib/prism/relocation.rb
new file mode 100644
index 00000000..ad914396
--- /dev/null
+++ b/lib/prism/relocation.rb
@@ -0,0 +1,504 @@
+# frozen_string_literal: true
+
+module Prism
+  # Prism parses deterministically for the same input. This provides a nice
+  # property that is exposed through the #node_id API on nodes. Effectively this
+  # means that for the same input, these values will remain consistent every
+  # time the source is parsed. This means we can reparse the source same with a
+  # #node_id value and find the exact same node again.
+  #
+  # The Relocation module provides an API around this property. It allows you to
+  # "save" nodes and locations using a minimal amount of memory (just the
+  # node_id and a field identifier) and then reify them later.
+  module Relocation
+    # An entry in a repository that will lazily reify its values when they are
+    # first accessed.
+    class Entry
+      # Raised if a value that could potentially be on an entry is missing
+      # because it was either not configured on the repository or it has not yet
+      # been fetched.
+      class MissingValueError < StandardError
+      end
+
+      # Initialize a new entry with the given repository.
+      def initialize(repository)
+        @repository = repository
+        @values = nil
+      end
+
+      # Fetch the filepath of the value.
+      def filepath
+        fetch_value(:filepath)
+      end
+
+      # Fetch the start line of the value.
+      def start_line
+        fetch_value(:start_line)
+      end
+
+      # Fetch the end line of the value.
+      def end_line
+        fetch_value(:end_line)
+      end
+
+      # Fetch the start byte offset of the value.
+      def start_offset
+        fetch_value(:start_offset)
+      end
+
+      # Fetch the end byte offset of the value.
+      def end_offset
+        fetch_value(:end_offset)
+      end
+
+      # Fetch the start character offset of the value.
+      def start_character_offset
+        fetch_value(:start_character_offset)
+      end
+
+      # Fetch the end character offset of the value.
+      def end_character_offset
+        fetch_value(:end_character_offset)
+      end
+
+      # Fetch the start code units offset of the value, for the encoding that
+      # was configured on the repository.
+      def start_code_units_offset
+        fetch_value(:start_code_units_offset)
+      end
+
+      # Fetch the end code units offset of the value, for the encoding that was
+      # configured on the repository.
+      def end_code_units_offset
+        fetch_value(:end_code_units_offset)
+      end
+
+      # Fetch the start byte column of the value.
+      def start_column
+        fetch_value(:start_column)
+      end
+
+      # Fetch the end byte column of the value.
+      def end_column
+        fetch_value(:end_column)
+      end
+
+      # Fetch the start character column of the value.
+      def start_character_column
+        fetch_value(:start_character_column)
+      end
+
+      # Fetch the end character column of the value.
+      def end_character_column
+        fetch_value(:end_character_column)
+      end
+
+      # Fetch the start code units column of the value, for the encoding that
+      # was configured on the repository.
+      def start_code_units_column
+        fetch_value(:start_code_units_column)
+      end
+
+      # Fetch the end code units column of the value, for the encoding that was
+      # configured on the repository.
+      def end_code_units_column
+        fetch_value(:end_code_units_column)
+      end
+
+      # Fetch the leading comments of the value.
+      def leading_comments
+        fetch_value(:leading_comments)
+      end
+
+      # Fetch the trailing comments of the value.
+      def trailing_comments
+        fetch_value(:trailing_comments)
+      end
+
+      # Fetch the leading and trailing comments of the value.
+      def comments
+        leading_comments.concat(trailing_comments)
+      end
+
+      # Reify the values on this entry with the given values. This is an
+      # internal-only API that is called from the repository when it is time to
+      # reify the values.
+      def reify!(values) # :nodoc:
+        @repository = nil
+        @values = values
+      end
+
+      private
+
+      # Fetch a value from the entry, raising an error if it is missing.
+      def fetch_value(name)
+        values.fetch(name) do
+          raise MissingValueError, "No value for #{name}, make sure the " \
+            "repository has been properly configured"
+        end
+      end
+
+      # Return the values from the repository, reifying them if necessary.
+      def values
+        @values || (@repository.reify!; @values)
+      end
+    end
+
+    # Represents the source of a repository that will be reparsed.
+    class Source
+      # The value that will need to be reparsed.
+      attr_reader :value
+
+      # Initialize the source with the given value.
+      def initialize(value)
+        @value = value
+      end
+
+      # Reparse the value and return the parse result.
+      def result
+        raise NotImplementedError, "Subclasses must implement #result"
+      end
+
+      # Create a code units cache for the given encoding.
+      def code_units_cache(encoding)
+        result.code_units_cache(encoding)
+      end
+    end
+
+    # A source that is represented by a file path.
+    class SourceFilepath < Source
+      # Reparse the file and return the parse result.
+      def result
+        Prism.parse_file(value)
+      end
+    end
+
+    # A source that is represented by a string.
+    class SourceString < Source
+      # Reparse the string and return the parse result.
+      def result
+        Prism.parse(value)
+      end
+    end
+
+    # A field that represents the file path.
+    class FilepathField
+      # The file path that this field represents.
+      attr_reader :value
+
+      # Initialize a new field with the given file path.
+      def initialize(value)
+        @value = value
+      end
+
+      # Fetch the file path.
+      def fields(_value)
+        { filepath: value }
+      end
+    end
+
+    # A field representing the start and end lines.
+    class LinesField
+      # Fetches the start and end line of a value.
+      def fields(value)
+        { start_line: value.start_line, end_line: value.end_line }
+      end
+    end
+
+    # A field representing the start and end byte offsets.
+    class OffsetsField
+      # Fetches the start and end byte offset of a value.
+      def fields(value)
+        { start_offset: value.start_offset, end_offset: value.end_offset }
+      end
+    end
+
+    # A field representing the start and end character offsets.
+    class CharacterOffsetsField
+      # Fetches the start and end character offset of a value.
+      def fields(value)
+        {
+          start_character_offset: value.start_character_offset,
+          end_character_offset: value.end_character_offset
+        }
+      end
+    end
+
+    # A field representing the start and end code unit offsets.
+    class CodeUnitOffsetsField
+      # A pointer to the repository object that is used for lazily creating a
+      # code units cache.
+      attr_reader :repository
+
+      # The associated encoding for the code units.
+      attr_reader :encoding
+
+      # Initialize a new field with the associated repository and encoding.
+      def initialize(repository, encoding)
+        @repository = repository
+        @encoding = encoding
+        @cache = nil
+      end
+
+      # Fetches the start and end code units offset of a value for a particular
+      # encoding.
+      def fields(value)
+        {
+          start_code_units_offset: value.cached_start_code_units_offset(cache),
+          end_code_units_offset: value.cached_end_code_units_offset(cache)
+        }
+      end
+
+      private
+
+      # Lazily create a code units cache for the associated encoding.
+      def cache
+        @cache ||= repository.code_units_cache(encoding)
+      end
+    end
+
+    # A field representing the start and end byte columns.
+    class ColumnsField
+      # Fetches the start and end byte column of a value.
+      def fields(value)
+        { start_column: value.start_column, end_column: value.end_column }
+      end
+    end
+
+    # A field representing the start and end character columns.
+    class CharacterColumnsField
+      # Fetches the start and end character column of a value.
+      def fields(value)
+        {
+          start_character_column: value.start_character_column,
+          end_character_column: value.end_character_column
+        }
+      end
+    end
+
+    # A field representing the start and end code unit columns for a specific
+    # encoding.
+    class CodeUnitColumnsField
+      # The repository object that is used for lazily creating a code units
+      # cache.
+      attr_reader :repository
+
+      # The associated encoding for the code units.
+      attr_reader :encoding
+
+      # Initialize a new field with the associated repository and encoding.
+      def initialize(repository, encoding)
+        @repository = repository
+        @encoding = encoding
+        @cache = nil
+      end
+
+      # Fetches the start and end code units column of a value for a particular
+      # encoding.
+      def fields(value)
+        {
+          start_code_units_column: value.cached_start_code_units_column(cache),
+          end_code_units_column: value.cached_end_code_units_column(cache)
+        }
+      end
+
+      private
+
+      # Lazily create a code units cache for the associated encoding.
+      def cache
+        @cache ||= repository.code_units_cache(encoding)
+      end
+    end
+
+    # An abstract field used as the parent class of the two comments fields.
+    class CommentsField
+      # An object that represents a slice of a comment.
+      class Comment
+        # The slice of the comment.
+        attr_reader :slice
+
+        # Initialize a new comment with the given slice.
+        def initialize(slice)
+          @slice = slice
+        end
+      end
+
+      private
+
+      # Create comment objects from the given values.
+      def comments(values)
+        values.map { |value| Comment.new(value.slice) }
+      end
+    end
+
+    # A field representing the leading comments.
+    class LeadingCommentsField < CommentsField
+      # Fetches the leading comments of a value.
+      def fields(value)
+        { leading_comments: comments(value.leading_comments) }
+      end
+    end
+
+    # A field representing the trailing comments.
+    class TrailingCommentsField < CommentsField
+      # Fetches the trailing comments of a value.
+      def fields(value)
+        { trailing_comments: comments(value.trailing_comments) }
+      end
+    end
+
+    # A repository is a configured collection of fields and a set of entries
+    # that knows how to reparse a source and reify the values.
+    class Repository
+      # Raised when multiple fields of the same type are configured on the same
+      # repository.
+      class ConfigurationError < StandardError
+      end
+
+      # The source associated with this repository. This will be either a
+      # SourceFilepath (the most common use case) or a SourceString.
+      attr_reader :source
+
+      # The fields that have been configured on this repository.
+      attr_reader :fields
+
+      # The entries that have been saved on this repository.
+      attr_reader :entries
+
+      # Initialize a new repository with the given source.
+      def initialize(source)
+        @source = source
+        @fields = {}
+        @entries = Hash.new { |hash, node_id| hash[node_id] = {} }
+      end
+
+      # Create a code units cache for the given encoding from the source.
+      def code_units_cache(encoding)
+        source.code_units_cache(encoding)
+      end
+
+      # Configure the filepath field for this repository and return self.
+      def filepath
+        raise ConfigurationError, "Can only specify filepath for a filepath source" unless source.is_a?(SourceFilepath)
+        field(:filepath, FilepathField.new(source.value))
+      end
+
+      # Configure the lines field for this repository and return self.
+      def lines
+        field(:lines, LinesField.new)
+      end
+
+      # Configure the offsets field for this repository and return self.
+      def offsets
+        field(:offsets, OffsetsField.new)
+      end
+
+      # Configure the character offsets field for this repository and return
+      # self.
+      def character_offsets
+        field(:character_offsets, CharacterOffsetsField.new)
+      end
+
+      # Configure the code unit offsets field for this repository for a specific
+      # encoding and return self.
+      def code_unit_offsets(encoding)
+        field(:code_unit_offsets, CodeUnitOffsetsField.new(self, encoding))
+      end
+
+      # Configure the columns field for this repository and return self.
+      def columns
+        field(:columns, ColumnsField.new)
+      end
+
+      # Configure the character columns field for this repository and return
+      # self.
+      def character_columns
+        field(:character_columns, CharacterColumnsField.new)
+      end
+
+      # Configure the code unit columns field for this repository for a specific
+      # encoding and return self.
+      def code_unit_columns(encoding)
+        field(:code_unit_columns, CodeUnitColumnsField.new(self, encoding))
+      end
+
+      # Configure the leading comments field for this repository and return
+      # self.
+      def leading_comments
+        field(:leading_comments, LeadingCommentsField.new)
+      end
+
+      # Configure the trailing comments field for this repository and return
+      # self.
+      def trailing_comments
+        field(:trailing_comments, TrailingCommentsField.new)
+      end
+
+      # Configure both the leading and trailing comment fields for this
+      # repository and return self.
+      def comments
+        leading_comments.trailing_comments
+      end
+
+      # This method is called from nodes and locations when they want to enter
+      # themselves into the repository. It it internal-only and meant to be
+      # called from the #save* APIs.
+      def enter(node_id, field_name) # :nodoc:
+        entry = Entry.new(self)
+        @entries[node_id][field_name] = entry
+        entry
+      end
+
+      # This method is called from the entries in the repository when they need
+      # to reify their values. It is internal-only and meant to be called from
+      # the various value APIs.
+      def reify! # :nodoc:
+        result = source.result
+
+        # Attach the comments if they have been requested as part of the
+        # configuration of this repository.
+        if fields.key?(:leading_comments) || fields.key?(:trailing_comments)
+          result.attach_comments!
+        end
+
+        queue = [result.value] #: Array[Prism::node]
+        while (node = queue.shift)
+          @entries[node.node_id].each do |field_name, entry|
+            value = node.public_send(field_name)
+            values = {}
+
+            fields.each_value do |field|
+              values.merge!(field.fields(value))
+            end
+
+            entry.reify!(values)
+          end
+
+          queue.concat(node.compact_child_nodes)
+        end
+
+        @entries.clear
+      end
+
+      private
+
+      # Append the given field to the repository and return the repository so
+      # that these calls can be chained.
+      def field(name, value)
+        raise ConfigurationError, "Cannot specify multiple #{name} fields" if @fields.key?(name)
+        @fields[name] = value
+        self
+      end
+    end
+
+    # Create a new repository for the given filepath.
+    def self.filepath(value)
+      Repository.new(SourceFilepath.new(value))
+    end
+
+    # Create a new repository for the given string.
+    def self.string(value)
+      Repository.new(SourceString.new(value))
+    end
+  end
+end
diff --git a/prism.gemspec b/prism.gemspec
index 3c8910d3..0682ca9b 100644
--- a/prism.gemspec
+++ b/prism.gemspec
@@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
     "docs/parser_translation.md",
     "docs/parsing_rules.md",
     "docs/releasing.md",
+    "docs/relocation.md",
     "docs/ripper_translation.md",
     "docs/ruby_api.md",
     "docs/ruby_parser_translation.md",
@@ -88,6 +89,7 @@ Gem::Specification.new do |spec|
     "lib/prism/polyfill/byteindex.rb",
     "lib/prism/polyfill/unpack1.rb",
     "lib/prism/reflection.rb",
+    "lib/prism/relocation.rb",
     "lib/prism/serialize.rb",
     "lib/prism/string_query.rb",
     "lib/prism/translation.rb",
@@ -130,6 +132,7 @@ Gem::Specification.new do |spec|
     "sig/prism/parse_result.rbs",
     "sig/prism/pattern.rbs",
     "sig/prism/reflection.rbs",
+    "sig/prism/relocation.rbs",
     "sig/prism/serialize.rbs",
     "sig/prism/string_query.rbs",
     "sig/prism/visitor.rbs",
diff --git a/sample/prism/relocate_constants.rb b/sample/prism/relocate_constants.rb
new file mode 100644
index 00000000..faa48f63
--- /dev/null
+++ b/sample/prism/relocate_constants.rb
@@ -0,0 +1,43 @@
+# This script finds the declaration of all classes and modules and stores them
+# in a hash for an in-memory database of constants.
+
+require "prism"
+
+class RelocationVisitor < Prism::Visitor
+  attr_reader :index, :repository, :scope
+
+  def initialize(index, repository, scope = [])
+    @index = index
+    @repository = repository
+    @scope = scope
+  end
+
+  def visit_class_node(node)
+    next_scope = scope + node.constant_path.full_name_parts
+    index[next_scope.join("::")] << node.constant_path.save(repository)
+    node.body&.accept(RelocationVisitor.new(index, repository, next_scope))
+  end
+
+  def visit_module_node(node)
+    next_scope = scope + node.constant_path.full_name_parts
+    index[next_scope.join("::")] << node.constant_path.save(repository)
+    node.body&.accept(RelocationVisitor.new(index, repository, next_scope))
+  end
+end
+
+# Create an index that will store a mapping between the names of constants to a
+# list of the locations where they are declared or re-opened.
+index = Hash.new { |hash, key| hash[key] = [] }
+
+# Loop through every file in the lib directory of this repository and parse them
+# with Prism. Then visit them using the RelocateVisitor to store their
+# repository entries in the index.
+Dir[File.expand_path("../../lib/**/*.rb", __dir__)].each do |filepath|
+  repository = Prism::Relocation.filepath(filepath).filepath.lines.code_unit_columns(Encoding::UTF_16LE)
+  Prism.parse_file(filepath).value.accept(RelocationVisitor.new(index, repository))
+end
+
+puts index["Prism::ParametersNode"].map { |entry| "#{entry.filepath}:#{entry.start_line}:#{entry.start_code_units_column}" }
+# =>
+# prism/lib/prism/node.rb:13889:8
+# prism/lib/prism/node_ext.rb:267:8
diff --git a/sig/prism/_private/relocation.rbs b/sig/prism/_private/relocation.rbs
new file mode 100644
index 00000000..fda123bb
--- /dev/null
+++ b/sig/prism/_private/relocation.rbs
@@ -0,0 +1,12 @@
+module Prism
+  module Relocation
+    class Entry
+      def reify!: (entry_values values) -> void
+    end
+
+    class Repository
+      def enter: (Integer node_id, Symbol field_name) -> Entry
+      def reify!: () -> void
+    end
+  end
+end
diff --git a/sig/prism/relocation.rbs b/sig/prism/relocation.rbs
new file mode 100644
index 00000000..7f5637d5
--- /dev/null
+++ b/sig/prism/relocation.rbs
@@ -0,0 +1,185 @@
+module Prism
+  module Relocation
+    interface _Value
+      def start_line: () -> Integer
+      def end_line: () -> Integer
+      def start_offset: () -> Integer
+      def end_offset: () -> Integer
+      def start_character_offset: () -> Integer
+      def end_character_offset: () -> Integer
+      def cached_start_code_units_offset: (_CodeUnitsCache cache) -> Integer
+      def cached_end_code_units_offset: (_CodeUnitsCache cache) -> Integer
+      def start_column: () -> Integer
+      def end_column: () -> Integer
+      def start_character_column: () -> Integer
+      def end_character_column: () -> Integer
+      def cached_start_code_units_column: (_CodeUnitsCache cache) -> Integer
+      def cached_end_code_units_column: (_CodeUnitsCache cache) -> Integer
+      def leading_comments: () -> Array[Comment]
+      def trailing_comments: () -> Array[Comment]
+    end
+
+    interface _Field
+      def fields: (_Value value) -> entry_values
+    end
+
+    type entry_value = untyped
+    type entry_values = Hash[Symbol, entry_value]
+
+    class Entry
+      class MissingValueError < StandardError
+      end
+
+      def initialize: (Repository repository) -> void
+
+      def filepath: () -> String
+
+      def start_line: () -> Integer
+      def end_line: () -> Integer
+
+      def start_offset: () -> Integer
+      def end_offset: () -> Integer
+      def start_character_offset: () -> Integer
+      def end_character_offset: () -> Integer
+      def start_code_units_offset: () -> Integer
+      def end_code_units_offset: () -> Integer
+
+      def start_column: () -> Integer
+      def end_column: () -> Integer
+      def start_character_column: () -> Integer
+      def end_character_column: () -> Integer
+      def start_code_units_column: () -> Integer
+      def end_code_units_column: () -> Integer
+
+      def leading_comments: () -> Array[CommentsField::Comment]
+      def trailing_comments: () -> Array[CommentsField::Comment]
+      def comments: () -> Array[CommentsField::Comment]
+
+      private
+
+      def fetch_value: (Symbol name) -> entry_value
+      def values: () -> entry_values
+    end
+
+    class Source
+      attr_reader value: untyped
+
+      def initialize: (untyped value) -> void
+
+      def result: () -> ParseResult
+      def code_units_cache: (Encoding encoding) -> _CodeUnitsCache
+    end
+
+    class SourceFilepath < Source
+      def result: () -> ParseResult
+    end
+
+    class SourceString < Source
+      def result: () -> ParseResult
+    end
+
+    class FilepathField
+      attr_reader value: String
+
+      def initialize: (String value) -> void
+
+      def fields: (_Value value) -> entry_values
+    end
+
+    class LinesField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class OffsetsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class CharacterOffsetsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class CodeUnitOffsetsField
+      attr_reader repository: Repository
+      attr_reader encoding: Encoding
+
+      def initialize: (Repository repository, Encoding encoding) -> void
+      def fields: (_Value value) -> entry_values
+
+      private
+
+      def cache: () -> _CodeUnitsCache
+    end
+
+    class ColumnsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class CharacterColumnsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class CodeUnitColumnsField
+      attr_reader repository: Repository
+      attr_reader encoding: Encoding
+
+      def initialize: (Repository repository, Encoding encoding) -> void
+      def fields: (_Value value) -> entry_values
+
+      private
+
+      def cache: () -> _CodeUnitsCache
+    end
+
+    class CommentsField
+      class Comment
+        attr_reader slice: String
+
+        def initialize: (String slice) -> void
+      end
+
+      private
+
+      def comments: (entry_value value) -> Array[Comment]
+    end
+
+    class LeadingCommentsField < CommentsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class TrailingCommentsField < CommentsField
+      def fields: (_Value value) -> entry_values
+    end
+
+    class Repository
+      class ConfigurationError < StandardError
+      end
+
+      attr_reader source: Source
+      attr_reader fields: Hash[Symbol, _Field]
+      attr_reader entries: Hash[Integer, Hash[Symbol, Entry]]
+
+      def initialize: (Source source) -> void
+
+      def code_units_cache: (Encoding encoding) -> _CodeUnitsCache
+
+      def filepath: () -> self
+      def lines: () -> self
+      def offsets: () -> self
+      def character_offsets: () -> self
+      def code_unit_offsets: (Encoding encoding) -> self
+      def columns: () -> self
+      def character_columns: () -> self
+      def code_unit_columns: (Encoding encoding) -> self
+      def leading_comments: () -> self
+      def trailing_comments: () -> self
+      def comments: () -> self
+
+      private
+
+      def field: (Symbol name, _Field) -> self
+    end
+
+    def self.filepath: (String value) -> Repository
+    def self.string: (String value) -> Repository
+  end
+end
diff --git a/templates/lib/prism/node.rb.erb b/templates/lib/prism/node.rb.erb
index f033cdea..72245cd3 100644
--- a/templates/lib/prism/node.rb.erb
+++ b/templates/lib/prism/node.rb.erb
@@ -12,6 +12,11 @@ module Prism
     # will be consistent across multiple parses of the same source code.
     attr_reader :node_id
 
+    # Save this node using a saved source so that it can be retrieved later.
+    def save(repository)
+      repository.enter(node_id, :itself)
+    end
+
     # A Location instance that represents the location of this node in the
     # source.
     def location
@@ -20,6 +25,21 @@ module Prism
       @location = Location.new(source, location >> 32, location & 0xFFFFFFFF)
     end
 
+    # Save the location using a saved source so that it can be retrieved later.
+    def save_location(repository)
+      repository.enter(node_id, :location)
+    end
+
+    # Delegates to the start_line of the associated location object.
+    def start_line
+      location.start_line
+    end
+
+    # Delegates to the end_line of the associated location object.
+    def end_line
+      location.end_line
+    end
+
     # The start offset of the node in the source. This method is effectively a
     # delegate method to the location object.
     def start_offset
@@ -34,6 +54,75 @@ module Prism
       location.is_a?(Location) ? location.end_offset : ((location >> 32) + (location & 0xFFFFFFFF))
     end
 
+    # Delegates to the start_character_offset of the associated location object.
+    def start_character_offset
+      location.start_character_offset
+    end
+
+    # Delegates to the end_character_offset of the associated location object.
+    def end_character_offset
+      location.end_character_offset
+    end
+
+    # Delegates to the cached_start_code_units_offset of the associated location
+    # object.
+    def cached_start_code_units_offset(cache)
+      location.cached_start_code_units_offset(cache)
+    end
+
+    # Delegates to the cached_end_code_units_offset of the associated location
+    # object.
+    def cached_end_code_units_offset(cache)
+      location.cached_end_code_units_offset(cache)
+    end
+
+    # Delegates to the start_column of the associated location object.
+    def start_column
+      location.start_column
+    end
+
+    # Delegates to the end_column of the associated location object.
+    def end_column
+      location.end_column
+    end
+
+    # Delegates to the start_character_column of the associated location object.
+    def start_character_column
+      location.start_character_column
+    end
+
+    # Delegates to the end_character_column of the associated location object.
+    def end_character_column
+      location.end_character_column
+    end
+
+    # Delegates to the cached_start_code_units_column of the associated location
+    # object.
+    def cached_start_code_units_column(cache)
+      location.cached_start_code_units_column(cache)
+    end
+
+    # Delegates to the cached_end_code_units_column of the associated location
+    # object.
+    def cached_end_code_units_column(cache)
+      location.cached_end_code_units_column(cache)
+    end
+
+    # Delegates to the leading_comments of the associated location object.
+    def leading_comments
+      location.leading_comments
+    end
+
+    # Delegates to the trailing_comments of the associated location object.
+    def trailing_comments
+      location.trailing_comments
+    end
+
+    # Delegates to the comments of the associated location object.
+    def comments
+      location.comments
+    end
+
     # Returns all of the lines of the source code associated with this node.
     def source_lines
       location.source_lines
@@ -318,6 +407,12 @@ module Prism
       return location if location.is_a?(Location)
       @<%= field.name %> = Location.new(source, location >> 32, location & 0xFFFFFFFF)
     end
+
+    # Save the <%= field.name %> location using the given saved source so that
+    # it can be retrieved later.
+    def save_<%= field.name %>(repository)
+      repository.enter(node_id, :<%= field.name %>)
+    end
     <%- when Prism::Template::OptionalLocationField -%>
     def <%= field.name %>
       location = @<%= field.name %>
@@ -330,6 +425,12 @@ module Prism
         @<%= field.name %> = Location.new(source, location >> 32, location & 0xFFFFFFFF)
       end
     end
+
+    # Save the <%= field.name %> location using the given saved source so that
+    # it can be retrieved later.
+    def save_<%= field.name %>(repository)
+      repository.enter(node_id, :<%= field.name %>) unless @<%= field.name %>.nil?
+    end
     <%- else -%>
     attr_reader :<%= field.name %>
     <%- end -%>


Test patch:
diff --git a/test/prism/ruby/relocation_test.rb b/test/prism/ruby/relocation_test.rb
new file mode 100644
index 00000000..f8372afa
--- /dev/null
+++ b/test/prism/ruby/relocation_test.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+require_relative "../test_helper"
+
+module Prism
+  class RelocationTest < TestCase
+    def test_repository_filepath
+      repository = Relocation.filepath(__FILE__).lines
+      declaration = Prism.parse_file(__FILE__).value.statements.body[1]
+
+      assert_equal 5, declaration.save(repository).start_line
+    end
+
+    def test_filepath
+      repository = Relocation.filepath(__FILE__).filepath
+      declaration = Prism.parse_file(__FILE__).value.statements.body[1]
+
+      assert_equal __FILE__, declaration.save(repository).filepath
+    end
+
+    def test_lines
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).lines
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.save(repository)
+      location_entry = declaration.save_location(repository)
+
+      assert_equal 1, node_entry.start_line
+      assert_equal 2, node_entry.end_line
+
+      assert_equal 1, location_entry.start_line
+      assert_equal 2, location_entry.end_line
+    end
+
+    def test_offsets
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).offsets
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_offset
+      assert_equal 13, node_entry.end_offset
+
+      assert_equal 6, location_entry.start_offset
+      assert_equal 13, location_entry.end_offset
+    end
+
+    def test_character_offsets
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).character_offsets
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_character_offset
+      assert_equal 10, node_entry.end_character_offset
+
+      assert_equal 6, location_entry.start_character_offset
+      assert_equal 10, location_entry.end_character_offset
+    end
+
+    def test_code_unit_offsets
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).code_unit_offsets(Encoding::UTF_16LE)
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_code_units_offset
+      assert_equal 11, node_entry.end_code_units_offset
+
+      assert_equal 6, location_entry.start_code_units_offset
+      assert_equal 11, location_entry.end_code_units_offset
+    end
+
+    def test_columns
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).columns
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_column
+      assert_equal 13, node_entry.end_column
+
+      assert_equal 6, location_entry.start_column
+      assert_equal 13, location_entry.end_column
+    end
+
+    def test_character_columns
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).character_columns
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_character_column
+      assert_equal 10, node_entry.end_character_column
+
+      assert_equal 6, location_entry.start_character_column
+      assert_equal 10, location_entry.end_character_column
+    end
+
+    def test_code_unit_columns
+      source = "class Foo😀\nend"
+      repository = Relocation.string(source).code_unit_columns(Encoding::UTF_16LE)
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.constant_path.save(repository)
+      location_entry = declaration.constant_path.save_location(repository)
+
+      assert_equal 6, node_entry.start_code_units_column
+      assert_equal 11, node_entry.end_code_units_column
+
+      assert_equal 6, location_entry.start_code_units_column
+      assert_equal 11, location_entry.end_code_units_column
+    end
+
+    def test_leading_comments
+      source = "# leading\nclass Foo\nend"
+      repository = Relocation.string(source).leading_comments
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.save(repository)
+      location_entry = declaration.save_location(repository)
+
+      assert_equal ["# leading"], node_entry.leading_comments.map(&:slice)
+      assert_equal ["# leading"], location_entry.leading_comments.map(&:slice)
+    end
+
+    def test_trailing_comments
+      source = "class Foo\nend\n# trailing"
+      repository = Relocation.string(source).trailing_comments
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.save(repository)
+      location_entry = declaration.save_location(repository)
+
+      assert_equal ["# trailing"], node_entry.trailing_comments.map(&:slice)
+      assert_equal ["# trailing"], location_entry.trailing_comments.map(&:slice)
+    end
+
+    def test_comments
+      source = "# leading\nclass Foo\nend\n# trailing"
+      repository = Relocation.string(source).comments
+      declaration = Prism.parse(source).value.statements.body.first
+
+      node_entry = declaration.save(repository)
+      location_entry = declaration.save_location(repository)
+
+      assert_equal ["# leading", "# trailing"], node_entry.comments.map(&:slice)
+      assert_equal ["# leading", "# trailing"], location_entry.comments.map(&:slice)
+    end
+
+    def test_misconfiguration
+      assert_raise Relocation::Repository::ConfigurationError do
+        Relocation.string("").comments.leading_comments
+      end
+
+      assert_raise Relocation::Repository::ConfigurationError do
+        Relocation.string("").comments.trailing_comments
+      end
+
+      assert_raise Relocation::Repository::ConfigurationError do
+        Relocation.string("").code_unit_offsets(Encoding::UTF_8).code_unit_offsets(Encoding::UTF_16LE)
+      end
+
+      assert_raise Relocation::Repository::ConfigurationError do
+        Relocation.string("").lines.lines
+      end
+    end
+
+    def test_missing_values
+      source = "class Foo; end"
+      repository = Relocation.string(source).lines
+
+      declaration = Prism.parse(source).value.statements.body.first
+      entry = declaration.constant_path.save(repository)
+
+      assert_raise Relocation::Entry::MissingValueError do
+        entry.start_offset
+      end
+    end
+  end
+end