lib/bootsnap/load_path_cache/loaded_features_index.rb



# frozen_string_literal: true

module Bootsnap
  module LoadPathCache
    # LoadedFeaturesIndex partially mirrors an internal structure in ruby that
    # we can't easily obtain an interface to.
    #
    # This works around an issue where, without bootsnap, *ruby* knows that it
    # has already required a file by its short name (e.g. require 'bundler') if
    # a new instance of bundler is added to the $LOAD_PATH which resolves to a
    # different absolute path. This class makes bootsnap smart enough to
    # realize that it has already loaded 'bundler', and not just
    # '/path/to/bundler'.
    #
    # If you disable LoadedFeaturesIndex, you can see the problem this solves by:
    #
    # 1. `require 'a'`
    # 2. Prepend a new $LOAD_PATH element containing an `a.rb`
    # 3. `require 'a'`
    #
    # Ruby returns false from step 3.
    # With bootsnap but with no LoadedFeaturesIndex, this loads two different
    #   `a.rb`s.
    # With bootsnap and with LoadedFeaturesIndex, this skips the second load,
    #   returning false like ruby.
    class LoadedFeaturesIndex
      def initialize
        @lfi = {}
        @mutex = Mutex.new

        # In theory the user could mutate $LOADED_FEATURES and invalidate our
        # cache. If this ever comes up in practice - or if you, the
        # enterprising reader, feels inclined to solve this problem - we could
        # parallel the work done with ChangeObserver on $LOAD_PATH to mirror
        # updates to our @lfi.
        $LOADED_FEATURES.each do |feat|
          hash = feat.hash
          $LOAD_PATH.each do |lpe|
            next unless feat.start_with?(lpe)

            # /a/b/lib/my/foo.rb
            #          ^^^^^^^^^
            short = feat[(lpe.length + 1)..-1]
            stripped = strip_extension_if_elidable(short)
            @lfi[short] = hash
            @lfi[stripped] = hash
          end
        end
      end

      # We've optimized for initialize and register to be fast, and purge to be tolerable.
      # If access patterns make this not-okay, we can lazy-invert the LFI on
      # first purge and work from there.
      def purge(feature)
        @mutex.synchronize do
          feat_hash = feature.hash
          @lfi.reject! { |_, hash| hash == feat_hash }
        end
      end

      def purge_multi(features)
        rejected_hashes = features.each_with_object({}) { |f, h| h[f.hash] = true }
        @mutex.synchronize do
          @lfi.reject! { |_, hash| rejected_hashes.key?(hash) }
        end
      end

      def key?(feature)
        @mutex.synchronize { @lfi.key?(feature) }
      end

      def cursor(short)
        unless Bootsnap.absolute_path?(short.to_s)
          $LOADED_FEATURES.size
        end
      end

      def identify(short, cursor)
        $LOADED_FEATURES[cursor..-1].detect do |feat|
          offset = 0
          while (offset = feat.index(short, offset))
            if feat.index(".", offset + 1) && !feat.index("/", offset + 2)
              break true
            else
              offset += 1
            end
          end
        end
      end

      # There is a relatively uncommon case where we could miss adding an
      # entry:
      #
      # If the user asked for e.g. `require 'bundler'`, and we went through the
      # `FALLBACK_SCAN` pathway in `kernel_require.rb` and therefore did not
      # pass `long` (the full expanded absolute path), then we did are not able
      # to confidently add the `bundler.rb` form to @lfi.
      #
      # We could either:
      #
      # 1. Just add `bundler.rb`, `bundler.so`, and so on, which is close but
      #    not quite right; or
      # 2. Inspect $LOADED_FEATURES upon return from yield to find the matching
      #    entry.
      def register(short, long)
        return if Bootsnap.absolute_path?(short)

        hash = long.hash

        # Do we have a filename with an elidable extension, e.g.,
        # 'bundler.rb', or 'libgit2.so'?
        altname = if extension_elidable?(short)
          # Strip the extension off, e.g. 'bundler.rb' -> 'bundler'.
          strip_extension_if_elidable(short)
        elsif long && (ext = File.extname(long.freeze))
          # We already know the extension of the actual file this
          # resolves to, so put that back on.
          short + ext
        end

        @mutex.synchronize do
          @lfi[short] = hash
          (@lfi[altname] = hash) if altname
        end
      end

      private

      STRIP_EXTENSION = /\.[^.]*?$/.freeze
      private_constant(:STRIP_EXTENSION)

      # Might Ruby automatically search for this extension if
      # someone tries to 'require' the file without it? E.g. Ruby
      # will implicitly try 'x.rb' if you ask for 'x'.
      #
      # This is complex and platform-dependent, and the Ruby docs are a little
      # handwavy about what will be tried when and in what order.
      # So optimistically pretend that all known elidable extensions
      # will be tried on all platforms, and that people are unlikely
      # to name files in a way that assumes otherwise.
      # (E.g. It's unlikely that someone will know that their code
      # will _never_ run on MacOS, and therefore think they can get away
      # with calling a Ruby file 'x.dylib.rb' and then requiring it as 'x.dylib'.)
      #
      # See <https://ruby-doc.org/core-2.6.4/Kernel.html#method-i-require>.
      def extension_elidable?(feature)
        feature.to_s.end_with?(".rb", ".so", ".o", ".dll", ".dylib")
      end

      def strip_extension_if_elidable(feature)
        if extension_elidable?(feature)
          feature.sub(STRIP_EXTENSION, "")
        else
          feature
        end
      end
    end
  end
end