lib/yard/server/library_version.rb



# frozen_string_literal: true
require 'fileutils'
require 'thread'

module YARD
  module Server
    # This exception is raised when {LibraryVersion#prepare!} fails, or discovers
    # that the library is not "prepared" to be served by
    class LibraryNotPreparedError < RuntimeError; end

    # A library version encapsulates a library's documentation at a specific version.
    # Although the version is optional, this allows for creating multiple documentation
    # points for a specific library, each representing a unique version. The term
    # "library" used in other parts of the YARD::Server documentation refers to
    # objects of this class unless otherwise noted.
    #
    # A library points to a location where a {#yardoc_file} is located so that
    # its documentation may be loaded and served. Optionally, a {#source_path} is
    # given to point to a location where any extra files (and {YARD::CLI::Yardoc .yardopts})
    # should be loaded from. Both of these methods may not be known immediately,
    # since the yardoc file may not be built until later. Resolving the yardoc
    # file and source path are dependent on the specific library "source type" used.
    # Source types (known as "library source") are discussed in detail below.
    #
    # == Using with Adapters
    # A list of libraries need to be passed into adapters upon creation. In
    # most cases, you will never do this manually, but if you use a {RackMiddleware},
    # you will need to pass in this list yourself. To build this list of libraries,
    # you should create a hash of library names mapped to an *Array* of LibraryVersion
    # objects. For example:
    #
    #   {'mylib' => [LibraryVersion.new('mylib', '1.0', ...),
    #                LibraryVersion.new('mylib', '2.0', ...)]}
    #
    # Note that you can also use {Adapter#add_library} for convenience.
    #
    # The "array" part is required, even for just one library version.
    #
    # == Library Sources
    # The {#source} method represents the library source type, ie. where the
    # library "comes from". It might come from "disk", or it might come from a
    # "gem" (technically the disk, but a separate type nonetheless). In these
    # two cases, the yardoc file sits somewhere on your filesystem, though
    # it may also be built dynamically if it does not yet exist. This behaviour
    # is controlled through the {#prepare!} method, which prepares the yardoc file
    # given a specific library source. We will see how this works in detail in
    # the following section.
    #
    # == Implementing a Custom Library Source
    # YARD can be extended to support custom library sources in order to
    # build or retrieve a yardoc file at runtime from many different locations.
    #
    # To implement this behaviour, 3 methods can be added to the +LibraryVersion+
    # class, +#load_yardoc_from_SOURCE+, +#yardoc_file_for_SOURCE+, and
    # +#source_path_for_SOURCE+. In all cases, "SOURCE" represents the source
    # type used in {#source} when creating the library object. The
    # +#yardoc_file_for_SOURCE+ and +#source_path_for_SOURCE+ methods are called upon
    # creation and should return the location where the source code for the library
    # lives. The load method is called from {#prepare!} if there is no yardoc file
    # and should set {#yardoc_file}. Below is a full example for
    # implementing a custom library source, +:http+, which reads packaged .yardoc
    # databases from zipped archives off of an HTTP server.
    #
    # Note that only +#load_yardoc_from_SOURCE+ is required. The other two
    # methods are optional and can be set manually (via {#source_path=} and
    # {#yardoc_file=}) on the object at any time.
    #
    # @example Implementing a Custom Library Source
    #   # Adds the source type "http" for .yardoc files zipped on HTTP servers
    #   class LibraryVersion
    #     def load_yardoc_from_http
    #       Thread.new do
    #         # zip/unzip method implementations are not shown
    #         download_zip_file("http://mysite.com/yardocs/#{self}.zip")
    #         unzip_file_to("/path/to/yardocs/#{self}")
    #       end
    #
    #       # tell the server it's not ready yet (but it might be next time)
    #       raise LibraryNotPreparedError
    #     end
    #
    #     def yardoc_file_for_http
    #       "/path/to/yardocs/#{self}/.yardoc"
    #     end
    #
    #     def source_path_for_http
    #       File.dirname(yardoc_file)
    #     end
    #   end
    #
    #   # Creating a library of this source type:
    #   LibraryVersion.new('name', '1.0', nil, :http)
    #
    class LibraryVersion
      # @return [String] the name of the library
      attr_accessor :name

      # @return [String] the version of the specific library
      attr_accessor :version

      # @return [String] the location of the yardoc file used to load the object
      #   information from.
      # @return [nil] if no yardoc file exists yet. In this case, {#prepare!} will
      #   be called on this library to build the yardoc file.
      # @note To implement a custom yardoc file getter, implement
      def yardoc_file
        @yardoc_file ||= load_yardoc_file
      end
      attr_writer :yardoc_file

      # @return [Symbol] the source type representing where the yardoc should be
      #   loaded from. Defaults are +:disk+ and +:gem+, though custom sources
      #   may be implemented. This value is used to inform {#prepare!} about how
      #   to load the necessary data in order to display documentation for an object.
      # @see LibraryVersion LibraryVersion documentation for "Implementing a Custom Library Source"
      attr_accessor :source

      # @return [String] the location of the source code for a library. This
      #   value is filled by calling +#source_path_for_SOURCE+ on this class.
      # @return [nil] if there is no source code
      # @see LibraryVersion LibraryVersion documentation for "Implementing a Custom Library Source"
      def source_path
        @source_path ||= load_source_path
      end
      attr_writer :source_path

      # @param [String] name the name of the library
      # @param [String] version the specific (usually, but not always, numeric) library
      #   version
      # @param [String] yardoc the location of the yardoc file, or nil if it is
      #   generated later
      # @param [Symbol] source the location of the files used to build the yardoc.
      #   Builtin source types are +:disk+ or +:gem+.
      def initialize(name, version = nil, yardoc = nil, source = :disk)
        self.name = name
        self.yardoc_file = yardoc
        self.version = version
        self.source = source
      end

      # @param [Boolean] url_format if true, returns the string in a URI-compatible
      #   format (for appending to a URL). Otherwise, it is given in a more human
      #   readable format.
      # @return [String] the string representation of the library.
      def to_s(url_format = true)
        version ? "#{name}#{url_format ? '/' : '-'}#{version}" : name.to_s
      end

      # @return [Fixnum] used for Hash mapping.
      def hash; to_s.hash end

      # @return [Boolean] whether another LibraryVersion is equal to this one
      def eql?(other)
        other.is_a?(LibraryVersion) && other.name == name &&
          other.version == version && other.yardoc_file == yardoc_file
      end
      alias == eql?
      alias equal? eql?

      # @return [Boolean] whether the library has been completely processed
      #   and is ready to be served
      def ready?
        return false if yardoc_file.nil?
        serializer.complete?
      end

      # @note You should not directly override this method. Instead, implement
      #   +load_yardoc_from_SOURCENAME+ when implementing loading for a specific
      #   source type. See the {LibraryVersion} documentation for "Implementing
      #   a Custom Library Source"
      #
      # Prepares a library to be displayed by the server. This callback is
      # performed before each request on a library to ensure that it is loaded
      # and ready to be viewed. If any steps need to be performed prior to loading,
      # they are performed through this method (though they should be implemented
      # through the +load_yardoc_from_SOURCE+ method).
      #
      # @raise [LibraryNotPreparedError] if the library is not ready to be
      #   displayed. Usually when raising this error, you would simultaneously
      #   begin preparing the library for subsequent requests, although this
      #   is not necessary.
      def prepare!
        return if ready?
        meth = "load_yardoc_from_#{source}"
        send(meth) if respond_to?(meth, true)
      end

      # @return [Gem::Specification] a gemspec object for a given library. Used
      #   for :gem source types.
      # @return [nil] if there is no installed gem for the library
      def gemspec
        ver = version ? "= #{version}" : ">= 0"
        YARD::GemIndex.find_all_by_name(name, ver).last
      end

      protected

      @@chdir_mutex = Mutex.new

      # Called when a library of source type "disk" is to be prepared. In this
      # case, the {#yardoc_file} should already be set, but the library may not
      # be prepared. Run preparation if not done.
      #
      # @raise [LibraryNotPreparedError] if the yardoc file has not been
      #   prepared.
      def load_yardoc_from_disk
        return if ready?

        @@chdir_mutex.synchronize do
          Dir.chdir(source_path_for_disk) do
            Thread.new do
              CLI::Yardoc.run('--no-stats', '-n', '-b', yardoc_file)
            end
          end
        end

        raise LibraryNotPreparedError
      end

      # Called when a library of source type "gem" is to be prepared. In this
      # case, the {#yardoc_file} needs to point to the correct location for
      # the installed gem. The yardoc file is built if it has not been done.
      #
      # @raise [LibraryNotPreparedError] if the gem does not have an existing
      #   yardoc file.
      def load_yardoc_from_gem
        return if ready?
        ver = version ? "= #{version}" : ">= 0"

        @@chdir_mutex.synchronize do
          Thread.new do
            # Build gem docs on demand
            log.debug "Building gem docs for #{to_s(false)}"
            CLI::Gems.run(name, ver)
            log.debug "Done building gem docs for #{to_s(false)}"
          end
        end

        raise LibraryNotPreparedError
      end

      # @return [String] the source path for a disk source
      def source_path_for_disk
        File.dirname(yardoc_file) if yardoc_file
      end

      # @return [String] the source path for a gem source
      def source_path_for_gem
        gemspec.full_gem_path if gemspec
      end

      # @return [String] the yardoc file for a gem source
      def yardoc_file_for_gem
        require 'rubygems'
        ver = version ? "= #{version}" : ">= 0"
        Registry.yardoc_file_for_gem(name, ver)
      end

      private

      def load_source_path
        meth = "source_path_for_#{source}"
        send(meth) if respond_to?(meth, true)
      end

      def load_yardoc_file
        meth = "yardoc_file_for_#{source}"
        send(meth) if respond_to?(meth, true)
      end

      def serializer
        return if yardoc_file.nil?
        Serializers::YardocSerializer.new(yardoc_file)
      end
    end
  end
end