diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..92f925b17 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +# current git branch +SOLARGRAPH_FORCE_VERSION=0.0.1.dev-$(git rev-parse --abbrev-ref HEAD | tr -d '\n' | tr -d '/' | tr -d '-'| tr -d '_') +export SOLARGRAPH_FORCE_VERSION diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index b4ef26bfe..faeb330bf 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -11,7 +11,7 @@ on: branches: [ master ] push: branches: - - 'main' + - 'master' tags: - 'v*' @@ -31,9 +31,8 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: 3.4 - bundler: latest bundler-cache: true - cache-version: 2025-06-06 + cache-version: 2026-01-11 - name: Update to best available RBS run: | diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index c82ade49b..25ab551b2 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -21,20 +21,51 @@ 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: '4.0.0.dev.4' + - ruby-version: '4.0' + rbs-version: '3.10.0' steps: - uses: actions/checkout@v3 - name: Set up Ruby diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 962ac9bb6..6406cc522 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' @@ -107,7 +106,6 @@ Layout/EndOfLine: Exclude: - 'Gemfile' - 'Rakefile' - - 'lib/solargraph/source/encoding_fixes.rb' - 'solargraph.gemspec' # This cop supports safe autocorrection (--autocorrect). @@ -576,7 +574,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' @@ -1144,7 +1141,7 @@ Style/SafeNavigation: # Configuration parameters: Max. Style/SafeNavigationChainLength: Exclude: - - 'lib/solargraph/doc_map.rb' + - 'lib/solargraph/workspace/gemspecs.rb' # This cop supports unsafe autocorrection (--autocorrect-all). Style/SlicingWithRange: @@ -1175,7 +1172,6 @@ Style/StringLiterals: # This cop supports safe autocorrection (--autocorrect). Style/SuperArguments: Exclude: - - 'lib/solargraph/pin/base_variable.rb' - 'lib/solargraph/pin/callable.rb' - 'lib/solargraph/pin/method.rb' - 'lib/solargraph/pin/signature.rb' diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index cc3031ea5..6c145e618 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -102,7 +102,7 @@ def catalog bench @doc_map&.uncached_rbs_collection_gemspecs&.any? || @doc_map&.rbs_collection_path != bench.workspace.rbs_collection_path if recreate_docmap - @doc_map = DocMap.new(unresolved_requires, [], bench.workspace) # @todo Implement gem preferences + @doc_map = DocMap.new(unresolved_requires, bench.workspace) # @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,12 +119,12 @@ def catalog bench # @return [DocMap] def doc_map - @doc_map ||= DocMap.new([], []) + @doc_map ||= DocMap.new([], Workspace.new('.')) end # @return [::Array] def uncached_gemspecs - @doc_map&.uncached_gemspecs || [] + doc_map.uncached_gemspecs || [] end # @return [::Array] @@ -195,7 +195,7 @@ def self.load directory # @param out [IO, nil] # @return [void] def cache_all!(out) - @doc_map.cache_all!(out) + doc_map.cache_all!(out) end # @param gemspec [Gem::Specification] @@ -203,7 +203,7 @@ def cache_all!(out) # @param out [IO, nil] # @return [void] def cache_gem(gemspec, rebuild: false, out: nil) - @doc_map.cache(gemspec, rebuild: rebuild, out: out) + doc_map.cache(gemspec, rebuild: rebuild, out: out) end class << self @@ -676,6 +676,11 @@ def resolve_method_aliases pins, visibility = [:public, :private, :protected] GemPins.combine_method_pins_by_path(with_resolved_aliases) end + # @return [Workspace] + 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 @@ -927,7 +932,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 +943,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/doc_map.rb b/lib/solargraph/doc_map.rb index e45ff0b65..da26e956e 100644 --- a/lib/solargraph/doc_map.rb +++ b/lib/solargraph/doc_map.rb @@ -5,7 +5,10 @@ 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 @@ -14,16 +17,13 @@ class DocMap attr_reader :requires alias required requires - # @return [Array] - attr_reader :preferences - # @return [Array] attr_reader :pins # @return [Array] def uncached_gemspecs uncached_yard_gemspecs.concat(uncached_rbs_collection_gemspecs) - .sort + .sort_by { |gemspec| "#{gemspec.name}:#{gemspec.version}" } .uniq { |gemspec| "#{gemspec.name}:#{gemspec.version}" } end @@ -46,11 +46,10 @@ def uncached_gemspecs attr_reader :environ # @param requires [Array] - # @param preferences [Array] # @param workspace [Workspace, nil] - def initialize(requires, preferences, workspace = nil) + # @param out [IO, nil] output stream for logging + def initialize requires, workspace, out: $stderr @requires = requires.compact - @preferences = preferences.compact @workspace = workspace @rbs_collection_path = workspace&.rbs_collection_path @rbs_collection_config_path = workspace&.rbs_collection_config_path @@ -106,8 +105,8 @@ def cache_rbs_collection_pins(gemspec, out) # @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 + build_yard = uncached_yard_gemspecs.map { |gs| "#{gs.name}:#{gs.version}" }.include?("#{gemspec.name}:#{gemspec.version}") || rebuild + build_rbs_collection = uncached_rbs_collection_gemspecs.map { |gs| "#{gs.name}:#{gs.version}" }.include?("#{gemspec.name}:#{gemspec.version}") || rebuild if build_yard || build_rbs_collection type = [] type << 'YARD' if build_yard @@ -166,7 +165,7 @@ def yard_plugins # @return [Set] def dependencies - @dependencies ||= (gemspecs.flat_map { |spec| fetch_dependencies(spec) } - gemspecs).to_set + @dependencies ||= (gemspecs.flat_map { |spec| workspace.fetch_dependencies(spec) } - gemspecs).to_set end private @@ -203,12 +202,7 @@ def load_serialized_gem_pins # @return [Hash{String => Array}] def required_gems_map - @required_gems_map ||= requires.to_h { |path| [path, resolve_path_to_gemspecs(path)] } - end - - # @return [Hash{String => Gem::Specification}] - def preference_map - @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } + @required_gems_map ||= requires.to_h { |path| [path, workspace.resolve_require(path)] } end # @param gemspec [Gem::Specification] @@ -294,11 +288,12 @@ def deserialize_stdlib_rbs_map path # @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]) + key = "#{gemspec.name}:#{gemspec.version}" + return if rbs_collection_pins_in_memory.key?([key, 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 + rbs_collection_pins_in_memory[[key, rbs_version_cache_key]] = cached cached else logger.debug "No RBS collection pin cache for #{gemspec.name} #{gemspec.version}" @@ -307,133 +302,8 @@ def deserialize_rbs_collection_cache gemspec, rbs_version_cache_key end end - # @param path [String] - # @return [::Array, nil] - def resolve_path_to_gemspecs path - return nil if path.empty? - return gemspecs_required_from_bundler if path == 'bundler/require' - - # @type [Gem::Specification, nil] - gemspec = Gem::Specification.find_by_path(path) - if gemspec.nil? - gem_name_guess = path.split('/').first - begin - # this can happen when the gem is included via a local path in - # a Gemfile; Gem doesn't try to index the paths in that case. - # - # See if we can make a good guess: - potential_gemspec = Gem::Specification.find_by_name(gem_name_guess) - file = "lib/#{path}.rb" - gemspec = potential_gemspec if potential_gemspec.files.any? { |gemspec_file| file == gemspec_file } - rescue Gem::MissingSpecError - logger.debug { "Require path #{path} could not be resolved to a gem via find_by_path or guess of #{gem_name_guess}" } - [] - end - end - return nil if gemspec.nil? - [gemspec_or_preference(gemspec)] - end - - # @param gemspec [Gem::Specification] - # @return [Gem::Specification] - def gemspec_or_preference gemspec - # :nocov: dormant feature - return gemspec unless preference_map.key?(gemspec.name) - return gemspec if gemspec.version == preference_map[gemspec.name].version - - change_gemspec_version gemspec, preference_map[gemspec.name].version - # :nocov: - end - - # @param gemspec [Gem::Specification] - # @param version [Gem::Version] - # @return [Gem::Specification] - def change_gemspec_version gemspec, version - Gem::Specification.find_by_name(gemspec.name, "= #{version}") - rescue Gem::MissingSpecError - Solargraph.logger.info "Gem #{gemspec.name} version #{version} not found. Using #{gemspec.version} instead" - gemspec - end - - # @param gemspec [Gem::Specification] - # @return [Array] - def fetch_dependencies gemspec - # @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 - - # @param gemspec [Gem::Specification] - # @return [Array] - def only_runtime_dependencies gemspec - gemspec.dependencies - gemspec.development_dependencies - end - - def inspect self.class.inspect end - - # @return [Array, nil] - def gemspecs_required_from_bundler - # @todo Handle projects with custom Bundler/Gemfile setups - return unless workspace.gemfile? - - if workspace.gemfile? && Bundler.definition&.lockfile&.to_s&.start_with?(workspace.directory) - # Find only the gems bundler is now using - Bundler.definition.locked_gems.specs.flat_map do |lazy_spec| - logger.info "Handling #{lazy_spec.name}:#{lazy_spec.version}" - [Gem::Specification.find_by_name(lazy_spec.name, lazy_spec.version)] - rescue Gem::MissingSpecError => e - logger.info("Could not find #{lazy_spec.name}:#{lazy_spec.version} with find_by_name, falling back to guess") - # can happen in local filesystem references - specs = resolve_path_to_gemspecs lazy_spec.name - logger.warn "Gem #{lazy_spec.name} #{lazy_spec.version} from bundle not found: #{e}" if specs.nil? - next specs - end.compact - else - logger.info 'Fetching gemspecs required from Bundler (bundler/require)' - gemspecs_required_from_external_bundle - end - end - - # @return [Array, nil] - def gemspecs_required_from_external_bundle - logger.info 'Fetching gemspecs required from external bundle' - return [] unless workspace&.directory - - Solargraph.with_clean_env do - cmd = [ - 'ruby', '-e', - "require 'bundler'; require 'json'; Dir.chdir('#{workspace&.directory}') { puts Bundler.definition.locked_gems.specs.map { |spec| [spec.name, spec.version] }.to_h.to_json }" - ] - o, e, s = Open3.capture3(*cmd) - if s.success? - Solargraph.logger.debug "External bundle: #{o}" - hash = o && !o.empty? ? JSON.parse(o.split("\n").last) : {} - hash.flat_map do |name, version| - Gem::Specification.find_by_name(name, version) - rescue Gem::MissingSpecError => e - logger.info("Could not find #{name}:#{version} with find_by_name, falling back to guess") - # can happen in local filesystem references - specs = resolve_path_to_gemspecs name - logger.warn "Gem #{name} #{version} from bundle not found: #{e}" if specs.nil? - next specs - end.compact - else - Solargraph.logger.warn "Failed to load gems from bundle at #{workspace&.directory}: #{e}" - end - end - end end end diff --git a/lib/solargraph/pin/callable.rb b/lib/solargraph/pin/callable.rb index edbc3f941..8e8cb0f58 100644 --- a/lib/solargraph/pin/callable.rb +++ b/lib/solargraph/pin/callable.rb @@ -21,6 +21,11 @@ def initialize block: nil, return_type: nil, parameters: [], **splat @parameters = parameters end + def reset_generated! + parameters.each(&:reset_generated!) + super + end + # @return [String] def method_namespace closure.namespace diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index 91c205921..e4d5b474a 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -135,6 +135,11 @@ def full end end + def reset_generated! + @return_type = nil if param_tag + super + end + # @return [ComplexType] def return_type if @return_type.nil? @@ -200,7 +205,8 @@ def param_tag # @return [ComplexType] def typify_block_param api_map block_pin = closure - if block_pin.is_a?(Pin::Block) && block_pin.receiver + if block_pin.is_a?(Pin::Block) && block_pin.receiver && index + # @sg-ignore flow-sensitive typing should handle is_a? with && return block_pin.typify_parameters(api_map)[index] end ComplexType::UNDEFINED diff --git a/lib/solargraph/rbs_map/stdlib_map.rb b/lib/solargraph/rbs_map/stdlib_map.rb index b6804157f..9308e8041 100644 --- a/lib/solargraph/rbs_map/stdlib_map.rb +++ b/lib/solargraph/rbs_map/stdlib_map.rb @@ -33,6 +33,22 @@ 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 + # @param library [String] # @return [StdlibMap] def self.load library diff --git a/lib/solargraph/shell.rb b/lib/solargraph/shell.rb index 14a1139ae..9983f82cf 100755 --- a/lib/solargraph/shell.rb +++ b/lib/solargraph/shell.rb @@ -119,6 +119,7 @@ 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 @@ -130,7 +131,9 @@ def uncache *gems next end - spec = Gem::Specification.find_by_name(gem) + spec = workspace.find_gem(gem) + raise Thor::InvocationError, "Gem '#{gem}' not found" if spec.nil? + PinCache.uncache_gem(spec, out: $stdout) end end @@ -141,12 +144,13 @@ def uncache *gems # @return [void] def gems *names api_map = ApiMap.load('.') + workspace = api_map.workspace if names.empty? Gem::Specification.to_a.each { |spec| do_cache spec, api_map } STDERR.puts "Documentation cached for all #{Gem::Specification.count} gems." else names.each do |name| - spec = Gem::Specification.find_by_name(*name.split('=')) + spec = workspace.find_gem(*name.split('=')) do_cache spec, api_map rescue Gem::MissingSpecError warn "Gem '#{name}' not found" @@ -326,7 +330,7 @@ def pin_description pin 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) + api_map.cache_gem(gemspec, rebuild: options[:rebuild], out: $stdout) end # @param type [ComplexType] diff --git a/lib/solargraph/version.rb b/lib/solargraph/version.rb index 5bb8e52f8..5a9f5f434 100755 --- a/lib/solargraph/version.rb +++ b/lib/solargraph/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Solargraph - VERSION = '0.58.1' + VERSION = ENV.fetch('SOLARGRAPH_FORCE_VERSION', '0.58.1') end diff --git a/lib/solargraph/workspace.rb b/lib/solargraph/workspace.rb index 06980e6d0..702fe5b0c 100644 --- a/lib/solargraph/workspace.rb +++ b/lib/solargraph/workspace.rb @@ -9,7 +9,10 @@ module Solargraph # in an associated Library or ApiMap. # class Workspace + include Logging + autoload :Config, 'solargraph/workspace/config' + autoload :Gemspecs, 'solargraph/workspace/gemspecs' autoload :RequirePaths, 'solargraph/workspace/require_paths' # @return [String] @@ -50,6 +53,26 @@ def config @config ||= Solargraph::Workspace::Config.new(directory) end + # @param stdlib_name [String] + # + # @return [Array] + def stdlib_dependencies stdlib_name + gemspecs.stdlib_dependencies(stdlib_name) + end + + # @param out [IO, nil] output stream for logging + # @param gemspec [Gem::Specification] + # @return [Array] + def fetch_dependencies gemspec, out: $stderr + gemspecs.fetch_dependencies(gemspec, out: out) + end + + # @param require [String] The string sent to 'require' in the code to resolve, e.g. 'rails', 'bundler/require' + # @return [Array] + def resolve_require require + gemspecs.resolve_require(require) + end + # @param level [Symbol] # @return [TypeChecker::Rules] def rules(level) @@ -126,6 +149,23 @@ def would_require? path false end + # True if the workspace contains at least one gemspec file. + # + # @return [Boolean] + def gemspec? + !gemspec_files.empty? + end + + # Get an array of all gemspec files in the workspace. + # + # @return [Array] + def gemspec_files + return [] if directory.empty? || directory == '*' + @gemspec_files ||= Dir[File.join(directory, '**/*.gemspec')].select do |gs| + config.allow? gs + end + end + # @return [String, nil] def rbs_collection_path @gem_rbs_collection ||= read_rbs_collection_path @@ -141,6 +181,19 @@ def rbs_collection_config_path end end + # @param name [String] + # @param version [String, nil] + # + # @return [Gem::Specification, nil] + def find_gem name, version = nil + gemspecs.find_gem(name, version) + end + + # @return [Array] + def all_gemspecs_from_bundle + gemspecs.all_gemspecs_from_bundle + end + # Synchronize the workspace from the provided updater. # # @param updater [Source::Updater] @@ -160,12 +213,9 @@ def directory_or_nil directory end - # True if the workspace has a root Gemfile. - # - # @todo Handle projects with custom Bundler/Gemfile setups (see DocMap#gemspecs_required_from_bundler) - # - def gemfile? - directory && File.file?(File.join(directory, 'Gemfile')) + # @return [Solargraph::Workspace::Gemspecs] + def gemspecs + @gemspecs ||= Solargraph::Workspace::Gemspecs.new(directory_or_nil) end private diff --git a/lib/solargraph/workspace/gemspecs.rb b/lib/solargraph/workspace/gemspecs.rb new file mode 100644 index 000000000..feddf641b --- /dev/null +++ b/lib/solargraph/workspace/gemspecs.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +require 'rubygems' +require 'bundler' + +module Solargraph + class Workspace + # Manages determining which gemspecs are available in a workspace + class Gemspecs + include Logging + + attr_reader :directory, :preferences + + # @param directory [String, nil] If nil, assume no bundle is present + # @param preferences [Array] + def initialize directory, preferences: [] + # @todo an issue with both external bundles and the potential + # preferences feature is that bundler gives you a 'clean' + # rubygems environment with only the specified versions + # installed. Possible alternatives: + # + # *) prompt the user to run solargraph outside of bundler + # and treat all bundles as external + # *) reinstall the needed gems dynamically each time + # *) manipulate the rubygems/bundler environment + @directory = directory && File.absolute_path(directory) + # @todo implement preferences as a config-exposed feature + @preferences = preferences + end + + # Take the path given to a 'require' statement in a source file + # and return the Gem::Specifications which will be brought into + # scope with it, so we can load pins for them. + # + # @param require [String] The string sent to 'require' in the code to resolve, e.g. 'rails', 'bundler/require' + # @return [::Array, nil] + def resolve_require require + return nil if require.empty? + + # This is added in the parser when it sees 'Bundler.require' - + # see https://bundler.io/guides/bundler_setup.html ' + # + # @todo handle different arguments to Bundler.require + return auto_required_gemspecs_from_bundler if require == 'bundler/require' + + # Determine gem name based on the require path + file = "lib/#{require}.rb" + spec_with_path = Gem::Specification.find_by_path(file) + + all_gemspecs = all_gemspecs_from_bundle + + gem_names_to_try = [ + spec_with_path&.name, + require.tr('/', '-'), + require.split('/').first + ].compact.uniq + gem_names_to_try.each do |gem_name| + gemspec = all_gemspecs.find { |gemspec| gemspec.name == gem_name } + return [gemspec_or_preference(gemspec)] if gemspec + + begin + gemspec = Gem::Specification.find_by_name(gem_name) + return [gemspec_or_preference(gemspec)] if gemspec + rescue Gem::MissingSpecError + logger.debug do + "Require path #{require} could not be resolved to a gem via find_by_path or guess of #{gem_name}" + end + end + + # look ourselves just in case this is hanging out somewhere + # that find_by_path doesn't index' + gemspec = all_gemspecs.find do |spec| + spec = to_gem_specification(spec) unless spec.respond_to?(:files) + + spec&.files&.any? { |gemspec_file| file == gemspec_file } + end + return [gemspec_or_preference(gemspec)] if gemspec + end + + nil + 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 + + # @param name [String] + # @param version [String, nil] + # @param out [IO, nil] output stream for logging + # + # @return [Gem::Specification, nil] + def find_gem name, version = nil, out: $stderr + specish = all_gemspecs_from_bundle.find { |specish| specish.name == name && specish.version == version } + return to_gem_specification specish if specish + + specish = all_gemspecs_from_bundle.find { |specish| specish.name == name } + return to_gem_specification specish if specish + + resolve_gem_ignoring_local_bundle name, version, out: out + end + + # @param gemspec [Gem::Specification] + # @param out[IO, nil] output stream for logging + # + # @return [Array] + def fetch_dependencies gemspec, out: $stderr + gemspecs = all_gemspecs_from_bundle + + # @type [Hash{String => Gem::Specification}] + deps_so_far = {} + + # @param runtime_dep [Gem::Dependency] + # @param deps [Hash{String => Gem::Specification}] + gem_dep_gemspecs = only_runtime_dependencies(gemspec).each_with_object(deps_so_far) do |runtime_dep, deps| + # @sg-ignore Unresolved call to requirement on Gem::Dependency + dep = find_gem(runtime_dep.name, runtime_dep.requirement) + next unless dep + + fetch_dependencies(dep, out: out).each { |sub_dep| deps[sub_dep.name] ||= sub_dep } + + deps[dep.name] ||= dep + end + + # RBS tracks implicit dependencies, like how the YAML standard + # library implies pulling in the psych library. + stdlib_deps = RbsMap::StdlibMap.stdlib_dependencies(gemspec.name, gemspec.version) || [] + stdlib_dep_gemspecs = stdlib_deps.map { |dep| find_gem(dep['name'], dep['version']) }.compact + (gem_dep_gemspecs.values.compact + stdlib_dep_gemspecs).uniq(&:name) + end + + # Returns all gemspecs directly depended on by this workspace's + # bundle (does not include transitive dependencies). + # + # @return [Array] + def all_gemspecs_from_bundle + return [] unless directory + + @all_gemspecs_from_bundle ||= + if in_this_bundle? + all_gemspecs_from_this_bundle + else + all_gemspecs_from_external_bundle + end + end + + # @return [Hash{Gem::Specification, Bundler::LazySpecification, Bundler::StubSpecification => Gem::Specification}] + def self.gem_specification_cache + @gem_specification_cache ||= {} + end + + private + + # @param specish [Gem::Specification, Bundler::LazySpecification, Bundler::StubSpecification] + # + # @return [Gem::Specification, nil] + def to_gem_specification specish + # print time including milliseconds + self.class.gem_specification_cache[specish] ||= case specish + when Gem::Specification + specish + when Bundler::LazySpecification + # materializing didn't work. Let's look in the local + # rubygems without bundler's help + resolve_gem_ignoring_local_bundle specish.name, + specish.version + when Bundler::StubSpecification + # turns a Bundler::StubSpecification into a + # Gem::StubSpecification if we can + if specish.respond_to?(:stub) + to_gem_specification specish.stub + else + # A Bundler::StubSpecification is a Bundler:: + # RemoteSpecification which ought to proxy a Gem:: + # Specification + specish + end + # @sg-ignore Unresolved constant Gem::StubSpecification + when Gem::StubSpecification + specish.to_spec + else + raise "Unexpected type while resolving gem: #{specish.class}" + end + end + + # @param command [String] The expression to evaluate in the external bundle + # @sg-ignore Need a JSON type + # @yield [undefined, nil] + def query_external_bundle command + Solargraph.with_clean_env do + cmd = [ + 'ruby', '-e', + "require 'bundler'; require 'json'; Dir.chdir('#{directory}') { puts begin; #{command}; end.to_json }" + ] + o, e, s = Open3.capture3(*cmd) + if s.success? + Solargraph.logger.debug "External bundle: #{o}" + o && !o.empty? ? JSON.parse(o.split("\n").last) : nil + else + Solargraph.logger.warn e + raise BundleNotFoundError, "Failed to load gems from bundle at #{directory}" + end + end + end + + def in_this_bundle? + Bundler.definition&.lockfile&.to_s&.start_with?(directory) + end + + # @return [Array] + def all_gemspecs_from_this_bundle + # Find only the gems bundler is now using + specish_objects = Bundler.definition.locked_gems.specs + if specish_objects.first.respond_to?(:materialize_for_installation) + specish_objects = specish_objects.map(&:materialize_for_installation) + end + specish_objects.map do |specish| + if specish.respond_to?(:name) && specish.respond_to?(:version) && specish.respond_to?(:gem_dir) + # duck type is good enough for outside uses! + specish + else + to_gem_specification(specish) + end + end.compact + end + + # @return [Array] + def auto_required_gemspecs_from_bundler + return [] unless directory + + logger.info 'Fetching gemspecs autorequired from Bundler (bundler/require)' + @auto_required_gemspecs_from_bundler ||= + if in_this_bundle? + auto_required_gemspecs_from_this_bundle + else + auto_required_gemspecs_from_external_bundle + end + end + + # @return [Array] + def auto_required_gemspecs_from_this_bundle + # Adapted from require() in lib/bundler/runtime.rb + dep_names = Bundler.definition.dependencies.select do |dep| + dep.groups.include?(:default) && dep.should_include? + end.map(&:name) + + all_gemspecs_from_bundle.select { |gemspec| dep_names.include?(gemspec.name) } + end + + # @return [Array] + def auto_required_gemspecs_from_external_bundle + @auto_required_gemspecs_from_external_bundle ||= + begin + logger.info 'Fetching auto-required gemspecs from Bundler (bundler/require)' + command = + 'Bundler.definition.dependencies' \ + '.select { |dep| dep.groups.include?(:default) && dep.should_include? }' \ + '.map(&:name)' + # @sg-ignore + # @type [Array] + dep_names = query_external_bundle command + + all_gemspecs_from_bundle.select { |gemspec| dep_names.include?(gemspec.name) } + end + end + + # @param gemspec [Gem::Specification] + # @return [Array] + def only_runtime_dependencies gemspec + unless gemspec.respond_to?(:dependencies) && gemspec.respond_to?(:development_dependencies) + gemspec = to_gem_specification(gemspec) + end + return [] if gemspec.nil? + + gemspec.dependencies - gemspec.development_dependencies + end + + # @todo Should this be using Gem::SpecFetcher and pull them automatically? + # + # @param name [String] + # @param version_or_requirement [String, nil] + # @param out [IO, nil] output stream for logging + # + # @return [Gem::Specification, nil] + def resolve_gem_ignoring_local_bundle name, version_or_requirement = nil, out: $stderr + Gem::Specification.find_by_name(name, version_or_requirement) + rescue Gem::MissingSpecError + begin + Gem::Specification.find_by_name(name) + rescue Gem::MissingSpecError + stdlibmap = RbsMap::StdlibMap.new(name) + unless stdlibmap.resolved? + gem_desc = name + gem_desc += ":#{version_or_requirement}" if version_or_requirement + out&.puts "Please install the gem #{gem_desc} in Solargraph's Ruby environment" + end + nil # either not here or in stdlib + end + end + + # @return [Array] + def all_gemspecs_from_external_bundle + @all_gemspecs_from_external_bundle ||= + begin + logger.info 'Fetching gemspecs required from external bundle' + + command = 'specish_objects = Bundler.definition.locked_gems&.specs; ' \ + 'if specish_objects.first.respond_to?(:materialize_for_installation);' \ + 'specish_objects = specish_objects.map(&:materialize_for_installation);' \ + 'end;' \ + 'specish_objects.map { |specish| [specish.name, specish.version] }' + # @type [Array] + query_external_bundle(command).map do |name, version| + resolve_gem_ignoring_local_bundle(name, version) + end.compact + rescue Solargraph::BundleNotFoundError => e + Solargraph.logger.info e.message + Solargraph.logger.debug e.backtrace.join("\n") + [] + end + end + + # @return [Hash{String => Gem::Specification}] + def preference_map + @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } + end + + # @param gemspec [Gem::Specification] + # + # @return [Gem::Specification] + def gemspec_or_preference gemspec + return gemspec unless preference_map.key?(gemspec.name) + return gemspec if gemspec.version == preference_map[gemspec.name].version + + change_gemspec_version gemspec, preference_map[gemspec.name].version + end + + # @param gemspec [Gem::Specification] + # @param version [String] + # @return [Gem::Specification] + def change_gemspec_version gemspec, version + Gem::Specification.find_by_name(gemspec.name, "= #{version}") + rescue Gem::MissingSpecError + Solargraph.logger.info "Gem #{gemspec.name} version #{version.inspect} not found. " \ + "Using #{gemspec.version} instead" + gemspec + end + end + end +end diff --git a/spec/api_map_method_spec.rb b/spec/api_map_method_spec.rb index 9d4e4f553..610ab5484 100644 --- a/spec/api_map_method_spec.rb +++ b/spec/api_map_method_spec.rb @@ -133,6 +133,38 @@ class B end end + describe '#cache_all!' do + it 'can cache gems without a bench' do + api_map = Solargraph::ApiMap.new + doc_map = instance_double(Solargraph::DocMap, cache_all!: true) + allow(Solargraph::DocMap).to receive(:new).and_return(doc_map) + api_map.cache_all!($stderr) + expect(doc_map).to have_received(:cache_all!).with($stderr) + end + end + + describe '#cache_gem' do + it 'can cache gem without a bench' do + api_map = Solargraph::ApiMap.new + gemspec = Gem::Specification.find_by_name('backport') + expect { api_map.cache_gem(gemspec, out: StringIO.new) }.not_to raise_error + 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..9f7128c3f 100644 --- a/spec/doc_map_spec.rb +++ b/spec/doc_map_spec.rb @@ -1,81 +1,168 @@ # 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 + + let(:out) { StringIO.new } + let(:pre_cache) { true } + let(:requires) { [] } + + let(:workspace) do + Solargraph::Workspace.new(Dir.pwd) 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) + let(:plain_doc_map) { described_class.new([], workspace, out: nil) } + + before do + doc_map.cache_all!(nil) if pre_cache end - it 'tracks unresolved requires' do - doc_map = Solargraph::DocMap.new(['not_a_gem'], []) - expect(doc_map.unresolved_requires).to include('not_a_gem') + context 'with a require in solargraph test bundle' do + let(:requires) do + ['ast'] + end + + 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 'tracks uncached_gemspecs' do - gemspec = Gem::Specification.new do |spec| - spec.name = 'not_a_gem' - spec.version = '1.0.0' + context 'understands rspec + rspec-mocks require pattern' do + let(:requires) do + ['rspec-mocks'] + end + + it 'generates pins from gems' do + ns_pin = doc_map.pins.find { |pin| pin.path == 'RSpec::Mocks' } + expect(ns_pin).to be_a(Solargraph::Pin::Namespace) 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]) 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 an invalid require' do + let(:requires) do + ['not_a_gem'] + end - expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + it 'tracks unresolved requires' do + # These are auto-required by solargraph-rspec in case the bundle + # includes these gems. In our case, it doesn't! + unprovided_solargraph_rspec_requires = [ + 'rspec-rails', + 'actionmailer', + 'activerecord', + 'shoulda-matchers', + 'rspec-sidekiq', + 'airborne', + 'activesupport', + 'actionpack' + ] + expect(doc_map.unresolved_requires - unprovided_solargraph_rspec_requires) + .to eq(['not_a_gem']) + 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 + 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 + pending('moving cache_stdlib_rbs_map into PinCache') + + pincache = instance_double(Solargraph::PinCache, cache_stdlib_rbs_map: false) + uncached_gemspec = Gem::Specification.new('uncached_gem', '1.0.0') + allow(workspace).to receive_messages(fresh_pincache: pincache, resolve_require: [uncached_gemspec], stdlib_dependencies: [], + fetch_dependencies: []) + allow(Gem::Specification).to receive(:find_by_path).with('uncached_gem').and_return(uncached_gemspec) + allow(workspace).to receive(:global_environ).and_return(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 + + 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_all!(nil) + expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + end end - it 'ignores empty requires' do - expect { Solargraph::DocMap.new([''], []) }.not_to raise_error + 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 - it 'collects dependencies' do - doc_map = Solargraph::DocMap.new(['rspec'], []) - expect(doc_map.dependencies.map(&:name)).to include('rspec-core') + context 'with a nil require' do + let(:requires) { [nil] } + + it 'does not raise error' do + expect { doc_map }.not_to raise_error + end 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 empty require' do + let(:requires) { [''] } + + it 'does not raise error' do + expect { doc_map }.not_to raise_error end + end + + context 'with a require that has dependencies' do + let(:requires) { ['rspec'] } + + it 'collects dependencies' do + # we include doc_map.requires as solargraph-rspec will bring it + # in directly and we exclude it from dependencies + expect(doc_map.dependencies.map(&:name) + doc_map.requires).to include('rspec-core') + end + end + + context 'with convention' do + let(:pre_cache) { false } - Solargraph::Convention.register dummy_convention + 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 - doc_map = Solargraph::DocMap.new(['original_gem'], []) + Solargraph::Convention.register dummy_convention - expect(doc_map.requires).to include('original_gem', 'convention_gem1', 'convention_gem2') + doc_map = Solargraph::DocMap.new(['original_gem'], workspace) - # Clean up the registered convention - Solargraph::Convention.unregister dummy_convention + # @todo this should probably not be in requires, which is a + # path, and instead be in a new gem_names property on the + # Environ + 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/language_server/host/diagnoser_spec.rb b/spec/language_server/host/diagnoser_spec.rb index 69ee0b866..697d352bd 100644 --- a/spec/language_server/host/diagnoser_spec.rb +++ b/spec/language_server/host/diagnoser_spec.rb @@ -1,9 +1,9 @@ describe Solargraph::LanguageServer::Host::Diagnoser do it "diagnoses on ticks" do host = double(Solargraph::LanguageServer::Host, options: { 'diagnostics' => true }, synchronizing?: false) + allow(host).to receive(:diagnose) diagnoser = Solargraph::LanguageServer::Host::Diagnoser.new(host) diagnoser.schedule 'file.rb' - allow(host).to receive(:diagnose) diagnoser.tick expect(host).to have_received(:diagnose).with('file.rb') end diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index b9dc6b327..12daabe99 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,161 @@ 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 + pending 'error message improvements' + + output = capture_both do + shell.gems('nonexistentgem') + end + + expect(output).to include("Gem 'nonexistentgem' not found") + end + + it 'caches core without erroring out' do + pending 'core caching suppport' + + 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 + pending 'error message improvements' + + output = capture_both do + shell.gems('solargraph123') + end + + expect(output).to include("Gem 'solargraph123' not found") + end + end + + context 'with mocked Workspace' do + let(:api_map) { instance_double(Solargraph::ApiMap) } + 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) + allow(Solargraph::ApiMap).to receive(:load).with('.').and_return(api_map) + allow(api_map).to receive(:cache_gem) + allow(api_map).to receive(:workspace).and_return(workspace) + end + + it 'caches all without erroring out' do + pending 'delegation to api_map' + + allow(api_map).to receive(:cache_all!) + + _output = capture_both { shell.gems } + + expect(api_map).to have_received(:cache_all!) + end + + it 'caches single gem without erroring out' do + allow(workspace).to receive(:find_gem).with('backport').and_return(gemspec) + + capture_both do + shell.options = { rebuild: false } + shell.gems('backport') + end + + expect(api_map).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 + pending 'better error message' + + # capture stderr output + expect { call }.to output(/not found/).to_stderr + end + end end # @type cmd [Array] diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index ee7e4bcfa..e33b599b4 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -1648,7 +1648,9 @@ def foo; end expect(array_names).to eq(["byteindex", "byterindex", "bytes", "bytesize", "byteslice", "bytesplice"]) string_names = api_map.clip_at('test.rb', [6, 22]).complete.pins.map(&:name) - expect(string_names).to eq(['upcase', 'upcase!', 'upto']) + # can be brought in by solargraph-rails + activesupport_completions = ['upcase_first'] + expect(string_names - activesupport_completions).to eq(['upcase', 'upcase!', 'upto']) end it 'completes global methods defined in top level scope inside class when referenced inside a namespace' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 59d107aa3..366c22cc3 100755 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -27,6 +27,9 @@ 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/workspace/gemspecs_fetch_dependencies_spec.rb b/spec/workspace/gemspecs_fetch_dependencies_spec.rb new file mode 100644 index 000000000..56504e7dd --- /dev/null +++ b/spec/workspace/gemspecs_fetch_dependencies_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'rubygems/commands/install_command' + +describe Solargraph::Workspace::Gemspecs, '#fetch_dependencies' do + subject(:deps) { gemspecs.fetch_dependencies(gemspec) } + + let(:gemspecs) { described_class.new(dir_path) } + let(:dir_path) { Dir.pwd } + + context 'when in our bundle' do + context 'with a Bundler::LazySpecification' do + let(:gemspec) do + Bundler::LazySpecification.new('solargraph', nil, nil) + end + + it 'finds a known dependency' do + expect(deps.map(&:name)).to include('backport') + end + end + + context 'with a Gem::Specification' do + let(:gemspec) do + Gem::Specification.find_by_name('solargraph') + end + + it 'finds a known dependency' do + expect(deps.map(&:name)).to include('backport') + end + end + + context 'with gem whose dependency does not exist in our bundle' do + let(:gemspec) do + instance_double(Gem::Specification, + dependencies: [Gem::Dependency.new('activerecord')], + development_dependencies: [], + name: 'my_fake_gem', + version: '123') + end + let(:gem_name) { 'my_fake_gem' } + + it 'gives a useful message' do + output = capture_both { deps.map(&:name) } + expect(output).to include('Please install the gem activerecord') + end + end + end + + context 'with external bundle' do + let(:dir_path) { File.realpath(Dir.mktmpdir).to_s } + + let(:gemspec) do + Bundler::LazySpecification.new(gem_name, nil, nil) + end + + before do + # write out Gemfile + File.write(File.join(dir_path, 'Gemfile'), <<~GEMFILE) + source 'https://rubygems.org' + gem '#{gem_name}' + GEMFILE + + # run bundle install + output, status = Solargraph.with_clean_env do + Open3.capture2e('bundle install --verbose', chdir: dir_path) + end + raise "Failure installing bundle: #{output}" unless status.success? + + # ensure Gemfile.lock exists + unless File.exist?(File.join(dir_path, 'Gemfile.lock')) + raise "Gemfile.lock not found after bundle install in #{dir_path}" + end + end + + context 'with gem that exists in our bundle' do + let(:gem_name) { 'undercover' } + + it 'finds dependencies' do + expect(deps.map(&:name)).to include('ast') + end + end + + context 'with gem does not exist in our bundle' do + let(:gem_name) { 'activerecord' } + + it 'gives a useful message' do + dep_names = nil + output = capture_both { dep_names = deps.map(&:name) } + expect(output).to include('Please install the gem activerecord') + end + end + end +end diff --git a/spec/workspace/gemspecs_find_gem_spec.rb b/spec/workspace/gemspecs_find_gem_spec.rb new file mode 100644 index 000000000..35f5e7a15 --- /dev/null +++ b/spec/workspace/gemspecs_find_gem_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'rubygems/commands/install_command' + +describe Solargraph::Workspace::Gemspecs, '#find_gem' do + subject(:gemspec) { gemspecs.find_gem(name, version, out: out) } + + let(:gemspecs) { described_class.new(dir_path) } + let(:out) { StringIO.new } + + context 'with local bundle' do + let(:dir_path) { File.realpath(Dir.pwd) } + + context 'with solargraph from bundle' do + let(:name) { 'solargraph' } + let(:version) { nil } + + it 'returns the gem' do + expect(gemspec.name).to eq(name) + end + end + + context 'with random from core' do + let(:name) { 'random' } + let(:version) { nil } + + it 'returns no gemspec' do + expect(gemspec).to be_nil + end + + it 'does not complain' do + expect(out.string).to be_empty + end + end + + context 'with ripper from core' do + let(:name) { 'ripper' } + let(:version) { nil } + + it 'returns no gemspec' do + expect(gemspec).to be_nil + end + end + + context 'with base64 from stdlib' do + let(:name) { 'base64' } + let(:version) { nil } + + it 'returns a gemspec' do + expect(gemspec).not_to be_nil + end + end + + context 'with gem not in bundle' do + let(:name) { 'checkoff' } + let(:version) { nil } + + it 'returns no gemspec' do + expect(gemspec).to be_nil + end + + it 'complains' do + gemspec + + expect(out.string).to include('install the gem checkoff ') + end + end + + context 'with gem not in bundle but no logger' do + let(:name) { 'checkoff' } + let(:version) { nil } + let(:out) { nil } + + it 'returns no gemspec' do + expect(gemspec).to be_nil + end + + it 'does not fail' do + expect { gemspec }.not_to raise_error + end + end + + context 'with gem not in bundle with version' do + let(:name) { 'checkoff' } + let(:version) { '1.0.0' } + + it 'returns no gemspec' do + expect(gemspec).to be_nil + end + + it 'complains' do + gemspec + + expect(out.string).to include('install the gem checkoff:1.0.0') + end + end + end +end diff --git a/spec/workspace/gemspecs_resolve_require_spec.rb b/spec/workspace/gemspecs_resolve_require_spec.rb new file mode 100644 index 000000000..8deba9ff8 --- /dev/null +++ b/spec/workspace/gemspecs_resolve_require_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'rubygems/commands/install_command' + +describe Solargraph::Workspace::Gemspecs, '#resolve_require' do + subject(:specs) { gemspecs.resolve_require(require) } + + let(:gemspecs) { described_class.new(dir_path) } + + def find_or_install gem_name, version + Gem::Specification.find_by_name(gem_name, version) + rescue Gem::LoadError + install_gem(gem_name, version) + end + + def add_bundle + # write out Gemfile + File.write(File.join(dir_path, 'Gemfile'), <<~GEMFILE) + source 'https://rubygems.org' + gem 'backport' + GEMFILE + # run bundle install + output, status = Solargraph.with_clean_env do + Open3.capture2e('bundle install --verbose', chdir: dir_path) + end + raise "Failure installing bundle: #{output}" unless status.success? + # ensure Gemfile.lock exists + return if File.exist?(File.join(dir_path, 'Gemfile.lock')) + raise "Gemfile.lock not found after bundle install in #{dir_path}" + end + + def install_gem gem_name, version + Bundler.with_unbundled_env do + cmd = Gem::Commands::InstallCommand.new + cmd.handle_options [gem_name, '-v', version] + cmd.execute + rescue Gem::SystemExitException => e + raise unless e.exit_code == 0 + end + end + + context 'with local bundle' do + let(:dir_path) { File.realpath(Dir.pwd) } + + context 'with a known gem' do + let(:require) { 'solargraph' } + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with an unknown type from Bundler / RubyGems' do + let(:require) { 'solargraph' } + let(:specish_objects) { [double] } + + before do + lockfile = instance_double(Pathname) + locked_gems = instance_double(Bundler::LockfileParser, specs: specish_objects) + + definition = instance_double(Bundler::Definition, + locked_gems: locked_gems, + lockfile: lockfile) + allow(Bundler).to receive(:definition).and_return(definition) + allow(lockfile).to receive(:to_s).and_return(dir_path) + end + + it 'raises a StandardException' do + expect { specs.size }.to raise_error(StandardError) + end + end + + def configure_bundler_spec stub_value + platform = Gem::Platform::RUBY + bundler_stub_spec = Bundler::StubSpecification.new('solargraph', '123', platform, spec_fetcher) + specish_objects = [bundler_stub_spec] + lockfile = instance_double(Pathname) + locked_gems = instance_double(Bundler::LockfileParser, specs: specish_objects) + definition = instance_double(Bundler::Definition, + locked_gems: locked_gems, + lockfile: lockfile) + # specish_objects = Bundler.definition.locked_gems.specs + allow(Bundler).to receive(:definition).and_return(definition) + allow(lockfile).to receive(:to_s).and_return(dir_path) + allow(bundler_stub_spec).to receive(:respond_to?).with(:name).and_return(true) + allow(bundler_stub_spec).to receive(:respond_to?).with(:version).and_return(true) + allow(bundler_stub_spec).to receive(:respond_to?).with(:gem_dir).and_return(false) + allow(bundler_stub_spec).to receive(:respond_to?).with(:materialize_for_installation).and_return(false) + allow(bundler_stub_spec).to receive(:respond_to?).with(:stub).and_return(false) + allow(bundler_stub_spec).to receive_messages(name: 'solargraph', stub: stub_value) + end + + context 'with a Bundler::StubSpecification from Bundler / RubyGems' do + # this can happen from local gems, which is hard to test + # organically + + let(:require) { 'solargraph' } + let(:spec_fetcher) { instance_double(Gem::SpecFetcher) } + + before do + platform = Gem::Platform::RUBY + real_spec = instance_double(Gem::Specification) + allow(real_spec).to receive(:name).and_return('solargraph') + gem_stub_spec = Gem::StubSpecification.new('solargraph', '123', platform, spec_fetcher) + configure_bundler_spec(gem_stub_spec) + allow(gem_stub_spec).to receive_messages(name: 'solargraph', version: '123', spec: real_spec) + end + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with a Bundler::StubSpecification that resolves straight to Gem::Specification' do + # have seen different behavior with different versions of rubygems/bundler + + let(:require) { 'solargraph' } + let(:spec_fetcher) { instance_double(Gem::SpecFetcher) } + let(:real_spec) { Gem::Specification.new('solargraph', '123') } + + before do + configure_bundler_spec(real_spec) + end + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with a less usual require mapping' do + let(:require) { 'diff/lcs' } + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['diff-lcs']) + end + end + + context 'with Bundler.require' do + let(:require) { 'bundler/require' } + + it 'returns the gemspec gem' do + expect(specs.map(&:name)).to include('solargraph') + end + end + end + + context 'with nil as directory' do + let(:dir_path) { nil } + + context 'with simple require' do + let(:require) { 'solargraph' } + + it 'finds solargraph' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with Bundler.require' do + let(:require) { 'bundler/require' } + + it 'finds nothing' do + expect(specs).to be_empty + end + end + end + + context 'with external bundle' do + let(:dir_path) { File.realpath(Dir.mktmpdir).to_s } + + context 'with no actual bundle' do + let(:require) { 'bundler/require' } + + it 'raises' do + expect { specs }.to raise_error(Solargraph::BundleNotFoundError) + end + end + + context 'with Gemfile and Bundler.require' do + before { add_bundle } + + let(:require) { 'bundler/require' } + + it 'does not raise' do + expect { specs }.not_to raise_error + end + + it 'returns gems' do + expect(specs.map(&:name)).to include('backport') + end + end + + context 'with Gemfile and deep require into a possibly-core gem' do + before { add_bundle } + + let(:require) { 'bundler/gem_tasks' } + + it 'returns gems' do + expect(specs&.map(&:name)).to include('bundler') + end + end + + context 'with Gemfile and deep require into a gem' do + before { add_bundle } + + let(:require) { 'rspec/mocks' } + + it 'returns gems' do + expect(specs&.map(&:name)).to include('rspec-mocks') + end + end + + context 'with Gemfile but an unknown gem' do + before { add_bundle } + + let(:require) { 'unknown_gemlaksdflkdf' } + + it 'returns nil' do + expect(specs).to be_nil + end + end + + context 'with a Gemfile and a gem preference' do + # find_or_install helper doesn't seem to work on older versions + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0') + before do + add_bundle + find_or_install('backport', '1.0.0') + Gem::Specification.find_by_name('backport', '= 1.0.0') + end + + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = '1.0.0' + end + ] + end + + it 'returns the preferred gemspec' do + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq('1.0.0') + end + + context 'with a gem preference that does not exist' do + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = '99.0.0' + end + ] + end + + it 'returns the gemspec we do have' do + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq('1.2.0') + end + end + + context 'with a gem preference already set to the version we use' do + let(:version) { Gem::Specification.find_by_name('backport').version.to_s } + + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = version + end + ] + end + + it 'returns the gemspec we do have' do + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq(version) + end + end + end + end + end +end diff --git a/spec/workspace_spec.rb b/spec/workspace_spec.rb index 37275bb86..c6c80e949 100644 --- a/spec/workspace_spec.rb +++ b/spec/workspace_spec.rb @@ -68,6 +68,13 @@ }.not_to raise_error end + it "detects gemspecs in workspaces" do + gemspec_file = File.join(dir_path, 'test.gemspec') + File.write(gemspec_file, '') + expect(workspace.gemspec?).to be(true) + expect(workspace.gemspec_files).to eq([gemspec_file]) + end + it "generates default require path" do expect(workspace.require_paths).to eq([File.join(dir_path, 'lib')]) end diff --git a/spec/yard_map/mapper_spec.rb b/spec/yard_map/mapper_spec.rb index d45af985b..1dfe64ae7 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_all!(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,10 +58,7 @@ 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) @@ -76,21 +66,15 @@ it 'adds corect 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| + inc = 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', '']) + expect(inc.gates).to eq(['AST::Processor::Mixin', 'AST::Processor', 'AST', '']) end 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)