diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index c7ad72cb4..edaebe3eb 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -116,12 +116,9 @@ jobs: - name: clone https://github.com/lekemula/solargraph-rspec/ run: | cd .. - # git clone https://github.com/lekemula/solargraph-rspec.git + git clone https://github.com/lekemula/solargraph-rspec.git - # pending https://github.com/lekemula/solargraph-rspec/pull/30 - git clone https://github.com/apiology/solargraph-rspec.git cd solargraph-rspec - git checkout reset_closures - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -153,10 +150,12 @@ jobs: run: | cd ../solargraph-rspec cp .solargraph.yml.example .solargraph.yml - - name: Solargraph generate RSpec gems YARD and RBS pins + - name: Solargraph generate RSpec gems YARD pins run: | cd ../solargraph-rspec - bundle exec appraisal rbs collection update + # solargraph-rspec's specs don't pass a workspace, so it + # doesn't know where to look for the RBS collection - let's + # not load one so that the solargraph gems command below works rspec_gems=$(bundle exec appraisal ruby -r './lib/solargraph-rspec' -e 'puts Solargraph::Rspec::Gems.gem_names.join(" ")' 2>/dev/null | tail -n1) bundle exec appraisal solargraph gems $rspec_gems - name: Run specs diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index c82ade49b..d9473bd3e 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -21,19 +21,50 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4', '4.0'] - rbs-version: ['3.6.1', '3.9.5', '4.0.0.dev.4'] + ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4', '4.0', 'head'] + rbs-version: ['3.6.1', '3.8.1', '3.9.5', '3.10.0', '4.0.0.dev.4'] # Ruby 3.0 doesn't work with RBS 3.9.4 or 4.0.0.dev.4 exclude: + # only include the 3.0 variants we include later - ruby-version: '3.0' - rbs-version: '3.9.5' - - ruby-version: '3.0' - rbs-version: '4.0.0.dev.4' - # Missing require in 'rbs collection update' - hopefully - # fixed in next RBS release + # only include the 3.1 variants we include later + - ruby-version: '3.1' + # only include the 3.2 variants we include later + - ruby-version: '3.2' + # only include the 3.3 variants we include later + - ruby-version: '3.3' + # only include the 3.4 variants we include later + - ruby-version: '3.4' + # only include the 4.0 variants we include later - ruby-version: '4.0' + # Don't exclude 'head' - let's test all RBS versions we + # can there. + # + # + # Just exclude some odd-ball compatibility issues we can't + # work around: + # + # https://github.com/castwide/solargraph/actions/runs/20627923548/job/59241444380?pr=1102 + - ruby-version: 'head' rbs-version: '3.6.1' - - ruby-version: '4.0' + - ruby-version: 'head' + rbs-version: '3.8.1' + - ruby-version: 'head' + rbs-version: '4.0.0.dev.4' + include: + - ruby-version: '3.0' + rbs-version: '3.6.1' + - ruby-version: '3.1' + rbs-version: '3.6.1' + - ruby-version: '3.2' + rbs-version: '3.8.1' + - ruby-version: '3.3' + rbs-version: '3.9.5' + - ruby-version: '3.3' + rbs-version: '3.10.0' + - ruby-version: '3.4' + rbs-version: '3.10.0' + - ruby-version: '3.4' rbs-version: '4.0.0.dev.4' steps: - uses: actions/checkout@v3 @@ -58,11 +89,8 @@ jobs: run: | bundle _2.5.23_ install bundle update rbs # use latest available for this Ruby version - bundle list - bundle exec solargraph pin 'Bundler::Dsl#source' - name: Update types - run: | - bundle exec rbs collection update + run: bundle exec rbs collection update - name: Run tests run: bundle exec rake spec undercover: @@ -77,9 +105,15 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' + # see https://github.com/castwide/solargraph/actions/runs/19391419903/job/55485410493?pr=1119 + # + # match version in Gemfile.lock and use same version below + bundler: 2.5.23 bundler-cache: false - name: Install gems run: bundle install + - name: Update types + run: bundle exec rbs collection update - name: Run tests run: bundle exec rake spec - name: Check PR coverage diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 962ac9bb6..9bcd826ba 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -80,7 +80,6 @@ Layout/ElseAlignment: # Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, DefLikeMacros, AllowAdjacentOneLineDefs, NumberOfEmptyLines. Layout/EmptyLineBetweenDefs: Exclude: - - 'lib/solargraph/doc_map.rb' - 'lib/solargraph/language_server/message/initialize.rb' - 'lib/solargraph/pin/delegated_method.rb' @@ -200,7 +199,6 @@ Layout/MultilineMethodCallIndentation: # SupportedStyles: aligned, indented Layout/MultilineOperationIndentation: Exclude: - - 'lib/solargraph/api_map.rb' - 'lib/solargraph/language_server/host/dispatch.rb' - 'lib/solargraph/source.rb' @@ -431,12 +429,6 @@ Lint/UnusedBlockArgument: Lint/UnusedMethodArgument: Enabled: false -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: ContextCreatingMethods, MethodCreatingMethods. -Lint/UselessAccessModifier: - Exclude: - - 'lib/solargraph/api_map.rb' - # This cop supports safe autocorrection (--autocorrect). Lint/UselessAssignment: Enabled: false @@ -553,7 +545,6 @@ Naming/VariableName: RSpec/Be: Exclude: - 'spec/rbs_map/stdlib_map_spec.rb' - - 'spec/rbs_map_spec.rb' - 'spec/source/source_chainer_spec.rb' # This cop supports unsafe autocorrection (--autocorrect-all). @@ -576,7 +567,6 @@ RSpec/BeforeAfterAll: - '**/spec/rails_helper.rb' - '**/spec/support/**/*.rb' - 'spec/api_map_spec.rb' - - 'spec/doc_map_spec.rb' - 'spec/language_server/host/dispatch_spec.rb' - 'spec/language_server/protocol_spec.rb' @@ -1112,7 +1102,6 @@ Style/RedundantRegexpEscape: Style/RedundantReturn: Exclude: - 'lib/solargraph/complex_type/type_methods.rb' - - 'lib/solargraph/doc_map.rb' - 'lib/solargraph/parser/parser_gem/node_methods.rb' - 'lib/solargraph/source/chain/z_super.rb' diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index cc3031ea5..e7fd65c82 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -30,6 +30,12 @@ def initialize pins: [] index pins end + # @param out [StringIO, IO, nil] output stream for logging + # @return [void] + def self.reset_core out: nil + @@core_map = RbsMap::CoreMap.new + end + # # This is a mutable object, which is cached in the Chain class - # if you add any fields which change the results of calls (not @@ -48,6 +54,7 @@ def ==(other) self.eql?(other) end + # @return [Integer] def hash equality_fields.hash end @@ -98,11 +105,11 @@ def catalog bench end unresolved_requires = (bench.external_requires + conventions_environ.requires + bench.workspace.config.required).to_a.compact.uniq recreate_docmap = @unresolved_requires != unresolved_requires || - @doc_map&.uncached_yard_gemspecs&.any? || - @doc_map&.uncached_rbs_collection_gemspecs&.any? || - @doc_map&.rbs_collection_path != bench.workspace.rbs_collection_path + workspace.rbs_collection_path != bench.workspace.rbs_collection_path || + @doc_map.any_uncached? + if recreate_docmap - @doc_map = DocMap.new(unresolved_requires, [], bench.workspace) # @todo Implement gem preferences + @doc_map = DocMap.new(unresolved_requires, [], bench.workspace, out: nil) # @todo Implement gem preferences @unresolved_requires = @doc_map.unresolved_requires end @cache.clear if store.update(@@core_map.pins, @doc_map.pins, conventions_environ.pins, iced_pins, live_pins) @@ -119,7 +126,7 @@ def catalog bench # @return [DocMap] def doc_map - @doc_map ||= DocMap.new([], []) + @doc_map ||= DocMap.new([], [], Workspace.new('.')) end # @return [::Array] @@ -127,22 +134,12 @@ def uncached_gemspecs @doc_map&.uncached_gemspecs || [] end - # @return [::Array] - def uncached_rbs_collection_gemspecs - @doc_map.uncached_rbs_collection_gemspecs - end - - # @return [::Array] - def uncached_yard_gemspecs - @doc_map.uncached_yard_gemspecs - end - # @return [Enumerable] def core_pins @@core_map.pins end - # @param name [String] + # @param name [String, nil] # @return [YARD::Tags::MacroDirective, nil] def named_macro name store.named_macros[name] @@ -192,18 +189,10 @@ def self.load directory api_map end - # @param out [IO, nil] + # @param out [StringIO, IO, nil] # @return [void] - def cache_all!(out) - @doc_map.cache_all!(out) - end - - # @param gemspec [Gem::Specification] - # @param rebuild [Boolean] - # @param out [IO, nil] - # @return [void] - def cache_gem(gemspec, rebuild: false, out: nil) - @doc_map.cache(gemspec, rebuild: rebuild, out: out) + def cache_all_for_doc_map! out + doc_map.cache_doc_map_gems!(out) end class << self @@ -215,17 +204,17 @@ class << self # # # @param directory [String] - # @param out [IO] The output stream for messages + # @param out [IO, StringIO, nil] The output stream for messages # # @return [ApiMap] - def self.load_with_cache directory, out + def self.load_with_cache directory, out = $stderr api_map = load(directory) if api_map.uncached_gemspecs.empty? logger.info { "All gems cached for #{directory}" } return api_map end - api_map.cache_all!(out) + api_map.cache_all_for_doc_map!(out) load(directory) end @@ -328,7 +317,7 @@ def get_includes(fqns) # @param namespace [String] A fully qualified namespace # @param scope [Symbol] :instance or :class # @return [Array] - def get_instance_variable_pins(namespace, scope = :instance) + def get_instance_variable_pins namespace, scope = :instance result = [] used = [namespace] result.concat store.get_instance_variables(namespace, scope) @@ -340,8 +329,10 @@ def get_instance_variable_pins(namespace, scope = :instance) result end - # @sg-ignore Missing @return tag for Solargraph::ApiMap#visible_pins # @see Solargraph::Parser::FlowSensitiveTyping#visible_pins + # @param (see Solargraph::Parser::FlowSensitiveTyping#visible_pins) + # @sg-ignore Missing @return tag for Solargraph::ApiMap#visible_pins + # @return (see Solargraph::Parser::FlowSensitiveTyping#visible_pins) def visible_pins(*args, **kwargs, &blk) Solargraph::Parser::FlowSensitiveTyping.visible_pins(*args, **kwargs, &blk) end @@ -350,7 +341,7 @@ def visible_pins(*args, **kwargs, &blk) # # @param namespace [String] A fully qualified namespace # @return [Enumerable] - def get_class_variable_pins(namespace) + def get_class_variable_pins namespace prefer_non_nil_variables(store.get_class_variables(namespace)) end @@ -672,10 +663,17 @@ def resolve_method_aliases pins, visibility = [:public, :private, :protected] next nil if resolved.respond_to?(:visibility) && !visibility.include?(resolved.visibility) resolved end.compact - logger.debug { "ApiMap#resolve_method_aliases(pins=#{pins.map(&:name)}, visibility=#{visibility}) => #{with_resolved_aliases.map(&:name)}" } + logger.debug do + "ApiMap#resolve_method_aliases(pins=#{pins.map(&:name)}, visibility=#{visibility}) => #{with_resolved_aliases.map(&:name)}" + end GemPins.combine_method_pins_by_path(with_resolved_aliases) end + # @return [Workspace, nil] + def workspace + doc_map.workspace + end + # @param fq_reference_tag [String] A fully qualified whose method should be pulled in # @param namespace_pin [Pin::Base] Namespace pin for the rooted_type # parameter - used to pull generics information @@ -773,7 +771,8 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false end rooted_sc_tag = qualify_superclass(rooted_tag) unless rooted_sc_tag.nil? - result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, visibility, true, skip, no_core) + result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, + visibility, true, skip, no_core) end else logger.info { "ApiMap#inner_get_methods(#{fqns}, #{scope}, #{visibility}, #{deep}, #{skip}) - looking for get_extends() from #{fqns}" } @@ -783,7 +782,8 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false end rooted_sc_tag = qualify_superclass(rooted_tag) unless rooted_sc_tag.nil? - result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, visibility, true, skip, true) + result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, + visibility, true, skip, true) end unless no_core || fqns.empty? type = get_namespace_type(fqns) @@ -841,8 +841,6 @@ def prefer_non_nil_variables pins include Logging - private - # @param alias_pin [Pin::MethodAlias] # @return [Pin::Method, nil] def resolve_method_alias(alias_pin) @@ -916,7 +914,7 @@ def create_resolved_alias_pin(alias_pin, original) # @param rooted_type [ComplexType] # @param pins [Enumerable] # @return [Array] - def erase_generics(namespace_pin, rooted_type, pins) + def erase_generics namespace_pin, rooted_type, pins return pins unless should_erase_generics_when_done?(namespace_pin, rooted_type) logger.debug("Erasing generics on namespace_pin=#{namespace_pin} / rooted_type=#{rooted_type}") @@ -927,7 +925,7 @@ def erase_generics(namespace_pin, rooted_type, pins) # @param namespace_pin [Pin::Namespace] # @param rooted_type [ComplexType] - def should_erase_generics_when_done?(namespace_pin, rooted_type) + def should_erase_generics_when_done? namespace_pin, rooted_type has_generics?(namespace_pin) && !can_resolve_generics?(namespace_pin, rooted_type) end @@ -938,7 +936,7 @@ def has_generics?(namespace_pin) # @param namespace_pin [Pin::Namespace] # @param rooted_type [ComplexType] - def can_resolve_generics?(namespace_pin, rooted_type) + def can_resolve_generics? namespace_pin, rooted_type has_generics?(namespace_pin) && !rooted_type.all_params.empty? end end diff --git a/lib/solargraph/api_map/index.rb b/lib/solargraph/api_map/index.rb index 944f02c79..ebc8eefcd 100644 --- a/lib/solargraph/api_map/index.rb +++ b/lib/solargraph/api_map/index.rb @@ -166,10 +166,13 @@ def map_overrides ovr.tags.each do |tag| pin.docstring.add_tag(tag) redefine_return_type pin, tag - if new_pin - new_pin.docstring.add_tag(tag) - redefine_return_type new_pin, tag - end + pin.reset_generated! + + next unless new_pin + + new_pin.docstring.add_tag(tag) + redefine_return_type new_pin, tag + new_pin.reset_generated! end end end @@ -186,7 +189,6 @@ def redefine_return_type pin, tag pin.signatures.each do |sig| sig.instance_variable_set(:@return_type, ComplexType.try_parse(tag.type)) end - pin.reset_generated! end end end diff --git a/lib/solargraph/doc_map.rb b/lib/solargraph/doc_map.rb index e45ff0b65..602cf0827 100644 --- a/lib/solargraph/doc_map.rb +++ b/lib/solargraph/doc_map.rb @@ -5,200 +5,170 @@ require 'open3' module Solargraph - # A collection of pins generated from required gems. + # A collection of pins generated from specific 'require' statements + # in code. Multiple can be created per workspace, to represent the + # pins available in different files based on their particular + # 'require' lines. # class DocMap include Logging - # @return [Array] - attr_reader :requires - alias required requires + # @return [Workspace] + attr_reader :workspace # @return [Array] attr_reader :preferences - # @return [Array] - attr_reader :pins - - # @return [Array] - def uncached_gemspecs - uncached_yard_gemspecs.concat(uncached_rbs_collection_gemspecs) - .sort - .uniq { |gemspec| "#{gemspec.name}:#{gemspec.version}" } - end - - # @return [Array] - attr_reader :uncached_yard_gemspecs - - # @return [Array] - attr_reader :uncached_rbs_collection_gemspecs - - # @return [String, nil] - attr_reader :rbs_collection_path - - # @return [String, nil] - attr_reader :rbs_collection_config_path - - # @return [Workspace, nil] - attr_reader :workspace - - # @return [Environ] - attr_reader :environ - # @param requires [Array] # @param preferences [Array] - # @param workspace [Workspace, nil] - def initialize(requires, preferences, workspace = nil) - @requires = requires.compact + # @param workspace [Workspace] + # @param out [IO, nil] output stream for logging + def initialize requires, preferences, workspace, out: $stderr + @provided_requires = requires.compact @preferences = preferences.compact @workspace = workspace - @rbs_collection_path = workspace&.rbs_collection_path - @rbs_collection_config_path = workspace&.rbs_collection_config_path - @environ = Convention.for_global(self) - @requires.concat @environ.requires if @environ - load_serialized_gem_pins - pins.concat @environ.pins - end - - # @param out [IO] - # @return [void] - def cache_all!(out) - # if we log at debug level: - if logger.info? - gem_desc = uncached_gemspecs.map { |gemspec| "#{gemspec.name}:#{gemspec.version}" }.join(', ') - logger.info "Caching pins for gems: #{gem_desc}" unless uncached_gemspecs.empty? - end - logger.debug { "Caching for YARD: #{uncached_yard_gemspecs.map(&:name)}" } - logger.debug { "Caching for RBS collection: #{uncached_rbs_collection_gemspecs.map(&:name)}" } - load_serialized_gem_pins - uncached_gemspecs.each do |gemspec| - cache(gemspec, out: out) - end - load_serialized_gem_pins - @uncached_rbs_collection_gemspecs = [] - @uncached_yard_gemspecs = [] - end - - # @param gemspec [Gem::Specification] - # @param out [IO] - # @return [void] - def cache_yard_pins(gemspec, out) - pins = GemPins.build_yard_pins(yard_plugins, gemspec) - PinCache.serialize_yard_gem(gemspec, pins) - logger.info { "Cached #{pins.length} YARD pins for gem #{gemspec.name}:#{gemspec.version}" } unless pins.empty? + @out = out end - # @param gemspec [Gem::Specification] - # @param out [IO] - # @return [void] - def cache_rbs_collection_pins(gemspec, out) - rbs_map = RbsMap.from_gemspec(gemspec, rbs_collection_path, rbs_collection_config_path) - pins = rbs_map.pins - rbs_version_cache_key = rbs_map.cache_key - # cache pins even if result is zero, so we don't retry building pins - pins ||= [] - PinCache.serialize_rbs_collection_gem(gemspec, rbs_version_cache_key, pins) - logger.info { "Cached #{pins.length} RBS collection pins for gem #{gemspec.name} #{gemspec.version} with cache_key #{rbs_version_cache_key.inspect}" unless pins.empty? } + # @return [Array] + def requires + @requires ||= @provided_requires + (workspace.global_environ&.requires || []) end + alias required requires - # @param gemspec [Gem::Specification] - # @param rebuild [Boolean] whether to rebuild the pins even if they are cached - # @param out [IO, nil] output stream for logging - # @return [void] - def cache(gemspec, rebuild: false, out: nil) - build_yard = uncached_yard_gemspecs.include?(gemspec) || rebuild - build_rbs_collection = uncached_rbs_collection_gemspecs.include?(gemspec) || rebuild - if build_yard || build_rbs_collection - type = [] - type << 'YARD' if build_yard - type << 'RBS collection' if build_rbs_collection - out.puts("Caching #{type.join(' and ')} pins for gem #{gemspec.name}:#{gemspec.version}") if out + # @return [Array] + def uncached_gemspecs + if @uncached_gemspecs.nil? + @uncached_gemspecs = [] + pins # force lazy-loaded pin lookup end - cache_yard_pins(gemspec, out) if build_yard - cache_rbs_collection_pins(gemspec, out) if build_rbs_collection + @uncached_gemspecs end - # @return [Array] - def gemspecs - @gemspecs ||= required_gems_map.values.compact.flatten + # @return [Array] + def pins + @pins ||= load_serialized_gem_pins + (workspace.global_environ&.pins || []) end - # @return [Array] - def unresolved_requires - @unresolved_requires ||= required_gems_map.select { |_, gemspecs| gemspecs.nil? }.keys + # @return [void] + def reset_pins! + @uncached_gemspecs = nil + @pins = nil end - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def self.all_yard_gems_in_memory - @yard_gems_in_memory ||= {} + # @return [Solargraph::PinCache] + def pin_cache + @pin_cache ||= workspace.fresh_pincache end - # @return [Hash{String => Hash{Array(String, String) => Array}}] stored by RBS collection path - def self.all_rbs_collection_gems_in_memory - @rbs_collection_gems_in_memory ||= {} + def any_uncached? + uncached_gemspecs.any? end - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def yard_pins_in_memory - self.class.all_yard_gems_in_memory + # Cache all pins needed for the sources in this doc_map + # @param out [StringIO, IO, nil] output stream for logging + # @return [void] + def cache_doc_map_gems! out + unless uncached_gemspecs.empty? + logger.info do + gem_desc = uncached_gemspecs.map { |gemspec| "#{gemspec.name}:#{gemspec.version}" }.join(', ') + "Caching pins for gems: #{gem_desc}" + end + end + time = Benchmark.measure do + uncached_gemspecs.each do |gemspec| + cache(gemspec, out: out) + end + end + milliseconds = (time.real * 1000).round + if (milliseconds > 500) && uncached_gemspecs.any? && out && uncached_gemspecs.any? + out.puts "Built #{uncached_gemspecs.length} gems in #{milliseconds} ms" + end + reset_pins! end - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def rbs_collection_pins_in_memory - self.class.all_rbs_collection_gems_in_memory[rbs_collection_path] ||= {} + # @return [Array] + def unresolved_requires + @unresolved_requires ||= required_gems_map.select { |_, gemspecs| gemspecs.nil? }.keys end - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def self.all_combined_pins_in_memory - @combined_pins_in_memory ||= {} + # @return [Array] + # @param out [IO] + def dependencies out: $stderr + @dependencies ||= + begin + all_deps = gemspecs + .flat_map { |spec| fetch_dependencies(spec, out: out) } + .uniq(&:name) + existing_gems = gemspecs.map(&:name) + all_deps.reject { |gemspec| existing_gems.include? gemspec.name } + end end - # @todo this should also include an index by the hash of the RBS collection - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def combined_pins_in_memory - self.class.all_combined_pins_in_memory + # Cache gem documentation if needed for this doc_map + # + # @param gemspec [Gem::Specification] + # @param rebuild [Boolean] whether to rebuild the pins even if they are cached + # @param out [StringIO, IO, nil] output stream for logging + # + # @return [void] + def cache gemspec, rebuild: false, out: nil + pin_cache.cache_gem(gemspec: gemspec, + rebuild: rebuild, + out: out) end - # @return [Array] - def yard_plugins - @environ.yard_plugins - end + private - # @return [Set] - def dependencies - @dependencies ||= (gemspecs.flat_map { |spec| fetch_dependencies(spec) } - gemspecs).to_set + # @return [Array] + def gemspecs + @gemspecs ||= required_gems_map.values.compact.flatten end - private - - # @return [void] - def load_serialized_gem_pins - @pins = [] - @uncached_yard_gemspecs = [] - @uncached_rbs_collection_gemspecs = [] + # @param out [IO, nil] + # @return [Array] + def load_serialized_gem_pins out: @out + serialized_pins = [] with_gemspecs, without_gemspecs = required_gems_map.partition { |_, v| v } # @sg-ignore Need support for RBS duck interfaces like _ToHash # @type [Array] - paths = Hash[without_gemspecs].keys + missing_paths = Hash[without_gemspecs].keys # @sg-ignore Need support for RBS duck interfaces like _ToHash # @type [Array] - gemspecs = Hash[with_gemspecs].values.flatten.compact + dependencies.to_a - - paths.each do |path| - rbs_pins = deserialize_stdlib_rbs_map path + gemspecs = Hash[with_gemspecs].values.flatten.compact + dependencies(out: out).to_a + + missing_paths.each do |path| + # this will load from disk if needed; no need to manage + # uncached_gemspecs to trigger that later + stdlib_name_guess = path.split('/').first + + # try to resolve the stdlib name + deps = workspace.stdlib_dependencies(stdlib_name_guess) || [] + [stdlib_name_guess, *deps].compact.each do |potential_stdlib_name| + rbs_pins = pin_cache.cache_stdlib_rbs_map potential_stdlib_name + serialized_pins.concat rbs_pins if rbs_pins + end end - logger.debug { "DocMap#load_serialized_gem_pins: Combining pins..." } + existing_pin_count = serialized_pins.length time = Benchmark.measure do gemspecs.each do |gemspec| - pins = deserialize_combined_pin_cache gemspec - @pins.concat pins if pins + # only deserializes already-cached gems + gemspec_pins = pin_cache.deserialize_combined_pin_cache gemspec + if gemspec_pins + serialized_pins.concat gemspec_pins + else + uncached_gemspecs << gemspec + end end end - logger.info { "DocMap#load_serialized_gem_pins: Loaded and processed serialized pins together in #{time.real} seconds" } - @uncached_yard_gemspecs.uniq! - @uncached_rbs_collection_gemspecs.uniq! - nil + pins_processed = serialized_pins.length - existing_pin_count + milliseconds = (time.real * 1000).round + if (milliseconds > 500) && out && gemspecs.any? + out.puts "Deserialized #{serialized_pins.length} gem pins from #{PinCache.base_dir} in #{milliseconds} ms" + end + uncached_gemspecs.uniq! { |gemspec| "#{gemspec.name}:#{gemspec.version}" } + serialized_pins end # @return [Hash{String => Array}] @@ -211,102 +181,6 @@ def preference_map @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } end - # @param gemspec [Gem::Specification] - # @return [Array, nil] - def deserialize_yard_pin_cache gemspec - if yard_pins_in_memory.key?([gemspec.name, gemspec.version]) - return yard_pins_in_memory[[gemspec.name, gemspec.version]] - end - - cached = PinCache.deserialize_yard_gem(gemspec) - if cached - logger.info { "Loaded #{cached.length} cached YARD pins from #{gemspec.name}:#{gemspec.version}" } - yard_pins_in_memory[[gemspec.name, gemspec.version]] = cached - cached - else - logger.debug "No YARD pin cache for #{gemspec.name}:#{gemspec.version}" - @uncached_yard_gemspecs.push gemspec - nil - end - end - - # @param gemspec [Gem::Specification] - # @return [void] - def deserialize_combined_pin_cache(gemspec) - unless combined_pins_in_memory[[gemspec.name, gemspec.version]].nil? - return combined_pins_in_memory[[gemspec.name, gemspec.version]] - end - - rbs_map = RbsMap.from_gemspec(gemspec, rbs_collection_path, rbs_collection_config_path) - rbs_version_cache_key = rbs_map.cache_key - - cached = PinCache.deserialize_combined_gem(gemspec, rbs_version_cache_key) - if cached - logger.info { "Loaded #{cached.length} cached YARD pins from #{gemspec.name}:#{gemspec.version}" } - combined_pins_in_memory[[gemspec.name, gemspec.version]] = cached - return combined_pins_in_memory[[gemspec.name, gemspec.version]] - end - - rbs_collection_pins = deserialize_rbs_collection_cache gemspec, rbs_version_cache_key - - yard_pins = deserialize_yard_pin_cache gemspec - - if !rbs_collection_pins.nil? && !yard_pins.nil? - logger.debug { "Combining pins for #{gemspec.name}:#{gemspec.version}" } - combined_pins = GemPins.combine(yard_pins, rbs_collection_pins) - PinCache.serialize_combined_gem(gemspec, rbs_version_cache_key, combined_pins) - combined_pins_in_memory[[gemspec.name, gemspec.version]] = combined_pins - logger.info { "Generated #{combined_pins_in_memory[[gemspec.name, gemspec.version]].length} combined pins for #{gemspec.name} #{gemspec.version}" } - return combined_pins - end - - if !yard_pins.nil? - logger.debug { "Using only YARD pins for #{gemspec.name}:#{gemspec.version}" } - combined_pins_in_memory[[gemspec.name, gemspec.version]] = yard_pins - return combined_pins_in_memory[[gemspec.name, gemspec.version]] - elsif !rbs_collection_pins.nil? - logger.debug { "Using only RBS collection pins for #{gemspec.name}:#{gemspec.version}" } - combined_pins_in_memory[[gemspec.name, gemspec.version]] = rbs_collection_pins - return combined_pins_in_memory[[gemspec.name, gemspec.version]] - else - logger.debug { "Pins not yet cached for #{gemspec.name}:#{gemspec.version}" } - return nil - end - end - - # @param path [String] require path that might be in the RBS stdlib collection - # @return [void] - def deserialize_stdlib_rbs_map path - map = RbsMap::StdlibMap.load(path) - if map.resolved? - logger.debug { "Loading stdlib pins for #{path}" } - @pins.concat map.pins - logger.debug { "Loaded #{map.pins.length} stdlib pins for #{path}" } - map.pins - else - # @todo Temporarily ignoring unresolved `require 'set'` - logger.debug { "Require path #{path} could not be resolved in RBS" } unless path == 'set' - nil - end - end - - # @param gemspec [Gem::Specification] - # @param rbs_version_cache_key [String] - # @return [Array, nil] - def deserialize_rbs_collection_cache gemspec, rbs_version_cache_key - return if rbs_collection_pins_in_memory.key?([gemspec, rbs_version_cache_key]) - cached = PinCache.deserialize_rbs_collection_gem(gemspec, rbs_version_cache_key) - if cached - logger.info { "Loaded #{cached.length} pins from RBS collection cache for #{gemspec.name}:#{gemspec.version}" } unless cached.empty? - rbs_collection_pins_in_memory[[gemspec, rbs_version_cache_key]] = cached - cached - else - logger.debug "No RBS collection pin cache for #{gemspec.name} #{gemspec.version}" - @uncached_rbs_collection_gemspecs.push gemspec - nil - end - end - # @param path [String] # @return [::Array, nil] def resolve_path_to_gemspecs path @@ -356,19 +230,19 @@ def change_gemspec_version gemspec, version end # @param gemspec [Gem::Specification] + # @param out [IO, nil] + # # @return [Array] - def fetch_dependencies gemspec + def fetch_dependencies gemspec, out: nil # @param spec [Gem::Dependency] # @param deps [Set] only_runtime_dependencies(gemspec).each_with_object(Set.new) do |spec, deps| Solargraph.logger.info "Adding #{spec.name} dependency for #{gemspec.name}" dep = Gem.loaded_specs[spec.name] # @todo is next line necessary? - # @sg-ignore Unresolved call to requirement on Gem::Dependency dep ||= Gem::Specification.find_by_name(spec.name, spec.requirement) deps.merge fetch_dependencies(dep) if deps.add?(dep) rescue Gem::MissingSpecError - # @sg-ignore Unresolved call to requirement on Gem::Dependency Solargraph.logger.warn "Gem dependency #{spec.name} #{spec.requirement} for #{gemspec.name} not found in RubyGems." end.to_a end @@ -376,9 +250,18 @@ def fetch_dependencies gemspec # @param gemspec [Gem::Specification] # @return [Array] def only_runtime_dependencies gemspec - gemspec.dependencies - gemspec.development_dependencies - end + gemspec_deps = gemspec.dependencies - gemspec.development_dependencies + stdlib_dep_names = workspace.stdlib_dependencies(gemspec.name) + stdlib_deps = workspace.stdlib_dependencies(gemspec.name).flat_map do |dep_name| + # already know about this dependency + next [] if gemspec_deps.any? { |dep| dep.name == dep_name } + stdlib_specs = resolve_path_to_gemspecs(dep_name) || [] + + stdlib_specs.map { |spec| Gem::Dependency.new spec.name, "= #{spec.version}" } + end + gemspec_deps + stdlib_deps + end def inspect self.class.inspect diff --git a/lib/solargraph/gem_pins.rb b/lib/solargraph/gem_pins.rb index 0c0016449..53b332b97 100644 --- a/lib/solargraph/gem_pins.rb +++ b/lib/solargraph/gem_pins.rb @@ -43,16 +43,6 @@ def self.combine_method_pins(*pins) out end - # @param yard_plugins [Array] The names of YARD plugins to use. - # @param gemspec [Gem::Specification] - # @return [Array] - def self.build_yard_pins(yard_plugins, gemspec) - Yardoc.cache(yard_plugins, gemspec) unless Yardoc.cached?(gemspec) - return [] unless Yardoc.cached?(gemspec) - yardoc = Yardoc.load!(gemspec) - YardMap::Mapper.new(yardoc, gemspec).map - end - # @param yard_pins [Array] # @param rbs_pins [Array] # @@ -72,7 +62,9 @@ def self.combine(yard_pins, rbs_pins) end out = combine_method_pins(rbs_pin, yard_pin) - logger.debug { "GemPins.combine: Combining yard.path=#{yard_pin.path} - rbs=#{rbs_pin.inspect} with yard=#{yard_pin.inspect} into #{out}" } + logger.debug do + "GemPins.combine: Combining yard.path=#{yard_pin.path} - rbs=#{rbs_pin.inspect} with yard=#{yard_pin.inspect} into #{out}" + end out end in_rbs_only = rbs_pins.select do |pin| diff --git a/lib/solargraph/language_server/message/extended/check_gem_version.rb b/lib/solargraph/language_server/message/extended/check_gem_version.rb index ead1eeaf2..0447a7feb 100644 --- a/lib/solargraph/language_server/message/extended/check_gem_version.rb +++ b/lib/solargraph/language_server/message/extended/check_gem_version.rb @@ -83,7 +83,6 @@ def available @fetched = true begin @available ||= begin - # @sg-ignore Variable type could not be inferred for tuple # @type [Gem::Dependency, nil] tuple = CheckGemVersion.fetcher.search_for_dependency(Gem::Dependency.new('solargraph')).flatten.first if tuple.nil? diff --git a/lib/solargraph/library.rb b/lib/solargraph/library.rb index 5c7851201..7de06bacd 100644 --- a/lib/solargraph/library.rb +++ b/lib/solargraph/library.rb @@ -1,9 +1,16 @@ # frozen_string_literal: true +require 'rubygems' require 'pathname' require 'observer' require 'open3' +# @!parse +# class ::Gem::Specification +# # @return [String] +# def name; end +# end + module Solargraph # A Library handles coordination between a Workspace and an ApiMap. # @@ -273,12 +280,12 @@ def references_from filename, line, column, strip: false, only: false # HACK: for language clients that exclude special characters from the start of variable names if strip && match = cursor.word.match(/^[^a-z0-9_]+/i) found.map! do |loc| - Solargraph::Location.new(loc.filename, Solargraph::Range.from_to(loc.range.start.line, loc.range.start.column + match[0].length, loc.range.ending.line, loc.range.ending.column)) + Solargraph::Location.new(loc.filename, + Solargraph::Range.from_to(loc.range.start.line, loc.range.start.column + match[0].length, loc.range.ending.line, + loc.range.ending.column)) end end - result.concat(found.sort do |a, b| - a.range.start.line <=> b.range.start.line - end) + result.concat(found.sort { |a, b| a.range.start.line <=> b.range.start.line }) end result.uniq end @@ -303,9 +310,7 @@ def locate_ref location return nil if pin.nil? # @param full [String] return_if_match = proc do |full| - if source_map_hash.key?(full) - return Location.new(full, Solargraph::Range.from_to(0, 0, 0, 0)) - end + return Location.new(full, Solargraph::Range.from_to(0, 0, 0, 0)) if source_map_hash.key?(full) end workspace.require_paths.each do |path| full = File.join path, pin.name @@ -500,6 +505,11 @@ def external_requires private + # @return [PinCache] + def pin_cache + workspace.pin_cache + end + # @return [Hash{String => Array}] def source_map_external_require_hash @source_map_external_require_hash ||= {} @@ -580,12 +590,13 @@ def cache_errors def cache_next_gemspec return if @cache_progress + # @type [Gem::Specification] spec = cacheable_specs.first return end_cache_progress unless spec pending = api_map.uncached_gemspecs.length - cache_errors.length - 1 - if Yardoc.processing?(spec) + if pin_cache.yardoc_processing?(spec) logger.info "Enqueuing cache of #{spec.name} #{spec.version} (already being processed)" queued_gemspec_cache.push(spec) return if pending - queued_gemspec_cache.length < 1 @@ -596,7 +607,10 @@ def cache_next_gemspec logger.info "Caching #{spec.name} #{spec.version}" Thread.new do report_cache_progress spec.name, pending - _o, e, s = Open3.capture3(workspace.command_path, 'cache', spec.name, spec.version.to_s) + kwargs = {} + kwargs[:chdir] = workspace.directory.to_s if workspace.directory && !workspace.directory.empty? + _o, e, s = Open3.capture3(workspace.command_path, 'cache', spec.name, spec.version.to_s, + **kwargs) if s.success? logger.info "Cached #{spec.name} #{spec.version}" else @@ -613,8 +627,7 @@ def cache_next_gemspec # @return [Array] def cacheable_specs - cacheable = api_map.uncached_yard_gemspecs + - api_map.uncached_rbs_collection_gemspecs - + cacheable = api_map.uncached_gemspecs + queued_gemspec_cache - cache_errors.to_a return cacheable unless cacheable.empty? @@ -673,8 +686,7 @@ def sync_catalog source_map_hash.values.each { |map| find_external_requires(map) } api_map.catalog bench logger.info "Catalog complete (#{api_map.source_maps.length} files, #{api_map.pins.length} pins)" - logger.info "#{api_map.uncached_yard_gemspecs.length} uncached YARD gemspecs" - logger.info "#{api_map.uncached_rbs_collection_gemspecs.length} uncached RBS collection gemspecs" + logger.info "#{api_map.uncached_gemspecs.length} uncached gemspecs" cache_next_gemspec @sync_count = 0 end diff --git a/lib/solargraph/parser/parser_gem/class_methods.rb b/lib/solargraph/parser/parser_gem/class_methods.rb index 2daf22fc7..b3caa900a 100644 --- a/lib/solargraph/parser/parser_gem/class_methods.rb +++ b/lib/solargraph/parser/parser_gem/class_methods.rb @@ -30,7 +30,9 @@ def parse code, filename = nil, line = 0 # @return [::Parser::Base] def parser @parser ||= Prism::Translation::Parser.new(FlawedBuilder.new).tap do |parser| + # @sg-ignore Unresolved call to diagnostics on Prism::Translation::Parser parser.diagnostics.all_errors_are_fatal = true + # @sg-ignore Unresolved call to diagnostics on Prism::Translation::Parser parser.diagnostics.ignore_warnings = true end end diff --git a/lib/solargraph/pin/callable.rb b/lib/solargraph/pin/callable.rb index edbc3f941..f1b41e55f 100644 --- a/lib/solargraph/pin/callable.rb +++ b/lib/solargraph/pin/callable.rb @@ -210,6 +210,11 @@ def arity_matches? arguments, with_block true end + def reset_generated! + super + @parameters.each(&:reset_generated!) + end + # @return [Integer] def mandatory_positional_param_count parameters.count(&:arg?) diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index 91c205921..fed1e9cbd 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -123,6 +123,11 @@ def full_name end end + def reset_generated! + super + @return_type = nil if @return_type&.undefined? + end + # @return [String] def full full_name + case decl diff --git a/lib/solargraph/pin_cache.rb b/lib/solargraph/pin_cache.rb index b3c162a15..97f5f5d6a 100644 --- a/lib/solargraph/pin_cache.rb +++ b/lib/solargraph/pin_cache.rb @@ -1,12 +1,429 @@ -require 'yard-activesupport-concern' require 'fileutils' require 'rbs' +require 'rubygems' module Solargraph - module PinCache + class PinCache + include Logging + + attr_reader :directory, :rbs_collection_path, :rbs_collection_config_path, :yard_plugins + + # @param rbs_collection_path [String, nil] + # @param rbs_collection_config_path [String, nil] + # @param directory [String, nil] + # @param yard_plugins [Array] + def initialize rbs_collection_path:, rbs_collection_config_path:, + directory:, + yard_plugins: + @rbs_collection_path = rbs_collection_path + @rbs_collection_config_path = rbs_collection_config_path + @directory = directory + @yard_plugins = yard_plugins + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + def cached? gemspec + rbs_version_cache_key = lookup_rbs_version_cache_key(gemspec) + combined_gem?(gemspec, rbs_version_cache_key) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rebuild [Boolean] whether to rebuild the cache regardless of whether it already exists + # @param out [StringIO, IO, nil] output stream for logging + # @return [void] + def cache_gem gemspec:, rebuild: false, out: nil + rbs_version_cache_key = lookup_rbs_version_cache_key(gemspec) + + build_yard, build_rbs_collection, build_combined = + calculate_build_needs(gemspec, + rebuild: rebuild, + rbs_version_cache_key: rbs_version_cache_key) + + return unless build_yard || build_rbs_collection || build_combined + + build_combine_and_cache(gemspec, + rbs_version_cache_key, + build_yard: build_yard, + build_rbs_collection: build_rbs_collection, + build_combined: build_combined, + out: out) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rbs_version_cache_key [String, nil] + def suppress_yard_cache? gemspec, rbs_version_cache_key + if gemspec.name == 'parser' && rbs_version_cache_key != RbsMap::CACHE_KEY_UNRESOLVED + # parser takes forever to build YARD pins, but has excellent RBS collection pins + return true + end + false + end + + # @param out [StringIO, IO, nil] output stream for logging + # + # @return [void] + def cache_all_stdlibs out: $stderr + possible_stdlibs.each do |stdlib| + RbsMap::StdlibMap.new(stdlib, out: out) + end + end + + # @param path [String] require path that might be in the RBS stdlib collection + # @return [void] + def cache_stdlib_rbs_map path + # these are held in memory in RbsMap::StdlibMap + map = RbsMap::StdlibMap.load(path) + if map.resolved? + logger.debug { "Loading stdlib pins for #{path}" } + pins = map.pins + logger.debug { "Loaded #{pins.length} stdlib pins for #{path}" } + pins + else + # @todo Temporarily ignoring unresolved `require 'set'` + logger.debug { "Require path #{path} could not be resolved in RBS" } unless path == 'set' + nil + end + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # + # @return [String] + def lookup_rbs_version_cache_key gemspec + rbs_map = RbsMap.from_gemspec(gemspec, rbs_collection_path, rbs_collection_config_path) + rbs_map.cache_key + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rbs_version_cache_key [String, nil] + # @param yard_pins [Array] + # @param rbs_collection_pins [Array] + # @return [void] + def cache_combined_pins gemspec, rbs_version_cache_key, yard_pins, rbs_collection_pins + combined_pins = GemPins.combine(yard_pins, rbs_collection_pins) + serialize_combined_gem(gemspec, rbs_version_cache_key, combined_pins) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [Array, nil] + def deserialize_combined_pin_cache gemspec + rbs_version_cache_key = lookup_rbs_version_cache_key(gemspec) + + load_combined_gem(gemspec, rbs_version_cache_key) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param out [StringIO, IO, nil] + # @return [void] + def uncache_gem gemspec, out: nil + PinCache.uncache(yardoc_path(gemspec), out: out) + PinCache.uncache(yard_gem_path(gemspec), out: out) + uncache_by_prefix(rbs_collection_pins_path_prefix(gemspec), out: out) + uncache_by_prefix(combined_path_prefix(gemspec), out: out) + rbs_version_cache_key = lookup_rbs_version_cache_key(gemspec) + combined_pins_in_memory.delete([gemspec.name, gemspec.version, rbs_version_cache_key]) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + def yardoc_processing? gemspec + Yardoc.processing?(yardoc_path(gemspec)) + end + + # @return [Array] a list of possible standard library names + def possible_stdlibs + # all dirs and .rb files in Gem::RUBYGEMS_DIR + Dir.glob(File.join(Gem::RUBYGEMS_DIR, '*')).map do |file_or_dir| + basename = File.basename(file_or_dir) + # remove .rb + basename = basename[0..-4] if basename.end_with?('.rb') + basename + end.sort.uniq + rescue StandardError => e + logger.info { "Failed to get possible stdlibs: #{e.message}" } + logger.debug { e.backtrace.join("\n") } + [] + end + + private + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rebuild [Boolean] whether to rebuild the cache regardless of whether it already exists + # @param rbs_version_cache_key [String, nil] the cache key for the gem in the RBS collection + # + # @return [Array(Boolean, Boolean, Boolean)] whether to build YARD + # pins, RBS collection pins, and combined pins + def calculate_build_needs gemspec, rebuild:, rbs_version_cache_key: + if rebuild + build_yard = true + build_rbs_collection = true + build_combined = true + else + build_yard = !yard_gem?(gemspec) + build_rbs_collection = !rbs_collection_pins?(gemspec, rbs_version_cache_key) + build_combined = !combined_gem?(gemspec, rbs_version_cache_key) || build_yard || build_rbs_collection + end + + build_yard = false if suppress_yard_cache?(gemspec, rbs_version_cache_key) + + [build_yard, build_rbs_collection, build_combined] + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rbs_version_cache_key [String, nil] + # @param build_yard [Boolean] + # @param build_rbs_collection [Boolean] + # @param build_combined [Boolean] + # @param out [StringIO, IO, nil] + # + # @return [void] + def build_combine_and_cache gemspec, + rbs_version_cache_key, + build_yard:, + build_rbs_collection:, + build_combined:, + out: + log_cache_info(gemspec, rbs_version_cache_key, + build_yard: build_yard, + build_rbs_collection: build_rbs_collection, + build_combined: build_combined, + out: out) + cache_yard_pins(gemspec, out) if build_yard + # this can be nil even if we aren't told to build it - see suppress_yard_cache? + yard_pins = deserialize_yard_pin_cache(gemspec) || [] + cache_rbs_collection_pins(gemspec, out) if build_rbs_collection + rbs_collection_pins = deserialize_rbs_collection_cache(gemspec, rbs_version_cache_key) || [] + cache_combined_pins(gemspec, rbs_version_cache_key, yard_pins, rbs_collection_pins) if build_combined + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rbs_version_cache_key [String, nil] + # @param build_yard [Boolean] + # @param build_rbs_collection [Boolean] + # @param build_combined [Boolean] + # @param out [StringIO, IO, nil] + # + # @return [void] + def log_cache_info gemspec, + rbs_version_cache_key, + build_yard:, + build_rbs_collection:, + build_combined:, + out: + type = [] + type << 'YARD' if build_yard + rbs_source_desc = RbsMap.rbs_source_desc(rbs_version_cache_key) + type << rbs_source_desc if build_rbs_collection && !rbs_source_desc.nil? + # we'll build it anyway, but it won't take long to build with + # only a single source + + # 'combining' is awkward terminology in this case + just_yard = build_yard && rbs_source_desc.nil? + + type << 'combined' if build_combined && !just_yard + out&.puts("Caching #{type.join(' and ')} pins for gem #{gemspec.name}:#{gemspec.version}") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param out [StringIO, IO, nil] + # @return [Array] + def cache_yard_pins gemspec, out + gem_yardoc_path = yardoc_path(gemspec) + Yardoc.build_docs(gem_yardoc_path, yard_plugins, gemspec) unless Yardoc.docs_built?(gem_yardoc_path) + pins = Yardoc.build_pins(gem_yardoc_path, gemspec, out: out) + serialize_yard_gem(gemspec, pins) + logger.info { "Cached #{pins.length} YARD pins for gem #{gemspec.name}:#{gemspec.version}" } unless pins.empty? + pins + end + + # @return [Hash{Array(String, String, String) => Array}] + def combined_pins_in_memory + PinCache.all_combined_pins_in_memory[yard_plugins] ||= {} + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param _out [StringIO, IO, nil] + # @return [Array] + def cache_rbs_collection_pins gemspec, _out + rbs_map = RbsMap.from_gemspec(gemspec, rbs_collection_path, rbs_collection_config_path) + pins = rbs_map.pins + rbs_version_cache_key = rbs_map.cache_key + # cache pins even if result is zero, so we don't retry building pins + pins ||= [] + serialize_rbs_collection_pins(gemspec, rbs_version_cache_key, pins) + logger.info do + unless pins.empty? + "Cached #{pins.length} RBS collection pins for gem #{gemspec.name} #{gemspec.version} with " \ + "cache_key #{rbs_version_cache_key.inspect}" + end + end + pins + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [Array, nil] + def deserialize_yard_pin_cache gemspec + cached = load_yard_gem(gemspec) + if cached + cached + else + logger.debug "No YARD pin cache for #{gemspec.name}:#{gemspec.version}" + nil + end + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param rbs_version_cache_key [String, nil] + # @return [Array] + def deserialize_rbs_collection_cache gemspec, rbs_version_cache_key + cached = load_rbs_collection_pins(gemspec, rbs_version_cache_key) + Solargraph.assert_or_log(:pin_cache_rbs_collection, 'Asked for non-existent rbs collection') if cached.nil? + logger.info do + "Loaded #{cached&.length} pins from RBS collection cache for #{gemspec.name}:#{gemspec.version}" + end + cached + end + + # @return [Array] + def yard_path_components + ["yard-#{YARD::VERSION}", + yard_plugins.sort.uniq.join('-')] + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [String] + def yardoc_path gemspec + File.join(PinCache.base_dir, + *yard_path_components, + "#{gemspec.name}-#{gemspec.version}.yardoc") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [String] + def yard_gem_path gemspec + File.join(PinCache.work_dir, *yard_path_components, "#{gemspec.name}-#{gemspec.version}.ser") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [Array, nil] + def load_yard_gem gemspec + PinCache.load(yard_gem_path(gemspec)) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param pins [Array] + # @return [void] + def serialize_yard_gem gemspec, pins + PinCache.save(yard_gem_path(gemspec), pins) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [Boolean] + def yard_gem? gemspec + exist?(yard_gem_path(gemspec)) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + # @return [String] + def rbs_collection_pins_path gemspec, hash + rbs_collection_pins_path_prefix(gemspec) + "#{hash || 0}.ser" + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [String] + def rbs_collection_pins_path_prefix gemspec + File.join(PinCache.work_dir, 'rbs', "#{gemspec.name}-#{gemspec.version}-") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String] + # + # @return [Array, nil] + def load_rbs_collection_pins gemspec, hash + PinCache.load(rbs_collection_pins_path(gemspec, hash)) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + # @param pins [Array] + # @return [void] + def serialize_rbs_collection_pins gemspec, hash, pins + PinCache.save(rbs_collection_pins_path(gemspec, hash), pins) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + # @return [String] + def combined_path gemspec, hash + File.join(combined_path_prefix(gemspec) + "-#{hash || 0}.ser") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @return [String] + def combined_path_prefix gemspec + File.join(PinCache.work_dir, 'combined', yard_plugins.sort.join('-'), "#{gemspec.name}-#{gemspec.version}") + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + # @param pins [Array] + # @return [void] + def serialize_combined_gem gemspec, hash, pins + PinCache.save(combined_path(gemspec, hash), pins) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String] + def combined_gem? gemspec, hash + exist?(combined_path(gemspec, hash)) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + # @return [Array, nil] + def load_combined_gem gemspec, hash + cached = combined_pins_in_memory[[gemspec.name, gemspec.version, hash]] + return cached if cached + loaded = PinCache.load(combined_path(gemspec, hash)) + combined_pins_in_memory[[gemspec.name, gemspec.version, hash]] = loaded if loaded + loaded + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param hash [String, nil] + def rbs_collection_pins? gemspec, hash + exist?(rbs_collection_pins_path(gemspec, hash)) + end + + include Logging + + # @param path [String] + def exist? *path + File.file? File.join(*path) + end + + # @return [void] + # @param path_segments [Array] + def uncache_by_prefix *path_segments, out: nil + path = File.join(*path_segments) + glob = "#{path}*" + out&.puts "Clearing pin cache in #{glob}" + Dir.glob(glob).each do |file| + next unless File.file?(file) + FileUtils.rm_rf file, secure: true + out&.puts "Clearing pin cache in #{file}" + end + end + class << self include Logging + # @return [Hash{Array => Hash{Array(String, String) => + # Array}}] yard plugins, then gemspec name and + # version + def all_combined_pins_in_memory + @all_combined_pins_in_memory ||= {} + end + # The base directory where cached YARD documentation and serialized pins are serialized # # @return [String] @@ -18,6 +435,32 @@ def base_dir File.join(Dir.home, '.cache', 'solargraph') end + # @param path_segments [Array] + # @return [void] + def uncache *path_segments, out: nil + path = File.join(*path_segments) + if File.exist?(path) + FileUtils.rm_rf path, secure: true + out&.puts "Clearing pin cache in #{path}" + else + out&.puts "Pin cache file #{path} does not exist" + end + end + + # @param out [StringIO, IO, nil] + # @return [void] + def uncache_core out: nil + uncache(core_path, out: out) + # ApiMap keep this in memory + ApiMap.reset_core(out: out) + end + + # @param out [StringIO, IO, nil] + # @return [void] + def uncache_stdlib out: nil + uncache(stdlib_path, out: out) + end + # The working directory for the current Ruby, RBS, and Solargraph versions. # # @return [String] @@ -27,15 +470,6 @@ def work_dir File.join(base_dir, "ruby-#{RUBY_VERSION}", "rbs-#{RBS::VERSION}", "solargraph-#{Solargraph::VERSION}") end - # @param gemspec [Gem::Specification] - # @return [String] - def yardoc_path gemspec - File.join(base_dir, - "yard-#{YARD::VERSION}", - "yard-activesupport-concern-#{YARD::ActiveSupport::Concern::VERSION}", - "#{gemspec.name}-#{gemspec.version}.yardoc") - end - # @return [String] def stdlib_path File.join(work_dir, 'stdlib') @@ -164,33 +598,11 @@ def has_rbs_collection?(gemspec, hash) exist?(rbs_collection_path(gemspec, hash)) end - # @return [void] - def uncache_core - uncache(core_path) - end - - # @return [void] - def uncache_stdlib - uncache(stdlib_path) - end - - # @param gemspec [Gem::Specification] - # @param out [IO, nil] - # @return [void] - def uncache_gem(gemspec, out: nil) - uncache(yardoc_path(gemspec), out: out) - uncache_by_prefix(rbs_collection_path_prefix(gemspec), out: out) - uncache(yard_gem_path(gemspec), out: out) - uncache_by_prefix(combined_path_prefix(gemspec), out: out) - end - # @return [void] def clear FileUtils.rm_rf base_dir, secure: true end - private - # @param file [String] # @return [Array, nil] def load file @@ -202,11 +614,6 @@ def load file nil end - # @param path [String] - def exist? *path - File.file? File.join(*path) - end - # @param file [String] # @param pins [Array] # @return [void] @@ -218,27 +625,19 @@ def save file, pins logger.debug { "Cache#save: Saved #{pins.length} pins to #{file}" } end - # @param path_segments [Array] - # @return [void] - def uncache *path_segments, out: nil - path = File.join(*path_segments) - if File.exist?(path) - FileUtils.rm_rf path, secure: true - out.puts "Clearing pin cache in #{path}" unless out.nil? - end + def core? + File.file?(core_path) end - # @return [void] - # @param path_segments [Array] - def uncache_by_prefix *path_segments, out: nil - path = File.join(*path_segments) - glob = "#{path}*" - out.puts "Clearing pin cache in #{glob}" unless out.nil? - Dir.glob(glob).each do |file| - next unless File.file?(file) - FileUtils.rm_rf file, secure: true - out.puts "Clearing pin cache in #{file}" unless out.nil? - end + # @param out [StringIO, IO, nil] + # @return [Array] + def cache_core out: $stderr + RbsMap::CoreMap.new.cache_core(out: out) + end + + # @param path [String] + def exist? *path + File.file? File.join(*path) end end end diff --git a/lib/solargraph/rbs_map.rb b/lib/solargraph/rbs_map.rb index 803e3677a..a42f984ad 100644 --- a/lib/solargraph/rbs_map.rb +++ b/lib/solargraph/rbs_map.rb @@ -26,7 +26,8 @@ class RbsMap # @param version [String, nil] # @param rbs_collection_config_path [String, Pathname, nil] # @param rbs_collection_paths [Array] - def initialize library, version = nil, rbs_collection_config_path: nil, rbs_collection_paths: [] + # @param out [IO, nil] where to log messages + def initialize library, version = nil, rbs_collection_config_path: nil, rbs_collection_paths: [], out: $stderr if rbs_collection_config_path.nil? && !rbs_collection_paths.empty? raise 'Please provide rbs_collection_config_path if you provide rbs_collection_paths' end @@ -37,6 +38,28 @@ def initialize library, version = nil, rbs_collection_config_path: nil, rbs_coll add_library loader, library, version end + CACHE_KEY_GEM_EXPORT = 'gem-export' + CACHE_KEY_UNRESOLVED = 'unresolved' + CACHE_KEY_STDLIB = 'stdlib' + CACHE_KEY_LOCAL = 'local' + + # @param cache_key [String, nil] + # @return [String, nil] a description of the source of the RBS info + def self.rbs_source_desc cache_key + case cache_key + when CACHE_KEY_GEM_EXPORT + 'RBS gem export' + when CACHE_KEY_UNRESOLVED + nil + when CACHE_KEY_STDLIB + 'RBS standard library' + when CACHE_KEY_LOCAL + 'local RBS shims' + else + 'RBS collection' + end + end + # @return [RBS::EnvironmentLoader] def loader @loader ||= RBS::EnvironmentLoader.new(core_root: nil, repository: repository) @@ -47,9 +70,13 @@ def loader # updated upstream for the same library and version. May change # if the config for where information comes form changes. def cache_key + return CACHE_KEY_UNRESOLVED unless resolved? + @hextdigest ||= begin # @type [String, nil] data = nil + # @type gem_config [nil, Hash{String => Hash{String => String}}] + gem_config = nil if rbs_collection_config_path lockfile_path = RBS::Collection::Config.to_lockfile_path(Pathname.new(rbs_collection_config_path)) if lockfile_path.exist? @@ -58,16 +85,21 @@ def cache_key data = gem_config&.to_s end end - if data.nil? || data.empty? - if resolved? - # definitely came from the gem itself and not elsewhere - - # only one version per gem - 'gem-export' + if gem_config.nil? + CACHE_KEY_STDLIB + else + # @type [String] + source = gem_config.dig('source', 'type') + case source + when 'rubygems' + CACHE_KEY_GEM_EXPORT + when 'local' + CACHE_KEY_LOCAL + when 'stdlib' + CACHE_KEY_STDLIB else - 'unresolved' + Digest::SHA1.hexdigest(data) end - else - Digest::SHA1.hexdigest(data) end end end @@ -77,6 +109,10 @@ def cache_key # @param rbs_collection_config_path [String, Pathname, nil] # @return [RbsMap] def self.from_gemspec gemspec, rbs_collection_path, rbs_collection_config_path + # prefers stdlib RBS if available + rbs_map = RbsMap::StdlibMap.new(gemspec.name) + return rbs_map if rbs_map.resolved? + rbs_map = RbsMap.new(gemspec.name, gemspec.version, rbs_collection_paths: [rbs_collection_path].compact, rbs_collection_config_path: rbs_collection_config_path) @@ -88,9 +124,15 @@ def self.from_gemspec gemspec, rbs_collection_path, rbs_collection_config_path rbs_collection_config_path: rbs_collection_config_path) end + # @param out [IO, nil] where to log messages # @return [Array] - def pins - @pins ||= resolved? ? conversions.pins : [] + def pins out: $stderr + @pins ||= if resolved? + loader.libs.each { |lib| log_caching(lib, out: out) } + conversions.pins + else + [] + end end # @generic T @@ -140,19 +182,32 @@ def conversions @conversions ||= Conversions.new(loader: loader) end + # @param lib [RBS::EnvironmentLoader::Library] + # @param out [IO, nil] where to log messages + # @return [void] + def log_caching lib, out:; end + + def resolve_dependencies? + # we need to resolve dependencies via gemfile.lock manually for + # YARD regardless, so use same mechanism here so we don't + # duplicate work generating pins from dependencies + false + end + # @param loader [RBS::EnvironmentLoader] # @param library [String] - # @param version [String, nil] + # @param version [String, nil] the version of the library to load, or nil for any + # @param out [IO, nil] where to log messages # @return [Boolean] true if adding the library succeeded - def add_library loader, library, version + def add_library loader, library, version, out: $stderr @resolved = if loader.has_library?(library: library, version: version) - loader.add library: library, version: version - logger.debug { "#{short_name} successfully loaded library #{library}:#{version}" } - true - else - logger.info { "#{short_name} did not find data for library #{library}:#{version}" } - false - end + loader.add library: library, version: version, resolve_dependencies: resolve_dependencies? + logger.debug { "#{short_name} successfully loaded library #{library}:#{version}" } + true + else + logger.info { "#{short_name} did not find data for library #{library}:#{version}" } + false + end end # @return [String] diff --git a/lib/solargraph/rbs_map/core_map.rb b/lib/solargraph/rbs_map/core_map.rb index d2836ffe3..8fe2afc83 100644 --- a/lib/solargraph/rbs_map/core_map.rb +++ b/lib/solargraph/rbs_map/core_map.rb @@ -15,31 +15,37 @@ def resolved? def initialize; end + # @param out [IO, nil] output stream for logging # @return [Enumerable] - def pins + def pins out: $stderr return @pins if @pins + @pins = cache_core(out: out) + end - @pins = [] + # @param out [StringIO, IO, nil] output stream for logging + # @return [Array] + def cache_core out: $stderr + new_pins = [] cache = PinCache.deserialize_core - if cache - @pins.replace cache - else - @pins.concat conversions.pins + return cache if cache + + new_pins.concat conversions.pins + + # Avoid RBS::DuplicatedDeclarationError by loading in a different EnvironmentLoader + fill_loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) + fill_loader.add(path: Pathname(FILLS_DIRECTORY)) + out&.puts 'Caching RBS pins for Ruby core' + fill_conversions = Conversions.new(loader: fill_loader) + new_pins.concat fill_conversions.pins - # Avoid RBS::DuplicatedDeclarationError by loading in a different EnvironmentLoader - fill_loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) - fill_loader.add(path: Pathname(FILLS_DIRECTORY)) - fill_conversions = Conversions.new(loader: fill_loader) - @pins.concat fill_conversions.pins + new_pins.concat RbsMap::CoreFills::ALL - @pins.concat RbsMap::CoreFills::ALL + processed = ApiMap::Store.new(new_pins).pins.reject { |p| p.is_a?(Solargraph::Pin::Reference::Override) } + new_pins.replace processed - processed = ApiMap::Store.new(pins).pins.reject { |p| p.is_a?(Solargraph::Pin::Reference::Override) } - @pins.replace processed + PinCache.serialize_core new_pins - PinCache.serialize_core @pins - end - @pins + new_pins end private diff --git a/lib/solargraph/rbs_map/stdlib_map.rb b/lib/solargraph/rbs_map/stdlib_map.rb index b6804157f..c2cfcabb6 100644 --- a/lib/solargraph/rbs_map/stdlib_map.rb +++ b/lib/solargraph/rbs_map/stdlib_map.rb @@ -12,19 +12,24 @@ class StdlibMap < RbsMap # @type [Hash{String => RbsMap}] @stdlib_maps_hash = {} + def log_caching lib, out: $stderr + out&.puts("Caching RBS pins for standard library #{lib.name}") + end + # @param library [String] - def initialize library + # @param out [IO, nil] where to log messages + def initialize library, out: $stderr cached_pins = PinCache.deserialize_stdlib_require library if cached_pins @pins = cached_pins @resolved = true @loaded = true logger.debug { "Deserialized #{cached_pins.length} cached pins for stdlib require #{library.inspect}" } - else + elsif self.class.source.has? library, nil super unless resolved? @pins = [] - logger.info { "Could not resolve #{library.inspect}" } + logger.debug { "StdlibMap could not resolve #{library.inspect}" } return end generated_pins = pins @@ -33,6 +38,29 @@ def initialize library end end + # @return [RBS::Collection::Sources::Stdlib] + def self.source + @source ||= RBS::Collection::Sources::Stdlib.instance + end + + # @param name [String] + # @param version [String, nil] + # @return [Array String}>, nil] + def self.stdlib_dependencies name, version = nil + if source.has?(name, version) + source.dependencies_of(name, version) + else + [] + end + end + + def resolve_dependencies? + # there are 'virtual' dependencies for stdlib gems in RBS that + # aren't represented in the actual gemspecs that we'd + # otherwise use + true + end + # @param library [String] # @return [StdlibMap] def self.load library diff --git a/lib/solargraph/shell.rb b/lib/solargraph/shell.rb index 14a1139ae..ee087dd48 100755 --- a/lib/solargraph/shell.rb +++ b/lib/solargraph/shell.rb @@ -3,6 +3,7 @@ require 'benchmark' require 'thor' require 'yard' +require 'yaml' module Solargraph class Shell < Thor @@ -104,9 +105,8 @@ def clear # @param gem [String] # @param version [String, nil] def cache gem, version = nil - api_map = Solargraph::ApiMap.load(Dir.pwd) - spec = Gem::Specification.find_by_name(gem, version) - api_map.cache_gem(spec, rebuild: options[:rebuild], out: $stdout) + gems(gem + (version ? "=#{version}" : '')) + # ' end desc 'uncache GEM [...GEM]', "Delete specific cached gem documentation" @@ -119,39 +119,73 @@ def cache gem, version = nil # @return [void] def uncache *gems raise ArgumentError, 'No gems specified.' if gems.empty? + workspace = Solargraph::Workspace.new(Dir.pwd) + gems.each do |gem| if gem == 'core' - PinCache.uncache_core + PinCache.uncache_core(out: $stdout) next end if gem == 'stdlib' - PinCache.uncache_stdlib + PinCache.uncache_stdlib(out: $stdout) next end - spec = Gem::Specification.find_by_name(gem) - PinCache.uncache_gem(spec, out: $stdout) + spec = workspace.find_gem(gem) + workspace.uncache_gem(spec, out: $stdout) end end - desc 'gems [GEM[=VERSION]]', 'Cache documentation for installed gems' + desc 'gems [GEM[=VERSION]...] [STDLIB...] [core]', 'Cache documentation for + installed libraries' + long_desc %( This command will cache the + generated type documentation for the specified libraries. While + Solargraph will generate this on the fly when needed, it takes + time. This command will generate it in advance, which can be + useful for CI scenarios. + + With no arguments, it will cache all libraries in the current + workspace. If a gem or standard library name is specified, it + will cache that library's type documentation. + + An equals sign after a gem will allow a specific gem version + to be cached. + + The 'core' argument can be used to cache the type + documentation for the core Ruby libraries. + + If the library is already cached, it will be rebuilt if the + --rebuild option is set. + + Cached documentation is stored in #{PinCache.base_dir}, which + can be stored between CI runs. + ) option :rebuild, type: :boolean, desc: 'Rebuild existing documentation', default: false # @param names [Array] # @return [void] def gems *names - api_map = ApiMap.load('.') + # print time with ms + workspace = Solargraph::Workspace.new('.') + if names.empty? - Gem::Specification.to_a.each { |spec| do_cache spec, api_map } - STDERR.puts "Documentation cached for all #{Gem::Specification.count} gems." + workspace.cache_all_for_workspace!($stdout, rebuild: options[:rebuild]) else + $stderr.puts("Caching these gems: #{names}") names.each do |name| - spec = Gem::Specification.find_by_name(*name.split('=')) - do_cache spec, api_map - rescue Gem::MissingSpecError - warn "Gem '#{name}' not found" + if name == 'core' + PinCache.cache_core(out: $stdout) if !PinCache.core? || options[:rebuild] + next + end + + gemspec = workspace.find_gem(*name.split('=')) + if gemspec.nil? + warn "Gem '#{name}' not found" + else + workspace.cache_gem(gemspec, rebuild: options[:rebuild], out: $stdout) + end end - STDERR.puts "Documentation cached for #{names.count} gems." + $stderr.puts "Documentation cached for #{names.count} gems." end end @@ -195,7 +229,6 @@ def typecheck *files filecount += 1 probcount += problems.length end - # " } puts "Typecheck finished in #{time.real} seconds." puts "#{probcount} problem#{probcount != 1 ? 's' : ''} found#{files.length != 1 ? " in #{filecount} of #{files.length} files" : ''}." @@ -320,15 +353,6 @@ def pin_description pin desc end - # @param gemspec [Gem::Specification] - # @param api_map [ApiMap] - # @return [void] - def do_cache gemspec, api_map - # @todo if the rebuild: option is passed as a positional arg, - # typecheck doesn't complain on the below line - api_map.cache_gem(gemspec, rebuild: options.rebuild, out: $stdout) - end - # @param type [ComplexType] # @return [void] def print_type(type) diff --git a/lib/solargraph/workspace.rb b/lib/solargraph/workspace.rb index 06980e6d0..23f23f212 100644 --- a/lib/solargraph/workspace.rb +++ b/lib/solargraph/workspace.rb @@ -2,6 +2,7 @@ require 'open3' require 'json' +require 'yaml' module Solargraph # A workspace consists of the files in a project's directory and the @@ -9,6 +10,8 @@ module Solargraph # in an associated Library or ApiMap. # class Workspace + include Logging + autoload :Config, 'solargraph/workspace/config' autoload :RequirePaths, 'solargraph/workspace/require_paths' @@ -19,7 +22,8 @@ class Workspace attr_reader :gemnames alias source_gems gemnames - # @param directory [String] TODO: Remove '' and '*' special cases + # @todo Remove '' and '*' special cases + # @param directory [String] # @param config [Config, nil] # @param server [Hash] def initialize directory = '', config = nil, server = {} @@ -50,6 +54,56 @@ def config @config ||= Solargraph::Workspace::Config.new(directory) end + # @return [Solargraph::PinCache] + def pin_cache + @pin_cache ||= fresh_pincache + end + + # @param stdlib_name [String] + # + # @return [Array] + def stdlib_dependencies stdlib_name + deps = RbsMap::StdlibMap.stdlib_dependencies(stdlib_name, nil) || [] + deps.map { |dep| dep['name'] }.compact + end + + # @return [Environ] + def global_environ + # empty docmap, since the result needs to work in any possible + # context here + @global_environ ||= Convention.for_global(DocMap.new([], [], self, out: nil)) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param out [StringIO, IO, nil] output stream for logging + # @param rebuild [Boolean] whether to rebuild the pins even if they are cached + # + # @return [void] + def cache_gem gemspec, out: nil, rebuild: false + pin_cache.cache_gem(gemspec: gemspec, out: out, rebuild: rebuild) + end + + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param out [StringIO, IO, nil] output stream for logging + # + # @return [void] + def uncache_gem gemspec, out: nil + pin_cache.uncache_gem(gemspec, out: out) + end + + # @return [Solargraph::PinCache] + def fresh_pincache + PinCache.new(rbs_collection_path: rbs_collection_path, + rbs_collection_config_path: rbs_collection_config_path, + yard_plugins: yard_plugins, + directory: directory) + end + + # @return [Array] + def yard_plugins + @yard_plugins ||= global_environ.yard_plugins.sort.uniq + end + # @param level [Symbol] # @return [TypeChecker::Rules] def rules(level) @@ -133,12 +187,53 @@ def rbs_collection_path # @return [String, nil] def rbs_collection_config_path - @rbs_collection_config_path ||= begin - unless directory.empty? || directory == '*' - yaml_file = File.join(directory, 'rbs_collection.yaml') - yaml_file if File.file?(yaml_file) + @rbs_collection_config_path ||= + begin + unless directory.empty? || directory == '*' + yaml_file = File.join(directory, 'rbs_collection.yaml') + yaml_file if File.file?(yaml_file) + end end + end + + # @param name [String] + # @param version [String, nil] + # + # @return [Gem::Specification, nil] + def find_gem name, version = nil + Gem::Specification.find_by_name(name, version) + rescue Gem::MissingSpecError + nil + end + + # @todo make this actually work against bundle instead of pulling + # all installed gemspecs - + # https://github.com/apiology/solargraph/pull/10 + # @return [Array] + def all_gemspecs_from_bundle + Gem::Specification.to_a + end + + # @param out [IO, nil] output stream for logging + # @param rebuild [Boolean] whether to rebuild the pins even if they are cached + # @return [void] + def cache_all_for_workspace! out, rebuild: false + PinCache.cache_core(out: out) unless PinCache.core? + + gem_specs = all_gemspecs_from_bundle + # try any possible standard libraries, but be quiet about it + stdlib_specs = pin_cache.possible_stdlibs.map { |stdlib| find_gem(stdlib, out: nil) }.compact + specs = (gem_specs + stdlib_specs) + specs.each do |spec| + pin_cache.cache_gem(gemspec: spec, rebuild: rebuild, out: out) unless pin_cache.cached?(spec) end + out&.puts "Documentation cached for all #{specs.length} gems." + + # do this after so that we prefer stdlib requires from gems, + # which are likely to be newer and have more pins + pin_cache.cache_all_stdlibs(out: out) + + out&.puts "Documentation cached for core, standard library and gems." end # Synchronize the workspace from the provided updater. @@ -186,7 +281,10 @@ def load_sources source_hash.clear unless directory.empty? || directory == '*' size = config.calculated.length - raise WorkspaceTooLargeError, "The workspace is too large to index (#{size} files, #{config.max_files} max)" if config.max_files > 0 and size > config.max_files + if config.max_files > 0 and size > config.max_files + raise WorkspaceTooLargeError, + "The workspace is too large to index (#{size} files, #{config.max_files} max)" + end config.calculated.each do |filename| begin source_hash[filename] = Solargraph::Source.load(filename) diff --git a/lib/solargraph/yardoc.rb b/lib/solargraph/yardoc.rb index 907afb2de..43364e838 100644 --- a/lib/solargraph/yardoc.rb +++ b/lib/solargraph/yardoc.rb @@ -8,15 +8,15 @@ module Solargraph module Yardoc module_function - # Build and cache a gem's yardoc and return the path. If the cache already - # exists, do nothing and return the path. + # Build and save a gem's yardoc into a given path. # - # @param yard_plugins [Array] The names of YARD plugins to use. + # @param gem_yardoc_path [String] the path to the yardoc cache of a particular gem + # @param yard_plugins [Array] # @param gemspec [Gem::Specification] - # @return [String] The path to the cached yardoc. - def cache(yard_plugins, gemspec) - path = PinCache.yardoc_path gemspec - return path if cached?(gemspec) + # + # @return [void] + def build_docs gem_yardoc_path, yard_plugins, gemspec + return if docs_built?(gem_yardoc_path) unless Dir.exist? gemspec.gem_dir # Can happen in at least some (old?) RubyGems versions when we @@ -24,35 +24,42 @@ def cache(yard_plugins, gemspec) # # https://github.com/apiology/solargraph/actions/runs/17650140201/job/50158676842?pr=10 Solargraph.logger.info { "Bad info from gemspec - #{gemspec.gem_dir} does not exist" } - return path + return end Solargraph.logger.info "Caching yardoc for #{gemspec.name} #{gemspec.version}" - cmd = "yardoc --db #{path} --no-output --plugin solargraph" + cmd = "yardoc --db #{gem_yardoc_path} --no-output --plugin solargraph" yard_plugins.each { |plugin| cmd << " --plugin #{plugin}" } Solargraph.logger.debug { "Running: #{cmd}" } # @todo set these up to run in parallel stdout_and_stderr_str, status = Open3.capture2e(current_bundle_env_tweaks, cmd, chdir: gemspec.gem_dir) - unless status.success? - Solargraph.logger.warn { "YARD failed running #{cmd.inspect} in #{gemspec.gem_dir}" } - Solargraph.logger.info stdout_and_stderr_str - end - path + return if status.success? + Solargraph.logger.warn { "YARD failed running #{cmd.inspect} in #{gemspec.gem_dir}" } + Solargraph.logger.info stdout_and_stderr_str + end + + # @param gem_yardoc_path [String] the path to the yardoc cache of a particular gem + # @param gemspec [Gem::Specification, Bundler::LazySpecification] + # @param out [StringIO, IO, nil] where to log messages + # @return [Array] + def build_pins gem_yardoc_path, gemspec, out: $stderr + yardoc = load!(gem_yardoc_path) + YardMap::Mapper.new(yardoc, gemspec).map end # True if the gem yardoc is cached. # - # @param gemspec [Gem::Specification] - def cached?(gemspec) - yardoc = File.join(PinCache.yardoc_path(gemspec), 'complete') + # @param gem_yardoc_path [String] + def docs_built? gem_yardoc_path + yardoc = File.join(gem_yardoc_path, 'complete') File.exist?(yardoc) end # True if another process is currently building the yardoc cache. # - # @param gemspec [Gem::Specification] - def processing?(gemspec) - yardoc = File.join(PinCache.yardoc_path(gemspec), 'processing') + # @param gem_yardoc_path [String] the path to the yardoc cache of a particular gem + def processing? gem_yardoc_path + yardoc = File.join(gem_yardoc_path, 'processing') File.exist?(yardoc) end @@ -60,10 +67,10 @@ def processing?(gemspec) # # @note This method modifies the global YARD registry. # - # @param gemspec [Gem::Specification] + # @param gem_yardoc_path [String] the path to the yardoc cache of a particular gem # @return [Array] - def load!(gemspec) - YARD::Registry.load! PinCache.yardoc_path gemspec + def load! gem_yardoc_path + YARD::Registry.load! gem_yardoc_path YARD::Registry.all end diff --git a/rbs/fills/rubygems/0/dependency.rbs b/rbs/fills/rubygems/0/dependency.rbs new file mode 100644 index 000000000..db572b80d --- /dev/null +++ b/rbs/fills/rubygems/0/dependency.rbs @@ -0,0 +1,193 @@ +# +# The Dependency class holds a Gem name and a Gem::Requirement. +# +class Gem::Dependency + @name: untyped + + @requirement: untyped + + @type: untyped + + @prerelease: untyped + + @version_requirements: untyped + + @version_requirement: untyped + + # + # Valid dependency types. + # + TYPES: ::Array[:development | :runtime] + + # + # Dependency name or regular expression. + # + attr_accessor name: untyped + + # + # Allows you to force this dependency to be a prerelease. + # + attr_writer prerelease: untyped + + # + # Constructs a dependency with `name` and `requirements`. The last argument can + # optionally be the dependency type, which defaults to `:runtime`. + # + def initialize: (untyped name, *untyped requirements) -> void + + def hash: () -> untyped + + def inspect: () -> untyped + + # + # Does this dependency require a prerelease? + # + def prerelease?: () -> untyped + + # + # Is this dependency simply asking for the latest version of a gem? + # + def latest_version?: () -> untyped + + def pretty_print: (untyped q) -> untyped + + # + # What does this dependency require? + # + def requirement: () -> untyped + + # + # + def requirements_list: () -> untyped + + def to_s: () -> ::String + + # + # Dependency type. + # + def type: () -> untyped + + # + # + def runtime?: () -> untyped + + def ==: (untyped other) -> untyped + + # + # Dependencies are ordered by name. + # + def <=>: (untyped other) -> untyped + + # + # Uses this dependency as a pattern to compare to `other`. This dependency will + # match if the name matches the other's name, and other has only an equal + # version requirement that satisfies this dependency. + # + def =~: (untyped other) -> (nil | false | untyped) + + # + # + alias === =~ + + # + # Does this dependency match the specification described by `name` and `version` + # or match `spec`? + # + # NOTE: Unlike #matches_spec? this method does not return true when the version + # is a prerelease version unless this is a prerelease dependency. + # + def match?: (untyped obj, ?untyped? version, ?bool allow_prerelease) -> (false | true | untyped) + + # + # Does this dependency match `spec`? + # + # NOTE: This is not a convenience method. Unlike #match? this method returns + # true when `spec` is a prerelease version even if this dependency is not a + # prerelease dependency. + # + def matches_spec?: (untyped spec) -> (false | true | untyped) + + # + # Merges the requirements of `other` into this dependency + # + def merge: (untyped other) -> untyped + + # + # + def matching_specs: (?bool platform_only) -> untyped + + # + # True if the dependency will not always match the latest version. + # + def specific?: () -> untyped + + # + # + def to_specs: () -> untyped + + # + # + def to_spec: () -> untyped + + # + # + def identity: () -> (:complete | :abs_latest | :latest | :released) + + def encode_with: (untyped coder) -> untyped +end diff --git a/spec/api_map/index_spec.rb b/spec/api_map/index_spec.rb new file mode 100644 index 000000000..8afb74759 --- /dev/null +++ b/spec/api_map/index_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +describe Solargraph::ApiMap::Index do + subject(:output_pins) { described_class.new(input_pins).pins } + + describe '#map_overrides' do + let(:foo_class) do + Solargraph::Pin::Namespace.new(name: 'Foo') + end + + let(:foo_initialize) do + init = Solargraph::Pin::Method.new(name: 'initialize', + scope: :instance, + parameters: [], + closure: foo_class) + # no return type specified + param = Solargraph::Pin::Parameter.new(name: 'bar', + closure: init) + init.parameters << param + init + end + + let(:foo_new) do + init = Solargraph::Pin::Method.new(name: 'new', + scope: :class, + parameters: [], + closure: foo_class) + # no return type specified + param = Solargraph::Pin::Parameter.new(name: 'bar', + closure: init) + init.parameters << param + init + end + + let(:foo_override) do + Solargraph::Pin::Reference::Override.from_comment('Foo#initialize', + '@param [String] bar') + end + + let(:input_pins) do + [ + foo_initialize, + foo_new, + foo_override + ] + end + + it 'has a docstring to process on override' do + expect(foo_override.docstring.tags).to be_empty + end + + it 'overrides .new method' do + method_pin = output_pins.find { |pin| pin.path == 'Foo.new' } + first_parameter = method_pin.parameters.first + expect(first_parameter.return_type.tag).to eq('String') + end + + it 'overrides #initialize method in signature' do + method_pin = output_pins.find { |pin| pin.path == 'Foo#initialize' } + first_parameter = method_pin.parameters.first + expect(first_parameter.return_type.tag).to eq('String') + end + end +end diff --git a/spec/api_map_method_spec.rb b/spec/api_map_method_spec.rb index 9d4e4f553..0ee0238cf 100644 --- a/spec/api_map_method_spec.rb +++ b/spec/api_map_method_spec.rb @@ -133,6 +133,30 @@ class B end end + describe '#cache_all_for_doc_map!' do + it 'can cache gems without a bench' do + api_map = Solargraph::ApiMap.new + doc_map = instance_double(Solargraph::DocMap, cache_doc_map_gems!: true) + allow(Solargraph::DocMap).to receive(:new).and_return(doc_map) + api_map.cache_all_for_doc_map!($stderr) + expect(doc_map).to have_received(:cache_doc_map_gems!).with($stderr) + end + end + + describe '#workspace' do + it 'can get a default workspace without a bench' do + api_map = Solargraph::ApiMap.new + expect(api_map.workspace).not_to be_nil + end + end + + describe '#uncached_gemspecs' do + it 'can get uncached gemspecs workspace without a bench' do + api_map = Solargraph::ApiMap.new + expect(api_map.uncached_gemspecs).not_to be_nil + end + end + describe '#get_methods' do it 'recognizes mixin references from context' do source = Solargraph::Source.load_string(%( diff --git a/spec/doc_map_spec.rb b/spec/doc_map_spec.rb index e82332161..1e50301f4 100644 --- a/spec/doc_map_spec.rb +++ b/spec/doc_map_spec.rb @@ -1,81 +1,131 @@ # frozen_string_literal: true +require 'bundler' +require 'benchmark' + describe Solargraph::DocMap do - before :all do - # We use ast here because it's a known dependency. - gemspec = Gem::Specification.find_by_name('ast') - yard_pins = Solargraph::GemPins.build_yard_pins([], gemspec) - Solargraph::PinCache.serialize_yard_gem(gemspec, yard_pins) + subject(:doc_map) do + described_class.new(requires, [], workspace, out: out) end - it 'generates pins from gems' do - doc_map = Solargraph::DocMap.new(['ast'], []) - doc_map.cache_all!($stderr) - node_pin = doc_map.pins.find { |pin| pin.path == 'AST::Node' } - expect(node_pin).to be_a(Solargraph::Pin::Namespace) - end + let(:out) { StringIO.new } + let(:pre_cache) { true } + let(:requires) { [] } - it 'tracks unresolved requires' do - doc_map = Solargraph::DocMap.new(['not_a_gem'], []) - expect(doc_map.unresolved_requires).to include('not_a_gem') + let(:workspace) do + Solargraph::Workspace.new(Dir.pwd) end - it 'tracks uncached_gemspecs' do - gemspec = Gem::Specification.new do |spec| - spec.name = 'not_a_gem' - spec.version = '1.0.0' - end - allow(Gem::Specification).to receive(:find_by_path).and_return(gemspec) - doc_map = Solargraph::DocMap.new(['not_a_gem'], [gemspec]) - expect(doc_map.uncached_yard_gemspecs).to eq([gemspec]) - expect(doc_map.uncached_rbs_collection_gemspecs).to eq([gemspec]) + let(:plain_doc_map) { described_class.new([], [], workspace, out: nil) } + + before do + doc_map.cache_doc_map_gems!(nil) if pre_cache end - it 'imports all gems when bundler/require used' do - workspace = Solargraph::Workspace.new(Dir.pwd) - plain_doc_map = Solargraph::DocMap.new([], [], workspace) - doc_map_with_bundler_require = Solargraph::DocMap.new(['bundler/require'], [], workspace) + context 'with a require in solargraph test bundle' do + let(:requires) do + ['ast'] + end - expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + it 'generates pins from gems' do + node_pin = doc_map.pins.find { |pin| pin.path == 'AST::Node' } + expect(node_pin).to be_a(Solargraph::Pin::Namespace) + end end it 'does not warn for redundant requires' do # Requiring 'set' is unnecessary because it's already included in core. It # might make sense to log redundant requires, but a warning is overkill. allow(Solargraph.logger).to receive(:warn).and_call_original - Solargraph::DocMap.new(['set'], []) + Solargraph::DocMap.new(['set'], [], workspace) expect(Solargraph.logger).not_to have_received(:warn).with(/path set/) end it 'ignores nil requires' do - expect { Solargraph::DocMap.new([nil], []) }.not_to raise_error + expect { Solargraph::DocMap.new([nil], [], workspace) }.not_to raise_error end it 'ignores empty requires' do - expect { Solargraph::DocMap.new([''], []) }.not_to raise_error + expect { Solargraph::DocMap.new([''], [], workspace) }.not_to raise_error end it 'collects dependencies' do - doc_map = Solargraph::DocMap.new(['rspec'], []) + doc_map = Solargraph::DocMap.new(['rspec'], [], workspace) expect(doc_map.dependencies.map(&:name)).to include('rspec-core') end - it 'includes convention requires from environ' do - dummy_convention = Class.new(Solargraph::Convention::Base) do - def global(doc_map) - Solargraph::Environ.new( - requires: ['convention_gem1', 'convention_gem2'] - ) - end + context 'with an uncached but valid gemspec' do + let(:requires) { ['uncached_gem'] } + let(:pre_cache) { false } + let(:workspace) { instance_double(Solargraph::Workspace) } + + it 'tracks uncached_gemspecs' do + pincache = instance_double(Solargraph::PinCache) + uncached_gemspec = Gem::Specification.new('uncached_gem', '1.0.0') + allow(workspace).to receive_messages(fresh_pincache: pincache) + allow(Gem::Specification).to receive(:find_by_path).with('uncached_gem').and_return(uncached_gemspec) + allow(workspace).to receive_messages(stdlib_dependencies: [], global_environ: Solargraph::Environ.new) + allow(pincache).to receive(:deserialize_combined_pin_cache).with(uncached_gemspec).and_return(nil) + expect(doc_map.uncached_gemspecs).to eq([uncached_gemspec]) end + end - Solargraph::Convention.register dummy_convention + context 'with require as bundle/require' do + it 'imports all gems when bundler/require used' do + doc_map_with_bundler_require = described_class.new(['bundler/require'], [], workspace, out: nil) + doc_map_with_bundler_require.cache_doc_map_gems!(nil) + expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + end + end - doc_map = Solargraph::DocMap.new(['original_gem'], []) + context 'with a require not needed by Ruby core' do + let(:requires) { ['set'] } + + it 'does not warn' do + # Requiring 'set' is unnecessary because it's already included in core. It + # might make sense to log redundant requires, but a warning is overkill. + allow(Solargraph.logger).to receive(:warn) + doc_map + expect(Solargraph.logger).not_to have_received(:warn).with(/path set/) + end + end - expect(doc_map.requires).to include('original_gem', 'convention_gem1', 'convention_gem2') + context 'with a nil require' do + let(:requires) { [nil] } - # Clean up the registered convention - Solargraph::Convention.unregister dummy_convention + it 'does not raise error' do + expect { doc_map }.not_to raise_error + end + end + + context 'with an empty require' do + let(:requires) { [''] } + + it 'does not raise error' do + expect { doc_map }.not_to raise_error + end + end + + context 'with convention' do + let(:pre_cache) { false } + + it 'includes convention requires from environ' do + dummy_convention = Class.new(Solargraph::Convention::Base) do + def global(doc_map) + Solargraph::Environ.new( + requires: ['convention_gem1', 'convention_gem2'] + ) + end + end + + Solargraph::Convention.register dummy_convention + + doc_map = Solargraph::DocMap.new(['original_gem'], [], workspace) + + expect(doc_map.requires).to include('original_gem', 'convention_gem1', 'convention_gem2') + ensure + # Clean up the registered convention + Solargraph::Convention.unregister dummy_convention + end end end diff --git a/spec/gem_pins_spec.rb b/spec/gem_pins_spec.rb index 8e3962341..4d2bb4ff5 100644 --- a/spec/gem_pins_spec.rb +++ b/spec/gem_pins_spec.rb @@ -1,19 +1,49 @@ # frozen_string_literal: true describe Solargraph::GemPins do - it 'can merge YARD and RBS' do - gemspec = Gem::Specification.find_by_name('rbs') - yard_pins = Solargraph::GemPins.build_yard_pins([], gemspec) - rbs_map = Solargraph::RbsMap.from_gemspec(gemspec, nil, nil) - pins = Solargraph::GemPins.combine yard_pins, rbs_map.pins - - core_root = pins.find { |pin| pin.path == 'RBS::EnvironmentLoader#core_root' } - expect(core_root.return_type.to_s).to eq('Pathname, nil') - expect(core_root.location.filename).to end_with('environment_loader.rb') + let(:workspace) { Solargraph::Workspace.new(Dir.pwd) } + let(:doc_map) { Solargraph::DocMap.new(requires, [], workspace, out: nil) } + let(:pin) { doc_map.pins.find { |pin| pin.path == path } } + + before do + doc_map.cache_doc_map_gems!(STDERR) # rubocop:disable Style/GlobalStdStream + end + + context 'with a combined method pin' do + let(:path) { 'RBS::EnvironmentLoader#core_root' } + let(:requires) { ['rbs'] } + + it 'can merge YARD and RBS' do + expect(pin.source).to eq(:combined) + end + + it 'finds types from RBS' do + expect(pin.return_type.to_s).to eq('Pathname, nil') + end + + it 'finds locations from YARD' do + expect(pin.location.filename).to end_with('environment_loader.rb') + end end - it 'does not error out when handed incorrect gemspec' do - gemspec = instance_double(Gem::Specification, name: 'foo', version: '1.0', gem_dir: '/not-there') - expect { Solargraph::GemPins.build_yard_pins([], gemspec) }.not_to raise_error + context 'with a YARD-only pin' do + let(:requires) { ['rake'] } + let(:path) { 'Rake::Task#prerequisites' } + + it 'found a pin' do + expect(pin.source).not_to be_nil + end + + it 'can merge YARD and RBS' do + expect(pin.source).to eq(:yardoc) + end + + it 'does not find types from YARD in this case' do + expect(pin.return_type.to_s).to eq('undefined') + end + + it 'finds locations from YARD' do + expect(pin.location.filename).to end_with('task.rb') + end end end diff --git a/spec/library_spec.rb b/spec/library_spec.rb index 34de9e1f0..f7daafdf4 100644 --- a/spec/library_spec.rb +++ b/spec/library_spec.rb @@ -26,6 +26,32 @@ expect(completion.pins.map(&:name)).to include('x') end + context 'with a require from a not-yet-cached external gem' do + before do + Solargraph::Shell.new.uncache('backport') + end + + it "returns a Completion", time_limit_seconds: 50 do + library = Solargraph::Library.new(Solargraph::Workspace.new(Dir.pwd, + Solargraph::Workspace::Config.new)) + library.attach Solargraph::Source.load_string(%( + require 'backport' + + # @param adapter [Backport::Adapter] + def foo(adapter) + adapter.remo + end + ), 'file.rb', 0) + completion = nil + # give Solargraph time to cache the gem + while (completion = library.completions_at('file.rb', 5, 19)).pins.empty? + sleep 0.25 + end + expect(completion).to be_a(Solargraph::SourceMap::Completion) + expect(completion.pins.map(&:name)).to include('remote') + end + end + context 'with a require from an already-cached external gem' do before do Solargraph::Shell.new.gems('backport') @@ -161,10 +187,47 @@ def bar expect(pins.map(&:path)).to include('Foo#bar') end - it "collects references to an instance method symbol" do - workspace = Solargraph::Workspace.new('*') - library = Solargraph::Library.new(workspace) - src1 = Solargraph::Source.load_string(%( + describe '#references_from' do + it "collects references to a new method on a constant from assignment of Class.new" do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + src1 = Solargraph::Source.load_string(%( + Foo.new + ), 'file1.rb', 0) + library.merge src1 + src2 = Solargraph::Source.load_string(%( + Foo = Class.new + ), 'file2.rb', 0) + library.merge src2 + library.catalog + locs = library.references_from('file1.rb', 1, 12) + expect(locs.map { |l| [l.filename, l.range.start.line] }) + .to eq([["file1.rb", 1]]) + end + + it "collects references to a new method to a constant from assignment" do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + src1 = Solargraph::Source.load_string(%( + Foo.new + ), 'file1.rb', 0) + library.merge src1 + src2 = Solargraph::Source.load_string(%( + class Foo + end + blah = Foo.new + ), 'file2.rb', 0) + library.merge src2 + library.catalog + locs = library.references_from('file2.rb', 3, 21) + expect(locs.map { |l| [l.filename, l.range.start.line] }) + .to eq([["file1.rb", 1], ["file2.rb", 3]]) + end + + it "collects references to an instance method symbol" do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + src1 = Solargraph::Source.load_string(%( class Foo def bar end @@ -172,8 +235,8 @@ def bar Foo.new.bar ), 'file1.rb', 0) - library.merge src1 - src2 = Solargraph::Source.load_string(%( + library.merge src1 + src2 = Solargraph::Source.load_string(%( foo = Foo.new foo.bar class Other @@ -181,17 +244,17 @@ def bar; end end Other.new.bar ), 'file2.rb', 0) - library.merge src2 - library.catalog - locs = library.references_from('file2.rb', 2, 11) - expect(locs.length).to eq(3) - expect(locs.select{|l| l.filename == 'file2.rb' && l.range.start.line == 6}).to be_empty - end + library.merge src2 + library.catalog + locs = library.references_from('file2.rb', 2, 11) + expect(locs.length).to eq(3) + expect(locs.select{|l| l.filename == 'file2.rb' && l.range.start.line == 6}).to be_empty + end - it "collects references to a class method symbol" do - workspace = Solargraph::Workspace.new('*') - library = Solargraph::Library.new(workspace) - src1 = Solargraph::Source.load_string(%( + it "collects references to a class method symbol" do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + src1 = Solargraph::Source.load_string(%( class Foo def self.bar end @@ -203,8 +266,8 @@ def bar Foo.bar Foo.new.bar ), 'file1.rb', 0) - library.merge src1 - src2 = Solargraph::Source.load_string(%( + library.merge src1 + src2 = Solargraph::Source.load_string(%( Foo.bar Foo.new.bar class Other @@ -214,48 +277,48 @@ def bar; end Other.bar Other.new.bar ), 'file2.rb', 0) - library.merge src2 - library.catalog - locs = library.references_from('file2.rb', 1, 11) - expect(locs.length).to eq(3) - expect(locs.select{|l| l.filename == 'file1.rb' && l.range.start.line == 2}).not_to be_empty - expect(locs.select{|l| l.filename == 'file1.rb' && l.range.start.line == 9}).not_to be_empty - expect(locs.select{|l| l.filename == 'file2.rb' && l.range.start.line == 1}).not_to be_empty - end + library.merge src2 + library.catalog + locs = library.references_from('file2.rb', 1, 11) + expect(locs.length).to eq(3) + expect(locs.select{|l| l.filename == 'file1.rb' && l.range.start.line == 2}).not_to be_empty + expect(locs.select{|l| l.filename == 'file1.rb' && l.range.start.line == 9}).not_to be_empty + expect(locs.select{|l| l.filename == 'file2.rb' && l.range.start.line == 1}).not_to be_empty + end - it "collects stripped references to constant symbols" do - workspace = Solargraph::Workspace.new('*') - library = Solargraph::Library.new(workspace) - src1 = Solargraph::Source.load_string(%( + it "collects stripped references to constant symbols" do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + src1 = Solargraph::Source.load_string(%( class Foo def bar end end Foo.new.bar ), 'file1.rb', 0) - library.merge src1 - src2 = Solargraph::Source.load_string(%( + library.merge src1 + src2 = Solargraph::Source.load_string(%( class Other foo = Foo.new foo.bar end ), 'file2.rb', 0) - library.merge src2 - library.catalog - locs = library.references_from('file1.rb', 1, 12, strip: true) - expect(locs.length).to eq(3) - locs.each do |l| - code = library.read_text(l.filename) - o1 = Solargraph::Position.to_offset(code, l.range.start) - o2 = Solargraph::Position.to_offset(code, l.range.ending) - expect(code[o1..o2-1]).to eq('Foo') + library.merge src2 + library.catalog + locs = library.references_from('file1.rb', 1, 12, strip: true) + expect(locs.length).to eq(3) + locs.each do |l| + code = library.read_text(l.filename) + o1 = Solargraph::Position.to_offset(code, l.range.start) + o2 = Solargraph::Position.to_offset(code, l.range.ending) + expect(code[o1..o2-1]).to eq('Foo') + end end - end - it 'rejects new references from different classes' do - workspace = Solargraph::Workspace.new('*') - library = Solargraph::Library.new(workspace) - source = Solargraph::Source.load_string(%( + it 'rejects new references from different classes' do + workspace = Solargraph::Workspace.new('*') + library = Solargraph::Library.new(workspace) + source = Solargraph::Source.load_string(%( class Foo def bar end @@ -263,106 +326,131 @@ def bar Foo.new Array.new ), 'test.rb') - library.merge source - library.catalog - foo_new_locs = library.references_from('test.rb', 5, 10) - expect(foo_new_locs).to eq([Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 10, 5, 13))]) - obj_new_locs = library.references_from('test.rb', 6, 12) - expect(obj_new_locs).to eq([Solargraph::Location.new('test.rb', Solargraph::Range.from_to(6, 12, 6, 15))]) - end + library.merge source + library.catalog + foo_new_locs = library.references_from('test.rb', 5, 10) + expect(foo_new_locs).to eq([Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 10, 5, 13))]) + obj_new_locs = library.references_from('test.rb', 6, 12) + expect(obj_new_locs).to eq([Solargraph::Location.new('test.rb', Solargraph::Range.from_to(6, 12, 6, 15))]) + end - it "searches the core for queries" do - library = Solargraph::Library.new - result = library.search('String') - expect(result).not_to be_empty - end + it "searches the core for queries" do + library = Solargraph::Library.new + result = library.search('String') + expect(result).not_to be_empty + end - it "returns YARD documentation from the core" do - library = Solargraph::Library.new - api_map, result = library.document('String') - expect(result).not_to be_empty - expect(result.first).to be_a(Solargraph::Pin::Base) - end + it "returns YARD documentation from the core" do + library = Solargraph::Library.new + api_map, result = library.document('String') + expect(result).not_to be_empty + expect(result.first).to be_a(Solargraph::Pin::Base) + end - it "returns YARD documentation from sources" do - library = Solargraph::Library.new - src = Solargraph::Source.load_string(%( + it "returns YARD documentation from sources" do + library = Solargraph::Library.new + src = Solargraph::Source.load_string(%( class Foo # My bar method def bar; end end ), 'test.rb', 0) - library.attach src - api_map, result = library.document('Foo#bar') - expect(result).not_to be_empty - expect(result.first).to be_a(Solargraph::Pin::Base) - end + library.attach src + api_map, result = library.document('Foo#bar') + expect(result).not_to be_empty + expect(result.first).to be_a(Solargraph::Pin::Base) + end - it "synchronizes sources from updaters" do - library = Solargraph::Library.new - src = Solargraph::Source.load_string(%( + it "synchronizes sources from updaters" do + library = Solargraph::Library.new + src = Solargraph::Source.load_string(%( class Foo end ), 'test.rb', 1) - library.attach src - repl = %( + library.attach src + repl = %( class Foo def bar; end end ) - updater = Solargraph::Source::Updater.new( - 'test.rb', - 2, - [Solargraph::Source::Change.new(nil, repl)] - ) - library.attach src.synchronize(updater) - expect(library.current.code).to eq(repl) - end + updater = Solargraph::Source::Updater.new( + 'test.rb', + 2, + [Solargraph::Source::Change.new(nil, repl)] + ) + library.attach src.synchronize(updater) + expect(library.current.code).to eq(repl) + end - it "finds unique references" do - library = Solargraph::Library.new(Solargraph::Workspace.new('*')) - src1 = Solargraph::Source.load_string(%( + it "finds unique references" do + library = Solargraph::Library.new(Solargraph::Workspace.new('*')) + src1 = Solargraph::Source.load_string(%( class Foo end ), 'src1.rb', 1) - library.merge src1 - src2 = Solargraph::Source.load_string(%( + library.merge src1 + src2 = Solargraph::Source.load_string(%( foo = Foo.new ), 'src2.rb', 1) - library.merge src2 - library.catalog - refs = library.references_from('src2.rb', 1, 12) - expect(refs.length).to eq(2) - end + library.merge src2 + library.catalog + refs = library.references_from('src2.rb', 1, 12) + expect(refs.length).to eq(2) + end - it "includes method parameters in references" do - library = Solargraph::Library.new(Solargraph::Workspace.new('*')) - source = Solargraph::Source.load_string(%( + it "includes method parameters in references" do + library = Solargraph::Library.new(Solargraph::Workspace.new('*')) + source = Solargraph::Source.load_string(%( class Foo def bar(baz) baz.upcase end end ), 'test.rb', 1) - library.attach source - from_def = library.references_from('test.rb', 2, 16) - expect(from_def.length).to eq(2) - from_ref = library.references_from('test.rb', 3, 10) - expect(from_ref.length).to eq(2) - end + library.attach source + from_def = library.references_from('test.rb', 2, 16) + expect(from_def.length).to eq(2) + from_ref = library.references_from('test.rb', 3, 10) + expect(from_ref.length).to eq(2) + end - it "includes block parameters in references" do - library = Solargraph::Library.new(Solargraph::Workspace.new('*')) - source = Solargraph::Source.load_string(%( + it "lies about names when client can't handle the truth" do + library = Solargraph::Library.new(Solargraph::Workspace.new('*')) + source = Solargraph::Source.load_string(%( + class Foo + def 🤦🏻foo♀️; 123; end + end + ), 'test.rb', 1) + library.attach source + from_def = library.references_from('test.rb', 2, 16, strip: true) + expect(from_def.first.range.start.column).to eq(14) + end + + it "tells the truth about names when client can handle the truth" do + library = Solargraph::Library.new(Solargraph::Workspace.new('*')) + source = Solargraph::Source.load_string(%( + class Foo + def 🤦🏻foo♀️; 123; end + end + ), 'test.rb', 1) + library.attach source + from_def = library.references_from('test.rb', 2, 16, strip: false) + expect(from_def.first.range.start.column).to eq(12) + end + + it "includes block parameters in references" do + library = Solargraph::Library.new(Solargraph::Workspace.new('*')) + source = Solargraph::Source.load_string(%( 100.times do |foo| puts foo end ), 'test.rb', 1) - library.attach source - from_def = library.references_from('test.rb', 1, 20) - expect(from_def.length).to eq(2) - from_ref = library.references_from('test.rb', 2, 13) - expect(from_ref.length).to eq(2) + library.attach source + from_def = library.references_from('test.rb', 1, 20) + expect(from_def.length).to eq(2) + from_ref = library.references_from('test.rb', 2, 13) + expect(from_ref.length).to eq(2) + end end it 'defines YARD tags' do diff --git a/spec/pin_cache_spec.rb b/spec/pin_cache_spec.rb new file mode 100644 index 000000000..0f766117a --- /dev/null +++ b/spec/pin_cache_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'bundler' +require 'benchmark' + +describe Solargraph::PinCache do + subject(:pin_cache) do + described_class.new(rbs_collection_path: '.gem_rbs_collection', + rbs_collection_config_path: 'rbs_collection.yaml', + directory: Dir.pwd, + yard_plugins: ['activesupport-concern']) + end + + describe '#cached?' do + it 'returns true for a gem that is cached' do + allow(File).to receive(:file?).with(%r{.*stdlib/backport.ser$}).and_return(false) + allow(File).to receive(:file?).with(%r{.*combined/.*/backport-.*.ser$}).and_return(true) + + gemspec = Gem::Specification.find_by_name('backport') + expect(pin_cache.cached?(gemspec)).to be true + end + + it 'returns false for a gem that is not cached' do + gemspec = Gem::Specification.new.tap do |spec| + spec.name = 'nonexistent' + spec.version = '0.0.1' + end + expect(pin_cache.cached?(gemspec)).to be false + end + end + + describe '.core?' do + it 'returns true when core pins exist' do + allow(File).to receive(:file?).with(%r{.*/core.ser$}).and_return(true) + + expect(described_class.core?).to be true + end + + it "returns true when core pins don't" do + allow(File).to receive(:file?).with(%r{.*/core.ser$}).and_return(false) + + expect(described_class.core?).to be false + end + end + + describe '#possible_stdlibs' do + it 'is tolerant of less usual Ruby installations' do + stub_const('Gem::RUBYGEMS_DIR', nil) + + expect(pin_cache.possible_stdlibs).to eq([]) + end + end + + describe '#cache_all_stdlibs' do + it 'creates stdlibmaps' do + allow(Solargraph::RbsMap::StdlibMap).to receive(:new).and_return(instance_double(Solargraph::RbsMap::StdlibMap)) + + pin_cache.cache_all_stdlibs + + expect(Solargraph::RbsMap::StdlibMap).to have_received(:new).at_least(:once) + end + end + + describe '#cache_gem' do + context 'with an already in-memory gem' do + let(:backport_gemspec) { Gem::Specification.find_by_name('backport') } + + before do + pin_cache.cache_gem(gemspec: backport_gemspec, out: nil) + end + + it 'does not load the gem again' do + allow(Marshal).to receive(:load).and_call_original + + pin_cache.cache_gem(gemspec: backport_gemspec, out: nil) + + expect(Marshal).not_to have_received(:load).with(anything) + end + end + + context 'with the parser gem' do + before do + Solargraph::Shell.new.uncache('parser') + allow(Solargraph::Yardoc).to receive(:build_docs) + end + + it 'chooses not to use YARD' do + parser_gemspec = Gem::Specification.find_by_name('parser') + pin_cache.cache_gem(gemspec: parser_gemspec, out: nil) + # if this fails, you may not have run `bundle exec rbs collection update` + expect(Solargraph::Yardoc).not_to have_received(:build_docs).with(any_args) + end + end + + context 'with an installed gem' do + before do + Solargraph::Shell.new.gems('kramdown') + end + + it 'uncaches when asked' do + gemspec = Gem::Specification.find_by_name('kramdown') + expect do + pin_cache.uncache_gem(gemspec, out: nil) + end.not_to raise_error + end + end + + context 'with the rebuild flag' do + before do + allow(Solargraph::Yardoc).to receive(:build_docs) + end + + it 'chooses not to use YARD' do + parser_gemspec = Gem::Specification.find_by_name('parser') + pin_cache.cache_gem(gemspec: parser_gemspec, rebuild: true, out: nil) + # if this fails, you may not have run `bundle exec rbs collection update` + expect(Solargraph::Yardoc).not_to have_received(:build_docs).with(any_args) + end + end + + context 'with a stdlib gem' do + let(:gem_name) { 'logger' } + + before do + Solargraph::Shell.new.uncache(gem_name) + end + + it 'caches' do + yaml_gemspec = Gem::Specification.find_by_name(gem_name) + allow(File).to receive(:write).and_call_original + + pin_cache.cache_gem(gemspec: yaml_gemspec, out: nil) + + # match arguments with regexp using rspec-matchers syntax + expect(File).to have_received(:write).with(%r{combined/.*/logger-.*-stdlib.ser$}, any_args).once + end + end + + context 'with gem packaged with its own RBS' do + let(:gem_name) { 'rubocop-yard' } + + before do + Solargraph::Shell.new.uncache(gem_name) + end + + it 'caches' do + yaml_gemspec = Gem::Specification.find_by_name(gem_name) + allow(File).to receive(:write).and_call_original + + pin_cache.cache_gem(gemspec: yaml_gemspec, out: nil) + + # match arguments with regexp using rspec-matchers syntax + expect(File).to have_received(:write).with(%r{combined/.*/rubocop-yard-.*-export.ser$}, any_args, mode: 'wb').once + end + end + end + + describe '#uncache_gem' do + subject(:call) { pin_cache.uncache_gem(gemspec, out: out) } + + let(:out) { StringIO.new } + + before do + allow(FileUtils).to receive(:rm_rf) + end + + context 'with an already cached gem' do + let(:gemspec) { Gem::Specification.find_by_name('backport') } + + it 'deletes files' do + call + + expect(FileUtils).to have_received(:rm_rf).at_least(:once) + end + end + + context 'with a non-existent gem' do + let(:gemspec) { instance_double(Gem::Specification, name: 'nonexistent', version: '0.0.1') } + + it 'does not raise an error' do + expect { call }.not_to raise_error + end + + it 'logs a message' do + call + + expect(out.string).to include('does not exist') + end + + it 'does not delete files' do + call + + expect(FileUtils).not_to have_received(:rm_rf) + end + end + end +end diff --git a/spec/rbs_map/stdlib_map_spec.rb b/spec/rbs_map/stdlib_map_spec.rb index 03f0b547f..c9db9bb48 100644 --- a/spec/rbs_map/stdlib_map_spec.rb +++ b/spec/rbs_map/stdlib_map_spec.rb @@ -11,30 +11,6 @@ expect(pin).to be_a(Solargraph::Pin::Base) end - it 'processes RBS class variables' do - map = Solargraph::RbsMap::StdlibMap.load('rbs') - store = Solargraph::ApiMap::Store.new(map.pins) - class_variable_pins = store.pins_by_class(Solargraph::Pin::ClassVariable) - count_pins = class_variable_pins.select do |pin| - pin.name.to_s == '@@count' && pin.context.to_s == 'Class' - end - expect(count_pins.length).to eq(1) - count_pin = count_pins.first - expect(count_pin.return_type.to_s).to eq('Integer') - end - - it 'processes RBS class instance variables' do - map = Solargraph::RbsMap::StdlibMap.load('rbs') - store = Solargraph::ApiMap::Store.new(map.pins) - instance_variable_pins = store.pins_by_class(Solargraph::Pin::InstanceVariable) - root_pins = instance_variable_pins.select do |pin| - pin.name.to_s == '@root' && pin.context.to_s == 'Class' && pin.scope == :class - end - expect(root_pins.length).to eq(1) - root_pin = root_pins.first - expect(root_pin.return_type.to_s).to eq('RBS::Namespace, nil') - end - it 'processes RBS module aliases' do map = Solargraph::RbsMap::StdlibMap.load('yaml') store = Solargraph::ApiMap::Store.new(map.pins) diff --git a/spec/rbs_map_spec.rb b/spec/rbs_map_spec.rb index b06c975d1..4631c9ca5 100644 --- a/spec/rbs_map_spec.rb +++ b/spec/rbs_map_spec.rb @@ -3,7 +3,18 @@ spec = Gem::Specification.find_by_name('rbs') rbs_map = Solargraph::RbsMap.from_gemspec(spec, nil, nil) pin = rbs_map.path_pin('RBS::EnvironmentLoader#add_collection') - expect(pin).to be + expect(pin).not_to be_nil + end + + it 'fails if it does not find data from gemspec' do + spec = Gem::Specification.find_by_name('backport') + rbs_map = Solargraph::RbsMap.from_gemspec(spec, nil, nil) + expect(rbs_map).not_to be_resolved + end + + it 'fails if it does not find data from name' do + rbs_map = Solargraph::RbsMap.new('lskdflaksdfjl') + expect(rbs_map.pins).to be_empty end it 'converts constants and aliases to correct types' do @@ -14,4 +25,30 @@ pin = rbs_map.path_pin('RBS::EnvironmentWalker::InstanceNode') expect(pin.return_type.tag).to eq('Class') end + + it 'processes RBS class variables' do + spec = Gem::Specification.find_by_name('rbs') + rbs_map = Solargraph::RbsMap.from_gemspec(spec, nil, nil) + store = Solargraph::ApiMap::Store.new(rbs_map.pins) + class_variable_pins = store.pins_by_class(Solargraph::Pin::ClassVariable) + count_pins = class_variable_pins.select do |pin| + pin.name.to_s == '@@count' && pin.context.to_s == 'Class' + end + expect(count_pins.length).to eq(1) + count_pin = count_pins.first + expect(count_pin.return_type.to_s).to eq('Integer') + end + + it 'processes RBS class instance variables' do + spec = Gem::Specification.find_by_name('rbs') + rbs_map = Solargraph::RbsMap.from_gemspec(spec, nil, nil) + store = Solargraph::ApiMap::Store.new(rbs_map.pins) + instance_variable_pins = store.pins_by_class(Solargraph::Pin::InstanceVariable) + root_pins = instance_variable_pins.select do |pin| + pin.name.to_s == '@root' && pin.context.to_s == 'Class' && pin.scope == :class + end + expect(root_pins.length).to eq(1) + root_pin = root_pins.first + expect(root_pin.return_type.to_s).to eq('RBS::Namespace, nil') + end end diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index b9dc6b327..ddd8b9f7e 100644 --- a/spec/shell_spec.rb +++ b/spec/shell_spec.rb @@ -11,12 +11,14 @@ before do File.open(File.join(temp_dir, 'Gemfile'), 'w') do |file| file.puts "source 'https://rubygems.org'" - file.puts "gem 'solargraph', path: #{File.expand_path('..', __dir__)}" + file.puts "gem 'solargraph', path: '#{File.expand_path('..', __dir__)}'" end output, status = Open3.capture2e("bundle install", chdir: temp_dir) raise "Failure installing bundle: #{output}" unless status.success? end + # @type cmd [Array] + # @return [String] def bundle_exec(*cmd) # run the command in the temporary directory with bundle exec output, status = Open3.capture2e("bundle exec #{cmd.join(' ')}", chdir: temp_dir) @@ -29,21 +31,148 @@ def bundle_exec(*cmd) FileUtils.rm_rf(temp_dir) end - describe "--version" do - it "returns a version when run" do - output = bundle_exec("solargraph", "--version") + describe '--version' do + let(:output) { bundle_exec('solargraph', '--version') } + it 'returns output' do expect(output).not_to be_empty + end + + it 'returns a version when run' do expect(output).to eq("#{Solargraph::VERSION}\n") end end - describe "uncache" do - it "uncaches without erroring out" do - output = bundle_exec("solargraph", "uncache", "solargraph") + describe 'uncache' do + it 'uncaches without erroring out' do + output = capture_stdout do + shell.uncache('backport') + end expect(output).to include('Clearing pin cache in') end + + it 'uncaches stdlib without erroring out' do + expect { shell.uncache('stdlib') }.not_to raise_error + end + + it 'uncaches core without erroring out' do + expect { shell.uncache('core') }.not_to raise_error + end + end + + describe 'scan' do + context 'with mocked dependencies' do + let(:api_map) { instance_double(Solargraph::ApiMap) } + + before do + allow(Solargraph::ApiMap).to receive(:load_with_cache).and_return(api_map) + end + + it 'scans without erroring out' do + allow(api_map).to receive(:pins).and_return([]) + output = capture_stdout do + shell.options = { directory: 'spec/fixtures/workspace' } + shell.scan + end + + expect(output).to include('Scanned ').and include(' seconds.') + end + end + end + + describe 'typecheck' do + context 'with mocked dependencies' do + let(:type_checker) { instance_double(Solargraph::TypeChecker) } + let(:api_map) { instance_double(Solargraph::ApiMap) } + + before do + allow(Solargraph::ApiMap).to receive(:load_with_cache).and_return(api_map) + allow(Solargraph::TypeChecker).to receive(:new).and_return(type_checker) + allow(type_checker).to receive(:problems).and_return([]) + end + + it 'typechecks without erroring out' do + output = capture_stdout do + shell.options = { level: 'normal', directory: '.' } + shell.typecheck('Gemfile') + end + + expect(output).to include('Typecheck finished in') + end + end + end + + describe 'gems' do + context 'without mocked ApiMap' do + it 'complains when gem does not exist' do + output = capture_both do + shell.gems('nonexistentgem') + end + + expect(output).to include("Gem 'nonexistentgem' not found") + end + + it 'caches core without erroring out' do + capture_both do + shell.uncache('core') + end + + expect { shell.cache('core') }.not_to raise_error + end + + it 'gives sensible error for gem that does not exist' do + output = capture_both do + shell.gems('solargraph123') + end + + expect(output).to include("Gem 'solargraph123' not found") + end + end + + context 'with mocked Workspace' do + let(:workspace) { instance_double(Solargraph::Workspace) } + let(:gemspec) { instance_double(Gem::Specification, name: 'backport') } + + before do + allow(Solargraph::Workspace).to receive(:new).and_return(workspace) + end + + it 'caches all without erroring out' do + allow(workspace).to receive(:cache_all_for_workspace!) + + _output = capture_both { shell.gems } + + expect(workspace).to have_received(:cache_all_for_workspace!) + end + + it 'caches single gem without erroring out' do + allow(workspace).to receive(:find_gem).with('backport').and_return(gemspec) + allow(workspace).to receive(:cache_gem) + + capture_both do + shell.options = { rebuild: false } + shell.gems('backport') + end + + expect(workspace).to have_received(:cache_gem).with(gemspec, out: an_instance_of(StringIO), rebuild: false) + end + end + end + + describe 'cache' do + it 'caches a stdlib gem without erroring out' do + expect { shell.cache('stringio') }.not_to raise_error + end + + context 'when gem does not exist' do + subject(:call) { shell.cache('nonexistentgem8675309') } + + it 'gives a good error message' do + # capture stderr output + expect { call }.to output(/not found/).to_stderr + end + end end # @type cmd [Array] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 59d107aa3..0a0c1dde4 100755 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -26,7 +26,9 @@ c.example_status_persistence_file_path = 'rspec-examples.txt' end require 'solargraph' -# Suppress logger output in specs (if possible) +# execute any logging blocks to make sure they don't blow up +Solargraph::Logging.logger.sev_threshold = Logger::DEBUG +# ...but still suppress logger output in specs (if possible) if Solargraph::Logging.logger.respond_to?(:reopen) && !ENV.key?('SOLARGRAPH_LOG') Solargraph::Logging.logger.reopen(File::NULL) end diff --git a/spec/type_checker/levels/normal_spec.rb b/spec/type_checker/levels/normal_spec.rb index 9af91efe1..668e62886 100644 --- a/spec/type_checker/levels/normal_spec.rb +++ b/spec/type_checker/levels/normal_spec.rb @@ -1,5 +1,5 @@ describe Solargraph::TypeChecker do - context 'normal level' do + context 'when checking at normal level' do def type_checker(code) Solargraph::TypeChecker.load_string(code, 'test.rb', :normal) end @@ -221,9 +221,9 @@ def bar; end # @todo This test uses kramdown-parser-gfm because it's a gem dependency known to # lack typed methods. A better test wouldn't depend on the state of # vendored code. + workspace = Solargraph::Workspace.new(Dir.pwd) gemspec = Gem::Specification.find_by_name('kramdown-parser-gfm') - yard_pins = Solargraph::GemPins.build_yard_pins([], gemspec) - Solargraph::PinCache.serialize_yard_gem(gemspec, yard_pins) + workspace.cache_gem(gemspec) checker = type_checker(%( require 'kramdown-parser-gfm' diff --git a/spec/workspace_spec.rb b/spec/workspace_spec.rb index 37275bb86..cb5821a4d 100644 --- a/spec/workspace_spec.rb +++ b/spec/workspace_spec.rb @@ -133,4 +133,41 @@ Solargraph::Workspace.new('./path', config) }.not_to raise_error end + + describe '#cache_all_for_workspace!' do + let(:pin_cache) { instance_double(Solargraph::PinCache) } + + before do + allow(Solargraph::PinCache).to receive(:cache_core) + allow(Solargraph::PinCache).to receive(:possible_stdlibs) + allow(Solargraph::PinCache).to receive(:new).and_return(pin_cache) + allow(pin_cache).to receive_messages(cache_gem: nil, possible_stdlibs: []) + allow(Solargraph::PinCache).to receive(:cache_all_stdlibs) + end + + it 'caches core pins' do + allow(Solargraph::PinCache).to receive_messages(core?: false) + allow(pin_cache).to receive_messages(cached?: true, + cache_all_stdlibs: nil) + + workspace.cache_all_for_workspace!(nil, rebuild: false) + + expect(Solargraph::PinCache).to have_received(:cache_core).with(out: nil) + end + + it 'caches gems' do + gemspec = instance_double(Gem::Specification, name: 'test_gem', version: '1.0.0') + allow(Gem::Specification).to receive(:to_a).and_return([gemspec]) + allow(pin_cache).to receive(:cached?).and_return(false) + allow(pin_cache).to receive(:cache_all_stdlibs).with(out: nil) + + allow(Solargraph::PinCache).to receive_messages(core?: true, + possible_stdlibs: []) + + workspace.cache_all_for_workspace!(nil, rebuild: false) + + expect(pin_cache).to have_received(:cache_gem).with(gemspec: gemspec, out: nil, + rebuild: false) + end + end end diff --git a/spec/yard_map/mapper_spec.rb b/spec/yard_map/mapper_spec.rb index d45af985b..14451b97f 100644 --- a/spec/yard_map/mapper_spec.rb +++ b/spec/yard_map/mapper_spec.rb @@ -1,4 +1,14 @@ describe Solargraph::YardMap::Mapper do + before :all do # rubocop:disable RSpec/BeforeAfterAll + @api_map = Solargraph::ApiMap.load('.') + end + + def pins_with require + doc_map = Solargraph::DocMap.new([require], [], @api_map.workspace, out: nil) + doc_map.cache_doc_map_gems!(nil) + doc_map.pins + end + it 'converts nil docstrings to empty strings' do dir = File.absolute_path(File.join('spec', 'fixtures', 'yard_map')) Dir.chdir dir do @@ -14,50 +24,33 @@ it 'marks explicit methods' do # Using rspec-expectations because it's a known dependency - rspec = Gem::Specification.find_by_name('rspec-expectations') - Solargraph::Yardoc.cache([], rspec) - Solargraph::Yardoc.load!(rspec) - pins = Solargraph::YardMap::Mapper.new(YARD::Registry.all).map - pin = pins.find { |pin| pin.path == 'RSpec::Matchers#be_truthy' } + pin = pins_with('rspec/expectations').find { |pin| pin.path == 'RSpec::Matchers#be_truthy' } + expect(pin).not_to be_nil expect(pin.explicit?).to be(true) end it 'marks correct return type from Logger.new' do # Using logger because it's a known dependency - logger = Gem::Specification.find_by_name('logger') - Solargraph::Yardoc.cache([], logger) - registry = Solargraph::Yardoc.load!(logger) - pins = Solargraph::YardMap::Mapper.new(registry).map - pins = pins.select { |pin| pin.path == 'Logger.new' } + pins = pins_with('logger').select { |pin| pin.path == 'Logger.new' } expect(pins.map(&:return_type).uniq.map(&:to_s)).to eq(['self']) end it 'marks correct return type from RuboCop::Options.new' do # Using rubocop because it's a known dependency - rubocop = Gem::Specification.find_by_name('rubocop') - Solargraph::Yardoc.cache([], rubocop) - Solargraph::Yardoc.load!(rubocop) - pins = Solargraph::YardMap::Mapper.new(YARD::Registry.all).map - pins = pins.select { |pin| pin.path == 'RuboCop::Options.new' } + pins = pins_with('rubocop').select { |pin| pin.path == 'RuboCop::Options.new' } expect(pins.map(&:return_type).uniq.map(&:to_s)).to eq(['self']) expect(pins.flat_map(&:signatures).map(&:return_type).uniq.map(&:to_s)).to eq(['self']) end it 'marks non-explicit methods' do # Using rspec-expectations because it's a known dependency - rspec = Gem::Specification.find_by_name('rspec-expectations') - Solargraph::Yardoc.load!(rspec) - pins = Solargraph::YardMap::Mapper.new(YARD::Registry.all).map - pin = pins.find { |pin| pin.path == 'RSpec::Matchers#expect' } + pin = pins_with('rspec/expectations').find { |pin| pin.path == 'RSpec::Matchers#expect' } expect(pin.explicit?).to be(false) end it 'adds superclass references' do # Asssuming the yard gem exists because it's a known dependency - gemspec = Gem::Specification.find_by_name('yard') - Solargraph::Yardoc.cache([], gemspec) - pins = Solargraph::YardMap::Mapper.new(Solargraph::Yardoc.load!(gemspec)).map - pin = pins.find do |pin| + pin = pins_with('yard').find do |pin| pin.is_a?(Solargraph::Pin::Reference::Superclass) && pin.name == 'YARD::CodeObjects::NamespaceObject' end expect(pin.closure.path).to eq('YARD::CodeObjects::ClassObject') @@ -65,21 +58,15 @@ it 'adds include references' do # Asssuming the ast gem exists because it's a known dependency - gemspec = Gem::Specification.find_by_name('ast') - Solargraph::Yardoc.cache([], gemspec) - pins = Solargraph::YardMap::Mapper.new(Solargraph::Yardoc.load!(gemspec)).map - inc= pins.find do |pin| + inc = pins_with('ast').find do |pin| pin.is_a?(Solargraph::Pin::Reference::Include) && pin.name == 'AST::Processor::Mixin' && pin.closure.path == 'AST::Processor' end expect(inc).to be_a(Solargraph::Pin::Reference::Include) end - it 'adds corect gates' do + it 'adds correct gates' do # Asssuming the ast gem exists because it's a known dependency - gemspec = Gem::Specification.find_by_name('ast') - Solargraph::Yardoc.cache([], gemspec) - pins = Solargraph::YardMap::Mapper.new(Solargraph::Yardoc.load!(gemspec)).map - pin = pins.find do |pin| + pin = pins_with('ast').find do |pin| pin.is_a?(Solargraph::Pin::Namespace) && pin.name == 'Mixin' && pin.closure.path == 'AST::Processor' end expect(pin.gates).to eq(['AST::Processor::Mixin', 'AST::Processor', 'AST', '']) @@ -87,10 +74,7 @@ it 'adds extend references' do # Asssuming the yard gem exists because it's a known dependency - gemspec = Gem::Specification.find_by_name('yard') - Solargraph::Yardoc.cache([], gemspec) - pins = Solargraph::YardMap::Mapper.new(Solargraph::Yardoc.load!(gemspec)).map - ext = pins.find do |pin| + ext = pins_with('yard').find do |pin| pin.is_a?(Solargraph::Pin::Reference::Extend) && pin.name == 'Enumerable' && pin.closure.path == 'YARD::Registry' end expect(ext).to be_a(Solargraph::Pin::Reference::Extend) diff --git a/spec/yardoc_spec.rb b/spec/yardoc_spec.rb index 5ad0e5805..6cd575de0 100644 --- a/spec/yardoc_spec.rb +++ b/spec/yardoc_spec.rb @@ -4,18 +4,42 @@ require 'open3' describe Solargraph::Yardoc do + around do |testobj| + @tmpdir = Dir.mktmpdir + + testobj.run + ensure + FileUtils.remove_entry(@tmpdir) + end + let(:gem_yardoc_path) do - Solargraph::PinCache.yardoc_path gemspec + File.join(@tmpdir, 'solargraph', 'yardoc', 'test_gem') end before do FileUtils.mkdir_p(gem_yardoc_path) end - describe '#cache' do - let(:api_map) { Solargraph::ApiMap.new } - let(:doc_map) { api_map.doc_map } - let(:gemspec) { Gem::Specification.find_by_path('rubocop') } + describe '#processing?' do + it 'returns true if the yardoc is being processed' do + FileUtils.touch(File.join(gem_yardoc_path, 'processing')) + expect(described_class.processing?(gem_yardoc_path)).to be(true) + end + + it 'returns false if the yardoc is not being processed' do + expect(described_class.processing?(gem_yardoc_path)).to be(false) + end + end + + describe '#load!' do + it 'does not blow up when called on empty directory' do + expect { described_class.load!(gem_yardoc_path) }.not_to raise_error + end + end + + describe '#build_docs' do + let(:workspace) { Solargraph::Workspace.new(Dir.pwd) } + let(:gemspec) { workspace.find_gem('rubocop') } let(:output) { '' } before do @@ -24,6 +48,41 @@ FileUtils.rm_rf(gem_yardoc_path) end + it 'builds docs for a gem' do + described_class.build_docs(gem_yardoc_path, [], gemspec) + expect(File.exist?(File.join(gem_yardoc_path, 'complete'))).to be true + end + + it 'bails quietly if directory given does not exist' do + allow(File).to receive(:exist?).and_return(false) + + expect do + described_class.build_docs(gem_yardoc_path, [], gemspec) + end.not_to raise_error + end + + it 'is idempotent' do + described_class.build_docs(gem_yardoc_path, [], gemspec) + described_class.build_docs(gem_yardoc_path, [], gemspec) # second time + expect(File.exist?(File.join(gem_yardoc_path, 'complete'))).to be true + end + + context 'with an error from yard' do + before do + allow(Open3).to receive(:capture2e).and_return([output, result]) + end + + let(:result) { instance_double(Process::Status) } + + it 'does not raise on error from yard' do + allow(result).to receive(:success?).and_return(false) + + expect do + described_class.build_docs(gem_yardoc_path, [], gemspec) + end.not_to raise_error + end + end + context 'when given a relative BUNDLE_GEMFILE path' do around do |example| # turn absolute BUNDLE_GEMFILE path into relative @@ -43,7 +102,7 @@ ['output', instance_double(Process::Status, success?: true)] end - described_class.cache([], gemspec) + described_class.build_docs(gem_yardoc_path, [], gemspec) expect(called_with[0]['BUNDLE_GEMFILE']).to eq(File.absolute_path('Gemfile')) end