lib/solargraph/yard_map.rb



require 'yard'

module Solargraph
  # The YardMap provides access to YARD documentation for the Ruby core, the
  # stdlib, and gems.
  #
  class YardMap
    autoload :Cache, 'solargraph/yard_map/cache'
    autoload :CoreDocs, 'solargraph/yard_map/core_docs'

    CoreDocs.require_minimum
    @@stdlib_yardoc = CoreDocs.yard_stdlib_file
    @@stdlib_paths = {}
    YARD::Registry.load! @@stdlib_yardoc
    YARD::Registry.all(:class, :module).each do |ns|
      next if ns.file.nil?
      path = ns.file.sub(/^(ext|lib)\//, '').sub(/\.(rb|c)$/, '')
      next if path.start_with?('-')
      @@stdlib_paths[path] ||= []
      @@stdlib_paths[path].push ns
    end

    # @return [Solargraph::Workspace]
    attr_reader :workspace

    # @return [Array<String>]
    attr_reader :required

    def initialize required: [], workspace: nil
      @workspace = workspace
      # HACK: YardMap needs its own copy of this array
      @required = required.clone
      @namespace_yardocs = {}
      @gem_paths = {}
      @stdlib_namespaces = []
      process_requires
      yardocs.push CoreDocs.yardoc_file
      yardocs.uniq!
      yardocs.delete_if{ |y| y.start_with? workspace.directory } unless workspace.nil? or workspace.directory.nil?
      yardocs.each do |y|
        load_yardoc y
        YARD::Registry.all(:class, :module).each do |ns|
          @namespace_yardocs[ns.path] ||= []
          @namespace_yardocs[ns.path].push y
        end
      end
      cache_core
    end

    # @return [Array<String>]
    def yardocs
      @yardocs ||= []
    end

    def unresolved_requires
      @unresolved_requires ||= []
    end

    def load_yardoc y
      begin
        if y.kind_of?(Array)
          YARD::Registry.load y, true
        else
          YARD::Registry.load! y
        end
      rescue Exception => e
        STDERR.puts "Error loading yardoc '#{y}' #{e.class} #{e.message}"
        yardocs.delete y
        nil
      end
    end

    # @param query [String]
    def search query
      found = []
      (yardocs + [@@stdlib_yardoc]).each { |y|
        yard = load_yardoc(y)
        unless yard.nil?
          yard.paths.each do |p|
            if found.empty? or (query.include?('.') or query.include?('#')) or !(p.include?('.') or p.include?('#'))
              found.push p if p.downcase.include?(query.downcase)
            end
          end
        end
      }
      found.uniq
    end

    # @param query [String]
    def document query
      found = []
      (yardocs + [@@stdlib_yardoc]).each { |y|
        yard = load_yardoc(y)
        unless yard.nil?
          obj = yard.at query
          found.push obj unless obj.nil?
        end
      }
      found
    end

    # @return [Array<Solargraph::Pin::Base>]
    def get_constants namespace , scope = ''
      cached = cache.get_constants(namespace, scope)
      return cached unless cached.nil?
      consts = []
      result = []
      combined_namespaces(namespace, scope).each do |ns|
        yardocs_documenting(ns).each do |y|
          # @todo Getting constants from the stdlib works slightly differently
          #   from methods
          next if y == @@stdlib_yardoc
          yard = load_yardoc(y)
          unless yard.nil?
            found = yard.at(ns)
            consts.concat found.children unless found.nil?
          end
        end
        consts.concat @stdlib_namespaces.select{|ns| ns.namespace.path == namespace}
      end
      consts.each { |c|
        detail = nil
        kind = nil
        return_type = nil
        if c.kind_of?(YARD::CodeObjects::ClassObject)
          detail = 'Class'
          return_type = "Class<#{c.to_s}>"
        elsif c.kind_of?(YARD::CodeObjects::ModuleObject)
          detail = 'Module'
          return_type = "Module<#{c.to_s}>"
        elsif c.kind_of?(YARD::CodeObjects::ConstantObject)
          detail = 'Constant'
        else
          next
        end
        result.push Pin::YardObject.new(c, object_location(c))
      }
      cache.set_constants(namespace, scope, result)
      result
    end

    # @return [Array<Solargraph::Pin::Base>]
    def get_methods namespace, scope = '', visibility: [:public]
      return [] if namespace == '' and scope == ''
      cached = cache.get_methods(namespace, scope, visibility)
      return cached unless cached.nil?
      meths = []
      combined_namespaces(namespace, scope).each do |ns|
        yardocs_documenting(ns).each do |y|
          yard = load_yardoc(y)
          unless yard.nil?
            ns = nil
            ns = find_first_resolved_object(yard, namespace, scope)
            unless ns.nil?
              has_new = false
              ns.meths(scope: :class, visibility: visibility).each { |m|
                has_new = true if m.name == 'new'
                meths.push Pin::YardObject.new(m, object_location(m))
              }
              # HACK: Convert #initialize to .new
              if visibility.include?(:public) and !has_new
                init = ns.meths(scope: :instance).select{|m| m.to_s.split(/[\.#]/).last == 'initialize'}.first
                unless init.nil?
                  ip = Solargraph::Pin::YardObject.new(init, object_location(init))
                  np = Solargraph::Pin::Method.new(ip.location, ip.namespace, 'new', ip.docstring, :class, :public, ip.parameters)
                  meths.push np
                end
              end
              # Collect superclass methods
              if ns.kind_of?(YARD::CodeObjects::ClassObject) and !ns.superclass.nil?
                meths += get_methods ns.superclass.to_s, '', visibility: [:public, :protected] unless ['Object', 'BasicObject', ''].include?(ns.superclass.to_s)
              end
            end
          end
        end
      end
      cache.set_methods(namespace, scope, visibility, meths)
      meths
    end

    # @return [Array<Solargraph::Pin::Base>]
    def get_instance_methods namespace, scope = '', visibility: [:public]
      return [] if namespace == '' and scope == ''
      cached = cache.get_instance_methods(namespace, scope, visibility)
      return cached unless cached.nil?
      meths = []
      combined_namespaces(namespace, scope).each do |ns|
        yardocs_documenting(ns).each do |y|
          yard = load_yardoc(y)
          unless yard.nil?
            ns = nil
            ns = find_first_resolved_object(yard, namespace, scope)
            unless ns.nil?
              ns.meths(scope: :instance, visibility: visibility).each do |m|
                n = m.to_s.split(/[\.#]/).last
                # HACK: Exception for Module#module_function in Class
                next if ns.name == :Class and m.path == 'Module#module_function'
                # HACK: Special treatment for #initialize
                next if n == 'initialize' and !visibility.include?(:private)
                if (namespace == 'Kernel' or !m.to_s.start_with?('Kernel#')) and !m.docstring.to_s.include?(':nodoc:')
                  meths.push Pin::YardObject.new(m, object_location(m))
                end
              end
              if ns.kind_of?(YARD::CodeObjects::ClassObject) and namespace != 'Object'
                unless ns.nil?
                  meths += get_instance_methods(ns.superclass.to_s)
                end
              end
              ns.instance_mixins.each do |m|
                meths += get_instance_methods(m.to_s) unless m.to_s == 'Kernel'
              end
              # HACK: Now get the #initialize method for private requests
              if visibility.include?(:private)
                init = ns.meths(scope: :instance).select{|m| m.to_s.split(/[\.#]/).last == 'initialize'}.first
                meths.push Pin::YardObject.new(init, object_location(init)) unless init.nil?
              end
            end
          end
        end
      end
      cache.set_instance_methods(namespace, scope, visibility, meths)
      meths
    end

    def find_fully_qualified_namespace namespace, scope
      unless scope.nil? or scope.empty?
        parts = scope.split('::')
        while parts.length > 0
          here = "#{parts.join('::')}::#{namespace}"
          return here unless yardocs_documenting(here).empty?
          return here if @stdlib_namespaces.any?{|ns| ns.path == here}
          parts.pop
        end
      end
      return namespace unless yardocs_documenting(namespace).empty?
      return namespace if @stdlib_namespaces.any?{|ns| ns.path == namespace}
      nil
    end

    def objects path, space = ''
      cached = cache.get_objects(path, space)
      return cached unless cached.nil?
      result = []
      yardocs.each { |y|
        yard = load_yardoc(y)
        unless yard.nil?
          obj = find_first_resolved_object(yard, path, space)
          unless obj.nil?
            result.push Pin::YardObject.new(obj, object_location(obj))
          end
        end
      }
      @stdlib_namespaces.each do |ns|
        result.push Pin::YardObject.new(ns, object_location(ns)) if ns.path == path
      end
      cache.set_objects(path, space, result)
      result
    end

    # @return [Symbol] :class, :module, or nil
    def get_namespace_type(fqns)
      yardocs_documenting(fqns).each do |y|
        yard = load_yardoc y
        unless yard.nil?
          obj = yard.at(fqns)
          unless obj.nil?
            return :class if obj.kind_of?(YARD::CodeObjects::ClassObject)
            return :module if obj.kind_of?(YARD::CodeObjects::ModuleObject)
            return nil
          end
        end
      end
      nil
    end

    private

    # @return [Solargraph::YardMap::Cache]
    def cache
      @cache ||= Cache.new
    end

    def find_first_resolved_object yard, namespace, scope
      unless scope.nil?
        parts = scope.split('::')
        while parts.length > 0
          ns = yard.resolve(P(parts.join('::')), namespace, true)
          return ns unless ns.nil?
          parts.pop
        end
      end
      yard.at(namespace)
    end

    def cache_core
      get_constants '', ''
    end

    def process_requires
      tried = []
      unresolved_requires.clear
      required.each do |r|
        next if r.nil?
        next if !workspace.nil? and workspace.would_require?(r)
        begin
          spec = Gem::Specification.find_by_path(r) || Gem::Specification.find_by_name(r.split('/').first)
          ver = spec.version.to_s
          ver = ">= 0" if ver.empty?
          add_gem_dependencies spec
          yd = YARD::Registry.yardoc_file_for_gem(spec.name, ver)
          @gem_paths[spec.name] = spec.full_gem_path
          unresolved_requires.push r if yd.nil?
          yardocs.unshift yd unless yd.nil? or yardocs.include?(yd)
        rescue Gem::LoadError => e
          next if !workspace.nil? and workspace.would_require?(r)
          stdnames = []
          @@stdlib_paths.each_pair do |path, objects|
            stdnames.concat objects if path == r or path.start_with?("#{r}/")
          end
          @stdlib_namespaces.concat stdnames
          unresolved_requires.push r if stdnames.empty?
        end
      end
    end

    def add_gem_dependencies spec
      (spec.dependencies - spec.development_dependencies).each do |dep|
        spec = Gem::Specification.find_by_name(dep.name)
        @gem_paths[spec.name] = spec.full_gem_path unless spec.nil?
        gy = YARD::Registry.yardoc_file_for_gem(dep.name)
        if gy.nil?
          unresolved_requires.push dep.name
        else
          yardocs.unshift gy unless yardocs.include?(gy)
        end
      end
    end

    def combined_namespaces namespace, scope = ''
      combined = [namespace]
      unless scope.empty?
        parts = scope.split('::')
        until parts.empty?
          combined.unshift parts.join('::') + '::' + namespace
          parts.pop
        end
      end
      combined
    end

    def yardocs_documenting namespace
      result = []
      if namespace == ''
        result.concat yardocs
      else
        result.concat @namespace_yardocs[namespace] unless @namespace_yardocs[namespace].nil?
      end
      if @stdlib_namespaces.map(&:path).include?(namespace)
        result.push @@stdlib_yardoc
      end
      result
    end

    # @param obj [YARD::CodeObjects::Base]
    # @return [Solargraph::Source::Location]
    def object_location obj
      return nil if obj.file.nil? or obj.line.nil?
      @gem_paths.values.each do |path|
        file = File.join(path, obj.file)
        return Solargraph::Source::Location.new(file, Solargraph::Source::Range.from_to(obj.line - 1, 0, obj.line - 1, 0)) if File.exist?(file)
      end
      nil
    end
  end
end