diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 218f598df..cfed714ca 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -196,7 +196,7 @@ jobs: # solargraph-rails supports Ruby 3.0+ # This job uses 3.2 due to a problem compiling sqlite3 in earlier versions ruby-version: '3.2' - bundler-cache: false + bundler-cache: true # https://github.com/apiology/solargraph/actions/runs/19400815835/job/55508092473?pr=17 rubygems: latest bundler: latest diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index f75bbd15d..6e4a688cb 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -25,14 +25,12 @@ jobs: # It currently 404s ("Unavailable version head for ruby"), failing CI: # https://github.com/castwide/solargraph/actions/runs/25863741955/job/76000137015?pr=1187 ruby-version: ['3.1', '3.2', '3.3', '3.4', '4.0'] - rbs-version: ['3.10.0', '4.0.0', '4.0.1', '4.0.2'] + rbs-version: ['3.10.0', '4.0.3', '4.1.1'] exclude: - ruby-version: '3.1' - rbs-version: '4.0.0' + rbs-version: '4.0.3' - ruby-version: '3.1' - rbs-version: '4.0.1' - - ruby-version: '3.1' - rbs-version: '4.0.2' + rbs-version: '4.1.1' steps: - uses: actions/checkout@v3 - name: Set up Ruby @@ -40,6 +38,15 @@ jobs: with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true + # Without this pin, ruby/setup-ruby uses whatever bundler ships + # with each matrix Ruby version individually, rather than one + # consistent version across the matrix. Confirmed by removing + # it: stdlib/default-gem resolution changed enough that + # Workspace::Gemspecs#resolve_require('yaml') started returning + # nil, failing spec/api_map_method_spec.rb's YAML test across + # most of the matrix. Keep this in sync with Gemfile.lock's + # BUNDLED WITH version, and with the same pin below. + bundler: 2.5.23 - name: Set rbs version run: echo "gem 'rbs', '${{ matrix.rbs-version }}'" >> .Gemfile # /home/runner/.rubies/ruby-head/lib/ruby/gems/3.5.0+2/gems/rbs-3.9.4/lib/rbs.rb:11: @@ -67,7 +74,15 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' + # See the matching pin in the rspec matrix job above for why + # this is needed - keep both in sync with Gemfile.lock's + # BUNDLED WITH version. + bundler: 2.5.23 bundler-cache: true + - name: Install gems + run: | + bundle _2.5.23_ install + bundle update rbs # use latest available for this Ruby version - name: Update types run: bundle exec rbs collection update - name: Run tests diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f9dbbf361..72a57e364 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -38,5 +38,3 @@ jobs: run: bundle exec rbs collection install - name: Typecheck self run: SOLARGRAPH_ASSERTS=on bundle exec solargraph typecheck --level strong - # @todo Temporary, expect to revert in 0.60 - continue-on-error: true diff --git a/.rubocop.yml b/.rubocop.yml index f4463bd11..e74b4decf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -92,6 +92,12 @@ Metrics/PerceivedComplexity: Max: 40 RSpec/ExampleLength: Max: 310 +# Autocorrect mangles short-style Hash tags with nested generics/parens +# (e.g. Hash{Array(String, Array) => String}) into invalid syntax. +# Confirmed broken through rubocop-yard 1.3.0 (latest as of this writing), +# against yard 0.9.45 (also latest as of this writing). +YARD/CollectionStyle: + Enabled: false plugins: - rubocop-rspec diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 408a6dfcd..0a287d8f7 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -85,6 +85,7 @@ Metrics/AbcSize: Exclude: - 'lib/solargraph/api_map/source_to_yard.rb' - 'lib/solargraph/parser/parser_gem/node_chainer.rb' + - 'lib/solargraph/shell.rb' - 'lib/solargraph/source/source_chainer.rb' - 'lib/solargraph/source_map/clip.rb' - 'lib/solargraph/source_map/mapper.rb' diff --git a/Gemfile b/Gemfile index bf33a0df5..1e689747b 100755 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source 'https://rubygems.org' gemspec name: 'solargraph' # Test fixture gems +# @sg-ignore Wrong argument type for Kernel#gem: arg_1 expected String, received Hash gem 'gem-with-yard-macros', path: 'spec/fixtures/gem-with-yard-macros' # Local gemfile for development tools, etc. diff --git a/Rakefile b/Rakefile index 398957b1a..e569f9bf4 100755 --- a/Rakefile +++ b/Rakefile @@ -49,7 +49,6 @@ task :full_spec do FileUtils.mv('coverage/full-new', 'coverage/full') end -# @sg-ignore #undercover return type could not be inferred # @return [Process::Status] def undercover simplecov_collate diff --git a/lib/solargraph.rb b/lib/solargraph.rb index 9ad6eac3c..35e34bf8e 100755 --- a/lib/solargraph.rb +++ b/lib/solargraph.rb @@ -80,11 +80,8 @@ def self.assert_or_log type, msg = nil, &block raise "No message given for #{type.inspect}" if msg.nil? # conditional aliases to handle compatibility corner cases - # @sg-ignore flow sensitive typing needs to handle 'raise if' return if type == :alias_target_missing && msg.include?('highline/compatibility.rb') - # @sg-ignore flow sensitive typing needs to handle 'raise if' return if type == :alias_target_missing && msg.include?('lib/json/add/date.rb') - # @sg-ignore flow sensitive typing needs to handle 'raise if' return if type == :alias_target_missing && msg.include?('rubocop-ast.rbs') # @todo :combine_with_visibility is not ready for prime time - # lots of disagreements found in practice that heuristics need diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 26b42ddb4..ce1901d5a 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -116,9 +116,8 @@ 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 || - # @sg-ignore Unresolved call to rbs_collection_path on Solargraph::Workspace, nil workspace.rbs_collection_path != bench.workspace.rbs_collection_path || - @doc_map.uncached_gemspecs.any? + @doc_map.any_uncached? if recreate_docmap @doc_map = DocMap.new(unresolved_requires, bench.workspace, out: nil) # @todo Implement gem preferences @@ -145,10 +144,12 @@ def process_macros closure = source_map.locate_closure_pin(node.location.line, node.location.column) chain = Solargraph::Parser::ParserGem::NodeChainer.chain(node) if node.children[0].nil? && store.macro_method_name_pins.key?(node.children[1].to_s) + # @sg-ignore Need to add nil check here match = store.macro_method_name_pins[node.children[1].to_s].find do |pin| get_complex_type_methods(closure.return_type).include?(pin) end if match + # @sg-ignore Need to add nil check here match.macros.each do |macro| macro_pins.concat macro.generate_pins_from(chain, match, source_map) end @@ -170,16 +171,6 @@ 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 @@ -188,7 +179,6 @@ def core_pins # @param name [String, nil] # @return [Solargraph::YardMap::Macro, nil] def named_macro name - # @sg-ignore Need to add nil check here store.named_macros[name] end @@ -205,9 +195,11 @@ def conventions_environ # @param filename [String] # @param position [Position, Array(Integer, Integer)] # @return [Source::Cursor] + # @sg-ignore Need to add nil check here def cursor_at filename, position position = Position.normalize(position) raise FileNotFoundError, "File not found: #{filename}" unless source_map_hash.key?(filename) + # @sg-ignore Need to add nil check here source_map_hash[filename].cursor_at(position) end @@ -241,7 +233,7 @@ def self.load directory, loose_unions: true # @param rebuild [Boolean] whether to rebuild the pins even if they are cached # @return [void] def cache_all_for_doc_map! out: $stderr, rebuild: false - doc_map.cache_all!(out, rebuild: rebuild) + doc_map.cache_doc_map_gems!(out, rebuild: rebuild) end # @param gemspec [Gem::Specification] @@ -416,7 +408,7 @@ def var_at_location candidates, name, closure, location !pin.visible_at?(closure, location) && !pin.starts_at?(location) end - vars_at_location.inject(&:combine_with) + vars_at_location.inject { |acc, pin| acc.combine_with(pin, location: location) } end # Get an array of class variable pins for a namespace. @@ -442,6 +434,20 @@ def get_block_pins store.pins_by_class(Pin::Block) end + # Get the methods a namespace (e.g. an RBS interface) declares + # directly on itself, excluding ones inherited from Object, + # superclasses, or mixins. Useful for structural (duck-type) + # checks against an interface's own contract, e.g. whether some + # other type implements every method `Hash::_Key` itself declares + # (`#hash`, `#eql?`), independent of the interface's name. + # + # @param rooted_tag [String] The fully qualified namespace/interface to search for methods + # @param scope [Symbol] :class or :instance + # @return [Array] + def get_own_methods rooted_tag, scope: :instance + get_methods(rooted_tag, scope: scope).select { |pin| pin.closure&.path == rooted_tag } + end + # Get an array of methods available in a particular context. # # @param rooted_tag [String] The fully qualified namespace to search for methods @@ -450,9 +456,14 @@ def get_block_pins # @param deep [Boolean] True to include superclasses, mixins, etc. # @return [Array] def get_methods rooted_tag, scope: :instance, visibility: [:public], deep: true + if rooted_tag.start_with? 'Array(' + # Array() are really tuples - use our fill, as the RBS repo + # does not give us definitions for it + rooted_tag = "Solargraph::Fills::Tuple(#{rooted_tag[6..-2]})" + end rooted_type = ComplexType.try_parse(rooted_tag) fqns = rooted_type.namespace - namespace_pin = store.get_path_pins(fqns).select { |p| p.is_a?(Pin::Namespace) }.first + namespace_pin = namespace_pin_for_generics(fqns) cached = cache.get_methods(rooted_tag, scope, visibility, deep) return cached.clone unless cached.nil? # @type [Array] @@ -592,6 +603,7 @@ def get_method_stack rooted_tag, name, scope: :instance, visibility: %i[private else get_methods(rooted_tag, scope: scope, visibility: visibility).select { |p| p.name == name } end + # @sg-ignore Need to add nil check here methods = erase_generics(namespace_pin, rooted_type, methods) unless preserve_generics methods end @@ -652,6 +664,7 @@ def query_symbols query # @return [Array] def locate_pins location return [] if location.nil? || !source_map_hash.key?(location.filename) + # @sg-ignore Need to add nil check here resolve_method_aliases source_map_hash[location.filename].locate_pins(location) end @@ -670,6 +683,7 @@ def clip cursor # @return [Array] def document_symbols filename return [] unless source_map_hash.key?(filename) # @todo Raise error? + # @sg-ignore Need to add nil check here resolve_method_aliases source_map_hash[filename].document_symbols end @@ -682,6 +696,7 @@ def source_maps # # @param filename [String] # @return [SourceMap] + # @sg-ignore Need to add nil check here def source_map filename raise FileNotFoundError, "Source map for `#{filename}` not found" unless source_map_hash.key?(filename) source_map_hash[filename] @@ -706,14 +721,12 @@ def super_and_sub? sup, sub # @todo If two literals are different values of the same type, it would # make more sense for super_and_sub? to return true, but there are a # few callers that currently expect this to be false. - # @sg-ignore flow-sensitive typing should be able to handle redefinition return false if sup.literal? && sub.literal? && sup.to_s != sub.to_s - # @sg-ignore flow sensitive typing should be able to handle redefinition sup = sup.simplify_literals.to_s - # @sg-ignore flow sensitive typing should be able to handle redefinition sub = sub.simplify_literals.to_s return true if sup == sub sc_fqns = sub + # @sg-ignore flow sensitive typing unions rather than overrides types across multiple sequential reassignments while (sc = store.get_superclass(sc_fqns)) # @sg-ignore flow sensitive typing needs to handle "if foo = bar" sc_new = store.constants.dereference(sc) @@ -743,17 +756,16 @@ def resolve_method_aliases pins, visibility = %i[public private protected] with_resolved_aliases = pins.map do |pin| next pin unless pin.is_a?(Pin::MethodAlias) resolved = resolve_method_alias(pin) - # @sg-ignore Need to add nil check here next nil if resolved.respond_to?(:visibility) && !visibility.include?(resolved.visibility) resolved end.compact logger.debug do "ApiMap#resolve_method_aliases(pins=#{pins.map(&:name)}, visibility=#{visibility}) => #{with_resolved_aliases.map(&:name)}" end - with_resolved_aliases + GemPins.combine_method_pins_by_path(with_resolved_aliases) end - # @return [Workspace, nil] + # @return [Workspace] def workspace doc_map.workspace end @@ -783,9 +795,10 @@ def inner_get_methods_from_reference fq_reference_tag, namespace_pin, type, scop # @todo Can inner_get_methods be cached? Lots of lookups of base types going on. methods = inner_get_methods(resolved_reference_type.tag, scope, visibility, deep, skip, no_core) if namespace_pin && !resolved_reference_type.all_params.empty? - reference_pin = store.get_path_pins(resolved_reference_type.name).select { |p| p.is_a?(Pin::Namespace) }.first + reference_pin = namespace_pin_for_generics(resolved_reference_type.name) # logger.debug { "ApiMap#add_methods_from_reference(type=#{type}) - resolving generics with #{reference_pin.generics}, #{resolved_reference_type.rooted_tags}" } methods = methods.map do |method_pin| + # @sg-ignore Need to add nil check here method_pin.resolve_generics(reference_pin, resolved_reference_type) end end @@ -799,6 +812,15 @@ def qualify_superclass fq_sub_tag store.qualify_superclass fq_sub_tag end + # @param require_path [String] + # + # @return [Array, nil] + def resolve_require require_path + raise "Unable to resolve require '#{require_path}' without a workspace" if workspace.nil? + + Workspace::Gemspecs.new(workspace.directory).resolve_require require_path + end + private # A hash of source maps with filename keys. @@ -811,6 +833,21 @@ def store @store ||= Store.new end + # Get the namespace pin that should be used to resolve generic type + # parameters for a fully qualified namespace. Multiple pins can + # exist for the same namespace (e.g., a gem's own class definition + # plus a `@!parse` stub in a different file that adds `@generic` + # tags); the one that actually declares the generics must be used, + # regardless of load order. + # + # @param fqns [String] + # @return [Pin::Namespace, nil] + def namespace_pin_for_generics fqns + # @type [Array] + candidates = store.get_path_pins(fqns).select { |p| p.is_a?(Pin::Namespace) } + candidates.find { |p| !p.generics.empty? } || candidates.first + end + # @return [Solargraph::ApiMap::Cache] attr_reader :cache @@ -826,7 +863,7 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false rooted_type = ComplexType.parse(rooted_tag).force_rooted fqns = rooted_type.namespace rooted_type.all_params - namespace_pin = store.get_path_pins(fqns).select { |p| p.is_a?(Pin::Namespace) }.first + namespace_pin = namespace_pin_for_generics(fqns) return [] if no_core && fqns =~ /^(Object|BasicObject|Class|Module)$/ reqstr = "#{fqns}|#{scope}|#{visibility.sort}|#{deep}" return [] if skip.include?(reqstr) @@ -867,6 +904,7 @@ 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? + # @sg-ignore Need to add nil check here result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, visibility, true, skip, no_core) end @@ -880,6 +918,7 @@ 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? + # @sg-ignore Need to add nil check here result.concat inner_get_methods_from_reference(rooted_sc_tag, namespace_pin, rooted_type, scope, visibility, true, skip, true) end @@ -973,7 +1012,6 @@ def resolve_method_alias alias_pin # :nocov: end - # @sg-ignore ignore `received nil` for original create_resolved_alias_pin(alias_pin, original) end diff --git a/lib/solargraph/api_map/cache.rb b/lib/solargraph/api_map/cache.rb index c69d223b4..84b336154 100644 --- a/lib/solargraph/api_map/cache.rb +++ b/lib/solargraph/api_map/cache.rb @@ -29,7 +29,7 @@ def inspect # @param scope [Symbol] # @param visibility [Array] # @param deep [Boolean] - # @return [Array] + # @return [Array, nil] def get_methods fqns, scope, visibility, deep @methods["#{fqns}|#{scope}|#{visibility}|#{deep}"] end @@ -46,7 +46,7 @@ def set_methods fqns, scope, visibility, deep, value # @param namespace [String] # @param contexts [Array] - # @return [Array] + # @return [Array, nil] def get_constants namespace, contexts @constants["#{namespace}|#{contexts}"] end @@ -75,7 +75,7 @@ def set_qualified_namespace name, context, value end # @param path [String] - # @return [Pin::Method] + # @return [Pin::Method, nil] def get_receiver_definition path @receiver_definitions[path] end diff --git a/lib/solargraph/api_map/constants.rb b/lib/solargraph/api_map/constants.rb index 880adacb6..244c15b31 100644 --- a/lib/solargraph/api_map/constants.rb +++ b/lib/solargraph/api_map/constants.rb @@ -27,7 +27,6 @@ def initialize store # @param name [String] Namespace which may relative and not be rooted. # @param gates [Array, String>] Namespaces to search while resolving the name # - # @sg-ignore flow sensitive typing needs to eliminate literal from union with return if foo == :bar # @return [String, nil] fully qualified namespace (i.e., is # absolute, but will not start with ::) def resolve(name, *gates) @@ -70,6 +69,7 @@ def collect(*gates) # @param gates [Array] # @return [String, nil] fully qualified tag def qualify tag, *gates + # @sg-ignore Wrong argument type for Solargraph::ComplexType.try_parse: strings expected String, received String, nil type = ComplexType.try_parse(tag) qualify_type(type, *gates)&.tag end @@ -108,7 +108,7 @@ def clear # @param name [String] # @param gates [Array] - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 # @return [String, nil] def resolve_and_cache name, gates cached_resolve[[name, gates]] = :in_process @@ -129,7 +129,6 @@ def resolve_uncached name, gates if resolved base = [resolved] else - # @sg-ignore flow sensitive typing needs better handling of ||= on lvars return resolve(name, first) unless first.empty? end end diff --git a/lib/solargraph/api_map/index.rb b/lib/solargraph/api_map/index.rb index e7a85b73f..a8e9de383 100644 --- a/lib/solargraph/api_map/index.rb +++ b/lib/solargraph/api_map/index.rb @@ -5,10 +5,10 @@ class ApiMap class Index include Logging - # @return [Array] + # @return [Set] attr_reader :macro_method_names - # @return [Hash{String => Array}] + # @return [Hash{String => Set}] attr_reader :macro_method_name_pins # @param pins [Array] @@ -25,6 +25,7 @@ def pins def namespace_hash # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @namespace_hash ||= Hash.new { |h, k| h[k] = [] } end @@ -32,6 +33,7 @@ def namespace_hash def pin_class_hash # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @pin_class_hash ||= Hash.new { |h, k| h[k] = [] } end @@ -39,6 +41,7 @@ def pin_class_hash def path_pin_hash # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @path_pin_hash ||= Hash.new { |h, k| h[k] = [] } end @@ -61,6 +64,7 @@ def pins_by_class klass def include_references # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @include_references ||= Hash.new { |h, k| h[k] = [] } end @@ -68,6 +72,7 @@ def include_references def include_reference_pins # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @include_reference_pins ||= Hash.new { |h, k| h[k] = [] } end @@ -75,6 +80,7 @@ def include_reference_pins def extend_references # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @extend_references ||= Hash.new { |h, k| h[k] = [] } end @@ -82,6 +88,7 @@ def extend_references def prepend_references # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @prepend_references ||= Hash.new { |h, k| h[k] = [] } end @@ -89,6 +96,7 @@ def prepend_references def superclass_references # @param h [String] # @param k [Array] + # @sg-ignore Wrong argument type for String#[]=: range expected Range>, _Range>, received Array @superclass_references ||= Hash.new { |h, k| h[k] = [] } end @@ -131,14 +139,17 @@ def catalog new_pins # @param k [String] # @param v [Set] set.classify(&:class) + # @sg-ignore Need to add nil check here .map { |k, v| pin_class_hash[k].concat v.to_a } # @param k [String] # @param v [Set] set.classify(&:namespace) + # @sg-ignore Need to add nil check here .map { |k, v| namespace_hash[k].concat v.to_a } # @param k [String] # @param v [Set] set.classify(&:path) + # @sg-ignore Need to add nil check here .map { |k, v| path_pin_hash[k].concat v.to_a } @namespaces = path_pin_hash.keys.compact.to_set map_references Pin::Reference::Include, include_references @@ -173,13 +184,10 @@ def map_overrides logger.debug { "ApiMap::Index#map_overrides: Looking at override #{ovr} for #{ovr.name}" } pins = path_pin_hash[ovr.name] logger.debug { "ApiMap::Index#map_overrides: pins for path=#{ovr.name}: #{pins}" } + # @sg-ignore Need to add nil check here pins.each do |pin| new_pin = (path_pin_hash[pin.path.sub('#initialize', '.new')].first if pin.path.end_with?('#initialize')) (ovr.tags.map(&:tag_name) + ovr.delete).uniq.each do |tag| - # @sg-ignore Wrong argument type for - # YARD::Docstring#delete_tags: name expected String, - # received String, Symbol - delete_tags is ok with a - # _ToS, but we should fix anyway pin.docstring.delete_tags tag new_pin&.docstring&.delete_tags tag end @@ -198,7 +206,7 @@ def map_overrides end end - # @param pin [Pin::Method] + # @param pin [Pin::Base] # @param tag [YARD::Tags::Tag] # @return [void] def redefine_return_type pin, tag @@ -206,6 +214,9 @@ def redefine_return_type pin, tag # proxy() / proxy_with_signatures() instead? return unless pin && tag.tag_name == 'return' pin.instance_variable_set(:@return_type, ComplexType.try_parse(tag.type)) + # Only methods carry signatures; constants and other pins have + # just the one return type set above. + return unless pin.is_a?(Pin::Method) pin.signatures.each do |sig| sig.instance_variable_set(:@return_type, ComplexType.try_parse(tag.type)) end diff --git a/lib/solargraph/api_map/source_to_yard.rb b/lib/solargraph/api_map/source_to_yard.rb index a121a348b..acab5c8fd 100644 --- a/lib/solargraph/api_map/source_to_yard.rb +++ b/lib/solargraph/api_map/source_to_yard.rb @@ -28,36 +28,44 @@ def rake_yard store YARD::Registry.clear code_object_map.clear store.namespace_pins.each do |pin| + # @sg-ignore Unresolved call to empty? next if pin.path.nil? || pin.path.empty? if pin.code_object + # @sg-ignore Unresolved call to path code_object_map[pin.path] ||= pin.code_object next end if pin.type == :class # @param obj [YARD::CodeObjects::RootObject] + # @sg-ignore Unresolved call to path code_object_map[pin.path] ||= YARD::CodeObjects::ClassObject.new(root_code_object, pin.path) do |obj| - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename next if pin.location.nil? || pin.location.filename.nil? - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename obj.add_file(pin.location.filename, pin.location.range.start.line, !pin.comments.empty?) end else # @param obj [YARD::CodeObjects::RootObject] + # @sg-ignore Unresolved call to path code_object_map[pin.path] ||= YARD::CodeObjects::ModuleObject.new(root_code_object, pin.path) do |obj| - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename next if pin.location.nil? || pin.location.filename.nil? - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename obj.add_file(pin.location.filename, pin.location.range.start.line, !pin.comments.empty?) end end + # @sg-ignore Need to add nil check here code_object_map[pin.path].docstring = pin.docstring store.get_includes(pin.path).each do |ref| + # @sg-ignore Unresolved call to path include_object = code_object_at(pin.path, YARD::CodeObjects::ClassObject) unless include_object.nil? || include_object.nil? + # @sg-ignore Wrong argument type for Array#push: objects expected YARD::CodeObjects::ModuleObject, received YARD::CodeObjects::Base, nil include_object.instance_mixins.push code_object_map[ref.type.to_s] end end store.get_extends(pin.path).each do |ref| + # @sg-ignore Unresolved call to path extend_object = code_object_at(pin.path, YARD::CodeObjects::ClassObject) next unless extend_object code_object = code_object_map[ref.type.to_s] @@ -68,6 +76,7 @@ def rake_yard store end store.method_pins.each do |pin| if pin.code_object + # @sg-ignore Unresolved call to code_object code_object_map[pin.path] ||= pin.code_object next end @@ -77,9 +86,9 @@ def rake_yard store code_object_map[pin.path] ||= YARD::CodeObjects::MethodObject.new( code_object_at(pin.namespace, YARD::CodeObjects::NamespaceObject), pin.name, pin.scope ) do |obj| - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename next if pin.location.nil? || pin.location.filename.nil? - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore Unresolved call to filename obj.add_file pin.location.filename, pin.location.range.start.line end method_object = code_object_at(pin.path, YARD::CodeObjects::MethodObject) diff --git a/lib/solargraph/api_map/store.rb b/lib/solargraph/api_map/store.rb index ad0f64f20..5b0c7628c 100644 --- a/lib/solargraph/api_map/store.rb +++ b/lib/solargraph/api_map/store.rb @@ -43,8 +43,10 @@ def update *pinsets, &block @indexes[changed + idx - 1].merge(pins) end end + # @sg-ignore Need to add nil check here # @type [Index] @index = @indexes.last.clone + # @sg-ignore Need to add nil check here @index = @index.merge(block.call) if block constants.clear cached_qualify_superclass.clear @@ -72,11 +74,12 @@ def get_constants fqns, visibility = [:public] # @param fqns [String] # @param scope [Symbol] # @param visibility [Array] - # @return [Enumerable] + # @return [Array] def get_methods fqns, scope: :instance, visibility: [:public] - namespace_children(fqns).select do |pin| + pins = namespace_children(fqns).select do |pin| pin.is_a?(Pin::Method) && pin.scope == scope && visibility.include?(pin.visibility) end + combine_duplicate_method_pins(pins) end BOOLEAN_SUPERCLASS_PIN = Pin::Reference::Superclass.new(name: 'Boolean', closure: Pin::ROOT_PIN, @@ -90,6 +93,7 @@ def get_superclass fqns return nil if fqns.nil? || fqns.empty? return BOOLEAN_SUPERCLASS_PIN if %w[TrueClass FalseClass].include?(fqns) + # @sg-ignore Need to add nil check here superclass_references[fqns].first || try_special_superclasses(fqns) end @@ -126,6 +130,7 @@ def get_extends fqns # @param path [String] # @return [Array] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 def get_path_pins path index.path_pin_hash[path] end @@ -205,6 +210,7 @@ def pins_by_class klass # @param fqns [String, nil] # @return [Array] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 def fqns_pins fqns return [] if fqns.nil? if fqns.include?('::') @@ -248,11 +254,8 @@ def get_ancestors fqns next if refs.nil? # @param ref [String] refs.map(&:type).map(&:to_s).each do |ref| - # @sg-ignore flow sensitive typing should be able to handle redefinition next if ref.nil? || ref.empty? || visited.include?(ref) - # @sg-ignore flow sensitive typing should be able to handle redefinition ancestors << ref - # @sg-ignore flow sensitive typing should be able to handle redefinition queue << ref end end @@ -279,12 +282,12 @@ def unalias name index.alias_hash[name] end - # @return [Array] + # @return [Set] def macro_method_names index.macro_method_names end - # @return [Hash{String => Array}] + # @return [Hash{String => Set}] def macro_method_name_pins index.macro_method_name_pins end @@ -296,6 +299,33 @@ def index @index ||= Index.new end + # A method can be defined by more than one pin with the same + # path - e.g., a gem's own implementation plus a `@!parse` stub + # in a separate file that overrides its documentation. Combine + # them into a single pin so callers see one consistent signature + # instead of an arbitrary pick among duplicates. + # + # Aliases are skipped: combining a MethodAlias pin with a + # non-alias pin at the same path produces a `:combined` pin that + # #resolve_method_alias can't trace back to its original target, + # which raises under SOLARGRAPH_ASSERTS=on. + # + # @param pins [Array] + # @return [Array] + def combine_duplicate_method_pins pins + result = [] + pins.group_by(&:path).each_value do |group| + if group.length == 1 || group.any? { |pin| pin.is_a?(Pin::MethodAlias) } + result.concat(group) + else + # @sg-ignore group is never empty here (group_by never yields an empty group) + combined = group[1..].reduce(group.first) { |memo, pin| memo.combine_with(pin) } + result.push(combined) + end + end + result + end + # @param pinsets [Array>] # # @return [true] @@ -311,6 +341,7 @@ def catalog pinsets, &block end end @index = @indexes.last.clone + # @sg-ignore Need to add nil check here @index = @index.merge(block.call) if block constants.clear cached_qualify_superclass.clear @@ -360,6 +391,7 @@ def extend_references # @param name [String] # @return [Enumerable] + # @sg-ignore Need to add nil check here def namespace_children name return [] unless index.namespace_hash.key?(name) index.namespace_hash[name] diff --git a/lib/solargraph/bench.rb b/lib/solargraph/bench.rb index dda2bbc88..a7af13dc4 100644 --- a/lib/solargraph/bench.rb +++ b/lib/solargraph/bench.rb @@ -11,6 +11,7 @@ class Bench attr_reader :workspace # @return [SourceMap] + # @sg-ignore Need to add nil check here attr_reader :live_map # @return [Set] @@ -29,7 +30,6 @@ def initialize source_maps: [], workspace: Workspace.new, live_map: nil, externa .to_set end - # @sg-ignore flow sensitive typing needs better handling of ||= on lvars # @return [Hash{String => SourceMap}] def source_map_hash # @todo Work around #to_h bug in current Ruby head (3.5) with #map#to_h diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 27d2ff08c..0981a43a6 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -36,7 +36,7 @@ def initialize types = [UniqueType::UNDEFINED] def qualify api_map, *gates red = reduce_object types = red.items.map do |t| - next t if %w[nil void undefined].include?(t.name) + next t if %w[nil void undefined bot].include?(t.name) next t if ['::Boolean'].include?(t.rooted_name) api_map.unalias(t.name) || t.qualify(api_map, *gates) end @@ -51,12 +51,13 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge return self unless generic? ComplexType.new(@items.map do |i| - i.resolve_generics_from_context(generics_to_resolve, context_type, + i.resolve_generics_from_context(generics_to_resolve, generic_item_context_type(i, context_type), resolved_generic_values: resolved_generic_values) end) end # @return [UniqueType] + # @sg-ignore Need to add nil check here def first @items.first end @@ -133,6 +134,7 @@ def to_a # @param index [Integer] # @return [UniqueType] + # @sg-ignore Need to add nil check here def [] index @items[index] end @@ -189,10 +191,21 @@ def literal? # @return [ComplexType] def downcast_to_literal_if_possible - return self ComplexType.new(items.map(&:downcast_to_literal_if_possible)) end + # Drop a literal item (e.g. `0`) when its non-literal base type + # (e.g. `Integer`) is also present in the same union - a wider + # type already subsumes it, so keeping both is redundant and + # reads as if the literal value were still specifically reachable. + # + # @return [ComplexType] + def without_redundant_literals + non_literal_names = items.reject(&:literal?).map(&:name) + new_items = items.reject { |item| item.literal? && non_literal_names.include?(item.non_literal_name) } + ComplexType.new(new_items) + end + # @return [String] def desc rooted_tags @@ -227,7 +240,7 @@ def conforms_to? api_map, expected, expected = expected.downcast_to_literal_if_possible inferred = downcast_to_literal_if_possible - return duck_types_match?(api_map, expected, inferred) if expected.duck_type? + return duck_types_match?(api_map, expected, inferred, rules) if expected.duck_type? if rules.include? :allow_any_match inferred.any? do |inf| @@ -245,18 +258,58 @@ def conforms_to? api_map, expected, # @param api_map [ApiMap] # @param expected [ComplexType, UniqueType] # @param inferred [ComplexType, UniqueType] + # @param rules [Array] # @return [Boolean] - def duck_types_match? api_map, expected, inferred + def duck_types_match? api_map, expected, inferred, rules = [] raise ArgumentError, 'Expected type must be duck type' unless expected.duck_type? + allow_any_match = rules.include?(:allow_any_match) expected.each do |exp| next unless exp.duck_type? - quack = exp.to_s[1..] - # @sg-ignore Need to add nil check here - return false if api_map.get_method_stack(inferred.namespace, quack, scope: inferred.scope).empty? + quack = exp.to_s[1..] || '' + matched = allow_any_match ? inferred.any? { |inf| duck_type_provides?(api_map, inf, quack) } : inferred.all? { |inf| duck_type_provides?(api_map, inf, quack) } + return false unless matched end true end + # @param api_map [ApiMap] + # @param inf [UniqueType] + # @param quack [String] + # @return [Boolean] + def duck_type_provides? api_map, inf, quack + return true if inf.duck_type? && inf.to_s[1..] == quack + return intersection_conjunct_quacks?(api_map, quack, inf) if inf.is_a?(UniqueType::Intersection) + + !api_map.get_method_stack(inf.namespace, quack, scope: inf.scope).empty? + end + private :duck_type_provides? + + # Intersection#namespace/#scope only report the first conjunct, + # which loses the "any one conjunct satisfies" semantics an + # intersection needs against a duck-typed expectation - e.g. a + # mock stubbed to satisfy an interface, typed `SomeMockClass & + # #some_method`, has to be checked against every conjunct rather + # than the first one. A conjunct is itself a full ComplexType (RBS + # allows a union as one member of an intersection), so a union + # conjunct only counts as satisfying the duck type if every one of + # its own alternatives does. + # + # @param api_map [ApiMap] + # @param quack [String] + # @param unique_type [ComplexType::UniqueType] + # @return [Boolean] + def intersection_conjunct_quacks? api_map, quack, unique_type + if unique_type.is_a?(UniqueType::Intersection) + return unique_type.conjuncts.any? do |conjunct| + conjunct.all? { |ut| intersection_conjunct_quacks?(api_map, quack, ut) } + end + end + # A duck-typed conjunct only vouches for the one method its own + # tag names - it has no namespace to look other methods up on. + return unique_type.to_s[1..] == quack if unique_type.duck_type? + !api_map.get_method_stack(unique_type.namespace, quack, scope: unique_type.scope).empty? + end + # @return [String] def rooted_tags map(&:rooted_tag).join(', ') @@ -298,6 +351,8 @@ def transform new_name = nil, &transform_type ComplexType.new(map { |ut| ut.transform(new_name, &transform_type) }) end + # @param named_types [Hash{String => ComplexType::UniqueType}] + # @return [ComplexType] def expand named_types ComplexType.new(map { |ut| ut.expand(named_types) }) end @@ -329,7 +384,9 @@ def without_nil end # @return [Array] + # @sg-ignore Need to add nil check here def all_params + # @sg-ignore Need to add nil check here @items.first.all_params || [] end @@ -354,6 +411,7 @@ def all_rooted? def erased_version_of? other return false if items.length != 1 || other.items.length != 1 + # @sg-ignore Need to add nil check here @items.first.erased_version_of?(other.items.first) end @@ -372,30 +430,64 @@ def exclude exclude_types, api_map return self if exclude_types.nil? types = items - exclude_types.items - types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + if types.empty? + # An exhausted exclusion (e.g., a `x.nil? || x.is_a?(Foo)` + # guard that together covers every member of the declared + # type) means the code past this point is unreachable, not a + # real type error. Tag it `bot` - a subtype of every type - + # instead of `undefined`, so calls made on it downstream are + # treated as vacuously valid rather than flagged unresolved. + types = [ComplexType::UniqueType::BOT] + end ComplexType.new(types) end - # @see https://en.wikipedia.org/wiki/Intersection_type + # Flow-sensitive type narrowing: given a type learned from a + # runtime guard (e.g. `x.is_a?(Foo)`), refines this type down to + # the more specific of each compatible pair between the two + # sides. When neither side is already known to be a subtype of + # the other but one is positively confirmed to be a mix-in (e.g. + # a declared class and an unrelated module), both facts are still + # true at once, so the pair is combined into a + # ComplexType::UniqueType::Intersection rather than discarded. + # Everything else - two different concrete classes (impossible; + # an object has exactly one class), or either side being a + # namespace we can't positively identify - falls back to the + # original behavior of dropping the pair; only if every pair is + # either dropped or empty does the result fall back to UNDEFINED. + # + # @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html # - # @param intersection_type [ComplexType, ComplexType::UniqueType, nil] + # @param narrowing_type [ComplexType, ComplexType::UniqueType, nil] # @param api_map [ApiMap] # @return [self, ComplexType::UniqueType] - def intersect_with intersection_type, api_map - return self if intersection_type.nil? - return intersection_type if undefined? + def narrow_with narrowing_type, api_map + return self if narrowing_type.nil? + return narrowing_type if undefined? types = [] # try to find common types via conformance items.each do |ut| - intersection_type.each do |int_type| - if int_type.conforms_to?(api_map, ut, :assignment) - types << int_type - elsif ut.conforms_to?(api_map, int_type, :assignment) + narrowing_type.each do |candidate| + if candidate.duck_type? + # A duck-type fact (e.g. from a respond_to? guard) selects + # the union arms that already provide the method; arms that + # don't are excluded by the guard, not replaced by the duck + # type. If no arm provides it (an opaque receiver like + # Object), the fall-through below keeps the bare duck type. + types << ut if duck_types_match?(api_map, candidate, ComplexType.new([ut])) + elsif candidate.conforms_to?(api_map, ut, :assignment) + types << candidate + elsif ut.conforms_to?(api_map, candidate, :assignment) types << ut + elsif mixin_pairing?(api_map, ut, candidate) + types << UniqueType::Intersection.new([ComplexType.new([ut]), ComplexType.new([candidate])]) end end end - types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + if types.empty? + duck_candidates = narrowing_type.select(&:duck_type?) + types = duck_candidates.empty? ? [ComplexType::UniqueType::UNDEFINED] : duck_candidates + end ComplexType.new(types) end @@ -418,6 +510,35 @@ def bottom? @items.all?(&:bot?) end + # Whether combining these two into an intersection is safe. Only + # true when at least one side is *positively confirmed* to be a + # mix-in: any class can pick up any module, so a class-and-module + # pairing is always plausible. Everything else - two different + # concrete classes (impossible; an object has exactly one class), + # or a namespace we have no pin for (synthetic names like + # `Boolean`, generics, literals, duck types, or simply unresolved) + # - defaults to false, preserving the original drop-the-pair + # behavior. This is deliberately conservative: it only recognizes + # the specific case it was added for rather than guessing about + # everything narrow_with might be asked to combine. + # + # @param api_map [ApiMap] + # @param declared [ComplexType::UniqueType] + # @param candidate [ComplexType::UniqueType] + # @return [Boolean] + def mixin_pairing? api_map, declared, candidate + namespace_kind(api_map, declared) == :module || namespace_kind(api_map, candidate) == :module + end + + # @param api_map [ApiMap] + # @param unique_type [ComplexType::UniqueType] + # @return [Symbol, nil] :class, :module, or nil if unknown + def namespace_kind api_map, unique_type + # @type [Pin::Namespace, nil] + pin = api_map.get_path_pins(unique_type.namespace).find { |p| p.is_a?(Pin::Namespace) } + pin&.type + end + class << self # Parse type strings into a ComplexType. # @@ -450,72 +571,7 @@ def parse *strings, partial: false types = [] key_types = nil strings.each do |type_string| - point_stack = 0 - curly_stack = 0 - paren_stack = 0 - base = String.new - subtype_string = String.new - # @param char [String] - type_string&.each_char do |char| - if char == '=' - # raise ComplexTypeError, "Invalid = in type #{type_string}" unless curly_stack > 0 - elsif char == '<' - point_stack += 1 - elsif char == '>' - if subtype_string.end_with?('=') && curly_stack.positive? - subtype_string += char - elsif base.end_with?('=') - raise ComplexTypeError, 'Invalid hash thing' unless key_types.nil? - # types.push ComplexType.new([UniqueType.new(base[0..-2].strip)]) - # @sg-ignore Need to add nil check here - types.push UniqueType.parse(base[0..-2].strip, subtype_string) - # @todo this should either expand key_type's type - # automatically or complain about not being - # compatible with key_type's type in type checking - key_types = types - types = [] - base.clear - subtype_string.clear - next - else - raise ComplexTypeError, "Invalid close in type #{type_string}" if point_stack.zero? - point_stack -= 1 - subtype_string += char - end - next - elsif char == '{' - curly_stack += 1 - elsif char == '}' - curly_stack -= 1 - subtype_string += char - raise ComplexTypeError, "Invalid close in type #{type_string}" if curly_stack.negative? - next - elsif char == '(' - paren_stack += 1 - elsif char == ')' - paren_stack -= 1 - subtype_string += char - raise ComplexTypeError, "Invalid close in type #{type_string}" if paren_stack.negative? - next - elsif char == ',' && point_stack.zero? && curly_stack.zero? && paren_stack.zero? - # types.push ComplexType.new([UniqueType.new(base.strip, subtype_string.strip)]) - types.push UniqueType.parse(base.strip, subtype_string.strip) - base.clear - subtype_string.clear - next - end - if point_stack.zero? && curly_stack.zero? && paren_stack.zero? - base.concat char - else - subtype_string.concat char - end - end - if point_stack != 0 || curly_stack != 0 || paren_stack != 0 - raise ComplexTypeError, - "Unclosed subtype in #{type_string}" - end - # types.push ComplexType.new([UniqueType.new(base, subtype_string)]) - types.push UniqueType.parse(base.strip, subtype_string.strip) + types, key_types = parse_type_string(type_string, types, key_types) end unless key_types.nil? raise ComplexTypeError, 'Invalid use of key/value parameters' unless partial @@ -535,6 +591,228 @@ def try_parse *strings Solargraph.logger.info "Error parsing complex type `#{strings.join(', ')}`: #{e.message}" ComplexType::UNDEFINED end + + private + + # Parses a single type string (one comma-separated slot of a + # types specifier list) and appends the resulting type(s) to + # +types+. + # + # A top-level `&` (not nested in `<>`, `{}`, or `()`) builds a + # ComplexType::UniqueType::Intersection instead of a plain + # UniqueType. This applies equally to ordinary YARD type tags + # and to RBS-derived tags, since both are parsed here; YARD has + # no official intersection syntax yet (see + # https://github.com/lsegal/yard/issues/1644), so this is a + # Solargraph extension using RBS's `&` convention. + # + # @param type_string [String, nil] + # @param types [Array] + # @param key_types [Array, nil] + # @return [Array(Array, Array, nil)] + def parse_type_string type_string, types, key_types + point_stack = 0 + curly_stack = 0 + paren_stack = 0 + bracket_stack = 0 + base = String.new + subtype_string = String.new + # conjuncts of an intersection type (`A & B`) seen so far in + # the current `|`-disjunct of the segment being parsed + # @type [Array] + conjuncts = [] + # disjuncts of a union type (`A | B`) seen so far in the + # segment currently being parsed + # @type [Array] + disjuncts = [] + # @param char [String] + type_string&.each_char do |char| + if char == '=' + # raise ComplexTypeError, "Invalid = in type #{type_string}" unless curly_stack > 0 + elsif char == '<' + point_stack += 1 + elsif char == '>' + if subtype_string.end_with?('=') && curly_stack.positive? + subtype_string += char + elsif base.end_with?('=') + raise ComplexTypeError, 'Invalid hash thing' unless key_types.nil? + key_types = close_key_types(base, subtype_string, conjuncts, disjuncts, types) + types = [] + next + else + raise ComplexTypeError, "Invalid close in type #{type_string}" if point_stack.zero? + point_stack -= 1 + subtype_string += char + end + next + elsif char == '{' + curly_stack += 1 + elsif char == '}' + curly_stack = close_bracket(curly_stack, subtype_string, char, type_string) + next + elsif char == '(' + paren_stack += 1 + elsif char == ')' + paren_stack = close_bracket(paren_stack, subtype_string, char, type_string) + next + elsif char == '[' && + (bracket_stack.positive? || + (base.strip.empty? && point_stack.zero? && curly_stack.zero? && paren_stack.zero?)) + # Only a fresh atom (blank base, not already nested in + # <>/{}/()) can start a `[...]` group - matching + # finish_atom's own precondition. Otherwise `[` is just an + # ordinary character, e.g. part of a quoted string literal + # type like `"[]"`, which has no concept of grouping. + bracket_stack += 1 + elsif char == ']' && bracket_stack.positive? + bracket_stack = close_bracket(bracket_stack, subtype_string, char, type_string) + next + elsif char == '&' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack) + conjuncts.push ComplexType.new([finish_atom(base, subtype_string)]) + base.clear + subtype_string.clear + next + elsif char == '|' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack) + disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string)) + conjuncts = [] + base.clear + subtype_string.clear + next + elsif char == ',' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack) + disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string)) + # @sg-ignore Wrong argument type for Array#push: objects expected Solargraph::ComplexType::UniqueType, received Solargraph::ComplexType::UniqueType, Solargraph::ComplexType + types.push close_disjunction(disjuncts) + conjuncts = [] + disjuncts = [] + base.clear + subtype_string.clear + next + end + if top_level?(point_stack, curly_stack, paren_stack, bracket_stack) + base.concat char + else + subtype_string.concat char + end + end + if point_stack != 0 || curly_stack != 0 || paren_stack != 0 || bracket_stack != 0 + raise ComplexTypeError, + "Unclosed subtype in #{type_string}" + end + disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string)) + # @sg-ignore Wrong argument type for Array#push: objects expected Solargraph::ComplexType::UniqueType, received Solargraph::ComplexType::UniqueType, Solargraph::ComplexType + types.push close_disjunction(disjuncts) + [types, key_types] + end + + # Decrements the stack counter for a closing `}`/`)`/`]` and + # appends it to the pending subtype substring. + # + # @param stack [Integer] + # @param subtype_string [String] + # @param char [String] + # @param type_string [String, nil] + # @return [Integer] the decremented stack counter + def close_bracket stack, subtype_string, char, type_string + stack -= 1 + subtype_string << char + raise ComplexTypeError, "Invalid close in type #{type_string}" if stack.negative? + stack + end + + # Closes the key-list portion of a `Hash{K=>V}` split (the base + # ending in `=` marks the boundary) and returns the types parsed + # so far to be stashed as the eventual key_types, leaving + # conjuncts/disjuncts/base/subtype_string cleared for the value + # list that follows. + # + # @todo this should either expand key_type's type automatically + # or complain about not being compatible with key_type's type + # in type checking + # + # @param base [String] + # @param subtype_string [String] + # @param conjuncts [Array] + # @param disjuncts [Array] + # @param types [Array] + # @return [Array] the key_types + def close_key_types base, subtype_string, conjuncts, disjuncts, types + # @sg-ignore Need to add nil check here + disjuncts.push close_intersection(conjuncts, finish_atom(base[0..-2], subtype_string)) + types.push close_disjunction(disjuncts) + conjuncts.clear + disjuncts.clear + base.clear + subtype_string.clear + types + end + + # @param point_stack [Integer] + # @param curly_stack [Integer] + # @param paren_stack [Integer] + # @param bracket_stack [Integer] + # @return [Boolean] + def top_level? point_stack, curly_stack, paren_stack, bracket_stack + point_stack.zero? && curly_stack.zero? && paren_stack.zero? && bracket_stack.zero? + end + + # Resolves one type atom - either an ordinary named type (`base` + # plus its optional `<...>`/`(...)`/`{...}` parameter substring), + # or a standalone `[...]` grouping with no leading name, used to + # override the default order of operations (e.g. `[Foo | Bar] & + # Baz`, where `[...]` is the only way to mark where the union + # ends). A bracket group's content is parsed the same way any + # other parameter substring is - recursively, via + # ComplexType.parse - and its result substituted directly, since + # it can itself be a multi-item union (or an intersection). + # + # @param base [String] + # @param subtype_string [String] + # @return [ComplexType::UniqueType, ComplexType] + def finish_atom base, subtype_string + base = base.strip + subtype_string = subtype_string.strip + if base.empty? && subtype_string.start_with?('[') + raise ComplexTypeError, "Unclosed bracket group in #{subtype_string}" unless subtype_string.end_with?(']') + # @sg-ignore Wrong argument type for Solargraph::ComplexType.parse: strings expected String, received String, nil + return ComplexType.new(ComplexType.parse(subtype_string[1..-2], partial: true)) + end + UniqueType.parse(base, subtype_string) + end + + # Wraps a just-parsed atom together with any pending + # intersection conjuncts (types seen so far in this disjunct, + # separated by `&`) into a single UniqueType. Each conjunct is + # a ComplexType (see UniqueType::Intersection), so the final + # parsed type is promoted to a single-item ComplexType too. + # + # @param conjuncts [Array] + # @param final_type [ComplexType::UniqueType, ComplexType] + # @return [ComplexType::UniqueType, ComplexType] + def close_intersection conjuncts, final_type + return final_type if conjuncts.empty? + UniqueType::Intersection.new(conjuncts + [ComplexType.new([final_type])]) + end + + # Collapses the disjuncts of a union type (`A | B`) seen so far + # in the segment currently being parsed into a single value to + # push into the enclosing types/subtypes list - a bare type when + # there was only one (the common case, `|` never used), or a + # real multi-item ComplexType union otherwise. This is also + # exactly what a top-level `,` in an already-implicit-union + # context (Array<...>, Set<...>, hash key/value lists, the + # top-level types list itself) reduces to, since each of those + # contexts flattens every comma-separated type into one union + # regardless of how it's grouped here - so `,` and `|` land on + # the same result there, matching RBS's own tag design. + # + # @param disjuncts [Array] + # @return [ComplexType::UniqueType, ComplexType] + # @sg-ignore #first is only nil for an empty array, and this is + # never called with one + def close_disjunction disjuncts + return disjuncts.first if disjuncts.length == 1 + ComplexType.new(disjuncts) + end end VOID = ComplexType.parse('void') @@ -548,6 +826,29 @@ def try_parse *strings private + # When this type is itself a union (e.g. `generic, nil`) being + # matched against a union context type (e.g. `String, nil`), a + # generic member should only be bound against the parts of the + # context union not already accounted for by this type's other, + # concrete (non-generic) members. Otherwise `generic` in + # `generic, nil` would bind to the entire `String, nil` context + # instead of just `String`. + # + # @param item [UniqueType] A member of @items + # @param context_type [ComplexType, UniqueType, nil] + # @return [ComplexType, UniqueType, nil] + def generic_item_context_type item, context_type + return context_type unless item.generic? && context_type.is_a?(ComplexType) && @items.length > 1 + + concrete_items = @items.reject(&:generic?) + return context_type if concrete_items.empty? + + remaining_items = context_type.items.reject { |ct| concrete_items.any? { |ci| ci.name == ct.name } } + return context_type if remaining_items.empty? + + ComplexType.new(remaining_items) + end + # @todo This is a quick and dirty hack that forces `self` keywords # to reference an instance of their class and never the class itself. # This behavior may change depending on which result is expected diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index c2a48b255..5ae928629 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -23,13 +23,11 @@ def initialize api_map, inferred, expected, @variance = variance # :nocov: unless expected.is_a?(UniqueType) - # @sg-ignore This should never happen and the typechecker is angry about it raise "Expected type must be a UniqueType, got #{expected.class} in #{expected.inspect}" end # :nocov: return if inferred.is_a?(UniqueType) # :nocov: - # @sg-ignore This should never happen and the typechecker is angry about it raise "Inferred type must be a UniqueType, got #{inferred.class} in #{inferred.inspect}" # :nocov: end @@ -41,7 +39,15 @@ def conforms_to_unique_type? # :nocov: end - return true if ignore_interface? + # An expectation of `A & B` can only be satisfied by + # something that conforms to every conjunct (A & B <: A and + # A & B <: B, so satisfying the intersection requires + # satisfying both). + return conforms_to_intersection_expectation? if expected.is_a?(UniqueType::Intersection) + + interface_verdict = interface_bypass_verdict + return interface_verdict unless interface_verdict.nil? + return true if conforms_via_reverse_match? downcast_inferred = inferred.downcast_to_literal_if_possible @@ -78,6 +84,21 @@ def conforms_to_unique_type? private + # @return [Boolean] + def conforms_to_intersection_expectation? + # only called when expected.is_a?(UniqueType::Intersection) + # @type [UniqueType::Intersection] + intersection = expected + # Wrap inferred in a ComplexType (rather than calling + # UniqueType#conforms_to? directly) so each conjunct check + # gets ComplexType#conforms_to?'s special-case handling (e.g. + # duck_type? conjuncts), not just UniqueType's. + wrapped_inferred = ComplexType.new([inferred]) + intersection.conjuncts.all? do |conjunct| + wrapped_inferred.conforms_to?(api_map, conjunct, situation, rules, variance: variance) + end + end + def only_inferred_parameters? !expected.parameters? && inferred.parameters? end @@ -86,9 +107,34 @@ def conforms_via_stripped_expected_parameters? with_new_types(inferred, expected.erase_parameters).conforms_to_unique_type? end - def ignore_interface? - (expected.any?(&:interface?) && rules.include?(:allow_unmatched_interface)) || - (inferred.interface? && rules.include?(:allow_unmatched_interface)) + # Resolves interface-typed conformance before any parameter/subtype + # comparisons run, the same way the old blanket + # :allow_unmatched_interface bypass did. That's deliberate: RBS + # interfaces can have their own type parameters (e.g. `_Each[Elem]`), + # and this doesn't verify those (see + # https://github.com/castwide/solargraph/issues/1267), so once the + # interface question is settled, comparing `expected`'s subtypes + # against `inferred`'s would either be meaningless or wrong. + # + # There's no structural check when the *inferred* type is itself an + # abstract interface (e.g., a method returns `_ToAry`), since + # Solargraph doesn't know which concrete type will show up at + # runtime, so :allow_unmatched_interface remains a blanket escape + # hatch for that direction. + # + # @return [Boolean, nil] true/false if the interface question + # settles conformance outright, nil if there's no interface + # involved (or no verdict could be reached and no fallback rule + # applies), meaning normal conformance checking should proceed + def interface_bypass_verdict + return true if inferred.interface? && rules.include?(:allow_unmatched_interface) + return nil unless expected.interface? + + verdict = structural_interface_verdict + return verdict unless verdict.nil? + return true if rules.include?(:allow_unmatched_interface) + + nil end def can_strip_expected_parameters? @@ -127,6 +173,25 @@ def erased_type_conforms? true end + # The methods `expected` declares directly on itself, excluding ones + # inherited from `Object` and other ancestors. + # + # @return [Array] + def required_interface_methods + api_map.get_own_methods(expected.name) + end + + # @return [Boolean, nil] true or false if `expected`'s directly + # declared methods could be checked against `inferred`'s method + # stack, or nil if no verdict could be reached (e.g., the interface + # has no pin, or declares no methods of its own) + def structural_interface_verdict + required = required_interface_methods + return nil if required.empty? + + required.all? { |pin| !api_map.get_method_stack(inferred.name, pin.name, scope: :instance).empty? } + end + def key_types_conform? return true if expected.key_types.empty? diff --git a/lib/solargraph/complex_type/type_methods.rb b/lib/solargraph/complex_type/type_methods.rb index ce7897e49..691f3acbf 100644 --- a/lib/solargraph/complex_type/type_methods.rb +++ b/lib/solargraph/complex_type/type_methods.rb @@ -58,8 +58,16 @@ def nil_type? @nil_type ||= name.casecmp('nil').zero? end + # Whether this type is one of Ruby's singleton values (nil, + # true, false) rather than a general class or a multi-valued + # literal (e.g. `0`, `:foo`). + # + # @return [Boolean] + def singleton? + nil_type? || %w[true false].include?(name) + end + def tuple? - return false @tuple ||= (name == 'Tuple') || (name == 'Array' && subtypes.length >= 1 && fixed_parameters?) end @@ -75,6 +83,14 @@ def undefined? name == 'undefined' end + # @return [Boolean] True if this type is RBS's bottom type - an + # expression that never produces a value (e.g., the return type + # of `raise` or `abort`). A bottom type is a subtype of every + # other type. + def bot? + name == 'bot' + end + # Variance of the type ignoring any type parameters # @return [Symbol] # @param situation [Symbol] The situation in which the variance is being considered. @@ -144,6 +160,7 @@ def namespace @namespace ||= lambda do return 'Object' if duck_type? return 'NilClass' if nil_type? + # @sg-ignore Need to add nil check here %w[Class Module].include?(name) && !subtypes.empty? ? subtypes.first.name : name end.call end @@ -190,7 +207,20 @@ def generate_substring_from &to_str elsif fixed_parameters? "(#{subtypes_str})" elsif name == 'Hash' - "<#{key_types_str}, #{subtypes_str}>" + # The notation only has room for exactly one key type and + # one value type -- a single top-level comma splits it into K + # and V, so a second comma on either side (whether from more + # than one entry in key_types/subtypes, or from a single entry + # that is itself a multi-item union) produces a string with too + # many top-level parameters to reparse. Fall back to the + # {K => V} notation, which can represent a comma-separated list + # on either side and still reparses correctly, whenever either + # side isn't exactly one single type. + if key_types.sum { |t| t.items.length } == 1 && subtypes.sum { |t| t.items.length } == 1 + "<#{key_types_str}, #{subtypes_str}>" + else + "{#{key_types_str} => #{subtypes_str}}" + end else "<#{key_types_str}#{subtypes_str}>" end @@ -217,7 +247,7 @@ def == other def qualify api_map, context = '' transform do |t| next t if t.name == GENERIC_TAG_NAME - next t if t.duck_type? || t.void? || t.undefined? + next t if t.duck_type? || t.void? || t.undefined? || t.bot? recon = (t.rooted? ? '' : context) fqns = api_map.qualify(t.name, recon) if fqns.nil? diff --git a/lib/solargraph/complex_type/unique_type.rb b/lib/solargraph/complex_type/unique_type.rb index 4bbdda5b2..633816585 100644 --- a/lib/solargraph/complex_type/unique_type.rb +++ b/lib/solargraph/complex_type/unique_type.rb @@ -9,8 +9,17 @@ class UniqueType include TypeMethods include Equality + autoload :Intersection, 'solargraph/complex_type/unique_type/intersection' + attr_reader :all_params, :subtypes, :key_types + # @type [Hash{String => String}] + ANONYMOUS_NAME_BY_STARTING_TAG = { + '{' => 'Hash', + '(' => 'Array', + '<' => 'Array' + }.freeze + # Create a UniqueType with the specified name and an optional substring. # The substring is the parameter section of a parametrized type, e.g., # for the type `Array`, the name is `Array` and the substring is @@ -22,6 +31,11 @@ class UniqueType # @return [UniqueType] def self.parse name, substring = '', make_rooted: nil raise ComplexTypeError, "Illegal prefix: #{name}" if name.start_with?(':::') + # Anonymous shorthand - ``, `(A)`, `{A=>B}` - omits the + # leading type name, defaulting it to Array or Hash. Resolved + # before the rooted/can_root_name? check below so an anonymous + # `` behaves exactly like the equivalent `Array`. + name = ANONYMOUS_NAME_BY_STARTING_TAG.fetch(substring[0]) if name.empty? && !substring.empty? if name.start_with?('::') name = name[2..] rooted = true @@ -38,9 +52,11 @@ def self.parse name, substring = '', make_rooted: nil subtypes = [] parameters_type = nil unless substring.empty? + # @sg-ignore Wrong argument type for Solargraph::ComplexType.parse: strings expected String, received String, nil subs = ComplexType.parse(substring[1..-2], partial: true) - # @sg-ignore Need to add nil check here - parameters_type = PARAMETERS_TYPE_BY_STARTING_TAG.fetch(substring[0]) + parameters_type = PARAMETERS_TYPE_BY_STARTING_TAG.fetch(substring[0]) do + raise ComplexTypeError, "Unrecognized parameter delimiter: name=#{name}, substring=#{substring}" + end if parameters_type == :hash unless !subs.is_a?(ComplexType) && (subs.length == 2) && !subs[0].is_a?(UniqueType) && !subs[1].is_a?(UniqueType) raise ComplexTypeError, @@ -57,10 +73,10 @@ def self.parse name, substring = '', make_rooted: nil key_types.concat(subs[0].map { |u| ComplexType.new([u]) }) subtypes.concat(subs[1].map { |u| ComplexType.new([u]) }) else + # @sg-ignore Wrong argument type for Array#concat: other_arrays expected Array>, _ToAry>, received Solargraph::ComplexType subtypes.concat subs end end - # @sg-ignore Need to add nil check here new(name, key_types, subtypes, rooted: rooted, parameters_type: parameters_type) end @@ -119,22 +135,37 @@ def exclude exclude_types, api_map ComplexType.new(types) end - # @see https://en.wikipedia.org/wiki/Intersection_type + # Flow-sensitive type narrowing: given a type learned from a + # runtime guard (e.g. `x.is_a?(Foo)`), refines this type down + # to the more specific of each compatible pair between the two + # sides. When neither side is already known to be a subtype of + # the other but one is positively confirmed to be a mix-in + # (e.g. a declared class and an unrelated module), both facts + # are still true at once, so the pair is combined into an + # Intersection rather than discarded. Everything else - two + # different concrete classes (impossible; an object has exactly + # one class), or either side being a namespace we can't + # positively identify - falls back to the original behavior of + # dropping the pair. # - # @param intersection_type [ComplexType, ComplexType::UniqueType, nil] + # @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html + # + # @param narrowing_type [ComplexType, ComplexType::UniqueType, nil] # @param api_map [ApiMap] # @return [self, ComplexType] - def intersect_with intersection_type, api_map - return self if intersection_type.nil? - return intersection_type if undefined? + def narrow_with narrowing_type, api_map + return self if narrowing_type.nil? + return narrowing_type if undefined? types = [] # try to find common types via conformance items.each do |ut| - intersection_type.each do |int_type| - if ut.conforms_to?(api_map, int_type, :assignment) + narrowing_type.each do |candidate| + if ut.conforms_to?(api_map, candidate, :assignment) types << ut - elsif int_type.conforms_to?(api_map, ut, :assignment) - types << int_type + elsif candidate.conforms_to?(api_map, ut, :assignment) + types << candidate + elsif mixin_pairing?(api_map, ut, candidate) + types << Intersection.new([ComplexType.new([ut]), ComplexType.new([candidate])]) end end end @@ -142,12 +173,38 @@ def intersect_with intersection_type, api_map ComplexType.new(types) end + # Whether combining these two into an intersection is safe. Only + # true when at least one side is *positively confirmed* to be a + # mix-in: any class can pick up any module, so a class-and-module + # pairing is always plausible. Everything else - two different + # concrete classes, or a namespace we have no pin for (synthetic + # names like `Boolean`, generics, literals, duck types, or + # simply unresolved) - defaults to false, preserving the + # original drop-the-pair behavior. + # + # @param api_map [ApiMap] + # @param declared [ComplexType::UniqueType] + # @param candidate [ComplexType::UniqueType] + # @return [Boolean] + def mixin_pairing? api_map, declared, candidate + namespace_kind(api_map, declared) == :module || namespace_kind(api_map, candidate) == :module + end + + # @param api_map [ApiMap] + # @param unique_type [ComplexType::UniqueType] + # @return [Symbol, nil] :class, :module, or nil if unknown + def namespace_kind api_map, unique_type + # @type [Pin::Namespace, nil] + pin = api_map.get_path_pins(unique_type.namespace).find { |p| p.is_a?(Pin::Namespace) } + pin&.type + end + private :mixin_pairing?, :namespace_kind + def simplifyable_literal? literal? && name != 'nil' end def literal? - return false non_literal_name != name end @@ -156,6 +213,25 @@ def non_literal_name @non_literal_name ||= determine_non_literal_name end + # Whether every one of this type's key_types is a literal type + # (e.g. `Hash{"Index" => Float}`'s key_types is `["Index"]`, a + # literal String) - a Hash-like type whose keys are specific + # values rather than a general key class. + # + # @return [Boolean] + def literal_keyed? + key_types.any? && key_types.all? { |kt| kt.items.all?(&:literal?) } + end + + # Whether any of this type's key_types has the given literal tag + # (e.g. `'"Index"'`, `':foo'`, `'1'`). + # + # @param tag [String] + # @return [Boolean] + def key_type_tag? tag + key_types.any? { |kt| kt.tag == tag } + end + # @return [self] def without_nil return UniqueType::UNDEFINED if nil_type? @@ -229,11 +305,11 @@ def parameter_variance _situation, default = :covariant # covariant # contravariant?: Proc - can be changed, so we can pass # in less specific super types - # if %w[Hash Tuple Array Set Enumerable].include?(name) && fixed_parameters? - # :covariant - # else - default - # end + if %w[Hash Tuple Array Set Enumerable].include?(name) && fixed_parameters? + :covariant + else + default + end end # Whether this is an RBS interface like _ToAry or Hash::_Key. @@ -253,6 +329,11 @@ def erased_version_of? other # @param variance [:invariant, :covariant, :contravariant] def conforms_to? api_map, expected, situation, rules = [], variance: erased_variance(situation) + # bot is a subtype of every type - not a leniency knob like + # :allow_undefined, but a type-theoretic fact (e.g., the return + # type of `raise` or `abort`) + return true if bot? + return true if undefined? && rules.include?(:allow_undefined) # @todo teach this to validate duck types as inferred type @@ -262,7 +343,7 @@ def conforms_to? api_map, expected, situation, rules = [], # match one of their unique types expected.any? do |expected_unique_type| # :nocov: - unless expected_unique_type.instance_of?(UniqueType) + unless expected_unique_type.is_a?(UniqueType) raise "Expected type must be a UniqueType, got #{expected_unique_type.class} in #{expected.inspect}" end # :nocov: @@ -342,6 +423,7 @@ def parameters? # @return [String] def rbs_union types if types.length == 1 + # @sg-ignore Need to add nil check here types.first.to_rbs else "(#{types.map(&:to_rbs).join(' | ')})" @@ -375,7 +457,6 @@ def all? &block # @return [UniqueType] def downcast_to_literal_if_possible - return self SINGLE_SUBTYPE.fetch(rooted_tag, self) end @@ -387,7 +468,6 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge if name == ComplexType::GENERIC_TAG_NAME type_param = subtypes.first&.name return self unless generics_to_resolve.include? type_param - # @sg-ignore flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) unless context_type.nil? || !resolved_generic_values[type_param].nil? new_binding = true # @sg-ignore flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) @@ -399,7 +479,6 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge resolved_generic_values: resolved_generic_values) end end - # @sg-ignore flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) return resolved_generic_values[type_param] || self end @@ -418,20 +497,29 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge # @return [Array] def resolve_param_generics_from_context generics_to_resolve, context_type, resolved_generic_values types = yield self - types.each_with_index.flat_map do |ct, i| - ct.items.flat_map do |ut| + types.each_with_index.map do |ct, i| + resolved = ct.items.flat_map do |ut| context_params = yield context_type if context_type if context_params && context_params[i] type_arg = context_params[i] + # @sg-ignore Unresolved call to map type_arg.map do |new_unique_context_type| ut.resolve_generics_from_context generics_to_resolve, new_unique_context_type, resolved_generic_values: resolved_generic_values end else - ut.resolve_generics_from_context generics_to_resolve, nil, - resolved_generic_values: resolved_generic_values + [ut.resolve_generics_from_context(generics_to_resolve, nil, + resolved_generic_values: resolved_generic_values)] end end + # A single position's resolution may be a union (e.g. a Hash key + # type of `String, Symbol` binding one slot of the yielded + # [K, V] pair). Keep the union inside one parameter position + # instead of splicing its members into extra positions, which + # inflates a tuple's arity (Array(K, V) must stay a pair). + # @sg-ignore Unresolved call to is_a? - resolved's element type is + # undefined under post-merge inference; runtime members are ComplexType/UniqueType + ComplexType.new(resolved.flat_map { |t| t.is_a?(ComplexType) ? t.items : [t] }) end end @@ -443,6 +531,13 @@ def resolve_param_generics_from_context generics_to_resolve, context_type, resol # @param context_type [ComplexType] The receiver type # @return [UniqueType, ComplexType] def resolve_generics definitions, context_type + # Method/signature-scoped generics (e.g. a `@generic T` on the + # method itself) aren't resolved against a receiver's type + # params -- they're bound per call site via + # resolve_generics_from_context. Leave them unresolved here + # rather than mistaking the method's own generics for the + # namespace's and erasing them to undefined. + return self if definitions.is_a?(Pin::Callable) return self if definitions.nil? || definitions.generics.empty? transform(name) do |t| @@ -458,10 +553,21 @@ def resolve_generics definitions, context_type else next ComplexType::UNDEFINED end - # @todo Treating parameterized classes and tuples the same for now - # elsif context_type.all?(&:implicit_union?) || true - elsif idx.zero? && !context_type.all_params.empty? - ComplexType.new(context_type.all_params) + elsif context_type.all?(&:implicit_union?) + if idx.zero? && !context_type.all_params.empty? + ComplexType.new(context_type.all_params) + else + ComplexType::UNDEFINED + end + elsif context_type.all_params[idx] + context_type.all_params[idx] + elsif definitions.generic_defaults[generic_name] + # Tuples declare later positional generics (e.g. C, D, ...) + # as defaults in terms of earlier ones (e.g. C = A | B). + # Resolve those defaults against the same context instead of + # returning them as unresolved generic placeholders. + # @sg-ignore Unresolved call to resolve_generics on Solargraph::ComplexType, nil + definitions.generic_defaults[generic_name].resolve_generics(definitions, context_type) else ComplexType::UNDEFINED end @@ -503,7 +609,6 @@ def recreate new_name: nil, make_rooted: nil, new_key_types: nil, new_subtypes: new_key_types ||= @key_types new_subtypes ||= @subtypes make_rooted = @rooted if make_rooted.nil? - # @sg-ignore flow sensitive typing needs better handling of ||= on lvars UniqueType.new(new_name, new_key_types, new_subtypes, rooted: make_rooted, parameters_type: parameters_type) end @@ -539,18 +644,36 @@ def transform new_name = nil, &transform_type new_key_types = @key_types new_subtypes = @subtypes else - new_key_types = @key_types.flat_map { |ct| ct.items.map { |ut| ut.transform(&transform_type) } } - new_subtypes = @subtypes.flat_map { |ct| ct.items.map { |ut| ut.transform(&transform_type) } } + # Rebuild per parameter position: a position holding (or + # transformed into) a union must stay one position, not have + # its members spliced in as extra positions (which would + # inflate a tuple's arity). + new_key_types = @key_types.map { |ct| transform_position(ct, &transform_type) } + new_subtypes = @subtypes.map { |ct| transform_position(ct, &transform_type) } end new_type = recreate(new_name: new_name || name, new_key_types: new_key_types, new_subtypes: new_subtypes, make_rooted: @rooted) yield new_type end + # @param named_types [Hash{String => UniqueType}] + # @return [UniqueType] def expand named_types named_types[name] || self end + # Transform one parameter position, keeping however many types the + # transformation produces inside that single position. + # + # @param position_type [ComplexType] + # @yieldparam t [UniqueType] + # @yieldreturn [self] + # @return [ComplexType] + def transform_position position_type, &transform_type + results = position_type.items.map { |ut| ut.transform(&transform_type) } + ComplexType.new(results.flat_map { |t| t.is_a?(ComplexType) ? t.items : [t] }) + end + # Generate a ComplexType that fully qualifies this type's namespaces. # # @param api_map [ApiMap] The ApiMap that performs qualification @@ -559,7 +682,7 @@ def expand named_types def qualify api_map, *gates transform do |t| next t if t.name == GENERIC_TAG_NAME - next t if t.duck_type? || t.void? || t.undefined? || t.literal? + next t if t.duck_type? || t.void? || t.undefined? || t.literal? || t.bot? open = t.rooted? ? [''] : gates fqns = api_map.qualify(t.non_literal_name, *open) if fqns.nil? @@ -621,6 +744,13 @@ def self.can_root_name? name end UNDEFINED = UniqueType.new('undefined', rooted: false) + # @note Equal (`==`) to ComplexType::BOT.first - rooted: true + # matches how ComplexType.parse('bot') constructs it, unlike + # UNDEFINED's rooted: false. Getting this wrong keeps #bot?/ + # #tag/#to_s working (they don't read @rooted) but silently + # breaks #== and anything relying on it (Array#uniq, Array#-, + # Set membership, pin dedup). + BOT = UniqueType.new('bot', rooted: true) BOOLEAN = UniqueType.new('Boolean', rooted: true) TRUE = UniqueType.new('true', rooted: true) FALSE = UniqueType.new('false', rooted: true) diff --git a/lib/solargraph/complex_type/unique_type/intersection.rb b/lib/solargraph/complex_type/unique_type/intersection.rb new file mode 100644 index 000000000..0eca9f2f7 --- /dev/null +++ b/lib/solargraph/complex_type/unique_type/intersection.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +module Solargraph + class ComplexType + class UniqueType + # A single unique type representing the intersection of two or + # more conjunct types, e.g., the RBS type `A & B`. + # + # Unlike ComplexType's comma-separated items (a union, where any + # one member describes the value), every conjunct of an + # Intersection must independently describe the value. That + # means the subtyping rules are the mirror image of a union's: + # + # A & B <: A + # A & B <: B + # + # i.e., a value typed as the intersection can be used wherever + # *any* conjunct is expected, but a value can only be used + # where the intersection itself is expected if it satisfies + # *every* conjunct. + # + # Each conjunct is a full ComplexType, not a plain UniqueType - + # the same way UniqueType#subtypes and #key_types already hold + # ComplexTypes rather than UniqueTypes. RBS itself allows a + # union as one member of an intersection (`(A | B) & C`), so a + # conjunct needs to be able to represent more than one + # alternative; a single type is just the common case of a + # one-item ComplexType. This also means a conjunct can itself be + # (or contain) another Intersection, since Intersection is a + # UniqueType and ComplexType already holds UniqueTypes. + # + # `A & B` is parsed the same way from plain YARD type tags + # (`@param`, `@return`, `@type`, etc.) as it is from inline RBS + # signatures, since both funnel through ComplexType.parse. YARD + # itself has no official intersection type syntax yet; `&` is + # Solargraph's extension pending upstream guidance. + # + # @see https://en.wikipedia.org/wiki/Intersection_type + # @see https://github.com/ruby/rbs/blob/master/docs/syntax.md#intersection-type + # @see https://github.com/lsegal/yard/issues/1644 + class Intersection < UniqueType + # @return [Array] + attr_reader :conjuncts + + # @param conjuncts [Array] + def initialize conjuncts + @conjuncts = conjuncts + super(conjuncts.map(&:tags).join(' & '), rooted: true) + end + + # @return [String] + def tag + @tag ||= conjuncts.map(&:tags).join(' & ') + end + + # @return [String] + def rooted_tag + @rooted_tag ||= conjuncts.map(&:rooted_tags).join(' & ') + end + + # @return [String] + def to_rbs + conjuncts.map(&:to_rbs).join(' & ') + end + + # @return [String] + def namespace + conjuncts.fetch(0).namespace + end + + # @return [::Symbol] + def scope + conjuncts.fetch(0).scope + end + + def generic? + conjuncts.any?(&:generic?) + end + + def rooted? + conjuncts.all?(&:rooted?) + end + + def all_rooted? + conjuncts.all?(&:all_rooted?) + end + + def duck_type? + false + end + + def interface? + false + end + + # @yieldparam [UniqueType] + # @return [void] + # @overload each_unique_type() + # @return [Enumerator] + def each_unique_type &block + return enum_for(__method__) unless block_given? + conjuncts.each { |conjunct| conjunct.each_unique_type(&block) } + end + + # An intersection can be assigned wherever any one of its + # conjuncts would be accepted (A & B <: A, A & B <: B). Each + # conjunct is checked as a full ComplexType, so a conjunct + # that's itself a union (from `(A | B) & C`) gets real union + # semantics (every member of that union must conform). + # + # When expected is *also* an intersection, that simple "any + # one conjunct" rule breaks down: a single one of our + # conjuncts, checked alone, would have to satisfy every + # conjunct expected of it - which fails whenever our conjuncts + # don't already relate to each other, even when checking an + # intersection against an identical copy of itself. The + # correct rule for A & B <: C & D is that every conjunct of + # the expected side must be satisfied by *some* conjunct of + # this one (not necessarily the same one each time), so that + # case is handled separately below. + # + # @param api_map [ApiMap] + # @param expected [ComplexType, ComplexType::UniqueType] + # @param situation [:method_call, :assignment, :return_type] + # @param rules [Array<:allow_subtype_skew, :allow_empty_params, :allow_reverse_match, :allow_any_match, :allow_undefined, :allow_unresolved_generic>] + # @param variance [:invariant, :covariant, :contravariant] + # @return [Boolean] + def conforms_to? api_map, expected, situation, rules = [], + variance: erased_variance(situation) + expected_intersection = sole_intersection(expected) + if expected_intersection + return expected_intersection.conjuncts.all? do |expected_conjunct| + conjuncts.any? do |conjunct| + conjunct.conforms_to?(api_map, expected_conjunct, situation, rules, variance: variance) + end + end + end + conjuncts.any? do |conjunct| + conjunct.conforms_to?(api_map, expected, situation, rules, variance: variance) + end + end + + # Applies the transformation to each conjunct independently + # and rebuilds the intersection from the results. + # + # @param new_name [String, nil] + # @yieldparam t [UniqueType] + # @yieldreturn [UniqueType] + # @return [self] + def transform new_name = nil, &transform_type + Intersection.new(conjuncts.map { |conjunct| conjunct.transform(new_name, &transform_type) }) + end + + # @return [self] + def erase_parameters + self + end + + private + + # Returns expected itself when it's a bare Intersection, or + # its one item when it's a ComplexType consisting of nothing + # but a single Intersection. Anything else - including a + # union with an intersection as just one of several + # alternatives - returns nil, leaving that (rarer, untested) + # case on the simpler existing "any conjunct" path rather + # than guessing at its semantics here. + # + # @param expected [ComplexType, ComplexType::UniqueType] + # @return [Intersection, nil] + def sole_intersection expected + return expected if expected.is_a?(Intersection) + return expected.first if expected.is_a?(ComplexType) && expected.length == 1 && expected.first.is_a?(Intersection) + nil + end + end + end + end +end diff --git a/lib/solargraph/convention.rb b/lib/solargraph/convention.rb index 5e73eb3bf..ba0a86cb3 100644 --- a/lib/solargraph/convention.rb +++ b/lib/solargraph/convention.rb @@ -11,6 +11,7 @@ module Convention autoload :Rakefile, 'solargraph/convention/rakefile' autoload :StructDefinition, 'solargraph/convention/struct_definition' autoload :DataDefinition, 'solargraph/convention/data_definition' + autoload :ClassDefinition, 'solargraph/convention/class_definition' autoload :ActiveSupportConcern, 'solargraph/convention/active_support_concern' # @type [Set] diff --git a/lib/solargraph/convention/active_support_concern.rb b/lib/solargraph/convention/active_support_concern.rb index ed1fba175..f15820a64 100644 --- a/lib/solargraph/convention/active_support_concern.rb +++ b/lib/solargraph/convention/active_support_concern.rb @@ -86,6 +86,7 @@ def process_include include_tag "found module extends of #{rooted_include_tag}: #{module_extends}" end return unless module_extends.include? 'ActiveSupport::Concern' + # @sg-ignore Need to add nil check here included_class_pins = api_map.inner_get_methods_from_reference(rooted_include_tag, namespace_pin, rooted_type, :class, visibility, deep, skip, true) logger.debug do @@ -96,6 +97,7 @@ def process_include include_tag # another pattern is to put class methods inside a submodule classmethods_include_tag = "#{rooted_include_tag}::ClassMethods" included_classmethods_pins = + # @sg-ignore Need to add nil check here api_map.inner_get_methods_from_reference(classmethods_include_tag, namespace_pin, rooted_type, :instance, visibility, deep, skip, true) logger.debug do diff --git a/lib/solargraph/convention/class_definition.rb b/lib/solargraph/convention/class_definition.rb new file mode 100644 index 000000000..b9cc34536 --- /dev/null +++ b/lib/solargraph/convention/class_definition.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Solargraph + module Convention + # Handles anonymous class definitions assigned to a constant, e.g. + # + # Specific = Class.new(StandardError) do + # def retry_after; 5; end + # end + # + # Without this, the constant is indexed as an untyped Pin::Constant and the + # methods in the block are attached to the enclosing namespace. + module ClassDefinition + autoload :ClassAssignmentNode, 'solargraph/convention/class_definition/class_assignment_node' + + module NodeProcessors + class ClassNode < Parser::NodeProcessor::Base + # @return [Boolean] continue processing the next processor of the same node. + def process + definition_node = class_definition_node + return true if definition_node.nil? + + loc = get_node_location(node) + nspin = Solargraph::Pin::Namespace.new( + type: :class, + location: loc, + closure: region.closure, + name: definition_node.class_name, + comments: comments_for(node), + visibility: :public, + gates: region.closure.gates.freeze, + source: :class_definition + ) + pins.push nspin + + superclass_name = definition_node.superclass_name + if superclass_name + pins.push Pin::Reference::Superclass.new( + location: loc, + closure: nspin, + name: superclass_name, + source: :class_definition + ) + end + + process_children region.update(closure: nspin, visibility: :public) + false + end + + private + + # @return [ClassDefinition::ClassAssignmentNode, nil] + def class_definition_node + @class_definition_node ||= ClassAssignmentNode.new(node) if ClassAssignmentNode.match?(node) + end + end + end + end + end +end diff --git a/lib/solargraph/convention/class_definition/class_assignment_node.rb b/lib/solargraph/convention/class_definition/class_assignment_node.rb new file mode 100644 index 000000000..3f78dceca --- /dev/null +++ b/lib/solargraph/convention/class_definition/class_assignment_node.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Solargraph + module Convention + module ClassDefinition + # A node wrapper for a class definition via const assignment. + # @example + # MyError = Class.new(StandardError) do + # def retry_after; 5; end + # end + class ClassAssignmentNode + class << self + # @example + # s(:casgn, nil, :Foo, + # s(:block, + # s(:send, + # s(:const, nil, :Class), :new, + # s(:const, nil, :StandardError)), + # s(:args), + # s(:def, :retry_after, s(:args), s(:int, 5)))) + # + # @param node [Parser::AST::Node] + # @return [Boolean] + def match? node + return false unless node&.type == :casgn + + class_new_node?(new_node(node)) + end + + # The `Class.new(...)` send node, whether or not a block is attached. + # + # @param node [Parser::AST::Node] + # @return [Parser::AST::Node, nil] + def new_node node + value = node.children[2] + return nil if value.nil? + return value unless value.type == :block + + value.children[0] + end + + private + + # @param send_node [Parser::AST::Node, nil] + # @return [Boolean] + def class_new_node? send_node + return false if send_node.nil? + return false unless send_node.type == :send + return false unless send_node.children[1] == :new + + receiver = send_node.children[0] + return false if receiver.nil? + return false unless receiver.type == :const + + receiver.children[1] == :Class + end + end + + # @param node [Parser::AST::Node] + def initialize node + @node = node + end + + # @return [String] + def class_name + namespace = node.children[0] + return node.children[1].to_s if namespace.nil? + + "#{Parser::NodeMethods.unpack_name(namespace)}::#{node.children[1]}" + end + + # The superclass, when it is written as a constant. `Class.new(expr)` + # with a non-constant argument yields nil (superclass stays Object). + # + # @return [String, nil] + def superclass_name + send_node = self.class.new_node(node) + return nil if send_node.nil? + + arg = send_node.children[2] + return nil if arg.nil? + return nil unless arg.type == :const + + Parser::NodeMethods.unpack_name(arg) + end + + private + + # @return [Parser::AST::Node] + attr_reader :node + end + end + end +end diff --git a/lib/solargraph/convention/data_definition.rb b/lib/solargraph/convention/data_definition.rb index 960852caa..5ce0bf111 100644 --- a/lib/solargraph/convention/data_definition.rb +++ b/lib/solargraph/convention/data_definition.rb @@ -17,7 +17,6 @@ def process type: :class, location: loc, closure: region.closure, - # @sg-ignore flow sensitive typing needs to handle attrs name: data_definition_node.class_name, comments: comments_for(node), visibility: :public, @@ -40,7 +39,6 @@ def process # Solargraph::SourceMap::Clip#complete_keyword_parameters does not seem to currently take into account [Pin::Method#signatures] hence we only one for :kwarg pins.push initialize_method_pin - # @sg-ignore flow sensitive typing needs to handle attrs data_definition_node.attributes.map do |attribute_node, attribute_name| initialize_method_pin.parameters.push( Pin::Parameter.new( @@ -53,7 +51,6 @@ def process end # define attribute readers and instance variables - # @sg-ignore flow sensitive typing needs to handle attrs data_definition_node.attributes.each do |attribute_node, attribute_name| name = attribute_name.to_s method_pin = Pin::Method.new( diff --git a/lib/solargraph/convention/data_definition/data_assignment_node.rb b/lib/solargraph/convention/data_definition/data_assignment_node.rb index 97ef272cf..4f5834091 100644 --- a/lib/solargraph/convention/data_definition/data_assignment_node.rb +++ b/lib/solargraph/convention/data_definition/data_assignment_node.rb @@ -27,18 +27,22 @@ def match? node return false unless node&.type == :casgn return false if node.children[2].nil? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 data_node = if node.children[2].type == :block + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 node.children[2].children[0] else node.children[2] end + # @sg-ignore Need to add nil check here data_definition_node?(data_node) end end def class_name if node.children[0] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Parser::NodeMethods.unpack_name(node.children[0]) + "::#{node.children[1]}" else node.children[1].to_s @@ -48,8 +52,11 @@ def class_name private # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here def data_node + # @sg-ignore Need to add nil check here if node.children[2].type == :block + # @sg-ignore Need to add nil check here node.children[2].children[0] else node.children[2] diff --git a/lib/solargraph/convention/data_definition/data_definition_node.rb b/lib/solargraph/convention/data_definition/data_definition_node.rb index 5c4b2276a..34097615e 100644 --- a/lib/solargraph/convention/data_definition/data_definition_node.rb +++ b/lib/solargraph/convention/data_definition/data_definition_node.rb @@ -30,6 +30,7 @@ class << self def match? node return false unless node&.type == :class + # @sg-ignore Need to add nil check here data_definition_node?(node.children[1]) end @@ -41,6 +42,7 @@ def data_definition_node? data_node return false unless data_node.is_a?(::Parser::AST::Node) return false unless data_node&.type == :send return false unless data_node.children[0]&.type == :const + # @sg-ignore Need to add nil check here return false unless data_node.children[0].children[1] == :Data return false unless data_node.children[1] == :define diff --git a/lib/solargraph/convention/gemfile.rb b/lib/solargraph/convention/gemfile.rb index bcebe1de9..df577ade1 100644 --- a/lib/solargraph/convention/gemfile.rb +++ b/lib/solargraph/convention/gemfile.rb @@ -4,6 +4,7 @@ module Solargraph module Convention class Gemfile < Base def local source_map + # @sg-ignore Wrong argument type for File.basename: file_name expected String, _ToStr, _ToPath, received String, nil return EMPTY_ENVIRON unless File.basename(source_map.filename) == 'Gemfile' @local ||= Environ.new( requires: ['bundler'], diff --git a/lib/solargraph/convention/gemspec.rb b/lib/solargraph/convention/gemspec.rb index ce4ce78c7..6bdc4246c 100644 --- a/lib/solargraph/convention/gemspec.rb +++ b/lib/solargraph/convention/gemspec.rb @@ -4,6 +4,7 @@ module Solargraph module Convention class Gemspec < Base def local source_map + # @sg-ignore Wrong argument type for File.basename: file_name expected String, _ToStr, _ToPath, received String, nil return Convention::Base::EMPTY_ENVIRON unless File.basename(source_map.filename).end_with?('.gemspec') @local ||= Environ.new( requires: ['rubygems'], diff --git a/lib/solargraph/convention/rakefile.rb b/lib/solargraph/convention/rakefile.rb index c5286bd54..86b5641c9 100644 --- a/lib/solargraph/convention/rakefile.rb +++ b/lib/solargraph/convention/rakefile.rb @@ -4,6 +4,7 @@ module Solargraph module Convention class Rakefile < Base def local source_map + # @sg-ignore Wrong argument type for File.basename: file_name expected String, _ToStr, _ToPath, received String, nil basename = File.basename(source_map.filename) return EMPTY_ENVIRON unless basename.end_with?('.rake') || basename == 'Rakefile' diff --git a/lib/solargraph/convention/struct_definition.rb b/lib/solargraph/convention/struct_definition.rb index f1d240363..71aa74c88 100644 --- a/lib/solargraph/convention/struct_definition.rb +++ b/lib/solargraph/convention/struct_definition.rb @@ -17,7 +17,6 @@ def process type: :class, location: loc, closure: region.closure, - # @sg-ignore flow sensitive typing needs to handle attrs name: struct_definition_node.class_name, docstring: docstring, visibility: :public, @@ -40,12 +39,11 @@ def process pins.push initialize_method_pin - # @sg-ignore flow sensitive typing needs to handle attrs struct_definition_node.attributes.map do |attribute_node, attribute_name| initialize_method_pin.parameters.push( Pin::Parameter.new( name: attribute_name, - decl: struct_definition_node.keyword_init? ? :kwarg : :arg, + decl: struct_definition_node.keyword_init? ? :kwoptarg : :arg, location: get_node_location(attribute_node), closure: initialize_method_pin, source: :struct_definition @@ -54,7 +52,6 @@ def process end # define attribute accessors and instance variables - # @sg-ignore flow sensitive typing needs to handle attrs struct_definition_node.attributes.each do |attribute_node, attribute_name| [attribute_name, "#{attribute_name}="].each do |name| docs = docstring.tags.find { |t| t.tag_name == 'param' && t.name == attribute_name } diff --git a/lib/solargraph/convention/struct_definition/struct_assignment_node.rb b/lib/solargraph/convention/struct_definition/struct_assignment_node.rb index 6dcafd068..50a203e50 100644 --- a/lib/solargraph/convention/struct_definition/struct_assignment_node.rb +++ b/lib/solargraph/convention/struct_definition/struct_assignment_node.rb @@ -28,18 +28,22 @@ def match? node return false unless node&.type == :casgn return false if node.children[2].nil? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 struct_node = if node.children[2].type == :block + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 node.children[2].children[0] else node.children[2] end + # @sg-ignore Need to add nil check here struct_definition_node?(struct_node) end end def class_name if node.children[0] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Parser::NodeMethods.unpack_name(node.children[0]) + "::#{node.children[1]}" else node.children[1].to_s @@ -49,8 +53,11 @@ def class_name private # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here def struct_node + # @sg-ignore Need to add nil check here if node.children[2].type == :block + # @sg-ignore Need to add nil check here node.children[2].children[0] else node.children[2] diff --git a/lib/solargraph/convention/struct_definition/struct_definition_node.rb b/lib/solargraph/convention/struct_definition/struct_definition_node.rb index 51518a687..374651dc4 100644 --- a/lib/solargraph/convention/struct_definition/struct_definition_node.rb +++ b/lib/solargraph/convention/struct_definition/struct_definition_node.rb @@ -30,6 +30,7 @@ class << self def match? node return false unless node&.type == :class + # @sg-ignore Need to add nil check here struct_definition_node?(node.children[1]) end @@ -41,6 +42,7 @@ def struct_definition_node? struct_node return false unless struct_node.is_a?(::Parser::AST::Node) return false unless struct_node&.type == :send return false unless struct_node.children[0]&.type == :const + # @sg-ignore Need to add nil check here return false unless struct_node.children[0].children[1] == :Struct return false unless struct_node.children[1] == :new @@ -68,16 +70,20 @@ def attributes def keyword_init? keyword_init_param = struct_attribute_nodes.find do |struct_def_param| + # @sg-ignore Need to add nil check here struct_def_param.type == :hash && struct_def_param.children[0].type == :pair && + # @sg-ignore Need to add nil check here struct_def_param.children[0].children[0].children[0] == :keyword_init end return false if keyword_init_param.nil? + # @sg-ignore Need to add nil check here keyword_init_param.children[0].children[1].type == :true end # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here def body_node node.children[2] end @@ -88,6 +94,7 @@ def body_node attr_reader :node # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here def struct_node node.children[1] end diff --git a/lib/solargraph/diagnostics/base.rb b/lib/solargraph/diagnostics/base.rb index ff91a9062..31b9a4342 100644 --- a/lib/solargraph/diagnostics/base.rb +++ b/lib/solargraph/diagnostics/base.rb @@ -20,8 +20,9 @@ def initialize *args # # @param source [Solargraph::Source] # @param api_map [Solargraph::ApiMap] + # @param workspace [Solargraph::Workspace, nil] # @return [Array] - def diagnose source, api_map + def diagnose source, api_map, workspace: nil [] end end diff --git a/lib/solargraph/diagnostics/rubocop.rb b/lib/solargraph/diagnostics/rubocop.rb index 39b79d9b7..ba5ed9c12 100644 --- a/lib/solargraph/diagnostics/rubocop.rb +++ b/lib/solargraph/diagnostics/rubocop.rb @@ -50,6 +50,7 @@ def diagnose source, _api_map # Extracts the rubocop version from _args_ # # @return [String] + # @sg-ignore Need to add nil check here def rubocop_version args.find { |a| a =~ /version=/ }.to_s.split('=').last end @@ -58,6 +59,7 @@ def rubocop_version # @return [Array] def make_array resp diagnostics = [] + # @sg-ignore Need to add nil check here resp['files'].each do |file| file['offenses'].each do |off| diagnostics.push offense_to_diagnostic(off) @@ -90,14 +92,18 @@ def offense_range off # @param off [Hash{String => Hash{String => Integer}}] # @return [Position] def offense_start_position off + # @sg-ignore Need to add nil check here Position.new(off['location']['start_line'] - 1, off['location']['start_column'] - 1) end # @param off [Hash{String => Hash{String => Integer}}] # @return [Position] def offense_ending_position off + # @sg-ignore Need to add nil check here if off['location']['start_line'] == off['location']['last_line'] + # @sg-ignore Need to add nil check here start_line = off['location']['start_line'] - 1 + # @sg-ignore Need to add nil check here # @type [Integer] last_column = off['location']['last_column'] line = @source.code.lines[start_line] @@ -111,6 +117,7 @@ def offense_ending_position off start_line, last_column - col_off ) else + # @sg-ignore Need to add nil check here Position.new(off['location']['start_line'], 0) end end diff --git a/lib/solargraph/diagnostics/rubocop_helpers.rb b/lib/solargraph/diagnostics/rubocop_helpers.rb index e97ca628e..b11db072d 100644 --- a/lib/solargraph/diagnostics/rubocop_helpers.rb +++ b/lib/solargraph/diagnostics/rubocop_helpers.rb @@ -18,7 +18,6 @@ def require_rubocop version = nil # @type [String] gem_path = Gem::Specification.find_by_name('rubocop', version).full_gem_path gem_lib_path = File.join(gem_path, 'lib') - # @sg-ignore Should better support meaning of '&' in RBS $LOAD_PATH.unshift(gem_lib_path) unless $LOAD_PATH.include?(gem_lib_path) rescue Gem::MissingSpecVersionError => e # @type [Array] diff --git a/lib/solargraph/diagnostics/type_check.rb b/lib/solargraph/diagnostics/type_check.rb index b1333f9d9..12d4f6c42 100644 --- a/lib/solargraph/diagnostics/type_check.rb +++ b/lib/solargraph/diagnostics/type_check.rb @@ -7,12 +7,12 @@ module Diagnostics # class TypeCheck < Base # @return [Array] - def diagnose source, api_map + def diagnose source, api_map, workspace: nil # return [] unless args.include?('always') || api_map.workspaced?(source.filename) severity = Diagnostics::Severities::ERROR level = args.reverse.find { |a| %w[normal typed strict strong].include?(a) } || :normal # @sg-ignore sensitive typing needs to handle || on nil types - checker = Solargraph::TypeChecker.new(source.filename, api_map: api_map, level: level.to_sym) + checker = Solargraph::TypeChecker.new(source.filename, api_map: api_map, level: level.to_sym, workspace: workspace) checker.problems .sort { |a, b| a.location.range.start.line <=> b.location.range.start.line } .map do |problem| diff --git a/lib/solargraph/diagnostics/update_errors.rb b/lib/solargraph/diagnostics/update_errors.rb index c2ca02408..72ac8047f 100644 --- a/lib/solargraph/diagnostics/update_errors.rb +++ b/lib/solargraph/diagnostics/update_errors.rb @@ -26,7 +26,9 @@ def combine_ranges code, ranges next if rng.nil? || lines.include?(rng.start.line) lines.push rng.start.line next if rng.start.line >= code.lines.length + # @sg-ignore Need to add nil check here scol = code.lines[rng.start.line].index(/[^\s]/) || 0 + # @sg-ignore Need to add nil check here ecol = code.lines[rng.start.line].length result.push Range.from_to(rng.start.line, scol, rng.start.line, ecol) end diff --git a/lib/solargraph/doc_map.rb b/lib/solargraph/doc_map.rb index 6ad366d2b..d176264ea 100644 --- a/lib/solargraph/doc_map.rb +++ b/lib/solargraph/doc_map.rb @@ -5,123 +5,84 @@ 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 [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] + # @return [Workspace] + # @sg-ignore Declared return type ::Solargraph::Workspace does not match inferred type ::Solargraph::Workspace, nil for Solargraph::DocMap#workspace attr_reader :workspace - # @return [Environ] - attr_reader :environ - # @param requires [Array] # @param workspace [Workspace, nil] - # @param [Object] out + # @param out [IO, nil] output stream for logging def initialize requires, workspace, out: $stderr - @requires = requires.compact + @provided_requires = requires.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 @out = out end - # @param out [IO, StringIO, nil] - # @return [void] - # @param [Boolean] rebuild - def cache_all! out, rebuild: false - # 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, rebuild: rebuild, out: out) + # @return [Array] + def requires + @requires ||= @provided_requires + (workspace.global_environ&.requires || []) + end + alias required requires + + # @sg-ignore flow sensitive typing needs to understand reassignment + # @return [Array] + def uncached_gemspecs + if @uncached_gemspecs.nil? + @uncached_gemspecs = [] + pins # force lazy-loaded pin lookup end - load_serialized_gem_pins - @uncached_rbs_collection_gemspecs = [] - @uncached_yard_gemspecs = [] + @uncached_gemspecs end - # @param gemspec [Gem::Specification] - # @param out [IO, StringIO, nil] - # @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? + # @return [Array] + def pins + @pins ||= load_serialized_gem_pins + (workspace.global_environ&.pins || []) end - # @param gemspec [Gem::Specification] - # @param out [IO, StringIO, nil] # @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? } + def reset_pins! + @uncached_gemspecs = nil + @pins = nil end - # @param gemspec [Gem::Specification] + # @return [Solargraph::PinCache] + def pin_cache + @pin_cache ||= workspace.fresh_pincache + end + + def any_uncached? + uncached_gemspecs.any? + end + + # Cache all pins needed for the sources in this doc_map + # @param out [StringIO, IO, nil] output stream for logging # @param rebuild [Boolean] whether to rebuild the pins even if they are cached - # @param out [IO, StringIO, 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}") + def cache_doc_map_gems! out, rebuild: false + 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 - cache_yard_pins(gemspec, out) if build_yard - cache_rbs_collection_pins(gemspec, out) if build_rbs_collection - end - - # @return [Array] - def gemspecs - @gemspecs ||= required_gems_map.values.compact.flatten + time = Benchmark.measure do + uncached_gemspecs.each do |gemspec| + cache(gemspec, rebuild: rebuild, 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 [Array] @@ -129,311 +90,110 @@ 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_yard_gems_in_memory - @all_yard_gems_in_memory ||= {} - end - - # @return [Hash{String => Hash{Array(String, String) => Array}}] stored by RBS collection path - def self.all_rbs_collection_gems_in_memory - @all_rbs_collection_gems_in_memory ||= {} - 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 - end - - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def rbs_collection_pins_in_memory - # @sg-ignore rbs_collection_path is String | nil but used as hash key - self.class.all_rbs_collection_gems_in_memory[rbs_collection_path] ||= {} - end - - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version - def self.all_combined_pins_in_memory - @all_combined_pins_in_memory ||= {} + # @return [Array] + # @param out [IO, nil] + def dependencies out: $stderr + @dependencies ||= + begin + gem_deps = gemspecs + .flat_map { |spec| workspace.fetch_dependencies(spec, out: out) } + .uniq(&:name) + stdlib_deps = gemspecs + .flat_map { |spec| workspace.stdlib_dependencies(spec.name) } + .flat_map { |dep_name| workspace.resolve_require(dep_name) } + .compact + existing_gems = gemspecs.map(&:name) + (gem_deps + stdlib_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 } # @type [Array] - paths = without_gemspecs.to_h.keys + missing_paths = without_gemspecs.to_h.keys # @type [Array] - gemspecs = with_gemspecs.to_h.values.flatten.compact + dependencies.to_a - - paths.each do |path| - deserialize_stdlib_rbs_map path + gemspecs = with_gemspecs.to_h.values.flatten.compact + dependencies(out: out).to_a + + # if we are type checking a gem project, we should not include + # pins from rbs or yard from that gem here - we use our own + # parser for those pins + + # @param gemspec [Gem::Specification, Bundler::LazySpecification, Bundler::StubSpecification] + gemspecs.reject! do |gemspec| + gemspec.respond_to?(:source) && + gemspec.source.instance_of?(Bundler::Source::Gemspec) && + gemspec.source.respond_to?(:path) && + gemspec.source.path == Pathname.new('.') + end + + 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 + # @type [Array] + # @sg-ignore Wrong argument type for Solargraph::Workspace#stdlib_dependencies: stdlib_name expected String, received String, nil + deps = workspace.stdlib_dependencies(stdlib_name_guess) || [] + [stdlib_name_guess, *deps].compact.each do |potential_stdlib_name| + # @sg-ignore Need to support splatting in literal array + rbs_pins = pin_cache.cache_stdlib_rbs_map potential_stdlib_name + # @sg-ignore Wrong argument type for Array#concat: other_arrays expected Array>, _ToAry>, received void + serialized_pins.concat rbs_pins if rbs_pins + end end - logger.debug { 'DocMap#load_serialized_gem_pins: Combining pins...' } + 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 + serialized_pins.length + 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}] 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] } - 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 - 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 - combined_pins_in_memory[[gemspec.name, gemspec.version]] - else - logger.debug { "Pins not yet cached for #{gemspec.name}:#{gemspec.version}" } - 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 - 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 - return nil if gem_name_guess.to_s.empty? - 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: - gemspec = Gem::Specification.find_by_name(gem_name_guess) - 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, 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} 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? - dep ||= Gem::Specification.find_by_name(spec.name, spec.requirement) - deps.merge fetch_dependencies(dep) if deps.add?(dep) - rescue Gem::MissingSpecError - Solargraph.logger.warn "Gem dependency #{spec.name} 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 + @required_gems_map ||= requires.to_h { |path| [path, workspace.resolve_require(path)] } 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? - - # @sg-ignore workspace is checked for nil above - if workspace.gemfile? && Bundler.definition&.lockfile&.to_s&.start_with?(workspace.directory) # rubocop:disable Style/SafeNavigationChainLength - # 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] - 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', - # @sg-ignore return above ensures workspace.directory is not nil - "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 - # @sg-ignore return above ensures workspace.directory is not nil - Solargraph.logger.warn "Failed to load gems from bundle at #{workspace.directory}: #{e}" - [] - end - end - end end end diff --git a/lib/solargraph/gem_pins.rb b/lib/solargraph/gem_pins.rb index d9e731d72..f39a4fdd2 100644 --- a/lib/solargraph/gem_pins.rb +++ b/lib/solargraph/gem_pins.rb @@ -11,6 +11,17 @@ class << self include Logging end + # @param pins [Array] + # @return [Array] + def self.combine_method_pins_by_path pins + method_pins, alias_pins = pins.partition { |pin| pin.instance_of?(Pin::Method) } + by_path = method_pins.group_by(&:path) + by_path.transform_values! do |pins| + GemPins.combine_method_pins(*pins) + end + by_path.values + alias_pins + end + # @param pins [Array] # @return [Pin::Method, nil] def self.combine_method_pins(*pins) @@ -32,36 +43,43 @@ 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 + # `pp`'s `class << ENV ... end` (to add `pretty_print`) makes YARD + # misdeclare ENV as a Class, contradicting RBS's correct ENVClass instance type. + KNOWN_BAD_YARD_PINS = { + 'pp' => ['ENV'] + }.freeze # @param yard_pins [Array] # @param rbs_pins [Array] # - # @return [Array] + # @return [Array] def self.combine yard_pins, rbs_pins in_yard = Set.new - rbs_api_map = Solargraph::ApiMap.new(pins: rbs_pins) + rbs_store = Solargraph::ApiMap::Store.new(rbs_pins) combined = yard_pins.map do |yard_pin| in_yard.add yard_pin.path - rbs_pin = rbs_api_map.get_path_pins(yard_pin.path).filter { |pin| pin.is_a? Pin::Method }.first - next yard_pin unless rbs_pin && yard_pin.instance_of?(Pin::Method) + rbs_pin = rbs_store.get_path_pins(yard_pin.path).filter { |pin| pin.is_a? Pin::Method }.first + + next yard_pin unless rbs_pin && yard_pin.is_a?(Pin::Method) unless rbs_pin - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 - logger.debug { "GemPins.combine: No rbs pin for #{yard_pin.path} - using YARD's '#{yard_pin.inspect} (return_type=#{yard_pin.return_type}; signatures=#{yard_pin.signatures})" } + logger.debug do + "GemPins.combine: No rbs pin for #{yard_pin.path} - using YARD's '#{yard_pin.inspect} (return_type=#{yard_pin.return_type}; signatures=#{yard_pin.signatures})" + end next yard_pin end + # at this point both yard_pins and rbs_pins are methods or + # method aliases. if not plain methods, prefer the YARD one + next yard_pin if rbs_pin.class != Pin::Method + + next rbs_pin if yard_pin.class != Pin::Method + + # both are method pins 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/host.rb b/lib/solargraph/language_server/host.rb index f503ea177..e06441a60 100644 --- a/lib/solargraph/language_server/host.rb +++ b/lib/solargraph/language_server/host.rb @@ -112,6 +112,7 @@ def receive request message elsif request['id'] if requests[request['id']] + # @sg-ignore Need to add nil check here requests[request['id']].process(request['result']) requests.delete request['id'] else @@ -316,6 +317,7 @@ def command_path def prepare_folders array return if array.nil? array.each do |folder| + # @sg-ignore Need to add nil check here prepare uri_to_file(folder['uri']), folder['name'] end end @@ -546,7 +548,7 @@ def completions_at uri, line, column library.completions_at uri_to_file(uri), line, column end - # @return [Bool] if has pending completion request + # @return [Boolean] if has pending completion request def pending_completions? message_worker.messages.reverse_each.any? { |req| req['method'] == 'textDocument/completion' } end @@ -702,7 +704,10 @@ def client_capabilities @client_capabilities ||= {} end + # @return [Boolean] + # @sg-ignore Need to add nil check here def client_supports_progress? + # @sg-ignore Need to add nil check here client_capabilities['window'] && client_capabilities['window']['workDoneProgress'] end @@ -772,7 +777,9 @@ def check_diff uri, change source = sources.find(uri) return change if source.code.length + 1 != change['text'].length diffs = Diff::LCS.diff(source.code, change['text']) + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 return change if diffs.empty? || diffs.length > 1 || diffs.first.length > 1 + # @sg-ignore Need to add nil check here # @type [Diff::LCS::Change] diff = diffs.first.first return change unless diff.adding? && ['.', ':', '(', ',', ' '].include?(diff.element) @@ -853,7 +860,10 @@ def dynamic_capability_options } end + # @return [Boolean] + # @sg-ignore Need to add nil check here def prepare_rename? + # @sg-ignore Need to add nil check here client_capabilities['rename'] && client_capabilities['rename']['prepareSupport'] end diff --git a/lib/solargraph/language_server/host/diagnoser.rb b/lib/solargraph/language_server/host/diagnoser.rb index 8c259c131..9d6db3680 100644 --- a/lib/solargraph/language_server/host/diagnoser.rb +++ b/lib/solargraph/language_server/host/diagnoser.rb @@ -63,6 +63,7 @@ def tick current = mutex.synchronize { queue.shift } return if queue.include?(current) begin + # @sg-ignore Need a downcast here host.diagnose current rescue InvalidOffsetError # @todo This error can occur when the Source is out of sync with @@ -78,7 +79,7 @@ def tick # @return [Host] attr_reader :host - # @return [Mutex] + # @return [Thread::Mutex] attr_reader :mutex # @return [::Array] diff --git a/lib/solargraph/language_server/host/sources.rb b/lib/solargraph/language_server/host/sources.rb index 72a155d73..96b0982f2 100644 --- a/lib/solargraph/language_server/host/sources.rb +++ b/lib/solargraph/language_server/host/sources.rb @@ -55,7 +55,6 @@ def update uri, updater # @raise [FileNotFoundError] if the URI does not match an open source. # # @param uri [String] - # @sg-ignore flow ensitive typing should understand raise # @return [Solargraph::Source] def find uri open_source_hash[uri] || raise(Solargraph::FileNotFoundError, "Host could not find #{uri}") diff --git a/lib/solargraph/language_server/message.rb b/lib/solargraph/language_server/message.rb index 170bfdb4f..44b6b7982 100644 --- a/lib/solargraph/language_server/message.rb +++ b/lib/solargraph/language_server/message.rb @@ -37,6 +37,7 @@ def register path, message_class # @param path [String] # @return [Class] + # @sg-ignore Need to add nil check here def select path if method_map.key?(path) method_map[path] diff --git a/lib/solargraph/language_server/message/completion_item/resolve.rb b/lib/solargraph/language_server/message/completion_item/resolve.rb index 83cc5c1fe..ca460f0e8 100644 --- a/lib/solargraph/language_server/message/completion_item/resolve.rb +++ b/lib/solargraph/language_server/message/completion_item/resolve.rb @@ -22,8 +22,10 @@ def merge pins .reject { |pin| pin.documentation.empty? && pin.return_type.undefined? } result = params .transform_keys(&:to_sym) + # @sg-ignore Need to add nil check here .merge(pins.first.resolve_completion_item) .merge(documentation: markup_content(join_docs(docs))) + # @sg-ignore Need to add nil check here result[:detail] = pins.first.detail result end 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 8909406a4..0c535524f 100644 --- a/lib/solargraph/language_server/message/extended/check_gem_version.rb +++ b/lib/solargraph/language_server/message/extended/check_gem_version.rb @@ -72,7 +72,6 @@ def process attr_reader :current # @return [Gem::Version] - # @sg-ignore Need to add nil check here def available if !@available && !@fetched @fetched = true @@ -84,6 +83,7 @@ def available @error = 'An error occurred fetching the gem data' GEM_ZERO else + # @sg-ignore Solargraph has no RBS coverage for Gem::NameTuple (the actual runtime type here, not Gem::Dependency) tuple.version end end @@ -99,7 +99,7 @@ def fetched? @fetched ||= false end - # @return [String, nil] + # @return [String, Hash, nil] attr_reader :error end end diff --git a/lib/solargraph/language_server/message/text_document/formatting.rb b/lib/solargraph/language_server/message/text_document/formatting.rb index c6cc3353a..91abc1399 100644 --- a/lib/solargraph/language_server/message/text_document/formatting.rb +++ b/lib/solargraph/language_server/message/text_document/formatting.rb @@ -48,6 +48,7 @@ def log_corrections corrections return if corrections&.empty? Solargraph.logger.info('Formatting result:') + # @sg-ignore flow sensitive typing should be able to handle redefinition corrections.each_line do |line| next if line.strip.empty? Solargraph.logger.info(line.strip) @@ -75,6 +76,7 @@ def cli_args file_uri, config ] %w[except only].each do |arg| + # @sg-ignore Need to add nil check here cops = cop_list(config[arg]) args += ["--#{arg}", cops] if cops end @@ -84,7 +86,6 @@ def cli_args file_uri, config end # @param config [Hash{String => String}] - # @sg-ignore # @return [Class] def formatter_class config if self.class.const_defined?('BlankRubocopFormatter') @@ -102,7 +103,6 @@ def formatter_class config # @return [String, nil] def cop_list value # @type [String] - # @sg-ignore Translate to something flow sensitive typing understands value = value.join(',') if value.respond_to?(:join) return nil if value == '' || !value.is_a?(String) value @@ -125,6 +125,7 @@ def format original, result else { line: original.lines.length - 1, + # @sg-ignore Need to add nil check here character: original.lines.last.length } end diff --git a/lib/solargraph/library.rb b/lib/solargraph/library.rb index 4f03fb862..1ecbc8415 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. # @@ -21,6 +28,7 @@ class Library attr_reader :current # @return [LanguageServer::Progress, nil] + # @sg-ignore Declared return type ::Solargraph::LanguageServer::Progress, nil does not match inferred type nil, false, ::Solargraph::LanguageServer::Progress for Solargraph::Library#cache_progr attr_reader :cache_progress # @param workspace [Solargraph::Workspace] @@ -33,6 +41,7 @@ def initialize workspace = Solargraph::Workspace.new, name = nil # @type [Source, nil] @current = nil @sync_count = 0 + @cache_progress = nil end def inspect @@ -265,11 +274,13 @@ def references_from filename, line, column, strip: false, only: false referenced&.path == pin.path end if pin.path == 'Class#new' + # @todo flow sensitive typing should allow shadowing of Kernel#caller caller = cursor.chain.base.infer(api_map, clip.send(:closure), clip.locals).first if caller.defined? found.select! do |loc| clip = api_map.clip_at(loc.filename, loc.range.start) other = clip.send(:cursor).chain.base.infer(api_map, clip.send(:closure), clip.locals).first + # @todo flow sensitive typing should allow shadowing of Kernel#caller caller == other end else @@ -279,13 +290,11 @@ 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| - # @sg-ignore Unresolved call to [] + # @sg-ignore Wrong argument type for Solargraph::Range.from_to: c1 expected Integer, received BigDecimal 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 @@ -310,9 +319,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 @@ -416,9 +423,9 @@ def diagnose filename else args = line.split(':').map(&:strip) name = args.shift + # @sg-ignore Need to add nil check here reporter = Diagnostics.reporter(name) raise DiagnosticsError, "Diagnostics reporter #{name} does not exist" if reporter.nil? - # @sg-ignore Hash errors repargs[reporter] ||= [] # @sg-ignore Hash errors repargs[reporter].concat args @@ -443,7 +450,6 @@ def bench source_maps: source_map_hash.values, workspace: workspace, external_requires: external_requires, - # @sg-ignore OK if @current.filename is nil live_map: @current ? source_map_hash[@current.filename] : nil ) end @@ -478,6 +484,7 @@ def mapped? end # @return [SourceMap, Boolean] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 def next_map return false if mapped? src = workspace.sources.find { |s| !source_map_hash.key?(s.filename) } @@ -485,7 +492,6 @@ def next_map Logging.logger.debug "Mapping #{src.filename}" # @sg-ignore OK if src.filename is nil source_map_hash[src.filename] = Solargraph::SourceMap.map(src) - # @sg-ignore OK if src.filename is nil source_map_hash[src.filename] else false @@ -515,6 +521,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 ||= {} @@ -600,7 +611,7 @@ def cache_next_gemspec 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 @@ -615,7 +626,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 @@ -632,8 +646,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? @@ -651,15 +664,11 @@ def queued_gemspec_cache # @return [void] def report_cache_progress gem_name, pending @total ||= pending - # @sg-ignore Wrong argument type for Integer#>: arg_0 expected Numeric, received Integer, nil @total = pending if pending > @total - # @sg-ignore Unresolved call to - on Integer, nil finished = @total - pending - # @sg-ignore @total should always be an Integer pct = if @total.zero? 0 else - # @sg-ignore Unresolved call to to_f ((finished.to_f / @total) * 100).to_i end message = "#{gem_name}#{" (+#{pending})" if pending.positive?}" @@ -673,7 +682,6 @@ def report_cache_progress gem_name, pending changed notify_observers @cache_progress end - # @sg-ignore Unresolved call to report @cache_progress.report(message, pct) changed notify_observers @cache_progress @@ -696,8 +704,7 @@ def sync_catalog source_map_hash.each_value { |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/location.rb b/lib/solargraph/location.rb index 2317c3cb4..545d2f743 100644 --- a/lib/solargraph/location.rb +++ b/lib/solargraph/location.rb @@ -9,6 +9,7 @@ class Location include Comparable # @return [String] + # @sg-ignore Need to add nil check here attr_reader :filename # @return [Solargraph::Range] diff --git a/lib/solargraph/parser/comment_ripper.rb b/lib/solargraph/parser/comment_ripper.rb index 89d4a2c91..409a52621 100644 --- a/lib/solargraph/parser/comment_ripper.rb +++ b/lib/solargraph/parser/comment_ripper.rb @@ -33,6 +33,7 @@ def on_comment *args chomped = '#' end @comments[result[2][0]] = + # @sg-ignore Need to add nil check here Snippet.new(Range.from_to(result[2][0], result[2][1], result[2][0], result[2][1] + chomped.length), chomped) end result diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 1606a32e0..38435a767 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -9,11 +9,35 @@ class FlowSensitiveTyping # @param ivars [Array] # @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil] # @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil] - def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin + # @param closure [Solargraph::Pin::Closure] The pin enclosing the + # code being processed (e.g. the current method), used to + # resolve a bare, implicit-self call like 'steps' as a call to + # a 0-arg method rather than a local variable. + # @param restricted_names [Array, nil] If given, only + # assert facts about variables with these names, ignoring any + # other variable the analyzed condition happens to mention. + def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, closure, + restricted_names: nil @locals = locals @ivars = ivars @enclosing_breakable_pin = enclosing_breakable_pin @enclosing_compound_statement_pin = enclosing_compound_statement_pin + @closure = closure + @restricted_names = restricted_names + end + + # Assert the facts implied by a condition being true/false over + # the given ranges. Public so that a differently-configured + # instance (see #initialize's restricted_names) can be handed a + # condition to analyze. + # + # @param conditional_node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def process_condition conditional_node, true_ranges, false_ranges + process_expression(conditional_node, true_ranges, false_ranges) end # @param and_node [Parser::AST::Node] @@ -25,8 +49,10 @@ def process_and and_node, true_ranges = [], false_ranges = [] return unless and_node.type == :and # @type [Parser::AST::Node] + # @sg-ignore Need to add nil check here lhs = and_node.children[0] # @type [Parser::AST::Node] + # @sg-ignore Need to add nil check here rhs = and_node.children[1] before_rhs_loc = rhs.location.expression.adjust(begin_pos: -1) @@ -51,8 +77,10 @@ def process_or or_node, true_ranges = [], false_ranges = [] return unless or_node.type == :or # @type [Parser::AST::Node] + # @sg-ignore Need to add nil check here lhs = or_node.children[0] # @type [Parser::AST::Node] + # @sg-ignore Need to add nil check here rhs = or_node.children[1] before_rhs_loc = rhs.location.expression.adjust(begin_pos: -1) @@ -80,8 +108,11 @@ def process_calls node, true_presences, false_presences return unless node.type == :send process_isa(node, true_presences, false_presences) + process_respond_to(node, true_presences, false_presences) process_nilp(node, true_presences, false_presences) process_bang(node, true_presences, false_presences) + process_eq(node, true_presences, false_presences) + process_neq(node, true_presences, false_presences) end # @param if_node [Parser::AST::Node] @@ -152,7 +183,11 @@ def process_if if_node, true_ranges = [], false_ranges = [] get_node_end_position(else_clause)) end + # @sg-ignore Need to add nil check here process_expression(conditional_node, true_ranges, false_ranges) + + # @sg-ignore Need to add nil check here + process_guarded_reassignment(if_node, conditional_node, then_clause, else_clause) end # @param while_node [Parser::AST::Node] @@ -187,9 +222,99 @@ def process_while while_node, true_ranges = [], false_ranges = [] get_node_end_position(do_clause)) end + # @sg-ignore Need to add nil check here process_expression(conditional_node, true_ranges, false_ranges) end + # @param case_node [Parser::AST::Node] + # + # @return [void] + def process_case case_node + return if case_node.type != :case + + # + # See if we can narrow the subject's type inside each 'when' + # branch based on the classes tested for in that branch + # + # [3] pry(main)> Parser::CurrentRuby.parse("case a; when B; c; end") + # => s(:case, + # s(:send, nil, :a), + # s(:when, + # s(:const, nil, :B), + # s(:send, nil, :c)), nil) + # [4] pry(main)> + subject_node = case_node.children[0] + return if subject_node.nil? + return unless %i[lvar ivar].include?(subject_node.type) + + variable_name = parse_variable(subject_node) + return if variable_name.nil? + + # @sg-ignore Need to add nil check here + subject_position = Range.from_node(subject_node).start + pin = find_var(variable_name, subject_position) + return unless pin + + # @sg-ignore Need to add nil check here + when_nodes = case_node.children[1..].compact.select { |child| child.type == :when } + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check + when_nodes.each do |when_node| + *value_nodes, body_node = when_node.children + next if body_node.nil? + + type_names = value_nodes.map { |value_node| type_name(value_node) } + # Only narrow if every value in this 'when' clause is a + # simple constant reference - a splat, range, regexp, etc. + # isn't a type we can express as a downcast. + next if type_names.any?(&:nil?) + + before_body_loc = body_node.location.expression.adjust(begin_pos: -1) + before_body_pos = Position.new(before_body_loc.line, before_body_loc.column) + presence = Range.new(before_body_pos, get_node_end_position(body_node)) + + facts = { pin => [{ type: ComplexType.parse(*type_names) }] } + process_facts(facts, [presence]) + end + end + + # @param or_asgn_node [Parser::AST::Node] + # @param presence [Range] + # + # @return [void] + def process_or_asgn or_asgn_node, presence + return if or_asgn_node.type != :or_asgn + + # + # 'x ||= value' only actually assigns when x is falsy (nil or + # false), so if x was already truthy, it keeps whatever + # non-nil type it already had. Narrow the existing pin by + # excluding nil, scoped to the rest of the enclosing closure. + # + # [3] pry(main)> Parser::CurrentRuby.parse("x ||= 1") + # => s(:or_asgn, + # s(:lvasgn, :x), + # s(:int, 1)) + # [4] pry(main)> + lhs = or_asgn_node.children[0] + # @sg-ignore Parser::AST::Node#children is declared to return a bare Array, losing its element type here + return unless %i[lvasgn ivasgn].include?(lhs.type) + + # @sg-ignore Parser::AST::Node#children is declared to return a bare Array, losing its element type here + variable_name = lhs.children[0].to_s + # @sg-ignore Parser::AST::Node#children is declared to return a bare Array, losing its element type here + return if variable_name.empty? + + range = Range.from_node(or_asgn_node) + return if range.nil? + + position = range.start + pin = find_var(variable_name, position) + return unless pin + + facts = { pin => [{ not_type: ComplexType::NIL }] } + process_facts(facts, [presence]) + end + class << self include Logging end @@ -198,6 +323,83 @@ class << self private + # The standard default-argument idiom reassigns a variable in + # the branch where the guard on that same variable fired: + # + # tasks = ['a'] if tasks.nil? + # tasks.each { ... } + # + # At a use site *after* the conditional, the two incoming paths + # are (a) the guard fired and the clause assigned a new value, + # and (b) the guard did not fire, leaving the original value - + # which the condition tells us something about. Path (a) is + # already handled: the assignment's pin is unioned in. Path (b) + # is what's asserted here - the opposite branch's facts from the + # condition hold over the rest of the enclosing compound + # statement. + # + # The facts are restricted to the variables the clause + # definitely reassigns. Without that restriction a condition + # like `x.nil? || y.nil?` would wrongly narrow `y` after the + # conditional, since the clause only replaced `x`'s value. + # + # @param if_node [Parser::AST::Node] + # @param conditional_node [Parser::AST::Node] + # @param then_clause [Parser::AST::Node, nil] + # @param else_clause [Parser::AST::Node, nil] + # + # @return [void] + def process_guarded_reassignment if_node, conditional_node, then_clause, else_clause + compound_statement_node = enclosing_compound_statement_pin&.node + return if compound_statement_node.nil? + + rest_of_compound_statement = Range.new(get_node_end_position(if_node), + get_node_end_position(compound_statement_node)) + + # the then clause ran only when the condition was true, so the + # path that preserved the original value is the false one - + # and vice versa for the else clause + assert_after_guard(conditional_node, definitely_assigned_names(then_clause), + [], [rest_of_compound_statement]) + assert_after_guard(conditional_node, definitely_assigned_names(else_clause), + [rest_of_compound_statement], []) + end + + # @param conditional_node [Parser::AST::Node] + # @param names [Array] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def assert_after_guard conditional_node, names, true_ranges, false_ranges + return if names.empty? + + FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, + closure, restricted_names: names) + .process_condition(conditional_node, true_ranges, false_ranges) + end + + # Names of the variables this clause assigns on every path + # through it. Only unconditional, plain assignments count - + # anything inside a nested conditional or loop may not run, and + # `||=`/`+=`-style assignments keep the previous value in play. + # + # @param clause_node [Parser::AST::Node, nil] + # + # @return [Array] + def definitely_assigned_names clause_node + return [] if clause_node.nil? + + case clause_node.type + when :lvasgn, :ivasgn + [clause_node.children[0].to_s] + when :begin, :kwbegin + clause_node.children.flat_map { |child| definitely_assigned_names(child) } + else + [] + end + end + # @param pin [Pin::BaseVariable] # @param presence [Range] # @param downcast_type [ComplexType, nil] @@ -205,8 +407,10 @@ class << self # # @return [void] def add_downcast_var pin, presence:, downcast_type:, downcast_not_type: + return if restricted_names && !restricted_names.include?(pin.name) + new_pin = pin.downcast(exclude_return_type: downcast_not_type, - intersection_return_type: downcast_type, + narrowed_return_type: downcast_type, source: :flow_sensitive_typing, presence: presence) if pin.is_a?(Pin::LocalVariable) @@ -249,14 +453,101 @@ def process_expression expression_node, true_ranges, false_ranges process_calls(expression_node, true_ranges, false_ranges) process_and(expression_node, true_ranges, false_ranges) process_or(expression_node, true_ranges, false_ranges) + process_parentheses(expression_node, true_ranges, false_ranges) + process_assignment(expression_node, true_ranges, false_ranges) process_variable(expression_node, true_ranges, false_ranges) + process_call_chain(expression_node, true_ranges, false_ranges) + end + + # Recognizes receivers of the form 'foo', '@foo', 'foo.bar', or + # '@foo.bar.baz' -- a chain of simple, argument-less, blockless + # calls/variable references rooted in a local variable, instance + # variable, or (for a bare call, e.g. 'foo' referring to a 0-arg + # method on self) an as-yet-unresolved name. + # + # @param node [Parser::AST::Node, nil] + # @return [::Array, nil] Dotted-word chain, e.g. ['pin', + # 'location'], or nil if `node` doesn't have this shape. + def parse_receiver_chain node + return unless node.is_a?(::Parser::AST::Node) + return [node.children[0].to_s] if %i[lvar ivar].include?(node.type) + return unless node.type == :send + # no arguments -- children[2..] is only nil (rather than []) + # if the start index is out of bounds, which can't happen here + return unless (node.children[2..] || []).empty? + + method_name = node.children[1] + return unless method_name.is_a?(Symbol) + + receiver = node.children[0] + # bare call, e.g. `s(:send, nil, :foo)` - implicit self, so + # 'foo' could be a local variable or a 0-arg method on self + return [method_name.to_s] if receiver.nil? + + # AST::Node, never any of the other types children[] can hold + # for other node shapes (e.g. Symbol, Array) -- and + # parse_receiver_chain re-checks node.is_a?(...) itself anyway + base = parse_receiver_chain(receiver) + return unless base + + base + [method_name.to_s] + end + + # `(foo)` parses as a one-child :begin wrapping the expression, + # which is how an assignment used as a condition normally shows + # up: `if (md = foo.match(...))`. A multi-statement :begin + # takes its truthiness from the last statement, which isn't + # worth handling here. + # + # @param node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def process_parentheses node, true_ranges, false_ranges + return unless node.type == :begin && node.children.length == 1 + + child = node.children[0] + return unless child.is_a?(::Parser::AST::Node) + + process_expression(child, true_ranges, false_ranges) + end + + # An assignment used as a condition - `if (md = foo.match(...))` + # - evaluates to the value assigned, so the branches tell us the + # same thing about the variable that a bare reference to it + # would. + # + # @param node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_assignment node, true_presences, false_presences + return unless %i[lvasgn ivasgn].include?(node.type) + + variable_name = node.children[0]&.to_s + return if variable_name.nil? || variable_name.empty? + + # look the variable up at the end of its own assignment, where + # the new value has become visible + pin = find_var(variable_name, get_node_end_position(node)) + return unless pin + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_true = { pin => [{ not_type: ComplexType::NIL }] } + process_facts(if_true, true_presences) + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_false = { pin => [{ type: ComplexType.parse('nil, false') }] } + process_facts(if_false, false_presences) end # @param call_node [Parser::AST::Node] # @param method_name [Symbol] - # @return [Array(String, String), nil] Tuple of rgument to - # function, then receiver of function if it's a variable, - # otherwise nil if no simple variable receiver + # @return [Array(String, ::Array), nil] Tuple of argument to + # function, then dotted-word chain for the receiver, otherwise nil + # if the receiver isn't a simple chain (see #parse_receiver_chain) def parse_call call_node, method_name return unless call_node&.type == :send && call_node.children[1] == method_name # Check if conditional node follows this pattern: @@ -265,46 +556,159 @@ def parse_call call_node, method_name # s(:const, nil, :Baz)), # call_receiver = call_node.children[0] + # @sg-ignore Need to add nil check here call_arg = type_name(call_node.children[2]) - # check if call_receiver looks like this: - # s(:send, nil, :foo) - # and set variable_name to :foo - if call_receiver&.type == :send && call_receiver.children[0].nil? && call_receiver.children[1].is_a?(Symbol) - variable_name = call_receiver.children[1].to_s - end - # or like this: - # (lvar :repr) - variable_name = call_receiver.children[0].to_s if %i[lvar ivar].include?(call_receiver&.type) - return unless variable_name + # nested arrays/symbols for other node shapes), but + # parse_receiver_chain re-checks node.is_a?(::Parser::AST::Node) + # itself and safely returns nil for anything else + chain_words = parse_receiver_chain(call_receiver) + return unless chain_words - [call_arg, variable_name] + [call_arg, chain_words] end # @param isa_node [Parser::AST::Node] - # @return [Array(String, String), nil] + # @return [Array(String, ::Array), nil] def parse_isa isa_node - call_type_name, variable_name = parse_call(isa_node, :is_a?) + call_type_name, chain_words = parse_call(isa_node, :is_a?) return unless call_type_name - [call_type_name, variable_name] + [call_type_name, chain_words] end # @param variable_name [String] # @param position [Position] # - # @sg-ignore Solargraph::Parser::FlowSensitiveTyping#find_var - # return type could not be inferred # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] def find_var variable_name, position - if variable_name.start_with?('@') - # @sg-ignore flow sensitive typing needs to handle attrs - ivars.find { |ivar| ivar.name == variable_name && (!ivar.presence || ivar.presence.include?(position)) } - else - # @sg-ignore flow sensitive typing needs to handle attrs - locals.find { |pin| pin.name == variable_name && (!pin.presence || pin.presence.include?(position)) } + pins = variable_name.start_with?('@') ? ivars : locals + # Prefer the pin whose presence starts latest - i.e., the + # most recent assignment reaching this position - rather + # than the first-declared pin for this name. Multiple pins + # can match (e.g. a variable's original declaration and a + # later reassignment both have presences that include this + # position), and picking the wrong one here would narrow the + # stale, superseded pin instead of the current one. + # + # Exclude pins whose own assignment is still being evaluated + # at this position (e.g. the receiver inside its own RHS, + # such as `baz ||= begin ... end`) - that pin's value isn't + # available yet, so its presence including this position + # would otherwise make it a false match ahead of the pin it's + # about to supersede. + matches = pins.select do |pin| + next false unless pin.name == variable_name + next false unless !pin.presence || pin.presence.include?(position) + + other_loc = Location.new(pin.location&.filename, Range.new(position, position)) + !pin.within_own_assignment?(other_loc) + end + matches.max_by { |pin| pin.presence&.start || Position.new(0, 0) } + end + + # Finds (for a single tracked local/instance variable) or builds + # (for a chain of simple calls off of one, e.g. ['pin', 'location'], + # or for a bare/explicit-self 0-arg method call, e.g. ['steps'] from + # 'steps' or 'self.steps') the pin flow-sensitive-typing facts + # should be recorded against. + # + # A synthesized pin's type is computed lazily, from `node` itself, + # by Pin::BaseVariable#probe the same way a real local variable's + # type is computed from its assignment node -- since `node` is the + # receiver expression at *this*, necessarily earlier, guard site, + # re-inferring it can never see the narrowing facts this same pin + # is about to be asked to carry. + # + # @param chain_words [::Array] + # @param node [Parser::AST::Node] the receiver expression, e.g. the + # node for 'pin.location' + # @param position [Position] + # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] + def chain_pin chain_words, node, position + if chain_words.length == 1 + # A bare word is ambiguous from chain_words alone -- 'steps' + # could be a real local variable (node.type == :lvar) or a + # 0-arg method call to self (node.type == :send, since the + # parser only emits :lvar for a name already assigned as a + # local in this scope). Only the former is a tracked variable. + # @sg-ignore chain_words is never empty - callers already checked + return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send + + return unless closure + + return self_call_pin(node) end + + # @sg-ignore chain_words is never empty - callers already checked + root_pin = find_var(chain_words.first, position) + return unless root_pin + + Pin::LocalVariable.new( + location: Location.from_node(node), + closure: root_pin.closure, + name: chain_words.join('.'), + assignment: node, + source: :flow_sensitive_typing + ) + end + + # Builds the synthesized pin for a bare, implicit-self call to a + # 0-arg method, e.g. 'steps'. Rooted at `closure` rather than at a + # tracked variable's pin, since there is no variable to inherit a + # closure from. Named after the bare method word itself (not + # e.g. 'self.steps') so it lines up with how Chain::Call#resolve + # looks up a head-position call: by the call's word, via + # ApiMap#var_at_location. + # + # @param node [Parser::AST::Node] the call node, e.g. 'steps' + # @return [Solargraph::Pin::LocalVariable] + def self_call_pin node + Pin::LocalVariable.new( + location: Location.from_node(node), + closure: closure, + name: node.children[1].to_s, + assignment: node, + source: :flow_sensitive_typing + ) + end + + # A true `x.respond_to?(:m)` proves x satisfies the duck type + # `#m` on the guarded path; ComplexType#narrow_with handles + # conformance against x's existing type (keeping conforming + # union arms, or pairing into an intersection where safe). A + # false respond_to? is not a sound class-level exclusion (duck + # conformance isn't class membership), so no false-path fact is + # asserted. + # + # @param rt_node [Parser::AST::Node] + # @param true_presences [Array] + # @param _false_presences [Array] + # + # @return [void] + def process_respond_to rt_node, true_presences, _false_presences + return unless rt_node.type == :send && rt_node.children[1] == :respond_to? + + # only a literal-symbol first argument is usable; the optional + # include_all second argument doesn't change the positive fact + arg = rt_node.children[2] + return unless arg.is_a?(::Parser::AST::Node) && arg.type == :sym + + method_sym = arg.children[0] + + chain_words = parse_receiver_chain(rt_node.children[0]) + return if chain_words.nil? || chain_words.empty? + + # @sg-ignore Need to add nil check here + position = Range.from_node(rt_node).start + # @sg-ignore chain_pin's tuple-destructured args typecheck oddly + pin = chain_pin(chain_words, rt_node.children[0], position) + return unless pin + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_true = { pin => [{ type: ComplexType.parse("##{method_sym}") }] } + process_facts(if_true, true_presences) end # @param isa_node [Parser::AST::Node] @@ -313,12 +717,13 @@ def find_var variable_name, position # # @return [void] def process_isa isa_node, true_presences, false_presences - isa_type_name, variable_name = parse_isa(isa_node) - return if variable_name.nil? || variable_name.empty? + isa_type_name, chain_words = parse_isa(isa_node) + return if chain_words.nil? || chain_words.empty? # @sg-ignore Need to add nil check here isa_position = Range.from_node(isa_node).start - pin = find_var(variable_name, isa_position) + # @sg-ignore chain_pin's tuple-destructured args typecheck oddly + pin = chain_pin(chain_words, isa_node.children[0], isa_position) return unless pin # @type Hash{Pin::BaseVariable => Array ComplexType}>} @@ -334,8 +739,117 @@ def process_isa isa_node, true_presences, false_presences process_facts(if_false, false_presences) end + # @param node [Parser::AST::Node, nil] + # @return [String, nil] YARD/RBS-style literal type tag (e.g., ':foo', '"foo"', '1', 'true') + def literal_type_name node + case node&.type + when :sym + # @sg-ignore flow sensitive typing needs to handle attrs + ":#{node.children[0]}" + when :str + # @sg-ignore flow sensitive typing needs to handle attrs + node.children[0].inspect + when :int + # @sg-ignore flow sensitive typing needs to handle attrs + node.children[0].to_s + when :true # rubocop:disable Lint/BooleanSymbol -- Parser::AST::Node#type for `true` literal + 'true' + when :false # rubocop:disable Lint/BooleanSymbol -- Parser::AST::Node#type for `false` literal + 'false' + end + end + + # @param op_node [Parser::AST::Node] + # @param method_name [Symbol] + # @return [Array(String, String), nil] Tuple of literal type name + # the variable is being compared against, then the variable name + def parse_literal_comparison op_node, method_name + return unless op_node&.type == :send && op_node.children[1] == method_name + + receiver = op_node.children[0] + arg = op_node.children[2] + + # variable on the left, literal on the right: `foo == :bar` + if %i[lvar ivar].include?(receiver&.type) + literal_type = literal_type_name(arg) + return unless literal_type + + # @sg-ignore flow sensitive typing needs to handle attrs + [literal_type, receiver.children[0].to_s] + # literal on the left, variable on the right: `:bar == foo` + elsif %i[lvar ivar].include?(arg&.type) + literal_type = literal_type_name(receiver) + return unless literal_type + + # @sg-ignore flow sensitive typing needs to handle attrs + [literal_type, arg.children[0].to_s] + end + end + + # @param eq_node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_eq eq_node, true_presences, false_presences + literal_type_name, variable_name = parse_literal_comparison(eq_node, :==) + return if variable_name.nil? || variable_name.empty? + + literal_type = ComplexType.try_parse(literal_type_name) + return if literal_type.undefined? + + # @sg-ignore Need to add nil check here + eq_position = Range.from_node(eq_node).start + + pin = find_var(variable_name, eq_position) + return unless pin + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_true = {} + if_true[pin] ||= [] + if_true[pin] << { type: literal_type } + process_facts(if_true, true_presences) + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_false = {} + if_false[pin] ||= [] + if_false[pin] << { not_type: literal_type } + process_facts(if_false, false_presences) + end + + # @param neq_node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_neq neq_node, true_presences, false_presences + literal_type_name, variable_name = parse_literal_comparison(neq_node, :!=) + return if variable_name.nil? || variable_name.empty? + + literal_type = ComplexType.try_parse(literal_type_name) + return if literal_type.undefined? + + # @sg-ignore Need to add nil check here + neq_position = Range.from_node(neq_node).start + + pin = find_var(variable_name, neq_position) + return unless pin + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_true = {} + if_true[pin] ||= [] + if_true[pin] << { not_type: literal_type } + process_facts(if_true, true_presences) + + # @type Hash{Pin::BaseVariable => Array ComplexType}>} + if_false = {} + if_false[pin] ||= [] + if_false[pin] << { type: literal_type } + process_facts(if_false, false_presences) + end + # @param nilp_node [Parser::AST::Node] - # @return [Array(String, String), nil] + # @return [Array(String, ::Array), nil] def parse_nilp nilp_node parse_call(nilp_node, :nil?) end @@ -346,8 +860,8 @@ def parse_nilp nilp_node # # @return [void] def process_nilp nilp_node, true_presences, false_presences - nilp_arg, variable_name = parse_nilp(nilp_node) - return if variable_name.nil? || variable_name.empty? + nilp_arg, chain_words = parse_nilp(nilp_node) + return if chain_words.nil? || chain_words.empty? # if .nil? got an argument, move on, this isn't the situation # we're looking for and typechecking will cover any invalid # ones @@ -355,7 +869,8 @@ def process_nilp nilp_node, true_presences, false_presences # @sg-ignore Need to add nil check here nilp_position = Range.from_node(nilp_node).start - pin = find_var(variable_name, nilp_position) + # @sg-ignore chain_pin's tuple-destructured args typecheck oddly + pin = chain_pin(chain_words, nilp_node.children[0], nilp_position) return unless pin # @type Hash{Pin::LocalVariable => Array ComplexType}>} @@ -392,6 +907,7 @@ def process_bang bang_node, true_presences, false_presences receiver = bang_node.children[0] # swap the two presences + # @sg-ignore Need to add nil check here process_expression(receiver, false_presences, true_presences) end @@ -433,6 +949,48 @@ def process_variable node, true_presences, false_presences process_facts(if_false, false_presences) end + # Handles a bare truthy check on a call chain, e.g. 'pin.location' + # in 'return nil unless pin.location', or on a bare, implicit-self + # 0-arg method call, e.g. 'steps' in 'return nil unless steps'. + # Bare references to a single local/instance *variable* are + # handled by #process_variable instead (node.type would be :lvar + # or :ivar there, not :send, so this never double-processes them). + # + # @param node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_call_chain node, true_presences, false_presences + return unless node.type == :send + # already handled (with inverted true/false semantics) by + # process_nilp/process_bang + return if %i[nil? !].include?(node.children[1]) + + chain_words = parse_receiver_chain(node) + return if chain_words.nil? || chain_words.empty? + + # @sg-ignore Range.from_node is nil only for a node without + # source location info, which doesn't happen for real parsed + # nodes reaching here (same as isa_position/nilp_position above) + position = Range.from_node(node).start + + pin = chain_pin(chain_words, node, position) + return unless pin + + # @type Hash{Pin::LocalVariable => Array ComplexType}>} + if_true = {} + if_true[pin] ||= [] + if_true[pin] << { not_type: ComplexType::NIL } + process_facts(if_true, true_presences) + + # @type Hash{Pin::LocalVariable => Array ComplexType}>} + if_false = {} + if_false[pin] ||= [] + if_false[pin] << { type: ComplexType.parse('nil, false') } + process_facts(if_false, false_presences) + end + # @param node [Parser::AST::Node] # # @return [String, nil] @@ -446,6 +1004,11 @@ def type_name node class_node = node.children[1] return class_node.to_s if module_node.nil? + # e.g., ::Baz parses as s(:const, s(:cbase), :Baz) - the + # leading :cbase marks root-namespace resolution and isn't + # itself a :const node, so it needs to be recognized here + # rather than falling into the generic recursive case below. + return "::#{class_node}" if module_node.type == :cbase module_type_name = type_name(module_node) return unless module_type_name @@ -454,18 +1017,12 @@ def type_name node end # @param clause_node [Parser::AST::Node, nil] - # @sg-ignore need boolish support for ? methods def always_breaks? clause_node clause_node&.type == :break end - # @param clause_node [Parser::AST::Node, nil] - def always_leaves_compound_statement? clause_node - # https://docs.ruby-lang.org/en/2.2.0/keywords_rdoc.html - %i[return raise next redo retry].include?(clause_node&.type) - end - - attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin + attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin, :closure, + :restricted_names end end end diff --git a/lib/solargraph/parser/node_processor.rb b/lib/solargraph/parser/node_processor.rb index d3579df80..3358dfd3a 100644 --- a/lib/solargraph/parser/node_processor.rb +++ b/lib/solargraph/parser/node_processor.rb @@ -18,8 +18,10 @@ class << self # @param type [Symbol] # @param cls [Class] # @return [Array>] + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars def register type, cls @@processors[type] ||= [] + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars @@processors[type] << cls end @@ -28,6 +30,7 @@ def register type, cls # # @return [void] def deregister type, cls + # @sg-ignore Need to add nil check here @@processors[type].delete(cls) end end diff --git a/lib/solargraph/parser/parser_gem/class_methods.rb b/lib/solargraph/parser/parser_gem/class_methods.rb index 62aa33e4b..12e72eee2 100644 --- a/lib/solargraph/parser/parser_gem/class_methods.rb +++ b/lib/solargraph/parser/parser_gem/class_methods.rb @@ -34,7 +34,9 @@ def parse code, filename = nil, starting_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 @@ -53,6 +55,7 @@ def map source # @return [Array] def references source, name if name.end_with?('=') + # @sg-ignore Need to add nil check here reg = /#{Regexp.escape name[0..-2]}\s*=/ # @param code [String] # @param offset [Integer] diff --git a/lib/solargraph/parser/parser_gem/node_chainer.rb b/lib/solargraph/parser/parser_gem/node_chainer.rb index 813b9cba6..a3618d46f 100644 --- a/lib/solargraph/parser/parser_gem/node_chainer.rb +++ b/lib/solargraph/parser/parser_gem/node_chainer.rb @@ -53,13 +53,16 @@ def load_string code, filename, starting_line # @return [Array] def generate_links n return [] unless n.is_a?(::Parser::AST::Node) + # @sg-ignore Need to add nil check here return generate_links(n.children[0]) if n.type == :splat # @type [Array] result = [] if n.type == :block + # @sg-ignore Need to add nil check here result.concat NodeChainer.chain(n.children[0], @filename, n).links elsif n.type == :send if n.children[0].is_a?(::Parser::AST::Node) + # @sg-ignore Need to add nil check here result.concat generate_links(n.children[0]) result.push Chain::Call.new(n.children[1].to_s, Location.from_node(n), node_args(n), passed_block(n)) elsif n.children[0].nil? @@ -73,6 +76,7 @@ def generate_links n end elsif n.type == :csend if n.children[0].is_a?(::Parser::AST::Node) + # @sg-ignore Need to add nil check here result.concat generate_links(n.children[0]) result.push Chain::QCall.new(n.children[1].to_s, Location.from_node(n), node_args(n)) elsif n.children[0].nil? @@ -107,32 +111,45 @@ def generate_links n # s(:or_asgn, # s(:ivasgn, :@bar), # s(:int, 123)) + or_asgn_rhs_node = n.children[1] # s(:int, 123) + # @sg-ignore Wrong argument type for Solargraph::Parser::ParserGem::NodeChainer.chain: node expected Parser::AST::Node, received Parser::AST::Node, nil lhs_chain = NodeChainer.chain n.children[0] # s(:ivasgn, :@bar) - rhs_chain = NodeChainer.chain n.children[1] # s(:int, 123) - or_link = Chain::Or.new([lhs_chain, rhs_chain]) + # @sg-ignore Need to add nil check here + rhs_chain = NodeChainer.chain or_asgn_rhs_node + or_asgn_rhs_never_returns = always_leaves_compound_statement?(or_asgn_rhs_node) + or_link = Chain::Or.new([lhs_chain, rhs_chain], rhs_never_returns: or_asgn_rhs_never_returns) # this is just for a call chain, so we don't need to record the assignment result.push(or_link) elsif %i[class module def defs].include?(n.type) # @todo Undefined or what? result.push Chain::UNDEFINED_CALL elsif n.type == :and + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 result.concat generate_links(n.children.last) elsif n.type == :or - result.push Chain::Or.new([NodeChainer.chain(n.children[0], @filename), - NodeChainer.chain(n.children[1], @filename, n)]) + or_rhs_node = n.children[1] + # @sg-ignore Need to add nil check here + or_lhs_chain = NodeChainer.chain(n.children[0], @filename) + # @sg-ignore Need to add nil check here + or_rhs_chain = NodeChainer.chain(or_rhs_node, @filename, n) + or_rhs_never_returns = always_leaves_compound_statement?(or_rhs_node) + result.push Chain::Or.new([or_lhs_chain, or_rhs_chain], rhs_never_returns: or_rhs_never_returns) elsif n.type == :if then_clause = if n.children[1] + # @sg-ignore Need to add nil check here NodeChainer.chain(n.children[1], @filename, n) else Source::Chain.new([Source::Chain::Literal.new('nil', nil)], n) end else_clause = if n.children[2] + # @sg-ignore Need to add nil check here NodeChainer.chain(n.children[2], @filename, n) else Source::Chain.new([Source::Chain::Literal.new('nil', nil)], n) end result.push Chain::If.new([then_clause, else_clause]) elsif %i[begin kwbegin].include?(n.type) + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 result.concat generate_links(n.children.last) elsif n.type == :block_pass block_variable_name_node = n.children[0] @@ -153,7 +170,6 @@ def generate_links n else lit = infer_literal_node_type(n) result.push(lit ? Chain::Literal.new(lit, n) : Chain::Link.new) - # result.push Chain::Link.new end result end @@ -161,7 +177,9 @@ def generate_links n # @param node [Parser::AST::Node] def hash_is_splatted? node return false unless Parser.is_ast_node?(node) && node.type == :hash + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 return false unless Parser.is_ast_node?(node.children.last) && node.children.last.type == :kwsplat + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 if Parser.is_ast_node?(node.children.last.children[0]) && node.children.last.children[0].type == :hash return false end diff --git a/lib/solargraph/parser/parser_gem/node_methods.rb b/lib/solargraph/parser/parser_gem/node_methods.rb index 59f2f255c..011b1f397 100644 --- a/lib/solargraph/parser/parser_gem/node_methods.rb +++ b/lib/solargraph/parser/parser_gem/node_methods.rb @@ -31,6 +31,7 @@ def pack_name node parts += pack_name(n) end else + # @sg-ignore Wrong argument type for Array#push: objects expected String, received Parser::AST::Node parts.push n unless n.nil? end end @@ -88,6 +89,7 @@ def get_node_end_position node def drill_signature node, signature return signature unless node.is_a?(AST::Node) if %i[const cbase].include?(node.type) + # @sg-ignore Translate to something flow sensitive typing understands signature += drill_signature(node.children[0], signature) unless node.children[0].nil? signature += '::' unless signature.empty? signature += node.children[1].to_s @@ -95,6 +97,7 @@ def drill_signature node, signature signature += '.' unless signature.empty? signature += node.children[0].to_s elsif node.type == :send + # @sg-ignore Translate to something flow sensitive typing understands signature += drill_signature(node.children[0], signature) unless node.children[0].nil? signature += '.' unless signature.empty? signature += node.children[1].to_s @@ -105,8 +108,7 @@ def drill_signature node, signature # Convert a DSL method call argument with directly inferrable simple params. # @param node [Parser::AST::Node] # @return [String, Integer, Float, Symbol, Array, Hash, Source::Chain, nil] - # @sg-ignore "does not match inferred type ::String, ::Parser::AST::Node" - this probably comes from the - # `.children[0]` call, which is not recognized as returning a literal value. + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 def simple_convert node return nil unless Parser.is_ast_node?(node) @@ -139,6 +141,7 @@ def simple_convert_hash node # @param pair [Parser::AST::Node] node.children.each do |pair| next unless Parser.is_ast_node?(pair) && pair.children[0] + # @sg-ignore https://github.com/castwide/solargraph/issues/1251 result[pair.children[0].children[0]] = simple_convert(pair.children[1]) end result @@ -181,14 +184,47 @@ def const_nodes_from node result end + # Determines whether a clause necessarily leaves its + # enclosing compound statement (method body, block, etc.) - + # e.g., via an explicit 'return', a loop-control keyword, or + # a raise/fail call - so code that depends on execution + # actually reaching the statements after it can treat those + # statements as unreachable from this clause. + # + # @param clause_node [Parser::AST::Node, nil] + # @return [Boolean] + def always_leaves_compound_statement? clause_node + # https://docs.ruby-lang.org/en/2.2.0/keywords_rdoc.html + return true if %i[return next redo retry].include?(clause_node&.type) + return false if clause_node.nil? + + # A clause with more than one statement (e.g., a raise + # preceded by other statements building the error message) + # parses as a :begin node - only its last child determines + # whether the clause leaves. + return always_leaves_compound_statement?(clause_node.children.last) if clause_node.type == :begin + + return false unless clause_node.type == :send + + # Unlike return/next/redo/retry, `raise` and `fail` are plain + # method calls to the parser - `raise 'msg'` parses as + # s(:send, nil, :raise, s(:str, "msg")), not a dedicated node + # type - so they need to be recognized by shape instead of type. + clause_node.children[0].nil? && %i[raise fail].include?(clause_node.children[1]) + end + # @param node [Parser::AST::Node] + # @return [Boolean] + # @sg-ignore https://github.com/castwide/solargraph/issues/1251 def splatted_hash? node + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Parser.is_ast_node?(node.children[0]) && node.children[0].type == :kwsplat end # @param node [Parser::AST::Node] def splatted_call? node return false unless Parser.is_ast_node?(node) + # @sg-ignore https://github.com/castwide/solargraph/issues/1251 Parser.is_ast_node?(node.children[0]) && node.children[0].type == :kwsplat && node.children[0].children[0].type != :hash end @@ -205,6 +241,7 @@ def call_nodes_from node result = [] if node.type == :block result.push node + # @sg-ignore https://github.com/castwide/solargraph/issues/1251 if Parser.is_ast_node?(node.children[0]) && node.children[0].children.length > 2 # @sg-ignore Need to add nil check here node.children[0].children[2..].each { |child| result.concat call_nodes_from(child) } @@ -213,6 +250,7 @@ def call_nodes_from node node.children[1..].each { |child| result.concat call_nodes_from(child) } elsif node.type == :send result.push node + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 result.concat call_nodes_from(node.children.first) # @sg-ignore Need to add nil check here node.children[2..].each { |child| result.concat call_nodes_from(child) } @@ -341,6 +379,7 @@ def find_recipient_node_by_text source, offset name_start = idx + 1 return nil if name_start >= name_end method_name = code[name_start...name_end] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 return nil if method_name.empty? # Check for receiver pattern: receiver.method( or receiver::method( @@ -354,8 +393,11 @@ def find_recipient_node_by_text source, offset recv_start = idx + 1 if recv_start < recv_end recv_name = code[recv_start...recv_end] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 unless recv_name.empty? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 receiver_node = ::Parser::AST::Node.new(:send, [nil, recv_name.to_sym]) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 return ::Parser::AST::Node.new(:send, [receiver_node, method_name.to_sym]) end end @@ -364,13 +406,17 @@ def find_recipient_node_by_text source, offset const_start = const_end const_start -= 1 while const_start.positive? && code[const_start - 1] =~ /[a-zA-Z0-9_]/ const_name = code[const_start...const_end] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 unless const_name.empty? || method_name.empty? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 const_node = ::Parser::AST::Node.new(:const, [nil, const_name.to_sym]) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 return ::Parser::AST::Node.new(:send, [const_node, method_name.to_sym]) end end # Simple method call without receiver + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 ::Parser::AST::Node.new(:send, [nil, method_name.to_sym]) end @@ -442,6 +488,11 @@ class << self CONDITIONAL_ALL_BUT_FIRST = %i[if unless].freeze ONLY_ONE_CHILD = [:return].freeze FIRST_TWO_CHILDREN = [:rescue].freeze + # :ensure's value is always its body's value (first + # child); the ensure clause itself (second child) never + # contributes to the method's return value unless it + # explicitly returns. + ENSURE = [:ensure].freeze COMPOUND_STATEMENTS = %i[begin kwbegin].freeze SKIPPABLE = %i[def defs class sclass module].freeze FUNCTION_VALUE = [:block].freeze @@ -488,6 +539,12 @@ def from_value_position_statement node, include_explicit_returns: true result.concat reduce_to_value_nodes([node.children[0]]) elsif FIRST_TWO_CHILDREN.include?(node.type) result.concat reduce_to_value_nodes([node.children[0], node.children[1]]) + elsif ENSURE.include?(node.type) + result.concat reduce_to_value_nodes([node.children[0]]) + if include_explicit_returns + # @sg-ignore Need to add nil check here + result.concat explicit_return_values_from_compound_statement(node.children[1]) + end elsif FUNCTION_VALUE.include?(node.type) # the block itself is a first class value that could be returned result.push node @@ -495,6 +552,7 @@ def from_value_position_statement node, include_explicit_returns: true # scope in which the proc is run. This asssumes # that the function is executed here. if include_explicit_returns + # @sg-ignore Need to add nil check here result.concat explicit_return_values_from_compound_statement(node.children[2]) end elsif CASE_STATEMENT.include?(node.type) @@ -535,11 +593,14 @@ def from_value_position_compound_statement parent nodes = parent.children.select { |n| n.is_a?(AST::Node) } nodes.each_with_index do |node, idx| if node.type == :block + # @sg-ignore Need to add nil check here result.concat explicit_return_values_from_compound_statement(node.children[2]) elsif node.type == :rescue # body statements + # @sg-ignore Need to add nil check here result.concat from_value_position_statement(node.children[0]) # rescue statements + # @sg-ignore Need to add nil check here result.concat from_value_position_statement(node.children[1]) elsif SKIPPABLE.include?(node.type) next @@ -557,10 +618,10 @@ def from_value_position_compound_statement parent # value position. we already have the explicit values # from above; now we need to also gather the value # position nodes - if idx == nodes.length - 1 - result.concat from_value_position_statement(nodes.last, - include_explicit_returns: false) - end + next unless idx == nodes.length - 1 + # @sg-ignore https://github.com/castwide/solargraph/pull/1223 + result.concat from_value_position_statement(nodes.last, + include_explicit_returns: false) end result end @@ -598,28 +659,17 @@ def reduce_to_value_nodes nodes nodes.each do |node| if !node.is_a?(::Parser::AST::Node) result.push nil - # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif COMPOUND_STATEMENTS.include?(node.type) result.concat from_value_position_compound_statement(node) - # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif CONDITIONAL_ALL_BUT_FIRST.include?(node.type) - # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check + # @sg-ignore Wrong argument type for Solargraph::Parser::ParserGem::NodeMethods::DeepInference.reduce_to_value_nodes: nodes expected Enumerable, received Array

] + # @return [void] + def add_mlhs_locals callable, mlhs_node, path + mlhs_node.children.each_with_index do |child, i| + if child.type == :mlhs + add_mlhs_locals callable, child, path + [i] + else + loc = get_node_location(child) + locals.push Solargraph::Pin::Parameter.new( + location: loc, + closure: callable, + comments: comments_for(node), + name: child.children[0].to_s, + # @sg-ignore Need to add nil check here + presence: callable.location.range, + decl: :arg, + mlhs_path: path + [i], + source: :parser + ) + end + end + end end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb index 750bb9929..b89fd1f2d 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -11,6 +11,7 @@ def process location = get_node_location(node) scope = region.scope || region.closure.context.scope if other_class_eval? + # @sg-ignore Need to add nil check here clazz_name = unpack_name(node.children[0].children[0]) # instance variables should come from the Class type # - i.e., treated as class instance variables @@ -20,6 +21,11 @@ def process block_pin = Solargraph::Pin::Block.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, + # a block's body may execute zero or multiple times (e.g. + # Enumerable#each), so an assignment inside it is never + # guaranteed to have executed + conditional: true, node: node, context: context, receiver: node.children[0], @@ -28,14 +34,17 @@ def process source: :parser ) pins.push block_pin - process_children region.update(closure: block_pin) + process_children region.update(closure: block_pin, compound_statement: block_pin) end private def other_class_eval? + # @sg-ignore Need to add nil check here node.children[0].type == :send && + # @sg-ignore Need to add nil check here node.children[0].children[1] == :class_eval && + # @sg-ignore Need to add nil check here %i[cbase const].include?(node.children[0].children[0]&.type) end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/case_node.rb b/lib/solargraph/parser/parser_gem/node_processors/case_node.rb new file mode 100644 index 000000000..d7922e18f --- /dev/null +++ b/lib/solargraph/parser/parser_gem/node_processors/case_node.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Solargraph + module Parser + module ParserGem + module NodeProcessors + class CaseNode < Parser::NodeProcessor::Base + include ParserGem::NodeMethods + + def process + FlowSensitiveTyping.new(locals, + ivars, + enclosing_breakable_pin, + enclosing_compound_statement_pin, + region.closure).process_case(node) + process_children + true + end + end + end + end + end +end diff --git a/lib/solargraph/parser/parser_gem/node_processors/casgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/casgn_node.rb index acdfe3064..efd11dba6 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/casgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/casgn_node.rb @@ -24,6 +24,7 @@ def process # @return [String] def const_name if node.children[0] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Parser::NodeMethods.unpack_name(node.children[0]) + "::#{node.children[1]}" else node.children[1].to_s diff --git a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb index f45f5544d..b6e6137d6 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb @@ -15,6 +15,7 @@ def process methpin = Solargraph::Pin::Method.new( location: get_node_location(node), closure: region.closure, + compound_statement: region.compound_statement, name: name, context: method_context, comments: comments_for(node), @@ -51,7 +52,7 @@ def process else pins.push methpin end - process_children region.update(closure: methpin, scope: methpin.scope) + process_children region.update(closure: methpin, scope: methpin.scope, compound_statement: methpin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb index 09679c7f7..bf04f76f1 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/defs_node.rb @@ -11,10 +11,12 @@ def process s_visi = region.visibility s_visi = :public if s_visi == :module_function || region.scope != :class loc = get_node_location(node) + # @sg-ignore Need to add nil check here closure = if node.children[0].is_a?(AST::Node) && node.children[0].type == :self region.closure else Solargraph::Pin::Namespace.new( + # @sg-ignore Need to add nil check here name: unpack_name(node.children[0]), source: :parser ) @@ -22,6 +24,7 @@ def process pins.push Solargraph::Pin::Method.new( location: loc, closure: closure, + compound_statement: region.compound_statement, name: node.children[1].to_s, comments: comments_for(node), scope: :class, @@ -29,7 +32,7 @@ def process node: node, source: :parser ) - process_children region.update(closure: pins.last, scope: :class) + process_children region.update(closure: pins.last, scope: :class, compound_statement: pins.last) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb index 0b9a75e77..dc1a25c13 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -8,40 +8,51 @@ class IfNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - FlowSensitiveTyping.new(locals, - ivars, - enclosing_breakable_pin, - enclosing_compound_statement_pin).process_if(node) condition_node = node.children[0] if condition_node pins.push Solargraph::Pin::CompoundStatement.new( location: get_node_location(condition_node), closure: region.closure, + compound_statement: region.compound_statement, node: condition_node, source: :parser ) NodeProcessor.process(condition_node, region, pins, locals, ivars) end + # after the condition, so that a variable the condition + # itself assigns (`if (md = foo.match(...))`) already has a + # pin to assert facts about + FlowSensitiveTyping.new(locals, + ivars, + enclosing_breakable_pin, + enclosing_compound_statement_pin, + region.closure).process_if(node) then_node = node.children[1] if then_node - pins.push Solargraph::Pin::CompoundStatement.new( + then_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(then_node), closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, node: then_node, source: :parser ) - NodeProcessor.process(then_node, region, pins, locals, ivars) + pins.push then_cs + NodeProcessor.process(then_node, region.update(compound_statement: then_cs), pins, locals, ivars) end else_node = node.children[2] if else_node - pins.push Solargraph::Pin::CompoundStatement.new( + else_cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(else_node), closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, node: else_node, source: :parser ) - NodeProcessor.process(else_node, region, pins, locals, ivars) + pins.push else_cs + NodeProcessor.process(else_node, region.update(compound_statement: else_cs), pins, locals, ivars) end true diff --git a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb index 63e2c55dc..33c429a93 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -19,6 +19,8 @@ def process assignment: node.children[1], comments: comments_for(node), presence: presence, + definite: !region.compound_statement.conditional, + compound_statement: region.compound_statement, source: :parser ) process_children diff --git a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb index 0acbf7ee0..07c545c60 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb @@ -8,6 +8,7 @@ class NamespaceNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process + # @sg-ignore Need to add nil check here name = unpack_name(node.children[0]) comments = comments_for(node) @@ -20,6 +21,7 @@ def process type: node.type, location: loc, closure: region.closure, + compound_statement: region.compound_statement, name: name, comments: comments, visibility: :public, @@ -27,7 +29,6 @@ def process source: :parser ) pins.push nspin - Solargraph.logger.warn "Superclass: #{superclass_name}" if superclass_name&.start_with?('Array') if superclass_name pins.push Pin::Reference::Superclass.new( location: loc, @@ -36,25 +37,39 @@ def process source: :parser ) end - process_children region.update(closure: nspin, visibility: :public) + process_children region.update(closure: nspin, visibility: :public, compound_statement: nspin) end private - # @param comments [String] + # Type arguments for a generic superclass in inline RBS syntax, e.g., + # `class Foo < Array #[String]`. Only recognized where RBS defines + # it: directly after the superclass, on the same line, with no space + # between `#` and `[`. Anything else is an ordinary comment. + # # @return [String, nil] def parameters_from_inline_rbs - source = region.source.code_for(node) - match = source.match(/[^\n]*?#\s?+\[([^\]]*)/) - return unless match && match[1] + superclass = node.children[1] + return unless superclass + source = region.source.code + pos = get_node_end_position(superclass) + offset = Position.line_char_to_offset(source, pos.line, pos.character) + eol = source.index("\n", offset) || source.length + match = source[offset...eol].to_s.match(/\A\s*#\[([^\]]*)\]/) + return unless match + + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 code = match[1].strip + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 return if code.empty? "<#{code}>" end + # @return [String, nil] def type_from_node + # @sg-ignore Need to add nil check here unpack_name(node.children[1]) if node.children[1]&.type == :const end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb index aab8106aa..cdecb6ffe 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb @@ -12,14 +12,17 @@ def process target = node.children[0] operator = node.children[1] argument = node.children[2] + # @sg-ignore Need to add nil check here if target.type == :send # @sg-ignore Need a downcast here process_send_target(target, operator, argument) + # @sg-ignore Need to add nil check here elsif target.type.to_s.end_with?('vasgn') # @sg-ignore Need a downcast here process_vasgn_target(target, operator, argument) else Solargraph.assert_or_log(:opasgn_unknown_target, + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 "Unexpected op_asgn target type: #{target.type}") end end @@ -71,6 +74,7 @@ def process_vasgn_target asgn, operator, argument # :+, # operator # s(:int, 2)) # argument + # @sg-ignore Need to add nil check here # @type [Parser::AST::Node] variable_name = asgn.children[0] # for lvasgn, gvasgn, cvasgn, convert to lvar, gvar, cvar diff --git a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb index 6c54f1c8c..4453a39b9 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -8,12 +8,26 @@ class OrNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - process_children + # the rhs of `a || b` only executes if `a` is falsy, so + # any assignment there isn't guaranteed to have executed + lhs, rhs = node.children + NodeProcessor.process(lhs, region, pins, locals, ivars) + # not pushed onto `pins` - see resbody_node.rb for why + rhs_cs = Solargraph::Pin::CompoundStatement.new( + location: get_node_location(rhs), + closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, + node: rhs, + source: :parser + ) + NodeProcessor.process(rhs, region.update(compound_statement: rhs_cs), pins, locals, ivars) FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_or(node) + enclosing_compound_statement_pin, + region.closure).process_or(node) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 17480adfb..687c5d24d 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -5,10 +5,33 @@ module Parser module ParserGem module NodeProcessors class OrasgnNode < Parser::NodeProcessor::Base + include ParserGem::NodeMethods + # @return [void] def process + closure_location = region.closure.location + if closure_location + here = get_node_start_position(node) + presence = Range.new(here, closure_location.range.ending) + FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, + enclosing_compound_statement_pin, region.closure).process_or_asgn(node, presence) + end + + # @sg-ignore Need to add nil check here new_node = node.updated(node.children[0].type, node.children[0].children + [node.children[1]]) - NodeProcessor.process(new_node, region, pins, locals, ivars) + # `x ||= y` only assigns when x is falsy/undefined, so + # it's never a guaranteed override of x's prior type + # + # not pushed onto `pins` - see resbody_node.rb for why + asgn_cs = Solargraph::Pin::CompoundStatement.new( + location: get_node_location(node), + closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, + node: node, + source: :parser + ) + NodeProcessor.process(new_node, region.update(compound_statement: asgn_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 24846748f..bdf5b3b7a 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -10,13 +10,16 @@ class ResbodyNode < Parser::NodeProcessor::Base # @return [void] def process if node.children[1] # Exception local variable name + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 here = get_node_start_position(node.children[1]) # @sg-ignore Need to add nil check here presence = Range.new(here, region.closure.location.range.ending) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 loc = get_node_location(node.children[1]) types = if node.children[0].nil? ['Exception'] else + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 node.children[0].children.map do |child| unpack_name(child) end @@ -24,13 +27,27 @@ def process locals.push Solargraph::Pin::LocalVariable.new( location: loc, closure: region.closure, + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 name: node.children[1].children[0].to_s, comments: "@type [#{types.join(',')}]", presence: presence, source: :parser ) end - NodeProcessor.process(node.children[2], region, pins, locals, ivars) + # not pushed onto `pins` - and/or/orasgn/resbody bodies are + # too common to warrant a pin per occurrence, so only the + # pointer is needed for the compound_statement chain + rescue_body_cs = Solargraph::Pin::CompoundStatement.new( + # @sg-ignore Need to add nil check here + location: node.children[2] ? get_node_location(node.children[2]) : nil, + closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, + node: node.children[2], + source: :parser + ) + # @sg-ignore Need to add nil check here + NodeProcessor.process(node.children[2], region.update(compound_statement: rescue_body_cs), pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb b/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb index 2d3d967cc..a7aa71796 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb @@ -24,6 +24,7 @@ def process if sclass.children[0].nil? && names.last != sclass.children[1].to_s names << sclass.children[1].to_s else + # @sg-ignore https://github.com/castwide/solargraph/issues/1251 names.push NodeMethods.unpack_name(sclass.children[0]), sclass.children[1].to_s end name = names.reject(&:empty?).join('::') diff --git a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb index a9e60cb65..2926af408 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb @@ -38,6 +38,7 @@ def process process_autoload elsif method_name == :private_constant process_private_constant + # @sg-ignore Need to add nil check here elsif method_name == :alias_method && node.children[2] && node.children[2] && node.children[2].type == :sym && node.children[3] && node.children[3].type == :sym process_alias_method elsif method_name == :private_class_method && node.children[2].is_a?(AST::Node) @@ -129,6 +130,7 @@ def process_attribute # @return [void] def process_include + # @sg-ignore Need to add nil check here return unless node.children[2].is_a?(AST::Node) && node.children[2].type == :const cp = region.closure # @sg-ignore Need to add nil check here @@ -145,6 +147,7 @@ def process_include # @return [void] def process_prepend + # @sg-ignore Need to add nil check here return unless node.children[2].is_a?(AST::Node) && node.children[2].type == :const cp = region.closure # @sg-ignore Need to add nil check here @@ -183,14 +186,18 @@ def process_extend # @return [void] def process_require + # @sg-ignore Need to add nil check here return unless node.children[2].is_a?(AST::Node) && node.children[2].type == :str + # @sg-ignore Need to add nil check here path = node.children[2].children[0].to_s pins.push Pin::Reference::Require.new(get_node_location(node), path, source: :parser) end # @return [void] def process_autoload + # @sg-ignore Need to add nil check here return unless node.children[3].is_a?(AST::Node) && node.children[3].type == :str + # @sg-ignore Need to add nil check here path = node.children[3].children[0].to_s pins.push Pin::Reference::Require.new(get_node_location(node), path, source: :parser) end @@ -200,6 +207,7 @@ def process_module_function if node.children[2].nil? # @todo Smelly instance variable access region.instance_variable_set(:@visibility, :module_function) + # @sg-ignore Need to add nil check here elsif %i[sym str].include?(node.children[2].type) # @sg-ignore Need to add nil check here node.children[2..].each do |x| @@ -251,14 +259,18 @@ def process_module_function ) end end + # @sg-ignore Need to add nil check here elsif node.children[2].type == :def + # @sg-ignore Need to add nil check here NodeProcessor.process node.children[2], region.update(visibility: :module_function), pins, locals, ivars end end # @return [void] def process_private_constant + # @sg-ignore Need to add nil check here return unless node.children[2] && %i[sym str].include?(node.children[2].type) + # @sg-ignore Need to add nil check here cn = node.children[2].children[0].to_s ref = pins.select do |p| [Solargraph::Pin::Namespace, @@ -274,7 +286,9 @@ def process_alias_method pins.push Solargraph::Pin::MethodAlias.new( location: get_node_location(node), closure: region.closure, + # @sg-ignore Need to add nil check here name: node.children[2].children[0].to_s, + # @sg-ignore Need to add nil check here original: node.children[3].children[0].to_s, scope: region.scope || :instance, source: :parser @@ -283,8 +297,10 @@ def process_alias_method # @return [Boolean] def process_private_class_method + # @sg-ignore Need to add nil check here if %i[sym str].include?(node.children[2].type) ref = pins.select do |p| + # @sg-ignore Need to add nil check here p.is_a?(Pin::Method) && p.namespace == region.closure.full_context.namespace && p.name == node.children[2].children[0].to_s end.first # HACK: Smelly instance variable access diff --git a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb index 2e091f41d..f345e0095 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/until_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/until_node.rb @@ -13,14 +13,17 @@ def process # until statement doesn't create a closure - e.g., # variables created inside can be seen from outside as # well - pins.push Solargraph::Pin::Until.new( + until_pin = Solargraph::Pin::Until.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, node: node, comments: comments_for(node), source: :parser ) - process_children region + pins.push until_pin + process_children region.update(compound_statement: until_pin) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb index 915eb57e6..144220d48 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -8,13 +8,16 @@ class WhenNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - pins.push Solargraph::Pin::CompoundStatement.new( + cs = Solargraph::Pin::CompoundStatement.new( location: get_node_location(node), closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, node: node, source: :parser ) - process_children + pins.push cs + process_children region.update(compound_statement: cs) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb index 6c4fe33d8..326b4a19d 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -11,20 +11,24 @@ def process FlowSensitiveTyping.new(locals, ivars, enclosing_breakable_pin, - enclosing_compound_statement_pin).process_while(node) + enclosing_compound_statement_pin, + region.closure).process_while(node) # Note - this should not be considered a block, as the # while statement doesn't create a closure - e.g., # variables created inside can be seen from outside as # well - pins.push Solargraph::Pin::While.new( + while_pin = Solargraph::Pin::While.new( location: location, closure: region.closure, + compound_statement: region.compound_statement, + conditional: true, node: node, comments: comments_for(node), source: :parser ) - process_children region + pins.push while_pin + process_children region.update(compound_statement: while_pin) end end end diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index 8c4caf6ac..7ab8ca1b9 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -10,6 +10,7 @@ class Region attr_reader :closure # @return [Symbol] + # @sg-ignore Need to add nil check here attr_reader :scope # @return [Symbol] @@ -21,15 +22,29 @@ class Region # @return [Array] attr_reader :lvars + # The nearest enclosing CompoundStatement pin (an if/when/while/ + # rescue/&&/||/||= body, a method/block body, or a namespace + # body) - a series of statements/expressions where a later one + # executing implies the earlier ones in the same series + # executed too. Every Closure is also a CompoundStatement, so + # this is a superset of the `closure` chain: it additionally + # includes branch bodies that aren't scopes. + # + # @return [Pin::CompoundStatement] + attr_reader :compound_statement + # @param source [Source] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] # @param lvars [Array] + # @param compound_statement [Pin::CompoundStatement, nil] def initialize source: Solargraph::Source.load_string(''), closure: nil, - scope: nil, visibility: :public, lvars: [] + scope: nil, visibility: :public, lvars: [], + compound_statement: nil @source = source @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) + @compound_statement = compound_statement || @closure @scope = scope @visibility = visibility @lvars = lvars @@ -43,7 +58,6 @@ def filename # @return [Pin::Namespace, nil] def namespace_pin ns = closure - # @sg-ignore flow sensitive typing needs to handle while ns = ns.closure while ns && !ns.is_a?(Pin::Namespace) ns end @@ -54,14 +68,17 @@ def namespace_pin # @param scope [Symbol, nil] # @param visibility [Symbol, nil] # @param lvars [Array, nil] + # @param compound_statement [Pin::CompoundStatement, nil] # @return [Region] - def update closure: nil, scope: nil, visibility: nil, lvars: nil + def update closure: nil, scope: nil, visibility: nil, lvars: nil, + compound_statement: nil Region.new( source: source, closure: closure || self.closure, scope: scope || self.scope, visibility: visibility || self.visibility, - lvars: lvars || self.lvars + lvars: lvars || self.lvars, + compound_statement: compound_statement || self.compound_statement ) end diff --git a/lib/solargraph/pin/base.rb b/lib/solargraph/pin/base.rb index f7ae58d38..2cee2fc43 100644 --- a/lib/solargraph/pin/base.rb +++ b/lib/solargraph/pin/base.rb @@ -23,9 +23,10 @@ class Base attr_reader :name # @return [String] + # @sg-ignore Need to add nil check here attr_reader :path - # @return [::Symbol] + # @return [::Symbol, nil] attr_accessor :source # @type [::Numeric, nil] A priority for determining if pins should be combined or not @@ -42,7 +43,7 @@ def presence_certain? # @param closure [Solargraph::Pin::Closure, nil] # @param name [String] # @param comments [String, nil] - # @param source [Symbol, nil] + # @param source [::Symbol, nil] # @param docstring [YARD::Docstring, nil] # @param directives [::Array, nil] # @param combine_priority [::Numeric, nil] See attr_reader for combine_priority @@ -75,6 +76,7 @@ def assert_location_provided # @return [Pin::Closure, nil] def closure + @closure ||= derive_closure_from_compound_statement unless @closure Solargraph.assert_or_log(:closure, "Closure not set on #{self.class} #{name.inspect} from #{source.inspect}") @@ -305,7 +307,6 @@ def assert_same_array_content(other, attr, &) values1 = arr1.map(&) # @type [undefined] values2 = arr2.map(&) - # @sg-ignore return arr1 if values1 == values2 Solargraph.assert_or_log(:"combine_with_#{attr}", "Inconsistent #{attr.inspect} values between \nself =#{inspect} and \nother=#{other.inspect}:\n\n self values = #{values1}\nother values =#{attr} = #{values2}") @@ -452,7 +453,6 @@ def erase_generics generics_to_erase # @return [String, nil] def filename return nil if location.nil? - # @sg-ignore flow sensitive typing needs to handle attrs location.filename end @@ -490,7 +490,7 @@ def nearly? other instance_of?(other.class) && # @sg-ignore Translate to something flow sensitive typing understands name == other.name && - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore https://github.com/castwide/solargraph/issues/1249 (closure.equal?(other.closure) || (closure&.nearly?(other.closure))) && # @sg-ignore Translate to something flow sensitive typing understands (comments == other.comments || @@ -539,6 +539,7 @@ def macros @macros ||= collect_macros end + # @return [Array] def macro_names parse_comments unless @macro_names @macro_names ||= collect_macro_names @@ -645,7 +646,15 @@ def proxy return_type # @deprecated # @return [String] def identity - @identity ||= "#{closure&.path}|#{name}|#{location}" + # Include presence (when available) alongside location: a merged + # multi-assignment variable pin and its earliest constituent + # assignment pin share the same #choose-d (earliest) location, but + # differ in presence, so this keeps Chain's recursion guard from + # conflating "resolving the merged pin" with "resolving one of its + # narrower assignments" and dropping a legitimate recursive lookup. + # @sg-ignore Unresolved call to presence + presence_fragment = respond_to?(:presence) ? presence&.inspect : nil + @identity ||= "#{closure&.path}|#{name}|#{location}|#{presence_fragment}" end # The namespaces available for resolving the current namespace. Each gate @@ -731,6 +740,25 @@ def equality_fields private + # Fallback for pins with no directly-assigned @closure: walk the + # CompoundStatement parent chain (present only on + # CompoundStatement-family pins - Closure, While, Until, etc.) + # until an ancestor is_a?(Closure). Every pin built through + # Region-threaded node processors already gets an explicit + # closure:, so this only matters for a pin constructed purely + # from a compound_statement chain with no closure: override. + # + # @return [Pin::Closure, nil] + def derive_closure_from_compound_statement + return nil unless is_a?(CompoundStatement) + + # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard + cs = compound_statement + # @sg-ignore flow sensitive typing doesn't narrow self past an is_a? guard + cs = cs.compound_statement while cs && !cs.is_a?(Closure) + cs + end + # @return [void] def parse_comments # HACK: Avoid a NoMethodError on nil with empty overload tags @@ -757,6 +785,7 @@ def parse_comments def compare_docstring_tags docstring1, docstring2 return false if docstring1.tags.length != docstring2.tags.length docstring1.tags.each_index do |i| + # @sg-ignore Need to add nil check here return false unless compare_tags(docstring1.tags[i], docstring2.tags[i]) end true @@ -768,6 +797,7 @@ def compare_docstring_tags docstring1, docstring2 def compare_directives dir1, dir2 return false if dir1.length != dir2.length dir1.each_index do |i| + # @sg-ignore Need to add nil check here return false unless compare_tags(dir1[i].tag, dir2[i].tag) end true @@ -793,6 +823,7 @@ def collect_macros end end + # @return [Array] def collect_macro_names "#{comments}\n".scan(/\s*?@macro +(\S+).*?[\n]/).map { |match| match[0] } end diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index c7945e599..4b465f7f2 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -14,6 +14,17 @@ class BaseVariable < Base # @return [Range, nil] attr_reader :presence + # @return [Boolean] + attr_reader :definite + + # The CompoundStatement pin this variable's (re)assignment was + # made within - i.e. Region#compound_statement at the point of + # assignment. Used by #definite_reaches? to decide whether a + # non-definite assignment still dominates a given reference. + # + # @return [Pin::CompoundStatement, nil] + attr_reader :compound_statement + # @param return_type [ComplexType, nil] # @param assignment [Parser::AST::Node, nil] First assignment # that was made to this variable @@ -31,46 +42,65 @@ class BaseVariable < Base # Example: If a return type is 'Float | Integer | nil' and the # exclude_return_type is 'Integer', the resulting return # type will be 'Float | nil' because Integer is excluded. - # @param intersection_return_type [ComplexType, nil] Ensure each unique + # @param narrowed_return_type [ComplexType, nil] Ensure each unique # return type is compatible with at least one element of this # complex type. If a ComplexType used as a return type is an - # union type - we can return any of these - these are - # intersection types - everything we return needs to meet at least - # one of these unique types. + # union type - we can return any of these - this is a + # flow-sensitive narrowing (e.g. from an `is_a?` guard), not a + # real intersection type (see + # ComplexType::UniqueType::Intersection for that) - everything + # we return needs to meet at least one of these unique types. # # Example: If a return type is 'Numeric | nil' and the - # intersection_return_type is 'Float | nil', the resulting return + # narrowed_return_type is 'Float | nil', the resulting return # type will be 'Float | nil' because Float is compatible # with Numeric and nil is compatible with nil. # @see https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types - # @see https://en.wikipedia.org/wiki/Intersection_type#TypeScript_example + # @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html # @param presence [Range, nil] + # @param definite [Boolean] True if this pin's assignment(s) are + # guaranteed to have executed at (and after) its presence's + # start position, as opposed to being inside a conditional + # branch or loop that may not run. Used to decide whether a + # reassignment's type may safely override a variable's + # previously declared/inferred type instead of merely being + # unioned with it. + # @param compound_statement [Pin::CompoundStatement, nil] The + # CompoundStatement this variable's (re)assignment was made + # within. When `definite` is false, a reference whose location + # falls within this pin's own range may still treat the + # assignment as an override rather than merely unioning it + # with earlier possible types - see #definite_reaches?. # @param [Hash{Symbol => Object}] splat def initialize assignment: nil, assignments: [], mass_assignment: nil, presence: nil, return_type: nil, - intersection_return_type: nil, exclude_return_type: nil, + narrowed_return_type: nil, exclude_return_type: nil, + definite: true, + compound_statement: nil, **splat super(**splat) @assignments = (assignment.nil? ? [] : [assignment]) + assignments # @type [nil, ::Array(Parser::AST::Node, Integer)] @mass_assignment = mass_assignment @return_type = return_type - @intersection_return_type = intersection_return_type + @narrowed_return_type = narrowed_return_type @exclude_return_type = exclude_return_type @presence = presence + @definite = definite + @compound_statement = compound_statement end # @param presence [Range] # @param exclude_return_type [ComplexType, nil] - # @param intersection_return_type [ComplexType, nil] - # @param source [::Symbol] + # @param narrowed_return_type [ComplexType, nil] + # @param source [::Symbol, nil] # # @return [self] - def downcast presence:, exclude_return_type: nil, intersection_return_type: nil, + def downcast presence:, exclude_return_type: nil, narrowed_return_type: nil, source: self.source result = dup result.exclude_return_type = exclude_return_type - result.intersection_return_type = intersection_return_type + result.narrowed_return_type = narrowed_return_type result.source = source result.presence = presence result.reset_generated! @@ -82,20 +112,55 @@ def reset_generated! super end - def combine_with other, attrs = {} - new_assignments = combine_assignments(other) + # @param other [self] + # @param attrs [Hash] + # @param location [Location, nil] The position being resolved, + # if known - used to decide whether a not-globally-definite + # `other` should still override us because the position falls + # within `other`'s compound_statement. + def combine_with other, attrs = {}, location: nil + superseded = override_assignments?(other, location) + # Facts expire only when a *different* assignment overwrote the + # value they describe. Two flow-sensitive downcasts of the same + # assignment - e.g. the one per operand that `a.nil? || + # a.empty? || a == 'x'` produces - are additional facts about + # one value, and must accumulate rather than replace each other. + facts_superseded = superseded && !same_assignment_sites?(other) + new_assignments = combine_assignments(other, location) new_attrs = attrs.merge({ # default values don't exist in RBS parameters; it just # tells you if the arg is optional or not. Prefer a # provided value if we have one here since we can't rely on # it from RBS so we can infer from it and typecheck on it. - assignment: choose(other, :assignment), + # + # When #combine_assignments supersedes rather than unions, + # skip this - the constructor prepends `assignment:` to + # `assignments:` unconditionally, which would re-introduce + # the dropped node. + assignment: superseded ? nil : choose(other, :assignment), assignments: new_assignments, mass_assignment: combine_mass_assignment(other), return_type: combine_return_type(other), - intersection_return_type: combine_types(other, :intersection_return_type), - exclude_return_type: combine_types(other, :exclude_return_type), - presence: combine_presence(other) + # Narrowing recorded against the old value expires + # when that value is definitely overwritten, so when + # `other`'s assignment supersedes ours, keep only the + # facts asserted about the new value. + narrowed_return_type: if facts_superseded + other.narrowed_return_type + else + combine_types(other, :narrowed_return_type) + end, + exclude_return_type: if facts_superseded + other.exclude_return_type + else + combine_types(other, :exclude_return_type) + end, + presence: combine_presence(other), + # if either side had an assignment guaranteed to + # have executed, that assignment's type is + # eligible to override (not just be unioned + # with) the variable's other possible types + definite: definite || other.definite || superseded }) super(other, new_attrs) end @@ -109,6 +174,22 @@ def combine_mass_assignment other mass_assignment || other.mass_assignment end + # True when `other`'s assignments are the very same ones as ours, + # identified by source position. Structural node equality is not + # usable here - two textually identical assignments on different + # lines compare equal, and telling those apart is the whole point. + # + # @param other [self] + # @return [Boolean] + def same_assignment_sites? other + assignment_sites == other.assignment_sites + end + + # @return [::Array] + def assignment_sites + assignments.map { |node| Solargraph::Range.from_node(node) } + end + # @return [Parser::AST::Node, nil] def assignment @assignment ||= assignments.last @@ -116,14 +197,18 @@ def assignment # @param other [self] # + # @param other [self] + # @param location [Location, nil] # @return [::Array] - def combine_assignments other + def combine_assignments other, location = nil + return other.assignments.dup if override_assignments?(other, location) + (other.assignments + assignments).uniq end def inner_desc super + ", presence=#{presence.inspect}, assignments=#{assignments}, " \ - "intersection_return_type=#{intersection_return_type&.rooted_tags.inspect}, " \ + "narrowed_return_type=#{narrowed_return_type&.rooted_tags.inspect}, " \ "exclude_return_type=#{exclude_return_type&.rooted_tags.inspect}" end @@ -164,8 +249,16 @@ def return_types_from_node parent_node, api_map # Use the return node for inference. The clip might infer from the # first node in a method call instead of the entire call. chain = Parser.chain(node, nil, nil) + # Exclude the pin(s) this exact assignment belongs to from the + # candidates available to resolve its own RHS - a self-reference + # (e.g. `a = a`, or `index += 1` desugared to `index = index + + # 1`) must resolve against the variable's *other* assignments, + # not against the not-yet-computed value being derived here. + self_excluded_locals = clip.locals.reject do |candidate| + candidate.respond_to?(:assignments) && candidate.assignments.include?(parent_node) + end # @sg-ignore Need to add nil check here - result = chain.infer(api_map, closure, clip.locals).self_to_type(closure.context) + result = chain.infer(api_map, closure, self_excluded_locals).self_to_type(closure.context) types.push result unless result.undefined? end end @@ -176,7 +269,14 @@ def return_types_from_node parent_node, api_map # @return [ComplexType, ComplexType::UniqueType] def probe api_map assignment_types = assignments.flat_map { |node| return_types_from_node(node, api_map) } - type_from_assignment = ComplexType.new(assignment_types.flat_map(&:items).uniq) unless assignment_types.empty? + unless assignment_types.empty? + # @type [Array] + items = assignment_types.flat_map(&:items).uniq + # A later, wider assignment (e.g. `index += 1`) can leave a + # stale literal (e.g. `0`) alongside its own non-literal + # base type in the union - drop the redundant literal. + type_from_assignment = ComplexType.new(items).without_redundant_literals + end return adjust_type api_map, type_from_assignment unless type_from_assignment.nil? # @todo should handle merging types from mass assignments as @@ -205,7 +305,21 @@ def probe api_map def == other return false unless super # @sg-ignore Should add type check on other - assignment == other.assignment + return false unless assignment == other.assignment + # combine_with results choose the earliest assignment's + # #location, so two combined pins covering a different number + # of assignments to the same variable can share #location while + # covering different #presence ranges - e.g. one pin combined + # through a variable's first reassignment, another combined + # through its second. Base#== doesn't compare presence, so + # without this check those pins looked identical to any caller + # keying off of #== (e.g. Array#include?). + # @sg-ignore Should add type check on other + presence == other.presence && + # @sg-ignore Should add type check on other + narrowed_return_type == other.narrowed_return_type && + # @sg-ignore Should add type check on other + exclude_return_type == other.exclude_return_type end def type_desc @@ -214,7 +328,7 @@ def type_desc # @return [ComplexType, nil] def return_type - generate_complex_type || @return_type || intersection_return_type || ComplexType::UNDEFINED + generate_complex_type || @return_type || narrowed_return_type || ComplexType::UNDEFINED end def typify api_map @@ -225,15 +339,13 @@ def typify api_map # @sg-ignore need boolish support for ? methods def presence_certain? - exclude_return_type || intersection_return_type + exclude_return_type || narrowed_return_type end # @param other_loc [Location] - # @sg-ignore flow sensitive typing needs to handle attrs def starts_at? other_loc location&.filename == other_loc.filename && presence && - # @sg-ignore flow sensitive typing needs to handle attrs presence.start == other_loc.range.start end @@ -245,7 +357,7 @@ def starts_at? other_loc def combine_presence other return presence || other.presence if presence.nil? || other.presence.nil? - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore https://github.com/castwide/solargraph/issues/1249 Range.new([presence.start, other.presence.start].max, [presence.ending, other.presence.ending].min) end @@ -263,14 +375,11 @@ def combine_closure other return closure || other.closure end - # @sg-ignore flow sensitive typing needs to handle attrs if closure.location.nil? || other.closure.location.nil? - # @sg-ignore flow sensitive typing needs to handle attrs return closure.location.nil? ? other.closure : closure end # if filenames are different, this will just pick one - # @sg-ignore flow sensitive typing needs to handle attrs return closure if closure.location <= other.closure.location other.closure @@ -279,22 +388,143 @@ def combine_closure other # @param other_closure [Pin::Closure] # @param other_loc [Location] def visible_at? other_closure, other_loc - # @sg-ignore flow sensitive typing needs to handle attrs + # @sg-ignore https://github.com/castwide/solargraph/issues/1249 location.filename == other_loc.filename && - # @sg-ignore flow sensitive typing needs to handle attrs (!presence || presence.include?(other_loc.range.start)) && + !within_own_assignment?(other_loc) && visible_in_closure?(other_closure) end protected - attr_accessor :exclude_return_type, :intersection_return_type + attr_accessor :exclude_return_type, :narrowed_return_type # @return [Range] + # @sg-ignore Need to add nil check here attr_writer :presence + # Flow-sensitive typing downcasts a variable/call pin into multiple + # copies that otherwise share name/location/closure/source (see + # FlowSensitiveTyping#add_downcast_var) -- they must stay distinct + # by presence and narrowed type, or callers that key off of pin + # equality (e.g. Chain's inference cache) can conflate two pins + # that carry different narrowing facts. + # + # @return [::Array] + def equality_fields + super + [presence, narrowed_return_type, exclude_return_type] + end + + public + + # True if `other_loc` falls inside the source range of one of this + # pin's own assignment value nodes - i.e., `other_loc` is + # resolving a reference that occurs *while* one of this + # variable's own assignments is still being evaluated, such as + # the receiver `x` in a self-referential reassignment (`x = + # x.length`, or `index += 1` desugared to `index = index + 1`). + # That reference must resolve against this variable's *other* + # assignments, not against the not-yet-assigned value being + # derived here, even though `other_loc` otherwise falls within + # this pin's presence. + # + # @param other_loc [Location] + # @return [Boolean] + def within_own_assignment? other_loc + return false unless location&.filename == other_loc.filename + + assignments.any? do |assignment_node| + next false unless assignment_node.respond_to?(:loc) + + rng = Range.from_node(assignment_node) + next false if rng.nil? + + # The position immediately at/after the assignment node's own + # end is where its new value becomes visible - only exclude + # positions strictly *inside* the node (i.e. still being + # evaluated), not that boundary itself. + rng.contain?(other_loc.range.start) && other_loc.range.start != rng.ending + end + end + private + # True if `other`'s assignment(s) should supersede ours + # instead of merely being unioned with them: `other` reassigns + # the same variable, in the same scope, via an assignment + # guaranteed to have executed, so by the time `other`'s + # presence begins our value has definitely been overwritten. + # + # Excludes self-referential reassignments (`x = x.foo`, + # desugared `+=`, etc.) - resolving their right-hand side needs + # our assignment(s) as the base case, so dropping them would + # leave nothing to resolve against. + # + # @param other [self] + # @param location [Location, nil] The position being resolved, + # if known - lets a conditional `other` still override us when + # `location` falls within `other`'s compound_statement. + # @return [Boolean] + def override_assignments? other, location = nil + (other.definite || other.definite_reaches?(location)) && other.closure == closure && + other.assignments.none? { |node| references_name?(node) } + end + + public + + # True if this pin's assignment, though not globally definite, + # is still guaranteed to dominate `location` - i.e., `location` + # falls within the CompoundStatement body (an if/while/until/ + # rescue/&&/||/||= branch) this assignment was made in, so no + # earlier branch exit could have skipped it by the time + # `location` is reached. A nested CompoundStatement's location + # is always a subrange of its parent's, so this single + # containment check already accounts for arbitrarily nested + # branches without walking the compound_statement chain further. + # + # @param location [Location, nil] + # @return [Boolean] + def definite_reaches? location + cs = compound_statement + return false unless location && cs&.location&.range + + # @sg-ignore flow sensitive typing needs to handle attrs + cs.location.filename == location.filename && + # @sg-ignore flow sensitive typing needs to handle attrs + cs.location.range.contain?(location.range.start) + end + + private + + # @param node [Parser::AST::Node, nil] + # @return [Boolean] + def references_name? node + return false unless node.is_a?(::AST::Node) + + return true if %i[lvar ivar].include?(node.type) && node.children[0].to_s == name + + # A block parameter of the same name shadows us for the whole + # block, so any mention inside the body is the parameter, not + # this variable. The receiver (children[0]) is evaluated + # outside the block, so it still counts. + return references_name?(node.children[0]) if shadowed_by_block_parameter?(node) + + node.children.any? { |child| references_name?(child) } + end + + # @param node [::AST::Node] + # @return [Boolean] + def shadowed_by_block_parameter? node + return false unless node.type == :block + + args = node.children[1] + return false unless args.is_a?(::AST::Node) + + args.children.any? do |arg| + arg.is_a?(::AST::Node) && arg.children[0].to_s == name + end + end + # @param api_map [ApiMap] # @param raw_return_type [ComplexType, ComplexType::UniqueType] # @@ -302,8 +532,8 @@ def visible_at? other_closure, other_loc def adjust_type api_map, raw_return_type qualified_exclude = exclude_return_type&.qualify(api_map, *(closure&.gates || [''])) minus_exclusions = raw_return_type.exclude qualified_exclude, api_map - qualified_intersection = intersection_return_type&.qualify(api_map, *(closure&.gates || [''])) - minus_exclusions.intersect_with qualified_intersection, api_map + qualified_narrowing = narrowed_return_type&.qualify(api_map, *(closure&.gates || [''])) + minus_exclusions.narrow_with qualified_narrowing, api_map end # See if this variable is visible within 'viewing_closure' @@ -316,13 +546,10 @@ def visible_in_closure? viewing_closure # if we're declared at top level, we can't be seen from within # methods declared tere - # @sg-ignore Need to add nil check here return false if viewing_closure.is_a?(Pin::Method) && closure.context.tags == 'Class<>' - # @sg-ignore Need to add nil check here return true if viewing_closure.binder.namespace == closure.binder.namespace - # @sg-ignore Need to add nil check here return true if viewing_closure.return_type == closure.context # classes and modules can't see local variables declared diff --git a/lib/solargraph/pin/block.rb b/lib/solargraph/pin/block.rb index 1ad503317..e39ae5074 100644 --- a/lib/solargraph/pin/block.rb +++ b/lib/solargraph/pin/block.rb @@ -6,9 +6,11 @@ class Block < Callable include Breakable # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here attr_reader :receiver # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here attr_reader :node # @param receiver [Parser::AST::Node, nil] @@ -28,12 +30,22 @@ def initialize receiver: nil, args: [], context: nil, node: nil, **splat # @param api_map [ApiMap] # @return [void] def rebind api_map - @rebind ||= maybe_rebind(api_map) + @rebind ||= begin + # An enclosing block's rebind (e.g. a class_eval receiver) + # also applies to this block unless overridden here + enclosing = closure + enclosing.rebind(api_map) if enclosing.is_a?(Block) + maybe_rebind(api_map) + end end def binder - out = @rebind if @rebind&.defined? - out ||= super + return @rebind if @rebind&.defined? + + enclosing = closure + return enclosing.binder if enclosing.is_a?(Block) + + super end def context @@ -49,6 +61,7 @@ def destructure_yield_types yield_types, parameters # yielding a tuple into a block will destructure the tuple if yield_types.length == 1 yield_type = yield_types.first + # @sg-ignore Need to add nil check here return yield_type.all_params if yield_type.tuple? && yield_type.all_params.length == parameters.length end parameters.map.with_index { |_, idx| yield_types[idx] || ComplexType::UNDEFINED } @@ -56,6 +69,10 @@ def destructure_yield_types yield_types, parameters # @param api_map [ApiMap] # @return [::Array] + # @sg-ignore Declared return type does not match inferred - three-way + # merge interaction: the `partial&.map || parameters.map` tail's + # or-expression now routes through Chain::Or (castwide/solargraph#1309) + # and widens with the partial-result union from apiology/solargraph#60 def typify_parameters api_map chain = Parser.chain(receiver, filename, node) # @sg-ignore Need to add nil check here @@ -63,23 +80,26 @@ def typify_parameters api_map locals = clip.locals - [self] # @sg-ignore Need to add nil check here meths = chain.define(api_map, closure, locals) + # @type [::Array, nil] + partial = nil # @todo Convert logic to use signatures # @param meth [Pin::Method] meths.each do |meth| next if meth.block.nil? - # @sg-ignore flow sensitive typing needs to handle attrs yield_types = meth.block.parameters.map(&:return_type) # 'arguments' is what the method says it will yield to the # block; 'parameters' is what the block accepts argument_types = destructure_yield_types(yield_types, parameters) param_types = argument_types.each_with_index.map do |arg_type, idx| param = parameters[idx] + # @sg-ignore Need to add nil check here param_type = chain.base.infer(api_map, param, locals) unless arg_type.nil? if arg_type.generic? && param_type.defined? # @sg-ignore Need to add nil check here namespace_pin = api_map.get_namespace_pins(meth.namespace, closure.namespace).first + # @sg-ignore Need to add nil check here arg_type.resolve_generics(namespace_pin, param_type) else arg_type.self_to_type(chain.base.infer(api_map, self, locals)).qualify(api_map, *meth.gates) @@ -87,8 +107,13 @@ def typify_parameters api_map end end return param_types if param_types.all?(&:defined?) + + # remember the best partial result so a single unresolvable + # yield type (e.g. an unbound generic) doesn't discard the + # positions that did resolve + partial ||= param_types if param_types.any? { |t| t&.defined? } end - parameters.map { ComplexType::UNDEFINED } + partial&.map { |t| t || ComplexType::UNDEFINED } || parameters.map { ComplexType::UNDEFINED } end private diff --git a/lib/solargraph/pin/callable.rb b/lib/solargraph/pin/callable.rb index ed87b79e4..655e681ca 100644 --- a/lib/solargraph/pin/callable.rb +++ b/lib/solargraph/pin/callable.rb @@ -4,6 +4,7 @@ module Solargraph module Pin class Callable < Closure # @return [Signature] + # @sg-ignore Need to add nil check here attr_reader :block attr_accessor :parameters @@ -14,12 +15,17 @@ class Callable < Closure # @param block [Signature, nil] # @param return_type [ComplexType, nil] # @param parameters [::Array] + # @param block_required [Boolean] Whether callers must pass a block for + # this signature to apply. Only ever true for RBS-sourced signatures + # with a non-optional block (`{ ... }` rather than `?{ ... }`); a bare + # `&block` parameter or YARD @yield tag never makes a block mandatory. # @param [Hash{Symbol => Object}] splat - def initialize block: nil, return_type: nil, parameters: [], **splat + def initialize block: nil, return_type: nil, parameters: [], block_required: false, **splat super(**splat) @block = block @return_type = return_type @parameters = parameters + @block_required = block_required end def reset_generated! @@ -55,6 +61,7 @@ def combine_blocks other def combine_with other, attrs = {} new_attrs = { block: combine_blocks(other), + block_required: block_required? || other.block_required?, return_type: combine_return_type(other) }.merge(attrs) new_attrs[:parameters] = choose_parameters(other).clone.freeze unless new_attrs.key?(:parameters) @@ -123,7 +130,6 @@ def type_arity # # @return [Array] def full_type_arity - # @sg-ignore flow sensitive typing needs to handle attrs [return_type ? return_type.items.count.to_s : nil] + type_arity end @@ -162,6 +168,7 @@ def resolve_generics_from_context generics_to_resolve, def typify api_map type = return_type + # @sg-ignore Need to add nil check here return type.qualify(api_map, *gates) if type.defined? if method_name.end_with?('?') logger.debug { "Callable#typify(self=#{self}) => Boolean (? suffix)" } @@ -172,11 +179,9 @@ def typify api_map end end - # @sg-ignore Need to add nil check here # @return [String] def method_name raise "closure was nil in #{inspect}" if closure.nil? - # @sg-ignore Need to add nil check here @method_name ||= closure.name end @@ -240,11 +245,13 @@ def transform_types &transform def arity_matches? arguments, with_block argcount = arguments.length parcount = mandatory_positional_param_count + # @sg-ignore Need to add nil check here parcount -= 1 if !parameters.empty? && parameters.last.block? - return false if block? && !with_block + return false if block? && block_required? && !with_block # @todo this and its caller should be changed so that this can # look at the kwargs provided and check names against what # we acccept + # @sg-ignore Need to add nil check here return false if argcount < parcount && !(argcount == parcount - 1 && parameters.last.restarg?) true end @@ -267,6 +274,11 @@ def block? !!@block end + # @return [Boolean] + def block_required? + !!@block_required + end + protected attr_writer :block diff --git a/lib/solargraph/pin/compound_statement.rb b/lib/solargraph/pin/compound_statement.rb index 39d9cf2d5..b9e9343f6 100644 --- a/lib/solargraph/pin/compound_statement.rb +++ b/lib/solargraph/pin/compound_statement.rb @@ -44,11 +44,68 @@ module Pin class CompoundStatement < Pin::Base attr_reader :node + # The immediately enclosing CompoundStatement, if any - nil only + # for the synthetic root Namespace Region creates for top-level + # code. Since Closure < CompoundStatement, walking this chain + # until an ancestor is_a?(Closure) is how Base#closure is + # derived when a pin has no directly-assigned @closure. + # + # @return [Pin::CompoundStatement, nil] + attr_reader :compound_statement + + # True if this construct's body may be skipped, or run zero or + # multiple times, at runtime - e.g. an if/while/until/rescue/&&/ + # ||/||= body, or a block body (which, despite being a Closure + # like Method/Namespace, may run zero or many times depending on + # the method it's passed to, unlike a method/namespace body, + # which always runs exactly once when reached). Defaults false - + # true only where a node processor explicitly marks a construct + # as conditionally executed. + # + # @return [Boolean] + attr_reader :conditional + # @param node [Parser::AST::Node, nil] + # @param compound_statement [Pin::CompoundStatement, nil] + # @param conditional [Boolean] # @param [Hash{Symbol => Object}] splat - def initialize node: nil, **splat + def initialize node: nil, compound_statement: nil, conditional: false, **splat super(**splat) @node = node + @compound_statement = compound_statement + @conditional = conditional + end + + # @param other [self] + # @param attrs [Hash{Symbol => Object}] + # @return [self] + def combine_with other, attrs = {} + new_attrs = { + compound_statement: combine_compound_statement(other), + conditional: choose(other, :conditional) + }.merge(attrs) + super(other, new_attrs) + end + + # Bare CompoundStatement pins (if/when/rescue/&&/||/||= bodies) + # all share name == '', so the same-name-assertion in + # Base#choose_pin_attr_with_same_name (used by #combine_closure) + # would be meaningless noise here - pick by location instead, + # mirroring BaseVariable#combine_closure. + # + # @param other [self] + # @return [Pin::CompoundStatement, nil] + def combine_compound_statement other + return compound_statement if compound_statement == other.compound_statement + return compound_statement || other.compound_statement if compound_statement.nil? || other.compound_statement.nil? + + if compound_statement.location.nil? || other.compound_statement.location.nil? + return compound_statement.location.nil? ? other.compound_statement : compound_statement + end + + return compound_statement if compound_statement.location <= other.compound_statement.location + + other.compound_statement end end end diff --git a/lib/solargraph/pin/documenting.rb b/lib/solargraph/pin/documenting.rb index 94bd8a551..ba2a7737a 100644 --- a/lib/solargraph/pin/documenting.rb +++ b/lib/solargraph/pin/documenting.rb @@ -82,11 +82,14 @@ def documentation ' '))).lines.each do |l| if l.start_with?(' ') # Code block + # @sg-ignore Unresolved call to code? on Solargraph::Pin::Documenting::DocSection, nil sections.push DocSection.new(true) unless sections.last.code? + # @sg-ignore Unresolved call to code? on Solargraph::Pin::Documenting::DocSection, nil elsif sections.last.code? # Regular documentation sections.push DocSection.new(false) end + # @sg-ignore Unresolved call to concat on Solargraph::Pin::Documenting::DocSection, nil sections.last.concat l end sections.map(&:to_s).join.strip diff --git a/lib/solargraph/pin/local_variable.rb b/lib/solargraph/pin/local_variable.rb index 077da21be..e73ba2588 100644 --- a/lib/solargraph/pin/local_variable.rb +++ b/lib/solargraph/pin/local_variable.rb @@ -16,9 +16,9 @@ def probe api_map super end - def combine_with other, attrs = {} + def combine_with other, attrs = {}, location: nil # keep this as a parameter - return other.combine_with(self, attrs) if other.is_a?(Parameter) && !is_a?(Parameter) + return other.combine_with(self, attrs, location: location) if other.is_a?(Parameter) && !is_a?(Parameter) super end diff --git a/lib/solargraph/pin/method.rb b/lib/solargraph/pin/method.rb index c1f8f8850..591a44952 100644 --- a/lib/solargraph/pin/method.rb +++ b/lib/solargraph/pin/method.rb @@ -13,6 +13,7 @@ class Method < Callable attr_writer :signatures # @return [Parser::AST::Node] + # @sg-ignore Need to add nil check here attr_reader :node # @param visibility [::Symbol] :public, :protected, or :private @@ -75,7 +76,7 @@ def combine_with other, attrs = {} # @param other [Pin::Method] def == other - super && other.node == node + super && other.node == node && other.signatures == signatures end def transform_types &transform @@ -148,12 +149,13 @@ def return_type # @param parameters [::Array] # @param return_type [ComplexType, nil] + # @param tag_docstring [YARD::Docstring] source of @yieldparam/@yieldreturn tags; pass an @overload tag's own docstring here # @return [Signature] - def generate_signature parameters, return_type + def generate_signature parameters, return_type, tag_docstring = docstring # @type [Pin::Signature, nil] block = nil - yieldparam_tags = docstring.tags(:yieldparam) - yieldreturn_tags = docstring.tags(:yieldreturn) + yieldparam_tags = tag_docstring.tags(:yieldparam) + yieldreturn_tags = tag_docstring.tags(:yieldreturn) generics = docstring.tags(:generic).map(&:name) needs_block_param_signature = parameters.last&.block? || !yieldreturn_tags.empty? || !yieldparam_tags.empty? @@ -211,6 +213,7 @@ def detail detail += if signatures.length > 1 '(*) ' else + # @sg-ignore Need to add nil check here "(#{signatures.first.parameters.map(&:full).join(', ')}) " unless signatures.first.parameters.empty? end.to_s # @sg-ignore Need to add nil check here @@ -248,6 +251,7 @@ def inner_desc def to_rbs return nil if signatures.empty? + # @sg-ignore Need to add nil check here rbs = "def #{name}: #{signatures.first.to_rbs}" # @sg-ignore Need to add nil check here signatures[1..].each do |sig| @@ -276,6 +280,7 @@ def typify api_map types = macro_names.flat_map do |mac| directive = api_map.named_macro(mac) next unless directive + # @sg-ignore Need a downcast here macro = Solargraph::YardMap::Macro.from_directive(directive, self) expanded = macro.macro_object.expand([name, *parameter_names]) docstring = Solargraph::Source.parse_docstring(expanded).to_docstring @@ -294,8 +299,7 @@ def typify api_map type = see_reference(api_map) || typify_from_super(api_map) logger.debug { "Method#typify(self=#{self}) - type=#{type&.rooted_tags.inspect}" } unless type.nil? - # @sg-ignore Need to add nil check here - qualified = type.qualify(api_map, *closure.gates) + qualified = type.qualify(api_map, *gates) logger.debug { "Method#typify(self=#{self}) => #{qualified.rooted_tags.inspect}" } return qualified end @@ -399,6 +403,7 @@ def overloads generics: generics, # @param src [Array(String, String)] parameters: tag.parameters.map do |src| + # @sg-ignore Need to add nil check here name, decl = parse_overload_param(src.first) Pin::Parameter.new( location: location, @@ -407,6 +412,7 @@ def overloads name: name, decl: decl, presence: location&.range, + # @sg-ignore Need to add nil check here return_type: param_type_from_name(tag, src.first), source: :overloads ) @@ -454,8 +460,12 @@ def rest_of_stack api_map protected - attr_writer :block, :signature_help, :documentation, :return_type + attr_writer :block, :signature_help, :documentation + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars + attr_writer :return_type + # @return [Boolean] + # @sg-ignore Need to add nil check here def dodgy_visibility_source? # as of 2025-03-12, the RBS generator used for # e.g. activesupport did not understand 'private' markings @@ -496,6 +506,7 @@ def combine_signatures_by_type_arity(*signature_pins) by_type_arity = {} signature_pins.each do |signature_pin| by_type_arity[signature_pin.type_arity] ||= [] + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars by_type_arity[signature_pin.type_arity] << signature_pin end @@ -611,13 +622,14 @@ def method_namespace end # @param api_map [ApiMap] - # @return [ComplexType, nil] + # @return [ComplexType, ComplexType::UniqueType, nil] def typify_from_super api_map stack = rest_of_stack api_map return nil if stack.empty? stack.each do |pin| # @sg-ignore Need to add nil check here - return pin.return_type unless pin.return_type.undefined? + next if pin.return_type.undefined? + return pin.typify(api_map) end nil end @@ -627,6 +639,7 @@ def typify_from_super api_map # @return [ComplexType, ComplexType::UniqueType, nil] def resolve_reference ref, api_map parts = ref.split(/[.#]/) + # @sg-ignore Need to add nil check here if parts.first.empty? || parts.one? path = "#{namespace}#{ref}" else @@ -646,7 +659,9 @@ def resolve_reference ref, api_map # @return [Parser::AST::Node, nil] def method_body_node return nil if node.nil? + # @sg-ignore Need to add nil check here return node.children[1].children.last if node.type == :DEFN + # @sg-ignore Need to add nil check here return node.children[2].children.last if node.type == :DEFS return node.children[2] if %i[def DEFS].include?(node.type) return node.children[3] if node.type == :defs @@ -667,14 +682,15 @@ def infer_from_return_nodes api_map end rng = Range.from_node(n) next unless rng - clip = api_map.clip_at( - # @sg-ignore Need to add nil check here - location.filename, - rng.ending - ) + # A flow-sensitive downcast's presence can end before the return + # node's own end (e.g. inside `!(foo.nil? || foo < 5)`); chain + # resolution re-checks each local's presence at its own sub-node + # location, so pass the full local set rather than pre-filtering here. + # @sg-ignore Need to add nil check here + all_locals = api_map.source_map(location.filename).locals # @sg-ignore Need to add nil check here chain = Solargraph::Parser.chain(n, location.filename) - type = chain.infer(api_map, self, clip.locals) + type = chain.infer(api_map, self, all_locals) result.push type unless type.undefined? end result.push ComplexType::NIL if has_nil @@ -729,6 +745,7 @@ def concat_example_tags def return_type_from_inline_rbs return nil if inline_rbs.empty? method_type = RBS::Parser.parse_method_type(inline_rbs) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 RbsTranslator.to_complex_type(method_type.type.return_type) rescue RBS::ParsingError nil @@ -737,6 +754,7 @@ def return_type_from_inline_rbs # @return [Array] def signatures_from_inline_rbs method_type = RBS::Parser.parse_method_type(inline_rbs) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 [RbsTranslator.to_signature(method_type, self, parameter_names)] rescue RBS::ParsingError signatures_from_yard @@ -747,7 +765,9 @@ def signatures_from_yard top_type = generate_complex_type result = [] result.push generate_signature(parameters, top_type) if top_type.defined? - result.concat(overloads.map { |meth| generate_signature(meth.parameters, meth.return_type) }) unless overloads.empty? + # @param meth [Pin::Signature] + # @param tag [YARD::Tags::OverloadTag] + result.concat(overloads.zip(docstring.tags(:overload).select(&:parameters)).map { |meth, tag| generate_signature(meth.parameters, meth.return_type, tag.docstring) }) unless overloads.empty? result.push generate_signature(parameters, @return_type || ComplexType::UNDEFINED) if result.empty? result end @@ -756,6 +776,7 @@ def signatures_from_yard def inline_rbs comments.lines .select { |line| line.start_with?(': ') } + # @sg-ignore Need to add nil check here .map { |line| line[2..].strip } .join("\n") end diff --git a/lib/solargraph/pin/method_alias.rb b/lib/solargraph/pin/method_alias.rb index feb6baccc..53acb2cc0 100644 --- a/lib/solargraph/pin/method_alias.rb +++ b/lib/solargraph/pin/method_alias.rb @@ -11,6 +11,7 @@ class MethodAlias < Method attr_reader :scope # @return [String] + # @sg-ignore Need to add nil check here attr_reader :original # @param scope [::Symbol] diff --git a/lib/solargraph/pin/namespace.rb b/lib/solargraph/pin/namespace.rb index 55bb52b0e..7bbaac87a 100644 --- a/lib/solargraph/pin/namespace.rb +++ b/lib/solargraph/pin/namespace.rb @@ -14,6 +14,8 @@ class Namespace < Closure # does not assert like super, as a namespace without a closure # may be the root level namespace, or it may not yet be # qualified + # @return [Solargraph::Pin::Closure, nil] + # @sg-ignore flow sensitive typing needs better handling of reassignment in #initialize attr_reader :closure # @param type [::Symbol] :class or :module @@ -56,6 +58,7 @@ def reset_generated! end def to_rbs + # @sg-ignore Need to add nil check here "#{@type} #{return_type.all_params.first.to_rbs}#{rbs_generics}".strip end diff --git a/lib/solargraph/pin/parameter.rb b/lib/solargraph/pin/parameter.rb index ba20976ec..f81ad2ad3 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -6,22 +6,31 @@ class Parameter < LocalVariable # @return [::Symbol] attr_reader :decl - # @return [String] + # @return [String, nil] attr_reader :asgn_code # allow this to be set to the method after the method itself has # been created attr_writer :closure - # @param decl [::Symbol] :arg, :optarg, :kwarg, :kwoptarg, :restarg, :kwrestarg, :block, :blockarg + # @param decl [::Symbol] :arg, :optarg, :kwarg, :kwoptarg, :restarg, :kwrestarg, :block, :blockarg, :mlhs # @param asgn_code [String, nil] + # @param mlhs_path [::Array, nil] for a variable inside a + # destructured block parameter group (`|(a, b), c|`): the group's + # position in the block signature followed by the element index at + # each nesting level (`a` -> [0, 0], `b` -> [0, 1]) # @param [Hash{Symbol => Object}] splat - def initialize decl: :arg, asgn_code: nil, **splat + def initialize decl: :arg, asgn_code: nil, mlhs_path: nil, **splat super(**splat) @asgn_code = asgn_code @decl = decl + @mlhs_path = mlhs_path end + # @return [::Array, nil] + # @return [::Array, nil] + attr_reader :mlhs_path + def type_location super || closure&.type_location end @@ -30,7 +39,7 @@ def location super || closure&.type_location end - def combine_with other, attrs = {} + def combine_with other, attrs = {}, location: nil # Parameters can only be combined with local variables in the same closure return self unless other.closure == closure @@ -45,7 +54,7 @@ def combine_with other, attrs = {} asgn_code: asgn_code } end - super(other, new_attrs.merge(attrs)) + super(other, new_attrs.merge(attrs), location: location) end def combine_return_type other @@ -63,7 +72,6 @@ def keyword? end def kwrestarg? - # @sg-ignore flow sensitive typing needs to handle attrs decl == :kwrestarg || (assignment && %i[HASH hash].include?(assignment.type)) end @@ -95,7 +103,7 @@ def arity_decl # @return [String] def type_arity_decl - arity_decl + return_type.items.count.to_s + arity_decl + return_type.tags end def arg? @@ -181,7 +189,7 @@ def return_type @return_type = ComplexType::UNDEFINED found = param_tag @return_type = ComplexType.try_parse(*found.types) unless found.nil? || found.types.nil? - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 if @return_type.undefined? case decl when :restarg @@ -208,9 +216,23 @@ def index # @param api_map [ApiMap] def typify api_map + if definite + # flow sensitive typing: this parameter was reassigned by + # an assignment guaranteed to have executed, so prefer the + # type of the value it was reassigned to over its declared + # @param type + reassigned_type = probe(api_map) + return reassigned_type if reassigned_type.defined? + end + new_type = super return new_type if new_type.defined? + if mlhs_path && closure.is_a?(Pin::Block) + projected = typify_mlhs_element(api_map) + return adjust_type api_map, projected.self_to_type(full_context) if projected.defined? + end + # sniff based on param tags new_type = closure.is_a?(Pin::Block) ? typify_block_param(api_map) : typify_method_param(api_map) @@ -221,20 +243,31 @@ def typify api_map # @param atype [ComplexType] # @param api_map [ApiMap] - def compatible_arg? atype, api_map + # @param require_literal [Boolean] Whether a literal-typed + # parameter requires the argument to also be a literal (see + # #literal_arg_matches?). Callers doing overload *selection* + # should retry with this set to false if no overload matches + # with it true, so a literal-typed parameter with no + # non-literal sibling overload to fall back to (e.g. a Hash's + # key type resolved to a literal from its receiver's declared + # type) doesn't reject every candidate outright. + def compatible_arg? atype, api_map, require_literal: true # make sure we get types from up the method # inheritance chain if we don't have them on this pin ptype = typify api_map return true if ptype.undefined? - return true if atype.conforms_to?(api_map, - ptype, - :method_call, - %i[allow_empty_params allow_undefined]) - ptype.generic? + return false unless atype.conforms_to?(api_map, + ptype, + :method_call, + %i[allow_empty_params allow_undefined]) || ptype.generic? + + return true unless require_literal + + # @sg-ignore Wrong argument type for Solargraph::Pin::Parameter#literal_arg_matches?: ptype expected Solargraph::ComplexType, received Solargraph::ComplexType, Solargraph::ComplexType::UniqueTy + literal_arg_matches? ptype, atype end - # @sg-ignore flow sensitive typing needs to handle attrs def documentation tag = param_tag return '' if tag.nil? || tag.text.nil? @@ -247,6 +280,42 @@ def generate_complex_type nil end + # #compatible_arg? alone is too permissive for picking *which* + # overload to use for return-type inference: it treats any + # Integer as "compatible" with a literal-0-typed parameter + # (correct for general call-validity checking - you can call + # `array[i]` with any Integer `i` - but wrong for overload + # *selection*, where it would make the first literal-typed + # overload always win over the safe catch-all for any argument + # that merely happens to be assignable to it). When this + # parameter's type is a literal type, require every possible + # value of the argument to also be literal, so a non-literal + # (or not-entirely-literal) argument falls through to a less + # specific overload instead. + # + # @param ptype [ComplexType] + # @param atype [ComplexType] + # @return [Boolean] + def literal_arg_matches? ptype, atype + return true unless ptype.items.any? { |item| dispatch_literal?(item) } + + atype.items.all?(&:literal?) + end + + # nil/true/false are technically "literal" per + # ComplexType::UniqueType#literal? (their non_literal_name is + # NilClass/TrueClass/FalseClass), but they're singletons, not + # dispatch-relevant values the way `0` vs `1` are for tuple + # indexing - excluding them keeps ordinary `T?`/nilable params + # (extremely common, e.g. String#split's `(Regexp | string | + # nil pattern)`) from tripping #literal_arg_matches?. + # + # @param unique_type [ComplexType::UniqueType] + # @return [Boolean] + def dispatch_literal? unique_type + unique_type.literal? && !unique_type.singleton? + end + # @return [YARD::Tags::Tag, nil] def param_tag # @sg-ignore Need to add nil check here @@ -259,11 +328,42 @@ def param_tag params[index] if index && params[index] && (params[index].name.nil? || params[index].name.empty?) end + # Project this variable's type out of its destructured parameter + # group's tuple type, one element index per nesting level. + # + # @param api_map [ApiMap] + # @return [ComplexType] + # @sg-ignore Declared return type does not match inferred - loop-reassigned + # `type` union widens under this branch's newer or/branch inference + # (castwide/solargraph#1309); apiology/solargraph#60 predates it. Specs green. + def typify_mlhs_element api_map + block_pin = closure + path = mlhs_path + return ComplexType::UNDEFINED unless path && block_pin.is_a?(Pin::Block) && block_pin.receiver + + # @sg-ignore Array#[] arg Integer, nil - path.first is nilable in general + # but an mlhs_path always has at least one element + type = block_pin.typify_parameters(api_map)[path.first] + path.drop(1).each do |idx| + # @sg-ignore Unresolved call to tuple? - loop-reassignment of `type` + # not narrowed (known loop-reassignment family); post-merge dogfood only + return ComplexType::UNDEFINED if type.nil? || !type.tuple? + + # @sg-ignore Unresolved call to all_params / Array#[] idx Integer, nil - + # same loop-reassignment gap as above; mlhs_path elements are Integers + type = type.all_params[idx] + end + type || ComplexType::UNDEFINED + end + # @param api_map [ApiMap] # @return [ComplexType] def typify_block_param api_map block_pin = closure - return block_pin.typify_parameters(api_map)[index] if block_pin.is_a?(Pin::Block) && block_pin.receiver && index + if block_pin.is_a?(Pin::Block) && block_pin.receiver && index + typed = block_pin.typify_parameters(api_map)[index] + return typed unless typed.nil? + end ComplexType::UNDEFINED end @@ -281,8 +381,9 @@ def typify_method_param api_map found = p break end - if found.nil? && !index.nil? && params[index] && (params[index].name.nil? || params[index].name.empty?) - found = params[index] + if found.nil? && !index.nil? + positional = params[index] + found = positional if positional && (positional.name.nil? || positional.name.empty?) end unless found.nil? || found.types.nil? return ComplexType.try_parse(*found.types).qualify(api_map, @@ -319,7 +420,7 @@ def resolve_reference ref, api_map, skip return nil if skip.include?(ref) skip.push ref parts = ref.split(/[.#]/) - if parts.first.empty? + if parts.first.to_s.empty? path = "#{namespace}#{ref}" else fqns = api_map.qualify(parts.first, namespace) diff --git a/lib/solargraph/pin/reference/override.rb b/lib/solargraph/pin/reference/override.rb index 76711f5dd..f8499907e 100644 --- a/lib/solargraph/pin/reference/override.rb +++ b/lib/solargraph/pin/reference/override.rb @@ -17,7 +17,7 @@ def closure # @param location [Location, nil] # @param name [String] # @param tags [::Array] - # @param delete [::Array] + # @param delete [::Array<::Symbol>] # @param splat [Hash] def initialize location, name, tags, delete = [], **splat super(location: location, name: name, **splat) @@ -27,7 +27,7 @@ def initialize location, name, tags, delete = [], **splat # @param name [String] # @param tags [::Array] - # @param delete [::Array] + # @param delete [::Array<::Symbol>] # @param splat [Hash] # @return [Solargraph::Pin::Reference::Override] def self.method_return name, *tags, delete: [], **splat diff --git a/lib/solargraph/pin/signature.rb b/lib/solargraph/pin/signature.rb index 221682ccd..83c70b46f 100644 --- a/lib/solargraph/pin/signature.rb +++ b/lib/solargraph/pin/signature.rb @@ -40,7 +40,6 @@ def typify api_map end return ComplexType::UNDEFINED if closure.nil? return ComplexType::UNDEFINED unless closure.is_a?(Pin::Method) - # @sg-ignore need is_a? support # @type [Array] method_stack = closure.rest_of_stack api_map logger.debug { "Signature#typify(self=#{self}) - method_stack: #{method_stack}" } @@ -59,6 +58,63 @@ def typify api_map logger.debug { "Signature#typify(self=#{self}) => #{out}" } out end + + # The index of the first parameter acting as a lookup key for + # this class's own `K` (e.g. `Hash#fetch`'s first parameter), or + # nil if none of this signature's parameters have that shape. + # + # Two shapes are recognized, because RBS's own `core/hash.rbs` + # changed how it declares them in 4.1.0: + # + # - RBS >= 4.1.0 declares `def fetch: (_Key key) -> V` (also + # `#[]`, `#dig`, `#delete`), so the parameter is typed exactly + # `::_Key`. + # - RBS < 4.1.0 declares `def fetch: (K arg0) -> V`, so the + # parameter is typed as the class's own generic `K`, which by + # this point has already been resolved against the receiver - + # for a literal-keyed receiver that makes it the literal key + # type itself, which is what `key_types` matches against. + # + # The `_Key` shape matches by the interface's literal name, so it + # only recognizes RBS's own `Hash::_Key` - a user-defined generic + # class using the same "marker interface for a lookup key" + # pattern under a different name/namespace won't be detected. + # + # TODO once castwide/solargraph#1266 (structurally verify RBS + # interface-typed expectations) lands - it lands with + # ApiMap#get_own_methods (a namespace's directly-declared + # methods, excluding inherited ones), extracted from + # Conformance#required_interface_methods for exactly this reuse. + # Replace the `_Key` name comparison below with a structural one: + # a parameter is key-shaped if its type is an interface whose own + # declared method names equal `Hash::_Key`'s (`hash`/`eql?`), + # regardless of the interface's actual name - + # https://github.com/castwide/solargraph/pull/1231#issuecomment-5207523909 + # + # key_interface_methods = api_map.get_own_methods("#{namespace}::_Key").map(&:name).to_set + # index = parameters.find_index do |p| + # type = p.typify(api_map) + # next false unless type.interface? + # api_map.get_own_methods(type.tag).map(&:name).to_set == key_interface_methods + # end + # + # That only replaces the first branch - the `key_tags` fallback is + # still needed for RBS < 4.1, which has no interface there at all. + # + # @param namespace [String] + # @param api_map [ApiMap] + # @param key_tags [::Array] the receiver's own resolved + # `key_types` tags, used to recognize the pre-4.1 `K`-typed + # shape. Empty disables that fallback. + # @return [Integer, nil] + def key_param_index namespace, api_map, key_tags = [] + key_tag = "#{namespace}::_Key" + index = parameters.find_index { |p| p.typify(api_map).tag == key_tag } + return index unless index.nil? + return nil if key_tags.empty? + + parameters.find_index { |p| key_tags.include?(p.typify(api_map).tag) } + end end end end diff --git a/lib/solargraph/pin_cache.rb b/lib/solargraph/pin_cache.rb index 803170764..60c00a0bb 100644 --- a/lib/solargraph/pin_cache.rb +++ b/lib/solargraph/pin_cache.rb @@ -1,13 +1,438 @@ -require 'yard-activesupport-concern' +# frozen_string_literal: true + require 'fileutils' -require 'pathname' # @todo Required by RBS but not loaded in some use cases 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] + # @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 + # @param rebuild [Boolean] build pins regardless of whether we + # have cached them already + # + # @return [void] + def cache_all_stdlibs rebuild: false, out: $stderr + possible_stdlibs.each do |stdlib| + RbsMap::StdlibMap.new(stdlib, rebuild: rebuild, 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 + # @sg-ignore flow sensitive typing should be able to handle redefinition + 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}" } + # @sg-ignore Need to add nil check here + 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) + # @sg-ignore Need to add nil check here + 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] + # @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] + # @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 => Array}] keyed by [gem name, gem version, RBS cache key] + 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, nil] + 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, nil] + # + # @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] + # @param out [StringIO, IO, nil] + 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] @@ -15,10 +440,52 @@ def base_dir # The directory is not stored in a variable so it can be overridden # in specs. ENV['SOLARGRAPH_CACHE'] || + # @sg-ignore Wrong argument type for File.join: arg_0 expected String, _ToStr, _ToPath, received String, nil (ENV['XDG_CACHE_HOME'] ? File.join(ENV['XDG_CACHE_HOME'], 'solargraph') : nil) || File.join(Dir.home, '.cache', 'solargraph') end + # @param path_segments [Array] + # @param out [IO, nil] + # @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 + + # @return [void] + # @param out [IO, nil] + # @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 + + # @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] @@ -28,15 +495,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') @@ -96,12 +554,6 @@ def serialize_yard_gem gemspec, pins save(yard_gem_path(gemspec), pins) end - # @param gemspec [Gem::Specification] - # @return [Boolean] - def has_yard? gemspec - exist?(yard_gem_path(gemspec)) - end - # @param gemspec [Gem::Specification] # @param hash [String, nil] # @return [String] @@ -165,35 +617,13 @@ 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, StringIO, 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] - # @sg-ignore Marshal.load returns Object; we know it's Array + # @sg-ignore Marshal.load evaluates to boolean here which is wrong # @return [Array, nil] def load file return nil unless File.file?(file) @@ -204,11 +634,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] @@ -220,28 +645,19 @@ def save file, pins logger.debug { "Cache#save: Saved #{pins.length} pins to #{file}" } end - # @param path_segments [Array] - # @return [void] - # @param [Object, nil] out - def uncache *path_segments, out: nil - path = File.join(*path_segments) - return unless File.exist?(path) - FileUtils.rm_rf path, secure: true - out&.puts "Clearing pin cache in #{path}" + def core? + File.file?(core_path) end - # @return [void] - # @param path_segments [Array] - # @param [Object, nil] out - 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 + # @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/position.rb b/lib/solargraph/position.rb index 11d8eb8d5..70d4c663a 100644 --- a/lib/solargraph/position.rb +++ b/lib/solargraph/position.rb @@ -58,7 +58,6 @@ def self.to_offset text, position line = -1 last_line_index = 0 - # @sg-ignore Typechecker thinks `newline_index` inside of the assignment # can be nil while (newline_index = text.index("\n", newline_index + 1)) && line <= position.line line += 1 @@ -68,8 +67,6 @@ def self.to_offset text, position end last_line_index += 1 if position.line.positive? - # @sg-ignore `last_line_index` is always an Integer because `newline_index` - # is never nil inside the while block last_line_index + position.character end @@ -99,16 +96,12 @@ def self.from_offset text, offset character = offset newline_index = -1 - # @sg-ignore Typechecker thinks `newline_index` inside of the assignment - # can be nil while (newline_index = text.index("\n", newline_index + 1)) && newline_index < offset line += 1 - # @sg-ignore `newline_index` is always an Integer inside the while block character = offset - newline_index - 1 end character = 0 if character.nil? && (cursor - offset).between?(0, 1) raise InvalidOffsetError if character.nil? - # @sg-ignore flow sensitive typing needs to handle 'raise if' Position.new(line, character) end diff --git a/lib/solargraph/range.rb b/lib/solargraph/range.rb index e1ed89592..e7f49e034 100644 --- a/lib/solargraph/range.rb +++ b/lib/solargraph/range.rb @@ -46,11 +46,8 @@ def to_hash # @return [Boolean] def contain? position position = Position.normalize(position) - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line < start.line || position.line > ending.line - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line == start.line && position.character < start.character - # @sg-ignore flow sensitive typing should be able to handle redefinition return false if position.line == ending.line && position.character > ending.character true end @@ -58,11 +55,9 @@ def contain? position # True if the range contains the specified position and the position does not precede it. # # @param position [Position, Array(Integer, Integer)] - # @sg-ignore flow sensitive typing should be able to handle redefinition # @return [Boolean] def include? position position = Position.normalize(position) - # @sg-ignore flow sensitive typing should be able to handle redefinition contain?(position) && !(position.line == start.line && position.character == start.character) end diff --git a/lib/solargraph/rbs_map.rb b/lib/solargraph/rbs_map.rb index c86dc6b74..8b54104dc 100644 --- a/lib/solargraph/rbs_map.rb +++ b/lib/solargraph/rbs_map.rb @@ -74,7 +74,6 @@ def cache_key # @type gem_config [nil, Hash{String => Hash{String => String}}] gem_config = nil if rbs_collection_config_path - # @sg-ignore rbs_collection_config_path is not nil here lockfile_path = RBS::Collection::Config.to_lockfile_path(Pathname.new(rbs_collection_config_path)) if lockfile_path.exist? collection_config = RBS::Collection::Config.from_path lockfile_path @@ -116,9 +115,13 @@ def self.from_gemspec gemspec, rbs_collection_path, rbs_collection_config_path return rbs_map if rbs_map.resolved? # try any version of the gem in the collection - RbsMap.new(gemspec.name, nil, - rbs_collection_paths: [rbs_collection_path].compact, - rbs_collection_config_path: rbs_collection_config_path) + rbs_map = RbsMap.new(gemspec.name, nil, + rbs_collection_paths: [rbs_collection_path].compact, + rbs_collection_config_path: rbs_collection_config_path) + + return rbs_map if rbs_map.resolved? + + StdlibMap.new(gemspec.name) end # @param out [IO, nil] where to log messages @@ -200,7 +203,9 @@ def add_library loader, library, version, out: $stderr end # @return [String] + # @sg-ignore Need to add nil check here def short_name + # @sg-ignore Need to add nil check here self.class.name.split('::').last end end diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index ebe7a6ce0..84390ad4a 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -37,6 +37,11 @@ def initialize loader: private + # @return [Hash{String => RBS::AST::Declarations::TypeAlias}] + def type_alias_decls + @type_alias_decls ||= {} + end + # @param loader [RBS::EnvironmentLoader] # # @return [void] @@ -48,56 +53,92 @@ def load_environment_to_pins loader "directories #{loader.dirs}" return end + # Register all type aliases up front so alias expansion in + # RbsTranslator doesn't depend on declaration order. + core_aware_environment(loader, fallback: environment).type_alias_decls.each_value do |entry| + # @sg-ignore Wrong argument type for Hash#[]=: value expected RBS::AST::Declarations::TypeAlias, received generic + type_alias_decls[entry.decl.name.to_s] = entry.decl + end + # @sg-ignore Wrong argument type for Solargraph::RbsMap::Conversions#convert_decl_to_pin: decl expected RBS::AST::Declarations::Base, received RBS::AST::Ruby::Declarations::ClassDecl, RBS::AST: environment.declarations.each { |decl| convert_decl_to_pin(decl, Solargraph::Pin::ROOT_PIN) } end + # `loader` intentionally omits RBS core (`core_root: nil`) for + # every non-core RbsMap, so a library's pins don't re-declare + # what RbsMap::CoreMap already provides. But some stdlib type + # aliases reference a name declared in core -- e.g. fileutils.rbs's + # `type path = ::path` points at core's own `path` alias in + # builtin.rbs. With core absent from the environment, + # RBS::Environment#resolve_type_names can't find that target and + # silently rebinds the reference back onto the alias's own name + # instead of raising, producing a spurious self-referential alias + # (`FileUtils::path` "expanding to" `FileUtils::path`). Resolve + # type alias names against a copy of the environment that does + # include core, so those cross-namespace references bind + # correctly; pin generation still uses the core-less environment + # above to avoid duplicating core's pins. + # + # @param loader [RBS::EnvironmentLoader] + # @param fallback [RBS::Environment] the already-resolved, core-less + # environment to use if a core-aware one can't be built + # @return [RBS::Environment] + def core_aware_environment loader, fallback: + return fallback unless loader.core_root.nil? + + core_loader = RBS::EnvironmentLoader.new(repository: loader.repository) + loader.libs.each { |lib| core_loader.add(library: lib.name, version: lib.version) } + loader.dirs.each { |dir| core_loader.add(path: dir) } + RBS::Environment.from_loader(core_loader).resolve_type_names + rescue RBS::DuplicatedDeclarationError => e + # A declaration in `loader` (e.g. a local RBS shim) collides + # with something in RBS core itself. Fall back to the + # core-less resolution for type aliases; cross-namespace + # references into core won't expand in this case, but we + # still get a nominal tag rather than failing to load the + # library's pins at all. + logger.debug { "Could not build a core-aware environment for #{loader.libs}: #{e.message}" } + fallback + end + # @param decl [RBS::AST::Declarations::Base] # @param closure [Pin::Closure] # @return [void] def convert_decl_to_pin decl, closure case decl when RBS::AST::Declarations::Class - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.name.absolute? Solargraph.assert_or_log(:rbs_closure, "Ignoring closure #{closure.inspect} on class #{decl.inspect}") end class_decl_to_pin decl when RBS::AST::Declarations::Interface - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.name.absolute? Solargraph.assert_or_log(:rbs_closure, "Ignoring closure #{closure.inspect} on interface #{decl.inspect}") end interface_decl_to_pin decl when RBS::AST::Declarations::TypeAlias - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.name.absolute? Solargraph.assert_or_log(:rbs_closure, - # @sg-ignore flow sensitive typing should support case/when "Ignoring closure #{closure.inspect} on alias type name #{decl.name}") end + # @sg-ignore Unresolved calls to name, type, type_location + alias_return_type = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted pins.push( - # @sg-ignore Wrong argument type for Solargraph::Pin::Reference::TypeAlias.new: return_type expected Solargraph::ComplexType, received Solargraph::ComplexType::UniqueType, Solargraph::ComplexType Solargraph::Pin::Reference::TypeAlias.new( - # @sg-ignore Unresolved calls to name, type, type_location; return_type type mismatch - name: ComplexType.try_parse(decl.name.to_s).to_s, return_type: RbsTranslator.to_complex_type(decl.type).force_rooted, closure: closure, source: :rbs, type_location: location_decl_to_pin_location(decl.location) + name: ComplexType.try_parse(decl.name.to_s).to_s, return_type: alias_return_type, closure: closure, source: :rbs, type_location: location_decl_to_pin_location(decl.location) ) ) when RBS::AST::Declarations::Module - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.name.absolute? Solargraph.assert_or_log(:rbs_closure, - # @sg-ignore flow sensitive typing should support case/when "Ignoring closure #{closure.inspect} on alias type name #{decl.name}") end module_decl_to_pin decl when RBS::AST::Declarations::Constant - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.name.absolute? Solargraph.assert_or_log(:rbs_closure, "Ignoring closure #{closure.inspect} on constant #{decl.inspect}") end constant_decl_to_pin decl when RBS::AST::Declarations::ClassAlias - # @sg-ignore flow sensitive typing should support case/when unless closure.name == '' || decl.new_name.absolute? Solargraph.assert_or_log(:rbs_closure, "Ignoring closure #{closure.inspect} on class alias #{decl.inspect}") end @@ -164,9 +205,9 @@ def fqns type_name # @return [void] def convert_self_type_to_pins decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) include_pin = Solargraph::Pin::Reference::Include.new( - name: decl.name.relative!.to_s, + name: type.name, type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, @@ -190,44 +231,32 @@ def convert_members_to_pins decl, closure def convert_member_to_pin member, closure, context case member when RBS::AST::Members::MethodDefinition - # @sg-ignore flow based typing needs to understand case when class pattern method_def_to_pin(member, closure, context) when RBS::AST::Members::AttrReader - # @sg-ignore flow based typing needs to understand case when class pattern attr_reader_to_pin(member, closure, context) when RBS::AST::Members::AttrWriter - # @sg-ignore flow based typing needs to understand case when class pattern attr_writer_to_pin(member, closure, context) when RBS::AST::Members::AttrAccessor - # @sg-ignore flow based typing needs to understand case when class pattern attr_accessor_to_pin(member, closure, context) when RBS::AST::Members::Include - # @sg-ignore flow based typing needs to understand case when class pattern include_to_pin(member, closure) when RBS::AST::Members::Prepend - # @sg-ignore flow based typing needs to understand case when class pattern prepend_to_pin(member, closure) when RBS::AST::Members::Extend - # @sg-ignore flow based typing needs to understand case when class pattern extend_to_pin(member, closure) when RBS::AST::Members::Alias - # @sg-ignore flow based typing needs to understand case when class pattern alias_to_pin(member, closure) when RBS::AST::Members::ClassInstanceVariable - # @sg-ignore flow based typing needs to understand case when class pattern civar_to_pin(member, closure) when RBS::AST::Members::ClassVariable - # @sg-ignore flow based typing needs to understand case when class pattern cvar_to_pin(member, closure) when RBS::AST::Members::InstanceVariable - # @sg-ignore flow based typing needs to understand case when class pattern ivar_to_pin(member, closure) when RBS::AST::Members::Public return Context.new(:public) when RBS::AST::Members::Private return Context.new(:private) when RBS::AST::Declarations::Base - # @sg-ignore flow based typing needs to understand case when class pattern convert_decl_to_pin(member, closure) else Solargraph.logger.warn "Skipping member type #{member.class}" @@ -253,10 +282,10 @@ def class_decl_to_pin decl # @type [Hash{String => ComplexType, ComplexType::UniqueType}] generic_defaults = {} decl.type_params.each do |param| - if param.default_type - complex_type = RbsTranslator.to_complex_type(param.default_type).force_rooted - generic_defaults[param.name.to_s] = complex_type - end + next unless param.default_type + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes, nil + complex_type = RbsTranslator.to_complex_type(param.default_type, type_alias_decls: type_alias_decls).force_rooted + generic_defaults[param.name.to_s] = complex_type end class_name = fqns(decl.name) @@ -279,8 +308,7 @@ def class_decl_to_pin decl pins.push class_pin if decl.super_class type = build_type(decl.super_class.name, decl.super_class.args) - generic_values = type.all_params.map(&:to_s) - superclass_name = decl.super_class.name.to_s + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Superclass.new( type_location: location_decl_to_pin_location(decl.super_class.location), closure: class_pin, @@ -299,7 +327,7 @@ def interface_decl_to_pin decl class_pin = Solargraph::Pin::Namespace.new( type: :module, type_location: location_decl_to_pin_location(decl.location), - name: decl.name.relative!.to_s, + name: fqns(decl.name), closure: Solargraph::Pin::ROOT_PIN, comments: decl.comment&.string, generics: type_parameter_names(decl), @@ -318,7 +346,7 @@ def interface_decl_to_pin decl def module_decl_to_pin decl module_pin = Solargraph::Pin::Namespace.new( type: :module, - name: decl.name.relative!.to_s, + name: fqns(decl.name), type_location: location_decl_to_pin_location(decl.location), closure: Solargraph::Pin::ROOT_PIN, comments: decl.comment&.string, @@ -392,7 +420,8 @@ def module_alias_decl_to_pin decl # @param decl [RBS::AST::Declarations::Constant] # @return [void] def constant_decl_to_pin decl - tag = RbsTranslator.to_complex_type(decl.type) + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls) pins.push create_constant(decl.name.relative!.to_s, tag, decl.comment&.string, decl) end @@ -408,7 +437,8 @@ def global_decl_to_pin decl type_location: location_decl_to_pin_location(decl.location), source: :rbs ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:type, '', rooted_tag)) pins.push pin end @@ -459,7 +489,6 @@ def global_decl_to_pin decl # @param scope [Symbol] :instance or :class # @param name [String] The name of the method # @return [Symbol] - # @sg-ignore Declared return type ::Symbol does not match inferred type # ::Symbol, :public, :private, nil for Solargraph::RbsMap::Conversions#calculate_method_visibility def calculate_method_visibility decl, context, closure, scope, name override_key = [closure.path, scope, name] @@ -507,6 +536,7 @@ def method_def_to_pin decl, closure, context visibility: visibility, source: :rbs ) + # @sg-ignore Wrong argument type for Array#concat: other_arrays expected Array>, _ToAry>, received void pin.signatures.concat method_def_to_sigs(decl, pin) pins.push pin if pin.name == 'initialize' @@ -514,24 +544,24 @@ def method_def_to_pin decl, closure, context pin.instance_variable_set(:@return_type, ComplexType::VOID) end end - if decl.singleton? - final_scope = :class - name = decl.name.to_s - visibility = calculate_method_visibility(decl, context, closure, final_scope, name) - pin = Solargraph::Pin::Method.new( - name: name, - closure: closure, - comments: decl.comment&.string, - type_location: location_decl_to_pin_location(decl.location), - visibility: visibility, - scope: final_scope, - signatures: [], - generics: generics, - source: :rbs - ) - pin.signatures.concat method_def_to_sigs(decl, pin) - pins.push pin - end + return unless decl.singleton? + final_scope = :class + name = decl.name.to_s + visibility = calculate_method_visibility(decl, context, closure, final_scope, name) + pin = Solargraph::Pin::Method.new( + name: name, + closure: closure, + comments: decl.comment&.string, + type_location: location_decl_to_pin_location(decl.location), + visibility: visibility, + scope: final_scope, + signatures: [], + generics: generics, + source: :rbs + ) + # @sg-ignore Wrong argument type for Array#concat: other_arrays expected Array>, _ToAry>, received void + pin.signatures.concat method_def_to_sigs(decl, pin) + pins.push pin end # @param decl [RBS::AST::Members::MethodDefinition] @@ -551,19 +581,23 @@ def method_def_to_sigs decl, pin Pin::Signature.new(generics: generics, parameters: block_parameters, return_type: block_return_type, source: :rbs, type_location: type_location, closure: pin) end - Pin::Signature.new(generics: generics, parameters: signature_parameters, return_type: signature_return_type, block: block, source: :rbs, + Pin::Signature.new(generics: generics, parameters: signature_parameters, return_type: signature_return_type, block: block, + block_required: overload.method_type.block&.required || false, source: :rbs, type_location: type_location, closure: pin) end end # @param location [RBS::Location, nil] # @return [Solargraph::Location, nil] - def location_decl_to_pin_location(location) + def location_decl_to_pin_location location return nil if location&.name.nil? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 start_pos = Position.new(location.start_line - 1, location.start_column) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 end_pos = Position.new(location.end_line - 1, location.end_column) range = Range.new(start_pos, end_pos) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Location.new(location.name.to_s, range) end @@ -573,7 +607,8 @@ def location_decl_to_pin_location(location) # @return [Array(Array, ComplexType)] def parts_of_function type, pin, implicit_nil [ - RbsTranslator.to_parameter_pins(type, pin, pin.parameter_names), + # @sg-ignore Wrong argument type for to_parameter_pins: method_type expected RBS::MethodType, received RBS::MethodType, RBS::Types::Block + RbsTranslator.to_parameter_pins(type, pin, pin.parameter_names, type_alias_decls: type_alias_decls), extract_method_type_return_type(type, implicit_nil).force_rooted ] end @@ -596,7 +631,8 @@ def attr_reader_to_pin decl, closure, context visibility: visibility, source: :rbs ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:return, '', rooted_tag)) logger.debug do "Conversions#attr_reader_to_pin(name=#{name.inspect}, visibility=#{visibility.inspect}) => #{pin.inspect}" @@ -627,12 +663,14 @@ def attr_writer_to_pin decl, closure, context pin.parameters << Solargraph::Pin::Parameter.new( name: 'value', - return_type: RbsTranslator.to_complex_type(decl.type).force_rooted, + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + return_type: RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted, source: :rbs, closure: pin, type_location: type_location ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:return, '', rooted_tag)) pins.push pin end @@ -657,7 +695,8 @@ def ivar_to_pin decl, closure comments: decl.comment&.string, source: :rbs ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:type, '', rooted_tag)) pins.push pin end @@ -674,7 +713,8 @@ def cvar_to_pin decl, closure type_location: location_decl_to_pin_location(decl.location), source: :rbs ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:type, '', rooted_tag)) pins.push pin end @@ -691,7 +731,8 @@ def civar_to_pin decl, closure type_location: location_decl_to_pin_location(decl.location), source: :rbs ) - rooted_tag = RbsTranslator.to_complex_type(decl.type).force_rooted.rooted_tags + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + rooted_tag = RbsTranslator.to_complex_type(decl.type, type_alias_decls: type_alias_decls).force_rooted.rooted_tags pin.docstring.add_tag(YARD::Tags::Tag.new(:type, '', rooted_tag)) pins.push pin end @@ -701,9 +742,9 @@ def civar_to_pin decl, closure # @return [void] def include_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Include.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, @@ -718,8 +759,9 @@ def prepend_to_pin decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Prepend.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) @@ -732,8 +774,9 @@ def extend_to_pin decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Extend.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) @@ -760,27 +803,30 @@ def alias_to_pin decl, closure 'int' => 'Integer', 'untyped' => '', 'NilClass' => 'nil' - } + }.freeze private_constant :RBS_TO_YARD_TYPE # Extract a ComplexType from a MethodType's return type. # # This method will convert type aliases to concrete types. # - # @param type [RBS::MethodType] + # @param type [RBS::MethodType, RBS::Types::Block] + # @param implicit_nil [Boolean] # @return [ComplexType] + # @param [Object] implicit_nil def extract_method_type_return_type type, implicit_nil - tag = RbsTranslator.to_complex_type(type.type.return_type) - return ComplexType.parse("#{tag}, nil") if tag && implicit_nil - tag + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + tag = RbsTranslator.to_complex_type(type.type.return_type, type_alias_decls: type_alias_decls) + return ComplexType.parse("#{tag}, nil") if tag && implicit_nil + tag end # @param type_name [RBS::TypeName] # @param type_args [Enumerable] # @return [ComplexType::UniqueType] - def build_type(type_name, type_args = []) + def build_type type_name, type_args = [] base = RBS_TO_YARD_TYPE[type_name.relative!.to_s] || type_name.relative!.to_s - params = type_args.map { |arg| RbsTranslator.to_complex_type(arg).force_rooted } + params = type_args.map { |arg| RbsTranslator.to_complex_type(arg, type_alias_decls: type_alias_decls).force_rooted } if base == 'Hash' && params.length == 2 ComplexType::UniqueType.new(base, [params.first], [params.last], rooted: true, parameters_type: :hash) else @@ -797,9 +843,9 @@ def add_mixins decl, namespace # @todo are we handling prepend correctly? klass = mixin.is_a?(RBS::AST::Members::Include) ? Pin::Reference::Include : Pin::Reference::Extend type = build_type(mixin.name, mixin.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push klass.new( - name: mixin.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(mixin.location), generic_values: generic_values, closure: namespace, diff --git a/lib/solargraph/rbs_map/core_fills.rb b/lib/solargraph/rbs_map/core_fills.rb index 0116b15eb..f8ce4983e 100644 --- a/lib/solargraph/rbs_map/core_fills.rb +++ b/lib/solargraph/rbs_map/core_fills.rb @@ -36,6 +36,13 @@ module CoreFills source: :core_fill), Override.from_comment('Module#module_exec', '@yieldreceiver [::Module]', source: :core_fill), + # RBS records this binding only on Class#initialize's block + # ([self: Class] in rbs >= 4.1), not on Class#new, which is the + # method resolved for `Class.new { ... }`. The runtime type is + # parameterized by the superclass argument, which @yieldreceiver + # can't reference, so this matches RBS's own unparameterized Class. + Override.from_comment('Class#new', '@yieldreceiver [::Class]', + source: :core_fill), # RBS does not define Class with a generic, so all calls to # generic() return an 'untyped'. We can do better: Override.method_return('Class#allocate', 'self', source: :core_fill) diff --git a/lib/solargraph/rbs_translator.rb b/lib/solargraph/rbs_translator.rb index a070de1e1..b5a7725a1 100644 --- a/lib/solargraph/rbs_translator.rb +++ b/lib/solargraph/rbs_translator.rb @@ -10,36 +10,130 @@ module RbsTranslator 'int' => 'Integer', 'untyped' => '', 'NilClass' => 'nil' - } + }.freeze + # Translates an RBS type into a ComplexType. + # + # Every RBS node that can *contain* another type - intersections, + # unions, optionals, tuples, and generic type arguments - is built + # directly as a ComplexType/UniqueType object graph by recursing + # through this method, rather than by rendering a tag string and + # re-parsing it. A joined string can't represent grouping that + # Solargraph's own tag grammar has no syntax for (e.g. a union + # nested inside an intersection, `(A | B) & C`), so round-tripping + # through one silently produces the wrong structure. Only leaf + # types that can't contain a nested type (literals, `bool`, `nil`, + # `void`, generic type variables, `self`/`instance`, `Proc`, etc.) + # go through the tag-string fallback in type_to_tag. + # # @param type [RBS::Types::Bases::Base] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] Names of aliases already + # being expanded in this call chain, to detect recursive aliases # @return [ComplexType] - def self.to_complex_type(type) - tag = type_to_tag(type) - ComplexType.try_parse(tag).force_rooted + def self.to_complex_type type, type_alias_decls: {}, expanding_aliases: [] + # See the comment on type_to_tag - case/when doesn't narrow + # `type` for the type checker, so member calls that only exist + # on the matched class (#name, #args) need an inline ignore + # comment. Tracked at https://github.com/castwide/solargraph/issues/1241 + case type + when RBS::Types::Intersection + intersection_complex_type(type, type_alias_decls, expanding_aliases) + when RBS::Types::Optional + optional_complex_type(type, type_alias_decls, expanding_aliases) + when RBS::Types::Union + union_complex_type(type, type_alias_decls, expanding_aliases) + when RBS::Types::Tuple + tuple_complex_type(type, type_alias_decls, expanding_aliases) + when RBS::Types::Alias + # A top-level type alias use, e.g., 'bool' in "type bool = true + # | false". Expand to the alias's underlying type so structural + # conformance checks (e.g., typechecking) can compare against + # its actual members rather than its nominal name. Fall back to + # the nominal tag if the alias definition isn't known, if it's + # recursive, or if it's generic (e.g. "type box[T] = Array[T] | + # nil") - expanding those would leak an unbound generic tag, + # since args aren't substituted into the expansion. + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type + alias_name = type.name.to_s + alias_decl = type_alias_decls[alias_name] + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type + if alias_decl.nil? || expanding_aliases.include?(alias_name) || !type.args.empty? + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type + ComplexType.new([build_unique_type(type.name, type.args, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases)]).force_rooted + else + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + to_complex_type(alias_decl.type, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases + [alias_name]) + end + when RBS::Types::ClassInstance, RBS::Types::Interface + # `Interface` represents a mix-in module which can be considered a + # subtype of a consumer of it + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type + ComplexType.new([build_unique_type(type.name, type.args, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases)]).force_rooted + when RBS::Types::ClassSingleton + # e.g., singleton(String) + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type + ComplexType.new([build_unique_type(type.name, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases)]).force_rooted + else + tag = type_to_tag(type) + ComplexType.try_parse(tag).force_rooted + end end # @param param_type [RBS::Types::Function::Param] # @param name [String] # @param decl [Symbol] # @param closure [Pin::Closure] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] # @return [Pin::Parameter] - def self.to_parameter_pin(param_type, name, decl, closure) - return_type = if decl == :restarg - ComplexType.parse('Array') - elsif decl == :kwrestarg - ComplexType.parse('Hash{Symbol => Object}') - else - RbsTranslator.to_complex_type(param_type.type) - end + def self.to_parameter_pin param_type, name, decl, closure, type_alias_decls: {} + return_type = case decl + when :restarg + RbsTranslator.to_restarg_return_type(param_type.type, type_alias_decls: type_alias_decls) + when :kwrestarg + RbsTranslator.to_kwrestarg_return_type(param_type.type, type_alias_decls: type_alias_decls) + else + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + RbsTranslator.to_complex_type(param_type.type, type_alias_decls: type_alias_decls) + end Solargraph::Pin::Parameter.new(decl: decl, name: name, closure: closure, return_type: return_type, source: :rbs, type_location: to_sg_location(param_type.location) || closure.type_location) end + # The type of the local variable a restarg is captured into + # inside the method body - a wrapped Array of its per-element + # type, e.g. `Array` for `*args: Integer`. When the + # element type isn't known (e.g. an untyped inline `#:` + # annotation), falls back to a bare, unparameterized Array. + # + # @param elem_rbs_type [RBS::Types::t] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @return [ComplexType] + def self.to_restarg_return_type elem_rbs_type, type_alias_decls: {} + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + elem_type = RbsTranslator.to_complex_type(elem_rbs_type, type_alias_decls: type_alias_decls) + return ComplexType.parse('Array') if elem_type.undefined? + ComplexType.new([ComplexType::UniqueType.new('Array', [], [elem_type], rooted: true, parameters_type: :list)]) + end + + # Likewise, the type of the local variable a kwrestarg is + # captured into - a wrapped Hash of Symbol to its per-value type. + # + # @param elem_rbs_type [RBS::Types::t] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @return [ComplexType] + def self.to_kwrestarg_return_type elem_rbs_type, type_alias_decls: {} + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + elem_type = RbsTranslator.to_complex_type(elem_rbs_type, type_alias_decls: type_alias_decls) + return ComplexType.parse('Hash{Symbol => Object}') if elem_type.undefined? + ComplexType.new([ComplexType::UniqueType.new('Hash', [ComplexType.try_parse('Symbol')], [elem_type], rooted: true, parameters_type: :hash)]) + end + # @param method_type [RBS::MethodType] # @param closure [Pin::Closure] # @param parameter_names [Array] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] # @return [Array] - def self.to_parameter_pins method_type, closure, parameter_names = [] + def self.to_parameter_pins method_type, closure, parameter_names = [], type_alias_decls: {} if defined?(RBS::Types::UntypedFunction) && method_type.type.is_a?(RBS::Types::UntypedFunction) return [ Solargraph::Pin::Parameter.new(decl: :restarg, name: 'arg', closure: closure, source: :rbs) @@ -49,27 +143,39 @@ def self.to_parameter_pins method_type, closure, parameter_names = [] arg_num = 0 params = [] method_type.type.required_positionals.each do |param| - params.push RbsTranslator.to_parameter_pin(param, param.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :arg, closure) + # @sg-ignore Unresolved call to name on RBS::Types::Function::Param + params.push RbsTranslator.to_parameter_pin(param, param.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :arg, closure, type_alias_decls: type_alias_decls) arg_num += 1 end method_type.type.optional_positionals.each do |param| - params.push RbsTranslator.to_parameter_pin(param, param.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :optarg, closure) + # @sg-ignore Unresolved call to name on RBS::Types::Function::Param + params.push RbsTranslator.to_parameter_pin(param, param.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :optarg, closure, type_alias_decls: type_alias_decls) arg_num += 1 end if method_type.type.rest_positionals - params.push RbsTranslator.to_parameter_pin(method_type.type.rest_positionals, method_type.type.rest_positionals.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :restarg, closure) + # @sg-ignore Unresolved call to rest_positionals on generic + rest_positionals = method_type.type.rest_positionals + # @sg-ignore Unresolved call to name on RBS::Types::Function::Param + rest_name = rest_positionals.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}" + params.push RbsTranslator.to_parameter_pin(rest_positionals, rest_name, :restarg, closure, type_alias_decls: type_alias_decls) arg_num += 1 end method_type.type.required_keywords.each do |param| - params.push RbsTranslator.to_parameter_pin(param.last, param.first.to_s, :kwarg, closure) + # @sg-ignore Unresolved calls to last, first on generic + params.push RbsTranslator.to_parameter_pin(param.last, param.first.to_s, :kwarg, closure, type_alias_decls: type_alias_decls) arg_num += 1 end method_type.type.optional_keywords.each do |param| - params.push RbsTranslator.to_parameter_pin(param.last, param.first.to_s, :kwoptarg, closure) + # @sg-ignore Unresolved calls to last, first on generic + params.push RbsTranslator.to_parameter_pin(param.last, param.first.to_s, :kwoptarg, closure, type_alias_decls: type_alias_decls) arg_num += 1 end if method_type.type.rest_keywords - params.push RbsTranslator.to_parameter_pin(method_type.type.rest_keywords, method_type.type.rest_keywords.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}", :kwrestarg, closure) + # @sg-ignore Unresolved call to rest_keywords on generic + rest_keywords = method_type.type.rest_keywords + # @sg-ignore Unresolved call to name on RBS::Types::Function::Param + rest_keywords_name = rest_keywords.name&.to_s || parameter_names[arg_num] || "arg_#{arg_num}" + params.push RbsTranslator.to_parameter_pin(rest_keywords, rest_keywords_name, :kwrestarg, closure, type_alias_decls: type_alias_decls) end params end @@ -77,30 +183,37 @@ def self.to_parameter_pins method_type, closure, parameter_names = [] # @param method_type [RBS::MethodType] # @param closure [Pin::Closure] # @param parameter_names [Array] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] # @return [Pin::Signature] - def self.to_signature method_type, closure, parameter_names = [] + def self.to_signature method_type, closure, parameter_names = [], type_alias_decls: {} # There may be edge cases here around different signatures # having different type params / orders - we may need to match # this data model and have generics live in signatures to # handle those correctly generics = method_type.type_params.map(&:name).map(&:to_s).uniq - parameters = to_parameter_pins(method_type, closure, parameter_names) - return_type = to_complex_type(method_type.type.return_type) + parameters = to_parameter_pins(method_type, closure, parameter_names, type_alias_decls: type_alias_decls) + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + return_type = to_complex_type(method_type.type.return_type, type_alias_decls: type_alias_decls) block = if method_type.block - block_parameters = to_parameter_pins(method_type.block, closure) - block_return_type = to_complex_type(method_type.block.type.return_type) - Pin::Signature.new(generics: generics, parameters: block_parameters, return_type: block_return_type, source: :rbs, type_location: closure.location, closure: closure) - end - Pin::Signature.new(generics: generics, parameters: parameters, return_type: return_type, block: block, source: :rbs, type_location: closure.location, closure: closure) + # @sg-ignore Wrong argument type for to_parameter_pins: method_type expected RBS::MethodType, received RBS::MethodType, RBS::Types::Block + block_parameters = to_parameter_pins(method_type.block, closure, type_alias_decls: type_alias_decls) + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + block_return_type = to_complex_type(method_type.block.type.return_type, type_alias_decls: type_alias_decls) + Pin::Signature.new(generics: generics, parameters: block_parameters, return_type: block_return_type, source: :rbs, type_location: closure.location, closure: closure) + end + Pin::Signature.new(generics: generics, parameters: parameters, return_type: return_type, block: block, + block_required: method_type.block&.required || false, source: :rbs, type_location: closure.location, closure: closure) end # @param type_name [RBS::TypeName] # @param type_args [Enumerable] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] # @return [ComplexType::UniqueType] - def self.build_unique_type(type_name, type_args = []) + def self.build_unique_type type_name, type_args = [], type_alias_decls: {}, expanding_aliases: [] base = RBS_TO_YARD_TYPE[type_name.relative!.to_s] || type_name.relative!.to_s params = type_args.map do |a| - RbsTranslator.to_complex_type(a) + RbsTranslator.to_complex_type(a, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases) end if base == 'Hash' && params.length == 2 ComplexType::UniqueType.new(base, [params.first], [params.last], rooted: true, parameters_type: :hash) @@ -111,32 +224,81 @@ def self.build_unique_type(type_name, type_args = []) # @param location [RBS::Location, nil] # @return [Solargraph::Location, nil] - def self.to_sg_location(location) + def self.to_sg_location location return nil if location&.name.nil? + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 start_pos = Position.new(location.start_line - 1, location.start_column) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 end_pos = Position.new(location.end_line - 1, location.end_column) range = Range.new(start_pos, end_pos) + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 Location.new(location.name.to_s, range) end class << self private + # @param type [RBS::Types::Intersection] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] + # @return [ComplexType] + def intersection_complex_type type, type_alias_decls = {}, expanding_aliases = [] + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + conjuncts = type.types.map { |member| RbsTranslator.to_complex_type(member, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases) } + ComplexType.new([ComplexType::UniqueType::Intersection.new(conjuncts)]).force_rooted + end + + # @param type [RBS::Types::Optional] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] + # @return [ComplexType] + def optional_complex_type type, type_alias_decls = {}, expanding_aliases = [] + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + inner = RbsTranslator.to_complex_type(type.type, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases) + ComplexType.new(inner.items + [ComplexType::UniqueType::NIL]).force_rooted + end + + # @param type [RBS::Types::Union] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] + # @return [ComplexType] + def union_complex_type type, type_alias_decls = {}, expanding_aliases = [] + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + ComplexType.new(type.types.flat_map { |t| RbsTranslator.to_complex_type(t, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases).items }).force_rooted + end + + # @param type [RBS::Types::Tuple] + # @param type_alias_decls [Hash{String => RBS::AST::Declarations::TypeAlias}] + # @param expanding_aliases [Array] + # @return [ComplexType] + def tuple_complex_type type, type_alias_decls = {}, expanding_aliases = [] + # @sg-ignore Wrong argument type for to_complex_type: type expected RBS::Types::Bases::Base, received union of concrete subtypes + subtypes = type.types.map { |t| RbsTranslator.to_complex_type(t, type_alias_decls: type_alias_decls, expanding_aliases: expanding_aliases) } + ComplexType.new([ComplexType::UniqueType.new('Array', [], subtypes, rooted: true, parameters_type: :fixed)]).force_rooted + end + + # Renders a leaf RBS type (one that can't contain another type) + # as a tag string. Composite/recursive types (Optional, Union, + # Tuple, Intersection, Alias, ClassInstance, ClassSingleton) are + # handled directly in to_complex_type instead - see its comment. + # # @param type [RBS::Types::Bases::Base] # @return [String] def type_to_tag type + # Every branch below narrows `type` by class via `when`, but + # the type checker doesn't propagate that narrowing to calls + # inside the branch body - it still sees the full RBS::Types::t + # union, so calls to members that only exist on the matched + # class (e.g. #type, #types, #literal, #name, #args) need an + # inline ignore comment. Tracked at + # https://github.com/castwide/solargraph/issues/1241 case type - when RBS::Types::Optional - "#{type_to_tag(type.type)}, nil" when RBS::Types::Bases::Bool 'Boolean' - when RBS::Types::Tuple - "Array(#{type.types.map { |t| type_to_tag(t) }.join(', ')})" when RBS::Types::Literal + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type type.literal.inspect - when RBS::Types::Union - type.types.map { |t| type_to_tag(t) }.join(', ') when RBS::Types::Record # @todo Better record support 'Hash' @@ -145,62 +307,26 @@ def type_to_tag type when RBS::Types::Bases::Void 'void' when RBS::Types::Variable + # @sg-ignore https://github.com/castwide/solargraph/issues/1241 - case/when doesn't narrow type "#{Solargraph::ComplexType::GENERIC_TAG_NAME}<#{type.name}>" when RBS::Types::Bases::Self, RBS::Types::Bases::Instance 'self' when RBS::Types::Bases::Top # `Top` is the most super superclass 'BasicObject' - when RBS::Types::Intersection - type.types.map { |member| type_to_tag(member) }.join(', ') when RBS::Types::Proc 'Proc' - when RBS::Types::ClassInstance, RBS::Types::Alias, RBS::Types::Interface - # `Alias` is a top-level type alias, e.g., 'bool' in "type bool = true | false" - # @todo ensure these get resolved after processing all aliases - # @todo handle recursive aliases - # - # `Interface represents a mix-in module which can be considered a - # subtype of a consumer of it - # - type_tag(type.name, type.args) - when RBS::Types::ClassSingleton - # e.g., singleton(String) - type_tag(type.name) - when RBS::Types::Bases::Any, RBS::Types::Bases::Bottom - # `Bottom`` is used in contexts where nothing will ever return - # - e.g., it could be the return type of 'exit()' or 'raise' - # @todo define a specific bottom type and use it to - # determine dead code - # + when RBS::Types::Bases::Any 'undefined' + when RBS::Types::Bases::Bottom + # `Bottom` is used in contexts where nothing will ever return + # - e.g., it could be the return type of 'exit()' or 'raise' + 'bot' else Solargraph.logger.warn "Unrecognized RBS type: #{type.class} at #{type.location}" 'undefined' end end - - # @param type_name [RBS::TypeName] - # @param type_args [Enumerable] - # @return [String] - def type_tag(type_name, type_args = []) - build_type(type_name, type_args).tags - end - - # @param type_name [RBS::TypeName] - # @param type_args [Enumerable] - # @return [ComplexType::UniqueType] - def build_type(type_name, type_args = []) - base = RBS_TO_YARD_TYPE[type_name.relative!.to_s] || type_name.relative!.to_s - params = type_args.map { |a| type_to_tag(a) }.map do |t| - ComplexType.try_parse(t) - end - if base == 'Hash' && params.length == 2 - ComplexType::UniqueType.new(base, [params.first], [params.last], rooted: true, parameters_type: :hash) - else - ComplexType::UniqueType.new(base, [], params.reject(&:undefined?), rooted: true, parameters_type: :list) - end - end end end end diff --git a/lib/solargraph/shell.rb b/lib/solargraph/shell.rb index 89859da21..23ed3d607 100755 --- a/lib/solargraph/shell.rb +++ b/lib/solargraph/shell.rb @@ -109,20 +109,8 @@ def clear # @param gem [String] # @param version [String, nil] def cache gem, version = nil - gemspec = Gem::Specification.find_by_name(gem, version) - - if options[:rebuild] || !PinCache.has_yard?(gemspec) - pins = GemPins.build_yard_pins(['yard-activesupport-concern'], gemspec) - PinCache.serialize_yard_gem(gemspec, pins) - end - - workspace = Solargraph::Workspace.new(Dir.pwd) if File.exist?('rbs_collection.yaml') - rbs_map = RbsMap.from_gemspec(gemspec, workspace&.rbs_collection_path, workspace&.rbs_collection_config_path) - if options[:rebuild] || !PinCache.has_rbs_collection?(gemspec, rbs_map.cache_key) - PinCache.serialize_rbs_collection_gem(gemspec, rbs_map.cache_key, rbs_map.pins) - end - rescue Gem::MissingSpecError - warn "Gem '#{gem}' not found" + gems(gem + (version ? "=#{version}" : '')) + # ' end desc 'uncache GEM [...GEM]', 'Delete specific cached gem documentation' @@ -135,19 +123,23 @@ 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) + raise Thor::InvocationError, "Gem '#{gem}' not found" if spec.nil? + + workspace.uncache_gem(spec, out: $stdout) end end @@ -183,14 +175,12 @@ def gems *names workspace = Solargraph::Workspace.new('.') if names.empty? - Gem::Specification.to_a.each { |spec| do_cache spec, rebuild: options[:rebuild] } - $stderr.puts "Documentation cached for all #{Gem::Specification.count} gems." + workspace.cache_all_for_workspace!($stdout, rebuild: options[:rebuild]) else warn("Caching these gems: #{names}") names.each do |name| if name == 'core' - # @sg-ignore cache_core and core? are dynamically defined - PinCache.cache_core(out: $stdout) # if !PinCache.core? || options[:rebuild] + PinCache.cache_core(out: $stdout) if !PinCache.core? || options[:rebuild] next end @@ -198,18 +188,7 @@ def gems *names if gemspec.nil? warn "Gem '#{name}' not found" else - if options[:rebuild] || !PinCache.has_yard?(gemspec) - pins = GemPins.build_yard_pins(['yard-activesupport-concern'], gemspec) - PinCache.serialize_yard_gem(gemspec, pins) - end - - workspace = Solargraph::Workspace.new(Dir.pwd) - rbs_map = RbsMap.from_gemspec(gemspec, workspace.rbs_collection_path, workspace.rbs_collection_config_path) - if options[:rebuild] || !PinCache.has_rbs_collection?(gemspec, rbs_map.cache_key) - # cache pins even if result is zero, so we don't retry building pins - pins = rbs_map.pins || [] - PinCache.serialize_rbs_collection_gem(gemspec, rbs_map.cache_key, pins) - end + workspace.cache_gem(gemspec, rebuild: options[:rebuild], out: $stdout) end rescue Gem::MissingSpecError warn "Gem '#{name}' not found" @@ -295,7 +274,7 @@ def scan api_map = nil time = Benchmark.measure do api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout) - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 api_map.pins.each do |pin| puts pin_description(pin) if options[:verbose] pin.typify api_map @@ -348,9 +327,7 @@ def pin path [:class, *path.split('.', 2)] end - # @sg-ignore Wrong argument type for - # Solargraph::ApiMap#get_method_stack: rooted_tag - # expected String, received Array + # @sg-ignore Wrong argument type for Solargraph::ApiMap#get_method_stack: rooted_tag expected String, received Array pins = api_map.get_method_stack(ns, meth, scope: scope) else pins = api_map.get_path_pins path @@ -364,7 +341,6 @@ def pin path exit 1 when Pin::Namespace if options[:references] - # @sg-ignore Need to add nil check here superclass_tag = api_map.qualify_superclass(pin.return_type.tag) superclass_pin = api_map.get_path_pins(superclass_tag).first if superclass_tag references[:superclass] = superclass_pin if superclass_pin @@ -476,6 +452,7 @@ def host.send_notification method, params end end + # @sg-ignore Wrong argument type for File.absolute_path: file_name expected String, _ToStr, _ToPath, received String, nil file_uri = Solargraph::LanguageServer::UriHelpers.file_to_uri(File.absolute_path(test_file)) puts "Profiling go-to-definition for #{test_file}" @@ -525,6 +502,7 @@ def host.send_notification method, params desc 'rbs', 'Generate RBS definitions' option :filename, type: :string, alias: :f, desc: 'Generated file name', default: 'sig.rbs' option :inference, type: :boolean, desc: 'Enhance definitions with type inference', default: true + # @return [void] def rbs api_map = Solargraph::ApiMap.load('.') pins = api_map.source_maps.flat_map(&:pins) @@ -534,7 +512,9 @@ def rbs store.method_pins.each do |pin| next unless pin.return_type.undefined? type = pin.typify(api_map) + # @sg-ignore Need to add nil check here type = pin.probe(api_map) if type.undefined? + # @sg-ignore Need to add nil check here pin.docstring.add_tag YARD::Tags::Tag.new('return', nil, type.items.map(&:to_s)) pin.instance_variable_set(:@return_type, type) end @@ -564,7 +544,6 @@ def rbs def pin_description pin desc = if pin.path.nil? || pin.path.empty? if pin.closure - # @sg-ignore Need to add nil check here "#{pin.closure.path} | #{pin.name}" else "#{pin.context.namespace} | #{pin.name}" @@ -572,7 +551,6 @@ def pin_description pin else pin.path end - # @sg-ignore Need to add nil check here desc += " (#{pin.location.filename} #{pin.location.range.start.line})" if pin.location desc end @@ -596,27 +574,5 @@ def print_pin pin puts pin.inspect end end - - # @param gemspec [Gem::Specification, nil] - # @param rebuild [Boolean] - # @return [void] - def do_cache gemspec, rebuild: false - if gemspec.nil? - warn "Gem '#{gemspec&.name}' not found" - else - if rebuild || !PinCache.has_yard?(gemspec) - pins = GemPins.build_yard_pins(['yard-activesupport-concern'], gemspec) - PinCache.serialize_yard_gem(gemspec, pins) - end - - workspace = Solargraph::Workspace.new(Dir.pwd) - rbs_map = RbsMap.from_gemspec(gemspec, workspace.rbs_collection_path, workspace.rbs_collection_config_path) - if rebuild || !PinCache.has_rbs_collection?(gemspec, rbs_map.cache_key) - # cache pins even if result is zero, so we don't retry building pins - pins = rbs_map.pins || [] - PinCache.serialize_rbs_collection_gem(gemspec, rbs_map.cache_key, pins) - end - end - end end end diff --git a/lib/solargraph/source.rb b/lib/solargraph/source.rb index 94bd3569b..dcea0bb98 100644 --- a/lib/solargraph/source.rb +++ b/lib/solargraph/source.rb @@ -74,6 +74,7 @@ def from_to l1, c1, l2, c2 # @param line [Integer] # @param column [Integer] # @return [AST::Node] + # @sg-ignore Need to add nil check here def node_at line, column tree_at(line, column).first end @@ -252,11 +253,13 @@ def synchronized? # @return [Hash{Integer => Array}] def associated_comments @associated_comments ||= begin - # @type [Hash{Integer => String}] + # @type [Hash{Integer => Array}] result = {} buffer = [] # @type [Integer, nil] last = nil + # @param num [Integer] + # @param snip [Solargraph::Parser::Snippet] comments.each_pair do |num, snip| if !last || num == last + 1 buffer.push "#{snip.text}\n" @@ -277,6 +280,7 @@ def associated_comments # @return [Integer] def first_not_empty_from line cursor = line + # @sg-ignore Need to add nil check here cursor += 1 while cursor < code_lines.length && code_lines[cursor].strip.empty? cursor = line if cursor > code_lines.length - 1 cursor @@ -294,6 +298,7 @@ def inner_folding_ranges top, result = [], parent = nil range = Range.from_node(top) # @sg-ignore Need to add nil check here if (result.empty? || range.start.line > result.last.start.line) && range.ending.line - range.start.line >= 2 + # @sg-ignore Wrong argument type for Array#push: objects expected Solargraph::Range, received Solargraph::Range, nil result.push range end end @@ -311,6 +316,7 @@ def stringify_comment_array comments ctxt = [] started = false skip = nil + # @param l [String] comments&.each do |l| if l =~ /^#-\R/ ctxt.clear @@ -323,7 +329,7 @@ def stringify_comment_array comments ctxt.push p else here = p.index(/[^ \t]/) - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 skip = here if skip.nil? || here < skip ctxt.push p[skip..] end @@ -395,6 +401,7 @@ def inner_tree_at node, position, stack # @sg-ignore Need to add nil check here return unless here.contain?(position) stack.unshift node + # @param c [Parser::AST::Node] node.children.each do |c| next unless Parser.is_ast_node?(c) next if c.loc.expression.nil? @@ -409,7 +416,7 @@ def changes @changes ||= [] end - # @return [String] + # @return [String, nil] attr_writer :filename # @return [Integer] @@ -476,10 +483,10 @@ def repaired @repaired end - # @return [Boolean] + # @return [Boolean, nil] attr_writer :parsed - # @return [Hash{Integer => String} + # @return [Hash{Integer => Solargraph::Parser::Snippet}] attr_writer :comments # @return [Boolean] diff --git a/lib/solargraph/source/chain.rb b/lib/solargraph/source/chain.rb index ce58e7c94..7f161915f 100644 --- a/lib/solargraph/source/chain.rb +++ b/lib/solargraph/source/chain.rb @@ -109,9 +109,16 @@ def define api_map, name_pin, locals # # @todo ProxyType uses 'type' for the binder, but ' working_pin = name_pin + # Dotted-word path of the receiver chain seen so far (e.g. ['pin', + # 'location'] while about to resolve 'filename' in + # 'pin.location.filename'), or nil once a link is seen that isn't a + # simple, argument-less variable/call reference. Lets Chain::Call + # look up flow-sensitive-typing facts recorded against repeated + # calls to the same accessor. + receiver_path = [] # @sg-ignore Need to add nil check here links[0..-2].each do |link| - pins = link.resolve(api_map, working_pin, locals) + pins = link.resolve(api_map, working_pin, locals, receiver_path) type = infer_from_definitions(pins, working_pin, api_map, locals) if type.undefined? logger.debug do @@ -125,12 +132,18 @@ def define api_map, name_pin, locals # for the binder, as this is chaining off of it, and the # binder is now the lhs of the rhs we are evaluating. working_pin = Pin::ProxyType.anonymous(name_pin.context, binder: type, closure: name_pin, source: :chain) + receiver_path = next_receiver_path(receiver_path, link) logger.debug do "Chain#define(links=#{links.map(&:desc)}, name_pin=#{name_pin.inspect}, locals=#{locals}) - after processing #{link.desc}, new working_pin=#{working_pin} with binder #{working_pin.binder}" end end - links.last.last_context = working_pin - links.last.resolve(api_map, working_pin, locals) + # links is never empty -- the constructor pads an empty links + # array with UNDEFINED_CALL -- but Array#last is typed nilable. + last_link = links.last + return [] if last_link.nil? + + last_link.last_context = working_pin + last_link.resolve(api_map, working_pin, locals, receiver_path) end # @param api_map [ApiMap] @@ -167,8 +180,9 @@ def infer_uncached api_map, name_pin, locals end return ComplexType::UNDEFINED end + # @sg-ignore Need to add nil check here type = infer_from_definitions(pins, links.last.last_context, api_map, locals) - out = maybe_nil(type) + out = maybe_nil(type, api_map) logger.debug do "Chain#infer_uncached(links=#{links.map(&:desc)}, locals=#{locals.map(&:desc)}, " \ "name_pin=#{name_pin}, name_pin.closure=#{name_pin&.closure&.inspect}, " \ @@ -199,8 +213,38 @@ def splat? @splat end - def nullable? - links.any?(&:nullable?) + # Whether this chain's inferred type should have nil added to it + # because a `&.` earlier in the chain might have short-circuited + # evaluation to nil. + # + # A `&.` only makes the *immediate* call nil-or-normal-result; a + # later, non-safe-navigated call in the same chain is still + # invoked on whatever that produced. If receiver is nil at that + # point, Ruby calls the method on nil itself instead of skipping + # it - so nil only continues to flow through subsequent links + # that NilClass defines as returning `self` (e.g., #tap, + # #itself). Any other subsequent call resolves to a concrete, + # non-nil-including type (e.g. NilClass#== returns Boolean), and + # nil no longer needs to be tracked past that point (unless a + # later `&.` reintroduces it). + # + # @param api_map [ApiMap] + # @return [Boolean] + def nullable? api_map + currently_nullable = false + links.each do |link| + if link.nullable? + currently_nullable = true + next + end + next unless currently_nullable + next unless link.is_a?(Chain::Call) + + pin = api_map.get_method_stack('NilClass', link.word, scope: :instance).first + # @sg-ignore Need to add nil check here + currently_nullable = pin.nil? || pin.return_type.tag == 'self' + end + currently_nullable end include Logging @@ -285,17 +329,35 @@ def infer_from_definitions pins, name_pin, api_map, locals type.self_to_type(name_pin.context) end + # Extends a receiver-chain path (see #define) with the word from + # `link`, or breaks the chain (returns nil) once `link` is anything + # other than a simple, argument-less variable/call reference. + # + # @param path [::Array, nil] + # @param link [Chain::Link] + # @return [::Array, nil] + def next_receiver_path path, link + return nil if path.nil? + return path + [link.word] if link.is_a?(Chain::InstanceVariable) + + simple_call = link.is_a?(Chain::Call) && !link.is_a?(Chain::ZSuper) && + link.arguments.empty? && !link.with_block? + return path + [link.word] if simple_call + + nil + end + # @param type [ComplexType, ComplexType::UniqueType] + # @param api_map [ApiMap] # @return [ComplexType, ComplexType::UniqueType] - def maybe_nil type + def maybe_nil type, api_map return type if type.undefined? || type.void? || type.nullable? - return type unless nullable? + return type unless nullable?(api_map) ComplexType.new(type.items + [ComplexType::NIL]) end protected - # @sg-ignore Fix "Not enough arguments to Module#protected" def equality_fields [links, node] end diff --git a/lib/solargraph/source/chain/array.rb b/lib/solargraph/source/chain/array.rb index 6159fd988..4a8580d15 100644 --- a/lib/solargraph/source/chain/array.rb +++ b/lib/solargraph/source/chain/array.rb @@ -18,8 +18,21 @@ def word # @param api_map [ApiMap] # @param name_pin [Pin::Base] # @param locals [::Array] - def resolve api_map, name_pin, locals - type = ComplexType::UniqueType.new('Array', rooted: true) + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil + child_types = @children.map do |child| + child.infer(api_map, name_pin, locals).simplify_literals + end + type = if child_types.empty? || child_types.any?(&:undefined?) + ComplexType::UniqueType.new('Array', rooted: true) + # @sg-ignore Unresolved call to defined? on Solargraph::ComplexType, nil + elsif child_types.uniq.length == 1 && child_types.first.defined? + ComplexType::UniqueType.new('Array', [], child_types.uniq, rooted: true, parameters_type: :list) + elsif child_types.empty? + ComplexType::UniqueType.new('Array', rooted: true, parameters_type: :list) + else + ComplexType::UniqueType.new('Array', [], child_types, rooted: true, parameters_type: :fixed) + end [Pin::ProxyType.anonymous(type, source: :chain)] end end diff --git a/lib/solargraph/source/chain/block_symbol.rb b/lib/solargraph/source/chain/block_symbol.rb index f0f9d789e..e73eca59e 100644 --- a/lib/solargraph/source/chain/block_symbol.rb +++ b/lib/solargraph/source/chain/block_symbol.rb @@ -4,7 +4,11 @@ module Solargraph class Source class Chain class BlockSymbol < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil [Pin::ProxyType.anonymous(ComplexType.try_parse('::Proc'), source: :chain)] end end diff --git a/lib/solargraph/source/chain/block_variable.rb b/lib/solargraph/source/chain/block_variable.rb index 5d9b00a13..b9303ff48 100644 --- a/lib/solargraph/source/chain/block_variable.rb +++ b/lib/solargraph/source/chain/block_variable.rb @@ -4,7 +4,11 @@ module Solargraph class Source class Chain class BlockVariable < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil [Pin::ProxyType.anonymous(ComplexType.try_parse('::Proc'), source: :chain)] end end diff --git a/lib/solargraph/source/chain/call.rb b/lib/solargraph/source/chain/call.rb index 52aa1121a..e65e7e324 100644 --- a/lib/solargraph/source/chain/call.rb +++ b/lib/solargraph/source/chain/call.rb @@ -43,10 +43,16 @@ def with_block? # @param api_map [ApiMap] # @param name_pin [Pin::Closure] name_pin.binder should give us the type of the object on which 'word' will be invoked # @param locals [::Array] - def resolve api_map, name_pin, locals + # @param receiver_path [::Array, nil] Dotted-word path of the + # receiver chain leading up to this call (see Chain#define). Used + # to find flow-sensitive-typing facts recorded against repeated + # calls to the same argument-less accessor, e.g. a nil check on + # 'pin.location' narrowing a later 'pin.location.filename'. + def resolve api_map, name_pin, locals, receiver_path = nil return super_pins(api_map, name_pin) if word == 'super' return yield_pins(api_map, name_pin) if word == 'yield' found = api_map.var_at_location(locals, word, name_pin, location) if head? + found ||= narrowed_call_pin(api_map, name_pin, locals, receiver_path) unless head? return inferred_pins([found], api_map, name_pin, locals) unless found.nil? binder = name_pin.binder @@ -55,22 +61,288 @@ def resolve api_map, name_pin, locals # chain.rb#maybe_nil will add the nil type later, we just # need to worry about the not-nil case - # @sg-ignore Need to handle duck-typed method calls on union types binder = binder.without_nil if nullable? - # @sg-ignore Need to handle duck-typed method calls on union types - pin_groups = binder.each_unique_type.map do |context| - ns_tag = context.namespace == '' ? '' : context.namespace_type.tag - stack = api_map.get_method_stack(ns_tag, word, scope: context.scope) - [stack.first].compact - end - pin_groups = [] if !api_map.loose_unions && pin_groups.any?(&:empty?) - pins = pin_groups.flatten.uniq(&:path) + pins = method_pins_for_binder(binder, api_map, name_pin.closure) return [] if pins.empty? inferred_pins(pins, api_map, name_pin, locals) end private + # Checks whether a single overload signature matches the call's + # arguments/block and, if so, resolves its return type. Threaded + # through the caller's accumulator so behavior matches the + # original inline loop: if the overload doesn't match, the + # incoming type/signature are returned unchanged. + # + # @param overload [Pin::Signature] + # @param pin [Pin::Method] + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param type [ComplexType] + # @param new_signature_pin [Pin::Signature, nil] + # @param require_literal [Boolean] see Pin::Parameter#compatible_arg? + # @return [::Array(ComplexType, Pin::Signature)] + def match_overload_type overload, pin, api_map, name_pin, locals, type, new_signature_pin, require_literal: true + return [type, new_signature_pin] unless overload.arity_matches?(arguments, with_block?) + + positional_arguments, keyword_argument = split_keyword_argument(arguments, overload) + atypes = [] + match = positional_arguments_match?(positional_arguments, overload, api_map, name_pin, locals, atypes, + require_literal: require_literal) + if match + match &&= keyword_argument_matches?(keyword_argument, overload, api_map, name_pin, locals, + require_literal: require_literal) + end + return [type, new_signature_pin] unless match + + if overload.block && with_block? + block_atypes = overload.block.parameters.map(&:return_type) + # @todo Need to add nil check here + # @sg-ignore Need to add nil check here + blocktype = if block.links.map(&:class) == [BlockSymbol] + # like the bar in foo(&:bar) + block_symbol_call_type(api_map, name_pin.context, block_atypes, locals) + else + block_call_type(api_map, name_pin, locals) + end + end + new_signature_pin = overload.resolve_generics_from_context_until_complete(overload.generics, atypes, nil, nil, + blocktype) + # @todo It shouldn't be necessary to choose either generics or macros + # @sg-ignore Need to add nil check here + new_return_type = if new_signature_pin.return_type.defined? + new_signature_pin.return_type + else + # @sg-ignore Need to add nil check here + named_types = pin.parameter_names.zip(arguments.map { |arg| ComplexType.try_parse(simple_convert(arg.node).to_s) }).to_h + pin.typify(api_map).expand(named_types) + end + self_type = if head? + # If we're at the head of the chain, we called a + # method somewhere that marked itself as returning + # self. Given we didn't invoke this on an object, + # this must be a method in this same class - so we + # use our own self type + name_pin.context + else + # if we're past the head in the chain, whatever the + # type of the lhs side is what 'self' will be in its + # declaration - we can't just use the type of the + # method pin, as this might be a subclass of the + # place where the method is defined + name_pin.binder + end + # This same logic applies to the YARD work done by + # 'with_params()'. + # + # qualify(), however, happens in the namespace where + # the docs were written - from the method pin. + # @todo Need to add nil check here + # @sg-ignore Unresolved call to defined? + if new_return_type.defined? + # @sg-ignore Unresolved call to self_to_type + type = with_params(new_return_type.self_to_type(self_type), self_type).qualify(api_map, *pin.gates) + end + type ||= ComplexType::UNDEFINED + [type, new_signature_pin] + end + + # Looks for a flow-sensitive-typing fact recorded against this + # exact call chained off of the same receiver expression -- e.g. + # the narrowing FlowSensitiveTyping records for 'pin.location' + # after a 'return unless pin.location' guard, consulted here while + # resolving the 'location' call in a later 'pin.location.filename'. + # + # Only applies to simple, argument-less, blockless calls whose + # entire receiver chain is itself simple -- the same shape + # FlowSensitiveTyping tracks facts against (see + # Chain#next_receiver_path). + # + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param receiver_path [::Array, nil] + # @return [Pin::Base, nil] + def narrowed_call_pin api_map, name_pin, locals, receiver_path + return nil if receiver_path.nil? || receiver_path.empty? + return nil unless arguments.empty? && !with_block? + + composite_name = (receiver_path + [word]).join('.') + api_map.var_at_location(locals, composite_name, name_pin, location) + end + + # Resolves the pins for calling `word` on every top-level + # alternative of a (possibly union) binder type. Each + # alternative must define the method unless + # api_map.loose_unions allows duck-typed leniency. + # + # @param binder_type [ComplexType, ComplexType::UniqueType] + # @param api_map [ApiMap] + # @param closure [Pin::Closure, nil] closure for any synthesized DuckMethod pins + # @return [::Array] + def method_pins_for_binder binder_type, api_map, closure + top_level_types = binder_type.is_a?(ComplexType) ? binder_type.to_a : [binder_type] + pin_groups = top_level_types.map { |unique_type| method_stack_pins(unique_type, api_map, closure) } + pin_groups = [] if !api_map.loose_unions && pin_groups.any?(&:nil?) + # Different alternatives can resolve to pins that share a + # path (e.g. the same generic method looked up against + # `Box` and `Box`) but that have already + # been resolved to different return types for their + # respective context - dedup on both so a real union + # doesn't silently lose every alternative but the first. + # @param p [Pin::Base] + pin_groups.compact.flatten.uniq { |p| [p.path, p.return_type.tag] } + end + + # Resolves the method stack's first pin for a single + # top-level unique type. An Intersection conjunct only needs + # *one* of its conjuncts to define the method (A & B <: A, + # A & B <: B) - the opposite of a union, where every + # alternative needs it - so it recurses through + # #method_pins_for_binder per conjunct (a conjunct is a full + # ComplexType, since RBS allows e.g. `(A | B) & C`) and + # accepts whichever conjuncts resolve. + # + # @param unique_type [ComplexType::UniqueType] + # @param api_map [ApiMap] + # @param closure [Pin::Closure, nil] closure for any synthesized DuckMethod pins + # @return [::Array, nil] nil when unresolved + def method_stack_pins unique_type, api_map, closure + if unique_type.is_a?(ComplexType::UniqueType::Intersection) + resolved = key_verified_conjuncts(unique_type.conjuncts, api_map).filter_map do |conjunct| + pins = method_pins_for_binder(conjunct, api_map, closure) + pins.empty? ? nil : pins + end + return nil if resolved.empty? + # @param p [Pin::Base] + resolved.flatten.uniq { |p| [p.path, p.return_type.tag] } + elsif unique_type.duck_type? && unique_type.name[1..] == word + # explicit: false skips arity checking; the duck type + # only tells us the method exists, not its signature + [Pin::DuckMethod.new(name: word, source: :chain, explicit: false, closure: closure)] + elsif unique_type.bot? + # bot is a subtype of every type, so any method call on a + # bot-typed receiver is vacuously valid - the code is + # unreachable, so there's no real pin to resolve against, + # but flagging it as "unresolved" would be a false + # positive. A DuckMethod pin (same one used for `#read`- + # style duck typing) gives match_overload_type a real + # Pin::Method to work with - explicit: false skips arity + # checking - while its return type stays bot, so bot keeps + # propagating through the rest of the chain instead of + # being treated as a real value. closure is threaded + # through from the call site's name_pin since DuckMethod + # pins have no location of their own to derive one from - + # without it, Pin::Base#closure raises under strict + # assertions the first time anything downstream (like + # match_overload_type) reads it. + [Pin::DuckMethod.new(name: word, source: :chain, explicit: false, return_type: ComplexType::BOT, closure: closure)] + else + ns_tag = unique_type.namespace == '' ? '' : unique_type.namespace_type.tag + stack = api_map.get_method_stack(ns_tag, word, scope: unique_type.scope) + return nil if stack.first.nil? + [stack.first] + end + end + + # Narrows a same-class-generic intersection's conjuncts to + # whichever ones we can positively verify match this call's own + # literal argument against a `_Key`-shaped parameter (e.g. + # `Hash#fetch: (Hash::_Key key) -> V`, `Hash#[]`), e.g. resolving + # `(Hash{"Index" => Float} & Hash{"Triggers" => Array<...>})#fetch("Index")` + # to just the "Index" conjunct instead of a union of both. + # + # RBS's own `_Key`-shaped signatures can't do this narrowing + # themselves - `_Key` is a structural hash/eql? interface, not + # literally `K`, so the key argument's type is never connected to + # the return type by ordinary overload resolution. This has to be + # done here, ahead of generic resolution, by matching the call's + # own literal argument directly against each conjunct's own + # `key_types` - which generalizes to any `_Key`-shaped method + # (`#fetch`, `#[]`, `#dig`, `#delete`, ...) without naming them. + # + # Conservative by construction: a conjunct is only ever narrowed + # away when *every* conjunct produced a positive verdict (matched + # or didn't). If we can't determine a verdict for even one + # conjunct - its method isn't `_Key`-shaped there, the argument + # isn't a literal, or it has no literal `key_types` to compare + # against - we don't have enough evidence to safely exclude + # anything, so every conjunct passes through unfiltered, same as + # before this method existed. + # + # @param conjuncts [::Array] + # @param api_map [ApiMap] + # @return [::Array] + def key_verified_conjuncts conjuncts, api_map + return conjuncts if arguments.empty? + + verdicts = conjuncts.map { |c| conjunct_key_verdict(c, api_map) } + return conjuncts if verdicts.any?(&:nil?) + + # @type [::Array] + matching = [] + conjuncts.each_with_index { |conjunct, i| matching.push(conjunct) if verdicts[i] } + matching.empty? ? conjuncts : matching + end + + # Whether this conjunct's own literal `key_types` positively match + # the call's own literal argument at the key parameter's position + # (see Pin::Signature#key_param_index), or nil if that can't be + # determined (no key-shaped parameter here, non-literal argument, + # or no literal `key_types` to compare against). + # + # @param conjunct [ComplexType] + # @param api_map [ApiMap] + # @return [Boolean, nil] + def conjunct_key_verdict conjunct, api_map + verdicts = conjunct.items.map { |unique_type| unique_type_key_verdict(unique_type, api_map) } + return nil if verdicts.any?(&:nil?) + + verdicts.all? + end + + # @param unique_type [ComplexType::UniqueType] + # @param api_map [ApiMap] + # @return [Boolean, nil] + def unique_type_key_verdict unique_type, api_map + return nil unless unique_type.literal_keyed? + + ns_tag = unique_type.namespace == '' ? '' : unique_type.namespace_type.tag + pin = api_map.get_method_stack(ns_tag, word, scope: unique_type.scope).first + return nil if pin.nil? + + key_tags = unique_type.key_types.map(&:tag) + index = pin.signatures.filter_map { |s| s.key_param_index(unique_type.namespace, api_map, key_tags) }.first + return nil if index.nil? || index >= arguments.length + + key_tag = literal_node_tag(arguments[index]&.node) + return nil if key_tag.nil? + + unique_type.key_type_tag?(key_tag) + end + + # The literal tag (e.g. `"Index"`, `:index`, `1`) a `UniqueType` + # built from this argument node's literal value would have, or nil + # if the node isn't a literal Hash-key-shaped value. + # + # @param node [Parser::AST::Node, Object] + # @return [String, nil] + def literal_node_tag node + return nil unless Parser.is_ast_node?(node) + + # @sg-ignore Translate to something flow sensitive typing understands + case node.type + # @sg-ignore Translate to something flow sensitive typing understands + when :str then node.children.first.inspect + # @sg-ignore Translate to something flow sensitive typing understands + when :sym then ":#{node.children.first}" + # @sg-ignore Translate to something flow sensitive typing understands + when :int then node.children.first.to_s + end + end + # @param pins [::Enumerable] # @param api_map [ApiMap] # @param name_pin [Pin::Base] @@ -93,79 +365,35 @@ def inferred_pins pins, api_map, name_pin, locals sorted_overloads = with_block + without_block # @type [Pin::Signature, nil] new_signature_pin = nil - # @sg-ignore flow sensitive typing should handle is_a? and next - # @param ol [Pin::Signature] - sorted_overloads.each do |ol| - next unless ol.arity_matches?(arguments, with_block?) - match = true - - atypes = [] - arguments.each_with_index do |arg, idx| - param = ol.parameters[idx] - if param.nil? - match = ol.parameters.any?(&:restarg?) - break - end - arg_name_pin = Pin::ProxyType.anonymous(name_pin.context, - closure: name_pin.closure, - gates: name_pin.gates, - source: :chain) - atype = atypes[idx] ||= arg.infer(api_map, arg_name_pin, locals) - unless param.compatible_arg?(atype, api_map) || param.restarg? - match = false - break - end - end - if match - if ol.block && with_block? - block_atypes = ol.block.parameters.map(&:return_type) - # @todo Need to add nil check here - blocktype = if block.links.map(&:class) == [BlockSymbol] - # like the bar in foo(&:bar) - block_symbol_call_type(api_map, name_pin.context, block_atypes, locals) - else - block_call_type(api_map, name_pin, locals) - end - end - new_signature_pin = ol.resolve_generics_from_context_until_complete(ol.generics, atypes, nil, nil, - blocktype) - # @todo It shouldn't be necessary to choose either generics or macros - new_return_type = if new_signature_pin.return_type.defined? - new_signature_pin.return_type - else - named_types = p.parameter_names.zip(arguments.map { |arg| ComplexType.try_parse(simple_convert(arg.node).to_s) }).to_h - p.typify(api_map).expand(named_types) - end - self_type = if head? - # If we're at the head of the chain, we called a - # method somewhere that marked itself as returning - # self. Given we didn't invoke this on an object, - # this must be a method in this same class - so we - # use our own self type - name_pin.context - else - # if we're past the head in the chain, whatever the - # type of the lhs side is what 'self' will be in its - # declaration - we can't just use the type of the - # method pin, as this might be a subclass of the - # place where the method is defined - name_pin.binder - end - # This same logic applies to the YARD work done by - # 'with_params()'. - # - # qualify(), however, happens in the namespace where - # the docs were written - from the method pin. - # @todo Need to add nil check here - if new_return_type.defined? - type = with_params(new_return_type.self_to_type(self_type), self_type).qualify(api_map, *p.gates) - end - type ||= ComplexType::UNDEFINED - end + # Two passes: first require an exact-literal match on any + # literal-typed overload parameter (so a real sibling + # catch-all, e.g. tuple's non-literal fallback, wins over a + # literal overload for a non-literal argument). If nothing + # matches at all, retry without that requirement - a + # literal-typed parameter with no non-literal sibling + # overload (e.g. Hash#fetch's key resolved to a literal + # from the receiver's declared type) would otherwise reject + # every candidate and fall through to the union of all + # overloads' return types instead of the one real match. + [true, false].each do |require_literal| break if type.defined? + # @sg-ignore flow sensitive typing should handle is_a? and next + # @param ol [Pin::Signature] + sorted_overloads.each do |ol| + type, new_signature_pin = match_overload_type(ol, p, api_map, name_pin, locals, type, new_signature_pin, + require_literal: require_literal) + break if type.defined? + end end p = p.with_single_signature(new_signature_pin) unless new_signature_pin.nil? next p.proxy(type) if type.defined? + if !p.macros.empty? + result = process_macro(p, api_map, name_pin.context, locals) + next result unless result.return_type.undefined? + elsif !p.directives.empty? + result = process_directive(p, api_map, name_pin.context, locals) + next result unless result.return_type.undefined? + end p end logger.debug do @@ -186,6 +414,145 @@ def inferred_pins pins, api_map, name_pin, locals end end + # @param pin [Pin::Base] + # @param api_map [ApiMap] + # @param context [ComplexType, ComplexType::UniqueType] + # @param locals [::Array] + # @return [Pin::Base] + def process_macro pin, api_map, context, locals + pin.macros.each do |macro| + # @todo 'Wrong argument type for + # Solargraph::Source::Chain::Call#inner_process_macro: + # macro expected YARD::Tags::MacroDirective, received + # generic' is because we lose 'rooted' information + # in the 'Chain::Array' class internally, leaving + # ::Array#each shadowed when it shouldn't be. + # @sg-ignore macro is Solargraph::YardMap::Macro, wraps a YARD::Tags::MacroDirective + result = inner_process_macro(pin, macro, api_map, context, locals) + return result unless result.return_type.undefined? + end + Pin::ProxyType.anonymous(ComplexType::UNDEFINED, source: :chain) + end + + # @param pin [Pin::Method] + # @param api_map [ApiMap] + # @param context [ComplexType, ComplexType::UniqueType] + # @param locals [::Array] + # @return [Pin::ProxyType] + def process_directive pin, api_map, context, locals + pin.directives.each do |dir| + macro = api_map.named_macro(dir.tag.name) + next if macro.nil? + # @sg-ignore macro is Solargraph::YardMap::Macro, wraps a YARD::Tags::MacroDirective + result = inner_process_macro(pin, macro, api_map, context, locals) + return result unless result.return_type.undefined? + end + Pin::ProxyType.anonymous ComplexType::UNDEFINED, source: :chain + end + + # @param pin [Pin::Base] + # @param macro [YARD::Tags::MacroDirective] + # @param api_map [ApiMap] + # @param context [ComplexType, ComplexType::UniqueType] + # @param locals [::Array] + # @return [Pin::ProxyType] + def inner_process_macro pin, macro, api_map, context, locals + vals = arguments.map { |c| Pin::ProxyType.anonymous(c.infer(api_map, pin, locals), source: :chain) } + txt = macro.tag.text.clone + # @sg-ignore Need to add nil check here + if txt.empty? && macro.tag.name + named = api_map.named_macro(macro.tag.name) + txt = named.tag.text.clone if named + end + i = 1 + vals.each do |v| + # @sg-ignore Need to add nil check here + txt.gsub!(/\$#{i}/, v.context.namespace) + i += 1 + end + # @sg-ignore Need to add nil check here + docstring = Solargraph::Source.parse_docstring(txt).to_docstring + tag = docstring.tag(:return) + unless tag.nil? || tag.types.nil? + return Pin::ProxyType.anonymous(ComplexType.try_parse(*tag.types), source: :chain) + end + Pin::ProxyType.anonymous(ComplexType::UNDEFINED, source: :chain) + end + + # A trailing keyword-arguments hash doesn't line up positionally + # with the method's declared parameters, so it's split off to be + # matched separately against the keyword/kwrest parameters + # instead of the positional ones. + # + # @param arguments [::Array] + # @param overload [Pin::Signature] + # @return [::Array(::Array, Chain, nil)] + def split_keyword_argument arguments, overload + keyword_params = overload.parameters.select { |param| param.keyword? || param.kwrestarg? } + last_argument = arguments.last + if !keyword_params.empty? && last_argument.is_a?(Chain) && last_argument.links.last.is_a?(Chain::Hash) + [arguments[0..-2], last_argument] + else + [arguments, nil] + end + end + + # @param positional_arguments [::Array] + # @param overload [Pin::Signature] + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param atypes [::Array] populated with the inferred type of each positional argument + # @param require_literal [Boolean] see Pin::Parameter#compatible_arg? + # @return [Boolean] + def positional_arguments_match? positional_arguments, overload, api_map, name_pin, locals, atypes, require_literal: true + positional_params = overload.parameters.reject { |param| param.keyword? || param.kwrestarg? } + positional_arguments.each_with_index do |arg, idx| + param = positional_params[idx] + return positional_params.any?(&:restarg?) if param.nil? + + arg_name_pin = Pin::ProxyType.anonymous(name_pin.context, + closure: name_pin.closure, + gates: name_pin.gates, + source: :chain) + atype = atypes[idx] ||= arg.infer(api_map, arg_name_pin, locals) + return false unless param.compatible_arg?(atype, api_map, require_literal: require_literal) || param.restarg? + end + true + end + + # @param keyword_argument [Chain, nil] + # @param overload [Pin::Signature] + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param require_literal [Boolean] see Pin::Parameter#compatible_arg? + # @return [Boolean] + def keyword_argument_matches? keyword_argument, overload, api_map, name_pin, locals, require_literal: true + return true if keyword_argument.nil? + + # @type [::Hash{::Symbol => Chain}] + kwargs = convert_hash(keyword_argument.node) + keyword_params = overload.parameters.select { |param| param.keyword? || param.kwrestarg? } + named_params = keyword_params.reject(&:kwrestarg?) + kwrestarg = keyword_params.find(&:kwrestarg?) + + kw_arg_name_pin = Pin::ProxyType.anonymous(name_pin.context, + closure: name_pin.closure, + gates: name_pin.gates, + source: :chain) + kwargs.each_pair do |key, value_chain| + param = named_params.find { |p| p.name.to_sym == key } + if param.nil? + return false if kwrestarg.nil? + next + end + atype = value_chain.infer(api_map, kw_arg_name_pin, locals) + return false unless param.compatible_arg?(atype, api_map, require_literal: require_literal) + end + named_params.none? { |param| param.decl == :kwarg && !kwargs.key?(param.name.to_sym) } + end + # @param docstring [YARD::Docstring] # @param context [ComplexType] # @return [ComplexType, nil] @@ -203,7 +570,6 @@ def extra_return_type docstring, context def find_method_pin name_pin method_pin = name_pin until method_pin.is_a?(Pin::Method) - # @sg-ignore Need to support this in flow sensitive typing method_pin = method_pin.closure return if method_pin.nil? end @@ -298,9 +664,7 @@ def block_call_type api_map, name_pin, locals protected - # @sg-ignore Fix "Not enough arguments to Module#protected" def equality_fields - # @sg-ignore literal arrays in this module turn into ::Solargraph::Source::Chain::Array super + [arguments, block] end end diff --git a/lib/solargraph/source/chain/class_variable.rb b/lib/solargraph/source/chain/class_variable.rb index f50028ffa..715d1b87d 100644 --- a/lib/solargraph/source/chain/class_variable.rb +++ b/lib/solargraph/source/chain/class_variable.rb @@ -4,7 +4,11 @@ module Solargraph class Source class Chain class ClassVariable < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil api_map.get_class_variable_pins(name_pin.context.namespace).select { |p| p.name == word } end end diff --git a/lib/solargraph/source/chain/constant.rb b/lib/solargraph/source/chain/constant.rb index e558a2e1f..e18d79fb5 100644 --- a/lib/solargraph/source/chain/constant.rb +++ b/lib/solargraph/source/chain/constant.rb @@ -10,7 +10,11 @@ def initialize word super end - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil return [Pin::ROOT_PIN] if word.empty? if word.start_with?('::') base = word[2..] diff --git a/lib/solargraph/source/chain/global_variable.rb b/lib/solargraph/source/chain/global_variable.rb index 335b8e42c..f1ab9e602 100644 --- a/lib/solargraph/source/chain/global_variable.rb +++ b/lib/solargraph/source/chain/global_variable.rb @@ -4,7 +4,11 @@ module Solargraph class Source class Chain class GlobalVariable < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil api_map.get_global_variable_pins.select { |p| p.name == word } end end diff --git a/lib/solargraph/source/chain/hash.rb b/lib/solargraph/source/chain/hash.rb index a75963478..69cf40ae4 100644 --- a/lib/solargraph/source/chain/hash.rb +++ b/lib/solargraph/source/chain/hash.rb @@ -16,7 +16,11 @@ def word @word ||= "<#{@type}>" end - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil [Pin::ProxyType.anonymous(@complex_type, source: :chain)] end @@ -26,9 +30,7 @@ def splatted? protected - # @sg-ignore Fix "Not enough arguments to Module#protected" def equality_fields - # @sg-ignore literal arrays in this module turn into ::Solargraph::Source::Chain::Array super + [@splatted] end end diff --git a/lib/solargraph/source/chain/head.rb b/lib/solargraph/source/chain/head.rb index 0f6d0cb57..16000b5c7 100644 --- a/lib/solargraph/source/chain/head.rb +++ b/lib/solargraph/source/chain/head.rb @@ -8,7 +8,11 @@ class Chain # # @note Chain::Head is only intended to handle `self` and `super`. class Head < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil return [Pin::ProxyType.anonymous(name_pin.binder, source: :chain)] if word == 'self' # return super_pins(api_map, name_pin) if word == 'super' [] diff --git a/lib/solargraph/source/chain/if.rb b/lib/solargraph/source/chain/if.rb index 186f6e6f0..012fa5078 100644 --- a/lib/solargraph/source/chain/if.rb +++ b/lib/solargraph/source/chain/if.rb @@ -11,7 +11,11 @@ def initialize links @links = links end - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil types = @links.map { |link| link.infer(api_map, name_pin, locals) } [Solargraph::Pin::ProxyType.anonymous(Solargraph::ComplexType.try_parse(types.map(&:tag).uniq.join(', ')), source: :chain)] @@ -19,9 +23,7 @@ def resolve api_map, name_pin, locals protected - # @sg-ignore Fix "Not enough arguments to Module#protected" def equality_fields - # @sg-ignore literal arrays in this module turn into ::Solargraph::Source::Chain::Array super + [@links] end end diff --git a/lib/solargraph/source/chain/instance_variable.rb b/lib/solargraph/source/chain/instance_variable.rb index ad5cc1fd9..16762e3c7 100644 --- a/lib/solargraph/source/chain/instance_variable.rb +++ b/lib/solargraph/source/chain/instance_variable.rb @@ -13,7 +13,8 @@ def initialize word, node, location @location = location end - def resolve api_map, name_pin, locals + # @sg-ignore Declared return type ::Array<::Solargraph::Pin::Base> does not match inferred type ::Array<::Solargraph::Pin::BaseVariable, ::NilClass> for Solargraph::Source::Chain::InstanceVaria + def resolve api_map, name_pin, locals, _receiver_path = nil ivars = api_map.get_instance_variable_pins(name_pin.context.namespace, name_pin.context.scope).select do |p| p.name == word end diff --git a/lib/solargraph/source/chain/link.rb b/lib/solargraph/source/chain/link.rb index 4eb6c7d5c..dfd27c96f 100644 --- a/lib/solargraph/source/chain/link.rb +++ b/lib/solargraph/source/chain/link.rb @@ -28,8 +28,15 @@ def constant? # @param api_map [ApiMap] # @param name_pin [Pin::Base] # @param locals [::Array] + # @param receiver_path [::Array, nil] Dotted-word path of the + # receiver chain leading up to (but not including) this link, when + # every preceding link is a simple, argument-less variable/call + # reference. Used by Chain::Call to look up flow-sensitive-typing + # facts recorded against repeated calls to the same accessor. nil + # once the chain includes something that breaks that guarantee + # (arguments, a block, a literal, etc). # @return [::Array] - def resolve api_map, name_pin, locals + def resolve api_map, name_pin, locals, receiver_path = nil [] end @@ -74,13 +81,6 @@ def desc protected - # @sg-ignore two problems - Declared return type - # ::Solargraph::Source::Chain::Array does not match inferred - # type ::Array(::Class<::Solargraph::Source::Chain::Link>, - # ::String) for - # Solargraph::Source::Chain::Link#equality_fields - # and - # Not enough arguments to Module#protected def equality_fields [self.class, word] end diff --git a/lib/solargraph/source/chain/literal.rb b/lib/solargraph/source/chain/literal.rb index 0c45c71f4..f60a3eb19 100644 --- a/lib/solargraph/source/chain/literal.rb +++ b/lib/solargraph/source/chain/literal.rb @@ -13,35 +13,29 @@ class Literal < Link def initialize type, node super("<#{type}>") - # @todo We might be able to do some light inference from literals and - # tuples as long as literal values are intransitive. - - # if node.is_a?(::Parser::AST::Node) - # # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check - # if node.type == :true - # @value = true - # # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check - # elsif node.type == :false - # @value = false - # # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check - # elsif %i[int sym].include?(node.type) - # # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check - # @value = node.children.first - # end - # end + if node.is_a?(::Parser::AST::Node) + if node.type == :true + @value = true + elsif node.type == :false + @value = false + elsif %i[int sym].include?(node.type) + @value = node.children.first + end + end @type = type - # @literal_type = ComplexType.try_parse(@value.inspect) - @literal_type = ComplexType::UNDEFINED + @literal_type = ComplexType.try_parse(@value.inspect) @complex_type = ComplexType.try_parse(type) end - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields - # @sg-ignore literal arrays in this module turn into ::Solargraph::Source::Chain::Array super + [@value, @type, @literal_type, @complex_type] end - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil if api_map.super_and_sub?(@complex_type.name, @literal_type.name) [Pin::ProxyType.anonymous(@literal_type, source: :chain)] else diff --git a/lib/solargraph/source/chain/or.rb b/lib/solargraph/source/chain/or.rb index 327d465b7..1dcdbdfbf 100644 --- a/lib/solargraph/source/chain/or.rb +++ b/lib/solargraph/source/chain/or.rb @@ -7,22 +7,45 @@ class Or < Link attr_reader :links # @param links [::Array] - def initialize links + # @param rhs_never_returns [Boolean] true if the right-hand side + # necessarily leaves the enclosing compound statement (e.g., + # `x || raise('missing')`) - it never contributes a value to + # this expression's result, so continuing past this + # expression implies the left-hand side was truthy + def initialize links, rhs_never_returns: false super('') @links = links + @rhs_never_returns = rhs_never_returns end - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil types = @links.map { |link| link.infer(api_map, name_pin, locals) } + + if @rhs_never_returns + lhs_type = types.first + return [Solargraph::Pin::ProxyType.anonymous(Solargraph::ComplexType::UNDEFINED, source: :chain)] if lhs_type.nil? + + return [Solargraph::Pin::ProxyType.anonymous(lhs_type.without_nil, source: :chain)] + end + combined_type = Solargraph::ComplexType.new(types) unless types.all?(&:nullable?) - # @sg-ignore flow sensitive typing should be able to handle redefinition combined_type = combined_type.without_nil end [Solargraph::Pin::ProxyType.anonymous(combined_type, source: :chain)] end + + protected + + def equality_fields + super + [@links, @rhs_never_returns] + end end end end diff --git a/lib/solargraph/source/chain/variable.rb b/lib/solargraph/source/chain/variable.rb index 8bf424e3b..07d00bacd 100644 --- a/lib/solargraph/source/chain/variable.rb +++ b/lib/solargraph/source/chain/variable.rb @@ -4,7 +4,11 @@ module Solargraph class Source class Chain class Variable < Link - def resolve api_map, name_pin, locals + # @param api_map [ApiMap] + # @param name_pin [Pin::Base] + # @param locals [::Array] + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil api_map.get_instance_variable_pins(name_pin.context.namespace, name_pin.context.scope).select do |p| p.name == word end diff --git a/lib/solargraph/source/chain/z_super.rb b/lib/solargraph/source/chain/z_super.rb index f6ef77106..990689ae2 100644 --- a/lib/solargraph/source/chain/z_super.rb +++ b/lib/solargraph/source/chain/z_super.rb @@ -19,7 +19,8 @@ def initialize word, with_block = false # @param api_map [ApiMap] # @param name_pin [Pin::Base] # @param locals [::Array] - def resolve api_map, name_pin, locals + # @param _receiver_path [::Array, nil] + def resolve api_map, name_pin, locals, _receiver_path = nil super_pins(api_map, name_pin) end end diff --git a/lib/solargraph/source/change.rb b/lib/solargraph/source/change.rb index acea51b67..35c8497dc 100644 --- a/lib/solargraph/source/change.rb +++ b/lib/solargraph/source/change.rb @@ -31,11 +31,9 @@ def write text, nullable = false if nullable && !range.nil? && new_text.match(/[.\[{(@$:]$/) [':', '@'].each do |dupable| next unless new_text == dupable - # @sg-ignore flow sensitive typing needs to handle attrs offset = Position.to_offset(text, range.start) if text[offset - 1] == dupable p = Position.from_offset(text, offset - 1) - # @sg-ignore flow sensitive typing needs to handle attrs r = Change.new(Range.new(p, range.start), ' ') text = r.write(text) end @@ -60,12 +58,11 @@ def repair text fixed else result = commit text, fixed - # @sg-ignore flow sensitive typing needs to handle attrs off = Position.to_offset(text, range.start) # @sg-ignore Need to add nil check here match = result[0, off].match(/[.:]+\z/) if match - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 result = result[0, off].sub(/#{match[0]}\z/, ' ' * match[0].length) + result[off..] end result diff --git a/lib/solargraph/source/cursor.rb b/lib/solargraph/source/cursor.rb index 077364910..ae4e31033 100644 --- a/lib/solargraph/source/cursor.rb +++ b/lib/solargraph/source/cursor.rb @@ -54,7 +54,6 @@ def start_of_word # `foo.bar`, the end_of_word at position (0,6) is `r`. # # @return [String] - # @sg-ignore Need to add nil check here def end_of_word @end_of_word ||= begin match = source.code[offset..].to_s.match(end_word_pattern) @@ -122,6 +121,7 @@ def recipient if rng Cursor.new(source, rng.ending) else + # @sg-ignore Wrong argument type for Solargraph::Position.new: character expected Integer, received Integer, nil pos = Position.new(position.line, [position.column - 1, 0].max) Cursor.new(source, pos) end diff --git a/lib/solargraph/source/source_chainer.rb b/lib/solargraph/source/source_chainer.rb index f96fa3319..c894dc3df 100644 --- a/lib/solargraph/source/source_chainer.rb +++ b/lib/solargraph/source/source_chainer.rb @@ -32,10 +32,10 @@ def initialize source, position # @return [Source::Chain] def chain # Special handling for files that end with an integer and a period - # if phrase =~ /^[0-9]+\.$/ - # return Chain.new([Chain::Literal.new('Integer', Integer(phrase[0..-2])), - # Chain::UNDEFINED_CALL]) - # end + if phrase =~ /^[0-9]+\.$/ + return Chain.new([Chain::Literal.new('Integer', Integer(phrase[0..-2])), + Chain::UNDEFINED_CALL]) + end if phrase.start_with?(':') && !phrase.start_with?('::') return Chain.new([Chain::Literal.new('Symbol', # @sg-ignore Need to add nil check here @@ -118,7 +118,6 @@ def fixed_position end # @return [String] - # @sg-ignore Need to add nil check here def end_of_phrase @end_of_phrase ||= begin match = phrase.match(/\s*(\.{1}|::)\s*$/) diff --git a/lib/solargraph/source_map.rb b/lib/solargraph/source_map.rb index 224223282..72fc59665 100644 --- a/lib/solargraph/source_map.rb +++ b/lib/solargraph/source_map.rb @@ -110,6 +110,7 @@ def cursor_at position # @param path [String] # @return [Pin::Base] + # @sg-ignore Need to add nil check here def first_pin path pins.select { |p| p.path == path }.first end @@ -124,6 +125,7 @@ def locate_pins location # @param line [Integer] # @param character [Integer] # @return [Pin::Method,Pin::Namespace] + # @sg-ignore Need a downcast here def locate_named_path_pin line, character _locate_pin line, character, Pin::Namespace, Pin::Method end @@ -131,6 +133,7 @@ def locate_named_path_pin line, character # @param line [Integer] # @param character [Integer] # @return [Pin::Closure] + # @sg-ignore Need a downcast here def locate_closure_pin line, character _locate_pin line, character, Pin::Closure end @@ -158,7 +161,7 @@ def method_call_nodes @method_call_nodes ||= Solargraph::Parser::ParserGem::NodeMethods.call_nodes_from(source.node) end - # @param macro_method_names [Array] + # @param macro_method_names [Set] # @return [Array] def macro_method_candidates macro_method_names return @macro_method_candidates if @macro_method_names == macro_method_names @@ -193,6 +196,7 @@ def map source private # @return [Array] + # @sg-ignore Need to add nil check here attr_writer :convention_pins # @return [Hash{Class => Array}] diff --git a/lib/solargraph/source_map/clip.rb b/lib/solargraph/source_map/clip.rb index 8df3f1669..a8f0ae6bf 100644 --- a/lib/solargraph/source_map/clip.rb +++ b/lib/solargraph/source_map/clip.rb @@ -40,6 +40,7 @@ def types # @return [Completion] def complete return package_completions([]) if !source_map.source.parsed? || cursor.string? + # @sg-ignore Need to add nil check here if cursor.chain.literal? && cursor.chain.links.last.word == '' return package_completions(api_map.get_symbols) end @@ -141,6 +142,7 @@ def complete_keyword_parameters next unless param.keyword? result.push Pin::KeywordParam.new(pin.location, "#{param.name}:") end + # @sg-ignore Need to add nil check here next unless !pin.parameters.empty? && pin.parameters.last.kwrestarg? pin.docstring.tags(:param).each do |tag| next if done.include?(tag.name) @@ -193,9 +195,11 @@ def code_complete result = [] result.concat complete_keyword_parameters if cursor.chain.constant? || cursor.start_of_constant? + # @sg-ignore Need to add nil check here full = cursor.chain.links.first.word type = if cursor.chain.undefined? cursor.chain.base.infer(api_map, context_pin, locals) + # @sg-ignore Need to add nil check here elsif full.include?('::') && cursor.chain.links.length == 1 # @sg-ignore Need to add nil check here ComplexType.try_parse(full.split('::')[0..-2].join('::')) @@ -205,6 +209,7 @@ def code_complete ComplexType::UNDEFINED end if type.undefined? + # @sg-ignore Need to add nil check here if full.include?('::') result.concat api_map.get_constants(full, *gates) else diff --git a/lib/solargraph/source_map/mapper.rb b/lib/solargraph/source_map/mapper.rb index 04815ef1f..f27323521 100644 --- a/lib/solargraph/source_map/mapper.rb +++ b/lib/solargraph/source_map/mapper.rb @@ -135,6 +135,8 @@ def remove_inline_comment_hashes comment def process_comment_directives return unless @code.encode('UTF-8', invalid: :replace, replace: '?') =~ DIRECTIVE_REGEXP code_lines = @code.lines + # @param line [Integer] + # @param comments [Array] @source.associated_comments.each do |line, comments| src_pos = if line Position.new(line, @@ -144,7 +146,6 @@ def process_comment_directives code_lines.length, 0 ) end - # @sg-ignore Need to add nil check here com_pos = Position.new(line + 1 - comments.length, 0) process_comment(src_pos, com_pos, comments.join('')) end diff --git a/lib/solargraph/type_checker.rb b/lib/solargraph/type_checker.rb index 2bd5d530e..dcadafef7 100644 --- a/lib/solargraph/type_checker.rb +++ b/lib/solargraph/type_checker.rb @@ -12,6 +12,7 @@ class TypeChecker include Parser::NodeMethods # @return [String] + # @sg-ignore Need to add nil check here attr_reader :filename # @return [Rules] @@ -33,6 +34,7 @@ def initialize filename, rules: workspace ? workspace.rules(level) : Rules.new(level, {}) @filename = filename # @todo Smarter directory resolution + # @sg-ignore Wrong argument type for File.dirname: file_name expected String, _ToStr, _ToPath, received String, nil @api_map = api_map || Solargraph::ApiMap.load(File.dirname(filename), loose_unions: !rules.require_all_unique_types_support_call?) @rules = rules @@ -118,7 +120,6 @@ def load_string code, filename = nil, level = :normal, api_map: nil rules = Rules.new(level, {}) api_map ||= Solargraph::ApiMap.new(loose_unions: !rules.require_all_unique_types_support_call?) - # @sg-ignore flow sensitive typing needs better handling of ||= on lvars api_map.map(source) new(filename, api_map: api_map, level: level, rules: rules) end @@ -177,7 +178,10 @@ def method_return_type_problems_for pin unless rules.ignore_all_undefined? || external?(pin) || pin.attribute? result.push Problem.new(pin.location, "#{pin.path} return type could not be inferred", pin: pin) end - else + elsif !inferred.bot? + # A method body that never returns (e.g., only ever raises or + # aborts) is compatible with any declared return type - bot is + # a subtype of everything, so there's nothing to check. unless return_type_conforms_to?(inferred, declared) result.push Problem.new(pin.location, "Declared return type #{declared.rooted_tags} does not match inferred type #{inferred.rooted_tags} for #{pin.path}", pin: pin) @@ -206,7 +210,6 @@ def resolved_constant? pin # @param pin [Pin::Base] def virtual_pin? pin - # @sg-ignore Need to add nil check here pin.location && source.comment_at?(pin.location.range.ending) end @@ -235,6 +238,7 @@ def method_param_type_problems_for pin # @param name [String] # @param data [Hash{Symbol => BasicObject}] params.each_pair do |name, data| + # @sg-ignore Need a downcast here # @type [ComplexType] type = data[:qualified] if type.undefined? @@ -349,6 +353,7 @@ def call_problems found = nil # @type [Array] all_found = [] + # @sg-ignore Need to add nil check here until base.links.first.undefined? # @sg-ignore Need to add nil check here all_found = base.define(api_map, closure_pin, locals) @@ -362,15 +367,27 @@ def call_problems # @todo remove the internal_or_core? check at a higher-than-strict level if (!found || found.is_a?(Pin::BaseVariable) || (closest.defined? && internal_or_core?(found))) && !(closest.generic? || ignored_pins.include?(found)) if closest.defined? + # @sg-ignore Need to add nil check here result.push Problem.new(location, "Unresolved call to #{missing.links.last.word} on #{closest}") else + # @sg-ignore Need to add nil check here result.push Problem.new(location, "Unresolved call to #{missing.links.last.word}") end + # @sg-ignore Wrong argument type for Array#push: objects expected Solargraph::Range, received Solargraph::Range, nil @marked_ranges.push rng end end # @sg-ignore Need to add nil check here result.concat argument_problems_for(chain, api_map, closure_pin, locals, location) + rescue StandardError => e + # A single call site's type inference should never take down the + # rest of the typecheck run (or, when this is invoked per-file + # across a workspace, every file after it) -- degrade to one + # reported problem for this call site instead. + # @sg-ignore Need to add nil check here + word = chain.links.last.word + Solargraph.logger.warn "Error checking call to #{word} at #{location.range}: [#{e.class}] #{e.message}" + result.push Problem.new(location, "Internal error inferring type for call to #{word}: [#{e.class}] #{e.message}") end result end @@ -416,10 +433,12 @@ def argument_problems_for chain, api_map, closure_pin, locals, location return [] if !rules.validate_calls? || base.links.first.is_a?(Solargraph::Source::Chain::ZSuper) all_errors = [] + receiver_type = base.base.infer(api_map, closure_pin, locals) pin.signatures.sort_by { |sig| sig.parameters.length }.each do |sig| params = param_details_from_stack(sig, pins) - signature_errors = signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin + signature_errors = signature_argument_problems_for(location, locals, closure_pin, params, arguments, sig, + pin, receiver_type) if signature_errors.empty? # we found a signature that works - meaning errors from @@ -440,16 +459,33 @@ def argument_problems_for chain, api_map, closure_pin, locals, location # @param arguments [Array] # @param sig [Pin::Signature] # @param pin [Pin::Method] + # @param receiver_type [ComplexType] the type of the object the + # method is being called on. Resolving a signature's generics + # (e.g. `Elem`) against the receiver's actual generic + # parameters (e.g. `Integer` for an `Array` receiver) + # is a general problem, but this is currently only plumbed + # through to the restarg path below (see #restarg_problems_for) + # - fixed-arity params still get their types from `params` + # (built by #param_details_from_stack), which doesn't resolve + # against the receiver. Generalizing that is tracked as a + # follow-up, not attempted here. # # @return [Array] - def signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin + def signature_argument_problems_for location, locals, closure_pin, params, arguments, sig, pin, receiver_type errors = [] # @todo add logic mapping up restarg parameters with # arguments (including restarg arguments). Use tuples # when possible, and when not, ensure provably # incorrect situations are detected. sig.parameters.each_with_index do |par, idx| - return errors if par.decl == :restarg # bail out and assume the rest is valid pending better arg processing + if par.decl == :restarg + # A restarg absorbs every remaining positional argument at + # the call site - check each of them against the restarg's + # own declared/resolved type instead of bailing out on the + # whole signature. This is what catches e.g. `y.push('two')` + # on a `y: Array`. + return restarg_problems_for(location, locals, closure_pin, arguments, sig, pin, receiver_type, par, idx) + end argchain = arguments[idx] if argchain.nil? final_arg = arguments.last @@ -509,6 +545,102 @@ def signature_argument_problems_for location, locals, closure_pin, params, argum errors end + # Checks each call-site argument absorbed by a restarg parameter + # against the restarg's own declared/resolved type, instead of + # bailing out on the whole signature. This is what catches e.g. + # `y.push('two')` on a `y: Array`. + # + # @param location [Location] + # @param locals [Array] + # @param closure_pin [Pin::Closure] + # @param arguments [Array] + # @param sig [Pin::Signature] + # @param pin [Pin::Method] + # @param receiver_type [ComplexType] the type of the object the + # method is being called on, used to resolve the restarg's + # declared type (e.g. `Elem` for `Array#push`) against the + # receiver's actual generic parameters (e.g. `Integer` for an + # `Array` receiver) + # @param par [Pin::Parameter] the restarg parameter + # @param idx [Integer] the restarg's index within sig.parameters + # + # @return [Array] + def restarg_problems_for location, locals, closure_pin, arguments, sig, pin, receiver_type, par, idx + errors = [] + + # par.return_type is the type of the local variable the + # restarg is captured into inside the method body (e.g. + # `Array`, not the unwrapped per-element `Integer`) - + # resolve any remaining generics against the receiver, then + # unwrap one level to get the type each individual argument + # must conform to. + # @sg-ignore pin.closure is a Pin::Namespace for a top-level method pin + wrapped_ptype = par.return_type.resolve_generics(pin.closure, receiver_type) + subtypes = wrapped_ptype.items.flat_map(&:subtypes) + # RbsTranslator#to_restarg_return_type falls back to a bare, + # unparameterized Array (no subtypes) when the RBS element type + # is untyped (e.g. `(*untyped)`) - there's no per-element type + # to check arguments against in that case. A ComplexType built + # from an empty item list isn't caught by the ptype.undefined? + # check below (ComplexType#method_missing only delegates to + # #items.first, so #undefined? comes back nil, not true, when + # #items is empty) so it has to be handled explicitly here. + return errors if subtypes.empty? + + ptype = ComplexType.new(subtypes.flat_map(&:items)) + # @sg-ignore pin.closure is a Pin::Namespace for a top-level method pin + ptype = ptype.qualify(api_map, *pin.closure.gates).self_to_type(par.context) + return errors if ptype.nil? || ptype.undefined? + + restarg_arguments(sig, arguments, idx).each do |restargchain| + # A spread argument's own element types aren't statically + # known here - skip it rather than guess. + next if restargchain.nil? || restargchain.node.type == :splat + + restargtype = restargchain.infer(api_map, closure_pin, locals).self_to_type(closure_pin.context) + next unless restargtype.defined? + next if arg_conforms_to?(restargtype, ptype) + + errors.push Problem.new(location, + "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{restargtype}") + end + errors + end + + # @param sig [Pin::Signature] + # @param arguments [Array] + # @param idx [Integer] the restarg's index within sig.parameters + # @return [Array] the call-site arguments absorbed + # by the restarg at idx + # @sg-ignore flow sensitive typing incorrectly includes an + # intermediate local variable's type in the inferred return type + def restarg_arguments sig, arguments, idx + # A restarg can be followed by trailing positional parameters + # (`def foo(*path, baz)`) - those consume the last N call-site + # arguments, so they don't belong to this restarg's own + # arguments. + # @type [Array] + trailing_positional_params = sig.parameters[(idx + 1)..] || [] + trailing_positional_count = trailing_positional_params.count { |p| p.decl == :arg } + # @type [Array] + # @sg-ignore flow sensitive typing issue with the ternary above + restargs = trailing_positional_count.zero? ? arguments[idx..] || [] : arguments[idx...-trailing_positional_count] || [] + + # A trailing bare hash argument (`foo(*args, key: val)`) is + # parsed as an implicit kwargs hash appended to the call's + # arguments - it belongs to the signature's keyword parameters, + # not the restarg. + # @type [Source::Chain, nil] + last_arg = restargs.last + has_trailing_hash = last_arg && last_arg.links.last.is_a?(Solargraph::Source::Chain::Hash) + has_keyword_params = sig.parameters.any? { |p| %i[kwarg kwoptarg kwrestarg].include?(p.decl) } + if has_trailing_hash && has_keyword_params + restargs[0...-1] + else + restargs + end + end + # @param sig [Pin::Signature] # @param argchain [Solargraph::Source::Chain] # @param api_map [ApiMap] @@ -525,10 +657,13 @@ def kwarg_problems_for sig, argchain, api_map, closure_pin, locals, location, pi kwargs = convert_hash(argchain.node) par = sig.parameters[idx] # @type [Solargraph::Source::Chain] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 argchain = kwargs[par.name.to_sym] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 if par.decl == :kwrestarg || (par.decl == :optarg && idx == pin.parameters.length - 1 && par.asgn_code == '{}') result.concat kwrestarg_problems_for(api_map, closure_pin, locals, location, pin, params, kwargs) elsif argchain + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 data = params[par.name] if data.nil? # @todo Some level (strong, I guess) should require the param here @@ -542,11 +677,14 @@ def kwarg_problems_for sig, argchain, api_map, closure_pin, locals, location, pi # @todo Unresolved call to defined? if argtype.defined? && ptype && !arg_conforms_to?(argtype, ptype) result.push Problem.new(location, + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{argtype}") end end end + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 elsif par.decl == :kwarg + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 result.push Problem.new(location, "Call to #{pin.path} is missing keyword argument #{par.name}") end result @@ -642,7 +780,9 @@ def add_to_param_details param_details, param_names, new_param_details next unless param_names.include?(param_name) param_details[param_name] ||= {} + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars param_details[param_name][:tagged] ||= details[:tagged] + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars param_details[param_name][:qualified] ||= details[:qualified] end end @@ -672,7 +812,6 @@ def param_details_from_stack signature, method_pin_stack # @param pin [Pin::Base] def internal? pin return false if pin.nil? - # @sg-ignore flow sensitive typing needs to handle attrs pin.location && api_map.bundled?(pin.location.filename) end @@ -693,7 +832,6 @@ def declared_externally? pin raise 'No assignment found' if pin.assignment.nil? chain = Solargraph::Parser.chain(pin.assignment, filename) - # @sg-ignore flow sensitive typing needs to handle attrs rng = Solargraph::Range.from_node(pin.assignment) # @sg-ignore Need to add nil check here closure_pin = source_map.locate_closure_pin(rng.start.line, rng.start.column) @@ -707,6 +845,7 @@ def declared_externally? pin found = nil # @type [Array] all_found = [] + # @sg-ignore Need to add nil check here until base.links.first.undefined? all_found = base.define(api_map, closure_pin, locals) found = all_found.first @@ -724,6 +863,7 @@ def declared_externally? pin # @param arguments [Array] # @param location [Location] # @return [Array] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 def arity_problems_for pin, arguments, location results = pin.signatures.map do |sig| r = parameterized_arity_problems_for(pin, sig.parameters, arguments, location) @@ -752,6 +892,7 @@ def parameterized_arity_problems_for pin, parameters, arguments, location if any_splatted_call?(unchecked.map(&:node)) settled_kwargs = parameters.count(&:keyword?) else + # @sg-ignore Need to add nil check here kwargs = convert_hash(unchecked.last.node) if parameters.any? { |param| %i[kwarg kwoptarg].include?(param.decl) || param.kwrestarg? } if kwargs.empty? @@ -764,7 +905,9 @@ def parameterized_arity_problems_for pin, parameters, arguments, location kwargs.delete param.name.to_sym settled_kwargs += 1 elsif param.decl == :kwarg + # @sg-ignore Need to add nil check here last_arg_last_link = arguments.last.links.last + # @sg-ignore Need to add nil check here return [] if last_arg_last_link.is_a?(Solargraph::Source::Chain::Hash) && last_arg_last_link.splatted? return [Problem.new(location, "Missing keyword argument #{param.name} to #{pin.path}")] end @@ -789,6 +932,7 @@ def parameterized_arity_problems_for pin, parameters, arguments, location end return [] if arguments.length - req == parameters.select { |p| %i[optarg kwoptarg].include?(p.decl) }.length return [Problem.new(location, "Too many arguments to #{pin.path}")] + # @sg-ignore Need to add nil check here elsif unchecked.length < req - settled_kwargs && (arguments.empty? || (!arguments.last.splat? && !arguments.last.links.last.is_a?(Solargraph::Source::Chain::Hash))) # HACK: Kernel#raise signature is incorrect in Ruby 2.7 core docs. # See https://github.com/castwide/solargraph/issues/418 @@ -828,13 +972,13 @@ def fake_args_for pin with_block = false # @param pin [Pin::Parameter] pin.parameters.each do |pin| - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 if %i[kwarg kwoptarg kwrestarg].include?(pin.decl) with_opts = true - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 elsif pin.decl == :block with_block = true - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 elsif pin.decl == :restarg args.push Solargraph::Source::Chain.new([Solargraph::Source::Chain::Variable.new(pin.name)], nil, true) else @@ -856,7 +1000,6 @@ def sg_ignore_lines_processed # @return [Set] def all_sg_ignore_lines source.associated_comments.select do |_line, text| - # @sg-ignore Need to add nil check here text.any? { |t| t.include?('@sg-ignore') } end.keys.to_set end diff --git a/lib/solargraph/type_checker/problem.rb b/lib/solargraph/type_checker/problem.rb index 8375a58ff..9f8929240 100644 --- a/lib/solargraph/type_checker/problem.rb +++ b/lib/solargraph/type_checker/problem.rb @@ -7,6 +7,7 @@ class TypeChecker class Problem # @todo Missed nil violation # @return [Solargraph::Location] + # @sg-ignore Need to add nil check here attr_reader :location # @return [String] @@ -14,6 +15,7 @@ class Problem # @todo Missed nil violation # @return [Pin::Base] + # @sg-ignore Need to add nil check here attr_reader :pin # @return [String, nil] diff --git a/lib/solargraph/type_checker/rules.rb b/lib/solargraph/type_checker/rules.rb index 6ce414a93..a9e953a9b 100644 --- a/lib/solargraph/type_checker/rules.rb +++ b/lib/solargraph/type_checker/rules.rb @@ -67,52 +67,47 @@ def require_inferred_type_params? # # False negatives: # - # @todo 4: Missed nil violation + # @todo 3: Missed nil violation # - # pending code fixes (277): + # pending code fixes (605): # - # @todo 281: Need to add nil check here - # @todo 22: Translate to something flow sensitive typing understands - # @todo 3: Need a downcast here + # @todo 433: Need to add nil check here + # @todo 79: https://github.com/castwide/solargraph/pull/1223 + # @todo 50: https://github.com/castwide/solargraph/pull/1245 + # @todo 26: Translate to something flow sensitive typing understands + # @todo 17: Need a downcast here # - # flow sensitive typing could handle (96): + # flow sensitive typing could handle (161): # - # @todo 36: flow sensitive typing needs to handle attrs - # @todo 29: flow sensitive typing should be able to handle redefinition - # @todo 19: flow sensitive typing needs to narrow down type with an if is_a? check - # @todo 13: Need to validate config + # @todo 30: https://github.com/castwide/solargraph/issues/1249 + # @todo 28: https://github.com/castwide/solargraph/issues/1241 + # @todo 20: https://github.com/castwide/solargraph/issues/1250 + # @todo 12: https://github.com/castwide/solargraph/issues/1251 + # @todo 11: flow sensitive typing needs better handling of ||= on lvars + # @todo 11: Need to validate config # @todo 8: flow sensitive typing should support .class == .class - # @todo 6: need boolish support for ? methods - # @todo 6: flow sensitive typing needs better handling of ||= on lvars - # @todo 5: literal arrays in this module turn into ::Solargraph::Source::Chain::Array - # @todo 5: flow sensitive typing needs to handle 'raise if' - # @todo 4: flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) - # @todo 4: nil? support in flow sensitive typing - # @todo 3: flow sensitive typing ought to be able to handle 'when ClassName' - # @todo 2: downcast output of Enumerable#select - # @todo 2: flow sensitive typing should handle return nil if location&.name.nil? + # @todo 5: need boolish support for ? methods + # @todo 4: flow sensitive typing ought to be able to handle 'when ClassName' + # @todo 4: literal arrays in this module turn into ::Solargraph::Source::Chain::Array + # @todo 4: flow sensitive typing needs to handle 'raise if' # @todo 2: flow sensitive typing should handle is_a? and next - # @todo 2: Need to look at Tuple#include? handling - # @todo 2: Should better support meaning of '&' in RBS - # @todo 2: (*) flow sensitive typing needs to handle "if foo = bar" # @todo 2: flow sensitive typing needs to handle "if foo = bar" - # @todo 2: Need to handle duck-typed method calls on union types + # @todo 2: flow sensitive typing needs to create separate ranges for postfix if # @todo 2: Need better handling of #compact - # @todo 2: flow sensitive typing should allow shadowing of Kernel#caller - # @todo 1: flow sensitive typing not smart enough to handle this case - # @todo 1: flow sensitive typing needs to handle if foo = bar - # @todo 1: flow sensitive typing needs to handle "if foo.nil?" - # @todo 1: flow sensitive typing should support case/when + # @todo 2: downcast output of Enumerable#select # @todo 1: flow sensitive typing should support ivars + # @todo 1: Need to be able to resolve generics based on a # @todo 1: Need to support this in flow sensitive typing - # @todo 1: flow sensitive typing needs to handle self.class == other.class - # @todo 1: flow sensitive typing needs to remove literal with - # @todo 1: flow sensitive typing needs to understand reassignment + # @todo 1: flow sensitive typing needs to handle "if foo.nil?" # @todo 1: flow sensitive typing should be able to identify more blocks that always return - # @todo 1: should warn on nil dereference below - # @todo 1: flow sensitive typing needs to create separate ranges for postfix if - # @todo 1: flow sensitive typing needs to handle constants # @todo 1: flow sensitive typing needs to eliminate literal from union with return if foo == :bar + # @todo 1: flow sensitive typing not smart enough to handle this case + # @todo 1: flow sensitive typing needs to handle self.class == other.class + # @todo 1: flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) + # @todo 1: Should better support meaning of '&' in RBS + # @todo 1: flow sensitive typing needs to handle constants + # @todo 1: Need to handle duck-typed method calls on union types + # @todo 1: flow sensitive typing needs to remove literal with def require_all_unique_types_match_expected? report?(:require_all_unique_types_match_expected, :strong) end diff --git a/lib/solargraph/workspace.rb b/lib/solargraph/workspace.rb index d3346c9b4..031843df8 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 :Gemspecs, 'solargraph/workspace/gemspecs' autoload :RequirePaths, 'solargraph/workspace/require_paths' @@ -16,11 +19,8 @@ class Workspace # @return [String] attr_reader :directory - # @return [Array] - 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 = {} @@ -34,7 +34,6 @@ def initialize directory = '', config = nil, server = {} @config = config @server = server load_sources - @gemnames = [] require_plugins end @@ -51,6 +50,69 @@ 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, nil] + def resolve_require require + gemspecs.resolve_require(require) + end + + # @return [Solargraph::PinCache] + def pin_cache + @pin_cache ||= fresh_pincache + 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] + # @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 @@ -112,6 +174,7 @@ def has_file? filename # # @param filename [String] # @return [Solargraph::Source] + # @sg-ignore Need to add nil check here def source filename source_hash[filename] end @@ -128,6 +191,31 @@ def would_require? path false end + # True if the workspace has a root Gemfile. + # + # @todo Handle projects with custom Bundler/Gemfile setups + # + def gemfile? + directory && File.file?(File.join(directory, 'Gemfile')) + 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 @rbs_collection_path ||= read_rbs_collection_path @@ -147,7 +235,34 @@ def rbs_collection_config_path # # @return [Gem::Specification, nil] def find_gem name, version = nil, out: nil - Gem::Specification.find_by_name(name, version) + gemspecs.find_gem(name, version, out: out) + end + + # @return [Array] + def all_gemspecs_from_bundle + gemspecs.all_gemspecs_from_bundle + end + + # @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_all_for_workspace! out, rebuild: false + PinCache.cache_core(out: out) unless PinCache.core? && !rebuild + + 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, rebuild: rebuild) + + out&.puts 'Documentation cached for core, standard library and gems.' end # Synchronize the workspace from the provided updater. @@ -155,11 +270,13 @@ def find_gem name, version = nil, out: nil # @param updater [Source::Updater] # @return [void] def synchronize! updater + # @sg-ignore Unresolved call to synchronize on Solargraph::Source, nil source_hash[updater.filename] = source_hash[updater.filename].synchronize(updater) end # @sg-ignore return type could not be inferred # @return [String] + # @sg-ignore Need to validate config def command_path server['commandPath'] || 'solargraph' end @@ -170,29 +287,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')) - 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 + # @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 index 2c29b948c..5cc9288a8 100644 --- a/lib/solargraph/workspace/gemspecs.rb +++ b/lib/solargraph/workspace/gemspecs.rb @@ -45,7 +45,7 @@ def resolve_require require # Determine gem name based on the require path file = "lib/#{require}.rb" - spec_with_path = Gem::Specification.find_by_path(file) + spec_with_path = Gem::Specification.find_by_path(require) all_gemspecs = all_gemspecs_from_bundle @@ -56,14 +56,11 @@ def resolve_require require ].compact.uniq # @param gem_name [String] gem_names_to_try.each do |gem_name| - # @sg-ignore Unresolved call to == on Boolean gemspec = all_gemspecs.find { |gemspec| gemspec.name == gem_name } - # @sg-ignore flow sensitive typing should be able to handle redefinition return [gemspec_or_preference(gemspec)] if gemspec begin gemspec = Gem::Specification.find_by_name(gem_name) - # @sg-ignore flow sensitive typing should be able to handle redefinition return [gemspec_or_preference(gemspec)] if gemspec rescue Gem::MissingSpecError logger.debug do @@ -76,10 +73,8 @@ def resolve_require require gemspec = all_gemspecs.find do |spec| spec = to_gem_specification(spec) unless spec.respond_to?(:files) - # @sg-ignore Translate to something flow sensitive typing understands spec&.files&.any? { |gemspec_file| file == gemspec_file } end - # @sg-ignore flow sensitive typing should be able to handle redefinition return [gemspec_or_preference(gemspec)] if gemspec end @@ -100,13 +95,10 @@ def stdlib_dependencies stdlib_name # # @return [Gem::Specification, nil] def find_gem name, version = nil, out: $stderr - # @sg-ignore flow sensitive typing should be able to handle redefinition specish = all_gemspecs_from_bundle.find { |specish| specish.name == name && specish.version == version } return to_gem_specification specish if specish - # @sg-ignore flow sensitive typing should be able to handle redefinition specish = all_gemspecs_from_bundle.find { |specish| specish.name == name } - # @sg-ignore flow sensitive typing needs to create separate ranges for postfix if return to_gem_specification specish if specish resolve_gem_ignoring_local_bundle name, version, out: out @@ -136,6 +128,7 @@ def fetch_dependencies gemspec, out: $stderr # 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) || [] + # @sg-ignore Need to add nil check here 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 @@ -180,7 +173,6 @@ def to_gem_specification specish # turns a Bundler::StubSpecification into a # Gem::StubSpecification if we can if specish.respond_to?(:stub) - # @sg-ignore flow sensitive typing ought to be able to handle 'when ClassName' to_gem_specification specish.stub else # A Bundler::StubSpecification is a Bundler:: @@ -348,13 +340,16 @@ def preference_map @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } end - # @param gemspec [Gem::Specification] + # @param gemspec [Gem::Specification, Bundler::LazySpecification, Bundler::StubSpecification] # # @return [Gem::Specification] + # @sg-ignore Declared return type ::Gem::Specification does not match inferred type ::Gem::Specification, nil for Solargraph::Workspace::Gemspecs#gemspec_or_preference def gemspec_or_preference gemspec - return gemspec unless preference_map.key?(gemspec.name) - return gemspec if gemspec.version == preference_map[gemspec.name].version + return to_gem_specification(gemspec) unless preference_map.key?(gemspec.name) + # @sg-ignore Need to add nil check here + return to_gem_specification(gemspec) if gemspec.version == preference_map[gemspec.name].version + # @sg-ignore Need to add nil check here change_gemspec_version gemspec, preference_map[gemspec.name].version end diff --git a/lib/solargraph/workspace/require_paths.rb b/lib/solargraph/workspace/require_paths.rb index d12364b07..f4b1b8491 100644 --- a/lib/solargraph/workspace/require_paths.rb +++ b/lib/solargraph/workspace/require_paths.rb @@ -26,6 +26,7 @@ def initialize directory, config def generate result = require_paths_from_gemspec_files return configured_require_paths if result.empty? + # @sg-ignore Wrong argument type for File.join: arg_0 expected String, _ToStr, _ToPath, received String, nil result.concat(config.require_paths.map { |p| File.join(directory, p) }) if config result end @@ -83,7 +84,7 @@ def require_path_from_gemspec_file gemspec_file_path return [] if hash.empty? hash['paths'].map { |path| File.join(base, path) } rescue StandardError => e - # @sg-ignore flow sensitive typing should be able to handle redefinition + # @sg-ignore https://github.com/castwide/solargraph/issues/1250 Solargraph.logger.warn "Error reading #{gemspec_file_path}: [#{e.class}] #{e.message}" [] end diff --git a/lib/solargraph/yard_map/cache.rb b/lib/solargraph/yard_map/cache.rb index 82e578d4a..d76abf5c9 100644 --- a/lib/solargraph/yard_map/cache.rb +++ b/lib/solargraph/yard_map/cache.rb @@ -17,6 +17,7 @@ def set_path_pins path, pins # @param path [String] # @return [Array] + # @sg-ignore Need to add nil check here def get_path_pins path @path_pins[path] end diff --git a/lib/solargraph/yard_map/directives/attribute_directive.rb b/lib/solargraph/yard_map/directives/attribute_directive.rb index cd1eee715..661672e14 100644 --- a/lib/solargraph/yard_map/directives/attribute_directive.rb +++ b/lib/solargraph/yard_map/directives/attribute_directive.rb @@ -56,6 +56,7 @@ def process_directive source, pins, source_position, comment_position, directive # @param [Array] pins # @param [Position] position # @return [Pin::Closure] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Closure) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/directives/domain_directive.rb b/lib/solargraph/yard_map/directives/domain_directive.rb index 270de3d9c..f97fac385 100644 --- a/lib/solargraph/yard_map/directives/domain_directive.rb +++ b/lib/solargraph/yard_map/directives/domain_directive.rb @@ -21,6 +21,7 @@ def process_directive source, pins, source_position, _comment_position, directiv # @param [Array] pins # @param [Position] position # @return [Pin::Namespace] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Namespace) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/directives/method_directive.rb b/lib/solargraph/yard_map/directives/method_directive.rb index b57af1698..9661ccbdb 100644 --- a/lib/solargraph/yard_map/directives/method_directive.rb +++ b/lib/solargraph/yard_map/directives/method_directive.rb @@ -42,6 +42,7 @@ def process_directive source, pins, source_position, comment_position, directive # @param [Array] pins # @param [Position] position # @return [Pin::Closure] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Closure) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/directives/override_directive.rb b/lib/solargraph/yard_map/directives/override_directive.rb index 6a8f0af5c..131d3d8c8 100644 --- a/lib/solargraph/yard_map/directives/override_directive.rb +++ b/lib/solargraph/yard_map/directives/override_directive.rb @@ -21,6 +21,7 @@ def process_directive source, _pins, _source_position, comment_position, directi # @param [Array] pins # @param [Position] position # @return [Pin::Closure] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Closure) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/directives/parse_directive.rb b/lib/solargraph/yard_map/directives/parse_directive.rb index 73f11dda5..e4aa69fd9 100644 --- a/lib/solargraph/yard_map/directives/parse_directive.rb +++ b/lib/solargraph/yard_map/directives/parse_directive.rb @@ -19,6 +19,7 @@ def process_directive source, pins, source_position, comment_position, directive region = Parser::Region.new(source: src, closure: ns) # @todo These pins may need to be marked not explicit old_pins_index = pins.length + # @sg-ignore Need to add nil check here loff = if source.code.lines[comment_position.line].strip.end_with?('@!parse') comment_position.line + 1 else @@ -29,9 +30,7 @@ def process_directive source, pins, source_position, comment_position, directive new_pins.each do |p| # @todo Smelly instance variable access next if p.location.nil? - # @sg-ignore Unresolved call to range on Solargraph::Location, nil - does not account for next clause above. p.location.range.start.instance_variable_set(:@line, p.location.range.start.line + loff) - # @sg-ignore Unresolved call to range on Solargraph::Location, nil p.location.range.ending.instance_variable_set(:@line, p.location.range.ending.line + loff) end @@ -44,6 +43,7 @@ def process_directive source, pins, source_position, comment_position, directive # @param [Array] pins # @param [Position] position # @return [Pin::Closure] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Closure) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/directives/visibility_directive.rb b/lib/solargraph/yard_map/directives/visibility_directive.rb index 6221b1008..c45c8f3a1 100644 --- a/lib/solargraph/yard_map/directives/visibility_directive.rb +++ b/lib/solargraph/yard_map/directives/visibility_directive.rb @@ -61,6 +61,7 @@ def no_empty_lines? code, line1, line2 # @param [Array] pins # @param [Position] position # @return [Pin::Closure] + # @sg-ignore Need to add nil check here def closure_at pins, position pins.select { |pin| pin.is_a?(Pin::Closure) and pin.location&.range&.contain?(position) }.last end diff --git a/lib/solargraph/yard_map/helpers.rb b/lib/solargraph/yard_map/helpers.rb index 8c1747d9a..4084a34b7 100644 --- a/lib/solargraph/yard_map/helpers.rb +++ b/lib/solargraph/yard_map/helpers.rb @@ -16,8 +16,8 @@ def object_location code_object, spec end return Solargraph::Location.new(__FILE__, Solargraph::Range.from_to(__LINE__ - 1, 0, __LINE__ - 1, 0)) end - # @sg-ignore flow sensitive typing should be able to identify more blocks that always return file = File.join(spec.full_gem_path, code_object.file) + # @sg-ignore Unresolved call to - Solargraph::Location.new(file, Solargraph::Range.from_to(code_object.line - 1, 0, code_object.line - 1, 0)) end diff --git a/lib/solargraph/yard_map/macro.rb b/lib/solargraph/yard_map/macro.rb index f3c9e4827..11aa912a5 100644 --- a/lib/solargraph/yard_map/macro.rb +++ b/lib/solargraph/yard_map/macro.rb @@ -89,6 +89,7 @@ def generate_pins_from chain, pin, source_map # @param [SourceMap] source_map # @return [Array] def generate_yardoc_from chain, source_map + # @sg-ignore Need to add nil check here name = chain.links.last.word # @sg-ignore chain.links.last is assumed to be a Chain::Call values = chain.links.last.arguments.map(&:node).map { |arg| Solargraph::Parser::ParserGem::NodeMethods.simple_convert(arg).to_s } diff --git a/lib/solargraph/yard_map/mapper.rb b/lib/solargraph/yard_map/mapper.rb index a65ee7e9a..22b3a20b0 100644 --- a/lib/solargraph/yard_map/mapper.rb +++ b/lib/solargraph/yard_map/mapper.rb @@ -6,6 +6,7 @@ class Mapper autoload :ToMethod, 'solargraph/yard_map/mapper/to_method' autoload :ToNamespace, 'solargraph/yard_map/mapper/to_namespace' autoload :ToConstant, 'solargraph/yard_map/mapper/to_constant' + autoload :ToClassDefinition, 'solargraph/yard_map/mapper/to_class_definition' # @param code_objects [Array] # @param spec [Gem::Specification, nil] @@ -25,7 +26,6 @@ def map end # Some yardocs contain documentation for dependencies that can be # ignored here. The YardMap will load dependencies separately. - # @sg-ignore does not consider `pin.location.nil? || ` condition @pins.keep_if { |pin| pin.location.nil? || File.file?(pin.location.filename) } if @spec @pins end @@ -41,22 +41,23 @@ def generate_pins code_object nspin = ToNamespace.make(code_object, @spec, @namespace_pins[code_object.namespace.to_s]) @namespace_pins[code_object.path] = nspin result.push nspin + # @sg-ignore Unresolved call to superclass on YARD::CodeObjects::NamespaceObject, YARD::CodeObjects::ClassObject if code_object.is_a?(YARD::CodeObjects::ClassObject) && !code_object.superclass.nil? # This method of superclass detection is a bit of a hack. If # the superclass is a Proxy, it is assumed to be undefined in its # yardoc and converted to a fully qualified namespace. + # @sg-ignore Unresolved call to is_a? superclass = if code_object.superclass.is_a?(YARD::CodeObjects::Proxy) "::#{code_object.superclass}" else + # @sg-ignore Unresolved call to to_s code_object.superclass.to_s end result.push Solargraph::Pin::Reference::Superclass.new(name: superclass, closure: nspin, source: :yard_map) end - # @sg-ignore flow sensitive typing ought to be able to handle 'when ClassName' code_object.class_mixins.each do |m| result.push Solargraph::Pin::Reference::Extend.new(closure: nspin, name: m.path, source: :yard_map) end - # @sg-ignore flow sensitive typing ought to be able to handle 'when ClassName' code_object.instance_mixins.each do |m| result.push Solargraph::Pin::Reference::Include.new( closure: nspin, # @todo Fix this @@ -67,7 +68,6 @@ def generate_pins code_object when YARD::CodeObjects::MethodObject closure = @namespace_pins[code_object.namespace.to_s] macros_for_method_object(code_object) - # @sg-ignore flow sensitive typing ought to be able to handle 'when ClassName' if code_object.name == :initialize && code_object.scope == :instance # @todo Check the visibility of .new result.push ToMethod.make(code_object, 'new', :class, :public, closure, @spec) @@ -76,8 +76,17 @@ def generate_pins code_object result.push ToMethod.make(code_object, nil, nil, nil, closure, @spec) end when YARD::CodeObjects::ConstantObject - closure = @namespace_pins[code_object.namespace] - result.push ToConstant.make(code_object, closure, @spec) + # `Foo = Class.new(Bar) do ... end` defines a class that YARD only + # records as a constant. Emit the class it defines instead of the + # constant -- emitting both would leave two pins competing at the + # same path. + class_pins = ToClassDefinition.make(code_object, @spec) + if class_pins + result.concat class_pins + else + closure = @namespace_pins[code_object.namespace] + result.push ToConstant.make(code_object, closure, @spec) + end end result end @@ -94,6 +103,7 @@ def attached_macros_by_method_object # @param method_object [YARD::CodeObjects::MethodObject] # @return [Array] + # @sg-ignore https://github.com/castwide/solargraph/pull/1245 def macros_for_method_object method_object attached_macros_by_method_object[method_object] end diff --git a/lib/solargraph/yard_map/mapper/to_class_definition.rb b/lib/solargraph/yard_map/mapper/to_class_definition.rb new file mode 100644 index 000000000..a5125e33a --- /dev/null +++ b/lib/solargraph/yard_map/mapper/to_class_definition.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Solargraph + class YardMap + class Mapper + # Converts a YARD `ConstantObject` whose value is an anonymous class + # definition into the pins that definition would have produced if it had + # been written as a `class` statement. + # + # YARD has no handler for `Class.new`, so `Foo = Class.new(Bar) do ... end` + # arrives as a constant whose block body survives only as the raw source + # string in `ConstantObject#value`. That source is reparsed here, wrapped + # in the constant's original module nesting (so a relative superclass name + # resolves through the same gates it did in the gem), and mapped with + # Solargraph's own parser, where `Convention::ClassDefinition` turns it + # into a namespace, a superclass reference, and method pins. + module ToClassDefinition + extend YardMap::Helpers + + autoload :NodeStripper, 'solargraph/yard_map/mapper/to_class_definition/node_stripper' + + # Pin types worth keeping from the reparse. Local variables, blocks and + # instance variables only carry meaning together with their parser + # nodes, which are stripped below. + KEPT_PIN_CLASSES = [Pin::Namespace, Pin::Reference, Pin::Method, Pin::Constant].freeze + + class << self + # @param code_object [YARD::CodeObjects::ConstantObject] + # @param spec [Gem::Specification, nil] + # @return [Array, nil] nil when the constant is not an + # anonymous class definition, or could not be reparsed + def make code_object, spec = nil + value = code_object.value.to_s + # Cheap rejection before parsing: a matching node always spells the + # `Class` constant out in the source. + return nil unless value.include?('Class') + + assignment = "#{code_object.name} = #{value}" + node = Source.load_string(assignment).node + return nil if node.nil? + return nil unless Convention::ClassDefinition::ClassAssignmentNode.match?(node) + + location = object_location(code_object, spec) + source = Source.load_string(nested_source(namespace_names(code_object), assignment), location.filename) + definition_pins(source, code_object, location) + rescue StandardError => e + Solargraph.logger.info "Could not reparse #{code_object.path} as a class definition: [#{e.class}] #{e.message}" + nil + end + + private + + # @param code_object [YARD::CodeObjects::ConstantObject] + # @return [Array] + def namespace_names code_object + code_object.namespace.to_s.split('::').reject(&:empty?) + end + + # @param namespaces [Array] + # @param assignment [String] + # @return [String] + def nested_source namespaces, assignment + [ + *namespaces.map { |ns| "module #{ns}" }, + assignment, + *namespaces.map { 'end' } + ].join("\n") + end + + # @param source [Source] + # @param code_object [YARD::CodeObjects::ConstantObject] + # @param location [Location] + # @return [Array, nil] + def definition_pins source, code_object, location + stripper = NodeStripper.new(location) + pins = SourceMap.map(source).pins + .select { |pin| keep?(pin, code_object) } + .map { |pin| stripper.strip(pin) } + namespace_pin = pins.find { |pin| pin.is_a?(Pin::Namespace) && pin.path == code_object.path } + return nil if namespace_pin.nil? + + # The constant's own documentation belongs to the class it defines. + namespace_pin.instance_variable_set(:@comments, code_object.docstring.all.to_s) if code_object.docstring + pins + end + + # @param pin [Pin::Base] + # @param code_object [YARD::CodeObjects::ConstantObject] + # @return [Boolean] + def keep? pin, code_object + return false unless KEPT_PIN_CLASSES.any? { |klass| pin.is_a?(klass) } + + path = pin.path.to_s + return true if path == code_object.path + return true if path.start_with?("#{code_object.path}#", "#{code_object.path}.", "#{code_object.path}::") + + # References (superclass, include, extend) have no path of their own. + path.empty? && descends_from?(pin, code_object.path) + end + + # @param pin [Pin::Base] + # @param path [String] + # @return [Boolean] + def descends_from? pin, path + closure = pin.closure + while closure + return true if closure.path == path + + closure = closure.closure + end + false + end + end + end + end + end +end diff --git a/lib/solargraph/yard_map/mapper/to_class_definition/node_stripper.rb b/lib/solargraph/yard_map/mapper/to_class_definition/node_stripper.rb new file mode 100644 index 000000000..4aa1f3e0c --- /dev/null +++ b/lib/solargraph/yard_map/mapper/to_class_definition/node_stripper.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Solargraph + class YardMap + class Mapper + module ToClassDefinition + # Copies pins produced by reparsing a gem's source, dropping the parser + # nodes they hold and pointing them at a location in the gem itself. + # + # Nodes have to go for two reasons. They keep a reference to the + # `Parser::Source::Buffer` they were parsed from, so marshalling the + # pins into the gem cache marshals the synthesized source with them; + # and any inference that walks back to a node calls `ApiMap#clip_at`, + # which raises `FileNotFoundError` unless the node's file has a + # cataloged source map -- which a gem's source never does. + # + # The cost is that these pins cannot infer a return type from a method + # body. They still carry whatever YARD tags the gem wrote, which is the + # only typing YARD-sourced pins have anyway. + class NodeStripper + # @param location [Location] the location to give every copied pin + # @param source [::Symbol] the provenance to record on every copied pin + def initialize location, source: :yardoc + @location = location + @source = source + # @type [Hash{Pin::Base => Pin::Base}] + @stripped = {}.compare_by_identity + end + + # @param pin [Pin::Base, nil] + # @return [Pin::Base, nil] + def strip pin + return pin if pin.nil? || pin.equal?(Pin::ROOT_PIN) + return @stripped[pin] if @stripped.key?(pin) + + copy = pin.dup + @stripped[pin] = copy + relocate copy + scrub_ivars copy + copy + end + + private + + # @return [Location] + attr_reader :location + + # @return [::Symbol] + attr_reader :source + + # The reparsed source does not exist on disk, so every pin points at + # the constant that produced it instead. + # + # @param pin [Pin::Base] + # @return [void] + def relocate pin + pin.instance_variable_set(:@location, location) + pin.instance_variable_set(:@type_location, location) unless pin.type_location.nil? + pin.instance_variable_set(:@source, source) + end + + # Every instance variable is examined by the type of what it holds + # rather than by name, because naming the ivars to clear only works + # until a pin grows another one -- `Pin::Method#compound_statement` + # arrived after this class was written and slipped straight through a + # name-based list. + # + # Pins reference each other (a method's parameters point back at the + # method, a pin's closure chain runs through its compound statements), + # so every reachable pin is replaced by its copy; leaving one in place + # would keep a node alive through a neighbor. + # + # @param pin [Pin::Base] + # @return [void] + def scrub_ivars pin + pin.instance_variables.each do |name| + value = pin.instance_variable_get(name) + scrubbed = scrub(name, value) + pin.instance_variable_set(name, scrubbed) unless scrubbed.equal?(value) + end + # A memoized YARD::Docstring links back to the code object it was + # parsed against, and marshalling one drags the entire YARD registry + # along with it. Pins regenerate it from #comments. + pin.instance_variable_set(:@docstring, nil) + end + + # @param name [::Symbol] + # @param value [Object] + # @return [Object] + def scrub name, value + case value + when ::Parser::AST::Node, ::YARD::Docstring then nil + when Pin::Base then strip(value) + when ::Array then scrub_array(name, value) + else value + end + end + + # @param name [::Symbol] + # @param value [::Array<::Object>] + # @return [Object] + def scrub_array name, value + return value.map { |item| item.is_a?(Pin::Base) ? strip(item) : item } if value.any?(Pin::Base) + return value unless value.any?(::Parser::AST::Node) + + # `@mass_assignment` is a (node, index) pair rather than a list of + # nodes, so emptying it would leave a malformed pair behind. + name == :@mass_assignment ? nil : [].freeze + end + end + end + end + end +end diff --git a/lib/solargraph/yard_map/mapper/to_method.rb b/lib/solargraph/yard_map/mapper/to_method.rb index 726e920f2..8983e3fe9 100644 --- a/lib/solargraph/yard_map/mapper/to_method.rb +++ b/lib/solargraph/yard_map/mapper/to_method.rb @@ -26,12 +26,9 @@ def self.make code_object, name = nil, scope = nil, visibility = nil, closure = return_type = ComplexType::SELF if name == 'new' comments = code_object.docstring ? code_object.docstring.all.to_s : '' final_scope = scope || code_object.scope - # @sg-ignore Need to add nil check here override_key = [closure.path, final_scope, name] final_visibility = VISIBILITY_OVERRIDE[override_key] - # @sg-ignore Need to add nil check here final_visibility ||= VISIBILITY_OVERRIDE[[closure.path, final_scope]] - # @sg-ignore Need to add nil check here if closure.path == 'Kernel' && Kernel.private_method_defined?(name.to_sym, false) final_visibility ||= :private end @@ -55,7 +52,6 @@ def self.make code_object, name = nil, scope = nil, visibility = nil, closure = source: :yardoc ) else - # @sg-ignore Need to add nil check here pin = Pin::Method.new( location: location, closure: closure, @@ -108,21 +104,29 @@ def get_parameters code_object, location, comments, pin # @param a [Array] # @return [String] + # @sg-ignore Need to add nil check here def arg_name a + # @sg-ignore Need to add nil check here a[0].gsub(/[^a-z0-9_]/i, '') end # @param a [Array] # @return [::Symbol] def arg_type a + # @sg-ignore Need to add nil check here if a[0].start_with?('**') :kwrestarg + # @sg-ignore Need to add nil check here elsif a[0].start_with?('*') :restarg + # @sg-ignore Need to add nil check here elsif a[0].start_with?('&') :blockarg + # @sg-ignore Need to add nil check here elsif a[0].end_with?(':') + # @sg-ignore Wrong argument type for Array#[]: range expected Range>, _Range>, received 1 a[1] ? :kwoptarg : :kwarg + # @sg-ignore Wrong argument type for Array#[]: range expected Range>, _Range>, received 1 elsif a[1] :optarg else diff --git a/lib/solargraph/yard_map/mapper/to_namespace.rb b/lib/solargraph/yard_map/mapper/to_namespace.rb index 3e0887ecd..e1c9215d6 100644 --- a/lib/solargraph/yard_map/mapper/to_namespace.rb +++ b/lib/solargraph/yard_map/mapper/to_namespace.rb @@ -21,7 +21,6 @@ def self.make code_object, spec, closure = nil type: code_object.is_a?(YARD::CodeObjects::ClassObject) ? :class : :module, visibility: code_object.visibility, closure: closure, - # @sg-ignore need to add a nil check here gates: closure.gates, source: :yardoc ) diff --git a/lib/solargraph/yardoc.rb b/lib/solargraph/yardoc.rb index 2150dcbef..29811a04f 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,37 +24,47 @@ 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 - # @todo Is the chdir argument being used here? - # @sg-ignore Unrecognized keyword argument chdir to Open3.capture2e + # @sg-ignore Our fill won't work properly due to an issue in + # Callable#arity_matches? - see comment there 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) + pins = YardMap::Mapper.new(yardoc, gemspec).map + bad_paths = GemPins::KNOWN_BAD_YARD_PINS[gemspec.name] + return pins unless bad_paths + pins.reject { |pin| bad_paths.include?(pin.path) } 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 @@ -62,10 +72,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 @@ -80,8 +90,9 @@ def load! gemspec # @return [Hash{String => String}] a hash of environment variables to override def current_bundle_env_tweaks tweaks = {} - # @sg-ignore Unresolved call to empty? on String, nil + # @sg-ignore Translate to something flow sensitive typing understands if ENV['BUNDLE_GEMFILE'] && !ENV['BUNDLE_GEMFILE'].empty? + # @sg-ignore Wrong argument type for File.expand_path: file_name expected String, _ToStr, _ToPath, received String, nil tweaks['BUNDLE_GEMFILE'] = File.expand_path(ENV['BUNDLE_GEMFILE']) end tweaks diff --git a/rbs/fills/tuple/tuple.rbs b/rbs/fills/tuple/tuple.rbs new file mode 100644 index 000000000..2af4611a4 --- /dev/null +++ b/rbs/fills/tuple/tuple.rbs @@ -0,0 +1,162 @@ +# Represents a fixed-size array literal, e.g. `[1, 'two']`, as a +# parameterized Array subtype so its element types are available to +# inference. +# +# @note Positional accessors like `#[]`, `#at`, and `#fetch` use +# literal-indexed overloads (e.g., `(0 index) -> A`) to give a +# precise element type when the index resolves to a literal - which +# now includes an index tracked through reassignment (e.g. `i = 0; +# i += 1; array[i]`). Any index that doesn't resolve to a literal +# (a variable of a wider type, a computed value, etc.) falls back +# to the safe union of all element types instead of a specific-but- +# possibly-wrong answer. +# +# This does NOT track mutation: once a tuple has been passed +# through a mutating call (`#unshift`, `#push`, `#shift`, etc.), +# Solargraph has no way to know its positions shifted, so a literal +# index can again return a wrong (not just imprecise) answer after +# such a call. That's a known, deliberately out-of-scope limitation +# - see https://github.com/castwide/solargraph/issues/1196 (scenario +# 4, `array.unshift 'zero'; array[0]`). +# +# Below, the calls that can shift, replace, or reorder existing +# positions (as opposed to `#push`/`#<<`/`#concat`, which only +# append past the known arity and can't invalidate an already-known +# position) are given a widened, position-erased `Array[...]` +# return type instead of the inherited `self`. This only helps the +# reassignment idiom (`array = array.unshift(x)` falls back to the +# safe union instead of keeping the stale Tuple type) - it does +# nothing for the more common bare-statement form +# (`array.unshift(x)` with no reassignment), which is exactly what +# the scenario 4 example above uses and remains unfixed by this. +module Solargraph + module Fills + class Tuple[unchecked out A, + unchecked out B = A, + unchecked out C = A | B, + unchecked out D = A | B | C, + unchecked out E = A | B | C | D, + unchecked out F = A | B | C | D | E, + unchecked out G = A | B | C | D | E | F, + unchecked out H = A | B | C | D | E | F | G, + unchecked out I = A | B | C | D | E | F | G | H, + unchecked out J = A | B | C | D | E | F | G | H | I] < Array[A | B | C | D | E | F | G | H | I | J] + def []: (0 index) -> A + | (1 index) -> B + | (2 index) -> C + | (3 index) -> D + | (4 index) -> E + | (5 index) -> F + | (6 index) -> G + | (7 index) -> H + | (8 index) -> I + | (9 index) -> J + | (int index) -> (A | B | C | D | E | F | G | H | I | J)? + + def at: (0 index) -> A + | (1 index) -> B + | (2 index) -> C + | (3 index) -> D + | (4 index) -> E + | (5 index) -> F + | (6 index) -> G + | (7 index) -> H + | (8 index) -> I + | (9 index) -> J + | (int index) -> (A | B | C | D | E | F | G | H | I | J)? + + def fetch: (0 index) -> A + | (1 index) -> B + | (2 index) -> C + | (3 index) -> D + | (4 index) -> E + | (5 index) -> F + | (6 index) -> G + | (7 index) -> H + | (8 index) -> I + | (9 index) -> J + | (int index) -> (A | B | C | D | E | F | G | H | I | J) + | [T] (0 index, T default) -> (A | T) + | [T] (1 index, T default) -> (B | T) + | [T] (2 index, T default) -> (C | T) + | [T] (3 index, T default) -> (D | T) + | [T] (4 index, T default) -> (E | T) + | [T] (5 index, T default) -> (F | T) + | [T] (6 index, T default) -> (G | T) + | [T] (7 index, T default) -> (H | T) + | [T] (8 index, T default) -> (I | T) + | [T] (9 index, T default) -> (J | T) + | [T] (int index, T default) -> (A | B | C | D | E | F | G | H | I | J | T) + | [T] (0 index) { (int index) -> T } -> (A | T) + | [T] (1 index) { (int index) -> T } -> (B | T) + | [T] (2 index) { (int index) -> T } -> (C | T) + | [T] (3 index) { (int index) -> T } -> (D | T) + | [T] (4 index) { (int index) -> T } -> (E | T) + | [T] (5 index) { (int index) -> T } -> (F | T) + | [T] (6 index) { (int index) -> T } -> (G | T) + | [T] (7 index) { (int index) -> T } -> (H | T) + | [T] (8 index) { (int index) -> T } -> (I | T) + | [T] (9 index) { (int index) -> T } -> (J | T) + | [T] (int index) { (int index) -> T } -> (A | B | C | D | E | F | G | H | I | J | T) + + def first: %a{implicitly-returns-nil} () -> A + + def unshift: (*(A | B | C | D | E | F | G | H | I | J) objects) -> Array[A | B | C | D | E | F | G | H | I | J] + + def prepend: (*(A | B | C | D | E | F | G | H | I | J) objects) -> Array[A | B | C | D | E | F | G | H | I | J] + + def insert: (int index, *(A | B | C | D | E | F | G | H | I | J) objects) -> Array[A | B | C | D | E | F | G | H | I | J] + + def delete_if: () { ((A | B | C | D | E | F | G | H | I | J) item) -> boolish } -> Array[A | B | C | D | E | F | G | H | I | J] + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]] + + def keep_if: () { ((A | B | C | D | E | F | G | H | I | J) element) -> boolish } -> Array[A | B | C | D | E | F | G | H | I | J] + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]] + + def reject!: () { ((A | B | C | D | E | F | G | H | I | J) item) -> boolish } -> Array[A | B | C | D | E | F | G | H | I | J]? + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]?] + + def select!: () { ((A | B | C | D | E | F | G | H | I | J) element) -> boolish } -> Array[A | B | C | D | E | F | G | H | I | J]? + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]?] + + def filter!: () { ((A | B | C | D | E | F | G | H | I | J) element) -> boolish } -> Array[A | B | C | D | E | F | G | H | I | J]? + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]?] + + def compact!: () -> Array[A | B | C | D | E | F | G | H | I | J]? + + def flatten!: (?int? level) -> Array[A | B | C | D | E | F | G | H | I | J]? + + def uniq!: () -> Array[A | B | C | D | E | F | G | H | I | J]? + | () { ((A | B | C | D | E | F | G | H | I | J) element) -> Hash::_Key } -> Array[A | B | C | D | E | F | G | H | I | J]? + + def sort!: () -> Array[A | B | C | D | E | F | G | H | I | J] + | () { ((A | B | C | D | E | F | G | H | I | J) a, (A | B | C | D | E | F | G | H | I | J) b) -> Comparable::_CompareToZero } -> Array[A | B | C | D | E | F | G | H | I | J] + | %a{warning: returning `nil` will always raise at runtime} () { ((A | B | C | D | E | F | G | H | I | J) a, (A | B | C | D | E | F | G | H | I | J) b) -> Comparable::_CompareToZero? } -> Array[A | B | C | D | E | F | G | H | I | J] + + def sort_by!: () { ((A | B | C | D | E | F | G | H | I | J) element) -> Comparable::_WithSpaceshipOperator } -> Array[A | B | C | D | E | F | G | H | I | J] + | %a{warning: returning `nil` will always raise at runtime} () { ((A | B | C | D | E | F | G | H | I | J) element) -> Comparable::_WithSpaceshipOperator? } -> Array[A | B | C | D | E | F | G | H | I | J] + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]] + + def reverse!: () -> Array[A | B | C | D | E | F | G | H | I | J] + + def rotate!: (?int count) -> Array[A | B | C | D | E | F | G | H | I | J] + + def shuffle!: (?random: _Rand) -> Array[A | B | C | D | E | F | G | H | I | J] + + def replace: (array[A | B | C | D | E | F | G | H | I | J] other_array) -> Array[A | B | C | D | E | F | G | H | I | J] + + def fill: ((A | B | C | D | E | F | G | H | I | J) object, ?int? start, ?int? length) -> Array[A | B | C | D | E | F | G | H | I | J] + | ((A | B | C | D | E | F | G | H | I | J) object, range[int?] range) -> Array[A | B | C | D | E | F | G | H | I | J] + | (?int? start, ?int? length) { (Integer index) -> (A | B | C | D | E | F | G | H | I | J) } -> Array[A | B | C | D | E | F | G | H | I | J] + | (range[int?] range) { (Integer index) -> (A | B | C | D | E | F | G | H | I | J) } -> Array[A | B | C | D | E | F | G | H | I | J] + + def clear: () -> Array[A | B | C | D | E | F | G | H | I | J] + + def collect!: () { ((A | B | C | D | E | F | G | H | I | J) element) -> (A | B | C | D | E | F | G | H | I | J) } -> Array[A | B | C | D | E | F | G | H | I | J] + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]] + + def map!: () { ((A | B | C | D | E | F | G | H | I | J) element) -> (A | B | C | D | E | F | G | H | I | J) } -> Array[A | B | C | D | E | F | G | H | I | J] + | () -> Enumerator[(A | B | C | D | E | F | G | H | I | J), Array[A | B | C | D | E | F | G | H | I | J]] + end + end +end diff --git a/solargraph.gemspec b/solargraph.gemspec index 67e9d1294..78b8015da 100755 --- a/solargraph.gemspec +++ b/solargraph.gemspec @@ -1,6 +1,5 @@ # frozen_string_literal: true -# @sg-ignore Should better support meaning of '&' in RBS $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/lib" require 'solargraph/version' require 'date' @@ -13,6 +12,7 @@ Gem::Specification.new do |s| s.description = 'IDE tools for code completion, inline documentation, and static analysis' s.authors = ['Fred Snyder'] s.email = 'admin@castwide.com' + # @sg-ignore Wrong argument type for File.expand_path: file_name expected String, _ToStr, _ToPath, received String, nil s.files = Dir.chdir(File.expand_path(__dir__)) do # @sg-ignore Need backtick support # @type [String] diff --git a/spec/api_map/index_spec.rb b/spec/api_map/index_spec.rb index 8afb74759..c503eb2b4 100644 --- a/spec/api_map/index_spec.rb +++ b/spec/api_map/index_spec.rb @@ -61,4 +61,27 @@ expect(first_parameter.return_type.tag).to eq('String') end end + + describe '#map_overrides on a constant' do + let(:baz_constant) do + Solargraph::Pin::Constant.new(name: 'BAZ', + closure: Solargraph::Pin::ROOT_PIN, + comments: '@return [String]') + end + + let(:baz_override) do + Solargraph::Pin::Reference::Override.from_comment('BAZ', '@return [Integer]') + end + + let(:input_pins) { [baz_constant, baz_override] } + + it 'does not raise on a pin without signatures' do + expect { output_pins }.not_to raise_error + end + + it 'redefines the return type of the constant' do + constant_pin = output_pins.find { |pin| pin.path == 'BAZ' } + expect(constant_pin.return_type.tag).to eq('Integer') + end + end end diff --git a/spec/api_map/store_spec.rb b/spec/api_map/store_spec.rb index 059c3eb1b..cb576599a 100644 --- a/spec/api_map/store_spec.rb +++ b/spec/api_map/store_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'timeout' + describe Solargraph::ApiMap::Store do it 'indexes multiple pinsets' do foo_pin = Solargraph::Pin::Namespace.new(name: 'Foo') @@ -49,6 +51,96 @@ expect(store.get_path_pins('Bar')).to eq([bar_pin]) end + describe '#get_methods' do + it 'combines pins for the same method path from different sources' do + plain_impl = Solargraph::SourceMap.load_string(%( + class Foo + def bar; end + end + ), 'plain.rb') + override = Solargraph::SourceMap.load_string(%( + class Foo + # @return [String] + def bar; end + end + ), 'override.rb') + store = described_class.new(plain_impl.pins + override.pins) + pins = store.get_methods('Foo', scope: :instance).select { |p| p.name == 'bar' } + expect(pins.length).to eq(1) + expect(pins.first.return_type.tag).to eq('String') + end + + it 'does not combine a method alias with a regular method sharing its path' do + # Combining an alias pin with a non-alias pin at the same path + # previously produced a `:combined` pin that couldn't be + # resolved back to its original target, raising under + # SOLARGRAPH_ASSERTS=on. + regular = Solargraph::SourceMap.load_string(%( + class Foo + def bar; end + end + ), 'regular.rb') + aliased = Solargraph::SourceMap.load_string(%( + class Foo + def baz; end + alias bar baz + end + ), 'aliased.rb') + store = described_class.new(regular.pins + aliased.pins) + pins = [] + expect { pins = store.get_methods('Foo', scope: :instance) }.not_to raise_error + bar_pins = pins.select { |p| p.name == 'bar' } + expect(bar_pins.length).to eq(2) + expect(bar_pins).to include(an_instance_of(Solargraph::Pin::MethodAlias)) + end + + it 'combines many same-path pins without timing out' do + # Smoke test for the concern that originally motivated + # castwide/solargraph#1186 ("Stub combine_same_type_arity_signatures", + # merged as a stopgap for "an infinite loop bug in Ruby 3.x") and + # castwide/solargraph#1195 ("Limit pin combination to doc maps", + # which removed this combining logic from Store#get_methods + # entirely). The "infinite loop" was later diagnosed as an + # exponential blowup in Pin::Method#combine_same_type_arity_signatures + # and fixed in castwide/solargraph#1238. + # + # This does NOT reproduce that specific bug: these hand-written + # single-type parameters all collapse to the same + # Pin::Parameter#type_arity_decl bucket (a separate, still-open + # gap - it groups by union member *count*, not the actual + # types), so they hit the ">10" bail-out before ever reaching + # the code path #1238 fixed. #1238's own regression spec + # (spec/pin/method_spec.rb "combines many non-mergeable + # same-type-arity signatures without exponential blowup") is + # the precise guard for that bug, exercising + # combine_same_type_arity_signatures directly with signatures + # engineered to never merge. This spec instead just confirms + # Store#get_methods' combination of many real same-path pins, + # as this PR adds, completes quickly rather than hanging. + maps = (1..30).map do |i| + Solargraph::SourceMap.load_string(%( + class Foo + # @param other [Type#{i}] + # @return [Type#{i}] + def bar(other); end + end + ), "source#{i}.rb") + end + store = nil + Timeout.timeout(5) { store = described_class.new(maps.flat_map(&:pins)) } + + result = nil + Timeout.timeout(5) { result = store.get_methods('Foo', scope: :instance) } + + bar_pins = result.select { |p| p.name == 'bar' } + expect(bar_pins.length).to eq(1) + # The real regression is combinatorial blowup, not the exact + # merge outcome - bound the result size rather than pin down + # merge semantics unrelated to this concern. + expect(bar_pins.first.signatures.length).to be <= maps.length + end + end + # @todo This will become #get_superclass describe '#get_superclass' do it 'returns simple superclasses' do diff --git a/spec/api_map_method_spec.rb b/spec/api_map_method_spec.rb index 063b22f32..0c81d3a4d 100644 --- a/spec/api_map_method_spec.rb +++ b/spec/api_map_method_spec.rb @@ -21,6 +21,20 @@ describe '#qualify' do let(:external_requires) { ['yaml'] } + it 'resolves a constant that aliases a namespace' do + source = Solargraph::Source.load_string(%( + class Foo; end + + module Bar + Baz = ::Foo + end + ), 'test.rb') + + api_map = described_class.new.map(source) + + expect(api_map.qualify('Bar::Baz')).to eq('Foo') + end + it 'understands alias namespaces resolving types' do source = Solargraph::Source.load_string(%( class Foo @@ -117,15 +131,15 @@ class B end end - describe '#get_method_stack' do + describe '#get_method_stack', time_limit_seconds: 400 do let(:out) { StringIO.new } let(:api_map) { described_class.load_with_cache(Dir.pwd, out) } - context 'with stdlib that has vital dependencies' do + context 'with stdlib that has vital dependencies', time_limit_seconds: 400 do let(:external_requires) { ['yaml'] } let(:method_stack) { api_map.get_method_stack('YAML', 'safe_load', scope: :class) } - it 'handles the YAML gem aliased to Psych' do + it 'handles the YAML gem aliased to Psych', time_limit_seconds: 400 do expect(method_stack).not_to be_empty end end @@ -135,6 +149,15 @@ class B let(:method_stack) { api_map.get_method_stack('Thor', 'desc', scope: :class) } it 'handles finding Thor.desc' do + # catalog first so doc_map registers 'thor' as required before we + # try to cache it - cache_gem is a no-op for gems doc_map doesn't + # yet know it needs + api_map.catalog bench + specs = api_map.resolve_require('thor') + specs.each { |spec| api_map.cache_gem(spec) } + api_map.catalog bench + + # if this fails you may not have an rbs collection installed expect(method_stack).not_to be_empty end end @@ -143,10 +166,10 @@ class B describe '#cache_all_for_doc_map!' do it 'can cache gems without a bench' do api_map = described_class.new - doc_map = instance_double(Solargraph::DocMap, cache_all!: true) + 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!(out: $stderr) - expect(doc_map).to have_received(:cache_all!).with($stderr, rebuild: false) + expect(doc_map).to have_received(:cache_doc_map_gems!).with($stderr, rebuild: false) end end diff --git a/spec/api_map_spec.rb b/spec/api_map_spec.rb index 6f367d229..b92a60f9d 100755 --- a/spec/api_map_spec.rb +++ b/spec/api_map_spec.rb @@ -7,7 +7,7 @@ @api_map = described_class.new end - it 'returns core methods' do + it 'returns core methods', time_limit_seconds: 200 do pins = @api_map.get_methods('String') expect(pins.map(&:path)).to include('String#upcase') end @@ -758,15 +758,15 @@ def bar; end expect(api_map.qualify('Boolean')).to eq('Boolean') end - # it 'knows that true is a "subtype" of Boolean' do - # api_map = described_class.new - # expect(api_map.super_and_sub?('Boolean', 'true')).to be(true) - # end + it 'knows that true is a "subtype" of Boolean' do + api_map = described_class.new + expect(api_map.super_and_sub?('Boolean', 'true')).to be(true) + end - # it 'knows that false is a "subtype" of Boolean' do - # api_map = described_class.new - # expect(api_map.super_and_sub?('Boolean', 'false')).to be(true) - # end + it 'knows that false is a "subtype" of Boolean' do + api_map = described_class.new + expect(api_map.super_and_sub?('Boolean', 'false')).to be(true) + end it 'resolves aliases for YARD methods' do dir = File.absolute_path(File.join('spec', 'fixtures', 'yard_map')) @@ -925,6 +925,34 @@ def self.multi_property expect(pins.map(&:return_type).map(&:tag)).to eq(%w[Integer Integer]) end + it 'preserves duck type return tags in attached macros' do + source = Solargraph::SourceMap.load_string(%( + class Macro + # @!macro [new] duck_attr + # @!method $1 + # @return [#quack] + # @param name [Symbol] + # @return [void] + def self.duck_attr(name); end + + # @!macro [new] class_attr + # @!method $1 + # @return [String] + # @param name [Symbol] + # @return [void] + def self.class_attr(name); end + + duck_attr :ducky + class_attr :stringy + end + ), 'test.rb') + @api_map.catalog(Solargraph::Bench.new(source_maps: [source])) + expect(@api_map.get_path_pins('Macro#ducky').first.return_type.tag).to eq('#quack') + expect(@api_map.get_path_pins('Macro#stringy').first.return_type.tag).to eq('String') + methods = @api_map.get_complex_type_methods(Solargraph::ComplexType.parse('#quack')) + expect(methods.map(&:name)).to include('quack') + end + it 'generates methods from @!attribute tag in attached dsl macros' do source = Solargraph::SourceMap.load_string(%( class Macro @@ -1005,4 +1033,22 @@ def self.property(name, default, type:, comment:) # @todo Undefined because the return tag expands to `type: String` expect(pins.map(&:return_type).map(&:tag)).to eq(%w[undefined]) end + + # @!override on a constant used to abort cataloging with NoMethodError + # because Pin::Constant does not respond to #signatures. + # https://github.com/castwide/solargraph/issues/1302 + it 'applies override directives to constants without aborting' do + source = Solargraph::Source.load_string(%( + # @!override Overridable::CONST + # @return [Integer] + + module Overridable + CONST = 'a string' + end + ), 'test.rb') + api_map = described_class.new + expect { api_map.map source }.not_to raise_error + pin = api_map.get_path_pins('Overridable::CONST').first + expect(pin.return_type.tag).to eq('Integer') + end end diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index 27e9356af..311fa19aa 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -81,7 +81,6 @@ class Sub < Sup; end end it 'handles singleton types compared against their literals' do - pending 'side of effect of inference changes' exp = Solargraph::ComplexType::UniqueType.new('nil', rooted: true) inf = Solargraph::ComplexType::UniqueType.new('NilClass', rooted: true) match = inf.conforms_to?(api_map, exp, :method_call) @@ -240,6 +239,204 @@ class Sub < Sup; end end end + context 'with intersection types' do + let(:source) do + Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + class Unrelated; end + )) + end + + before do + api_map.map source + end + + it 'lets an intersection satisfy an expectation of any one conjunct (A & B <: A)' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Sub') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + + it 'lets an intersection satisfy an expectation of any one conjunct (A & B <: B)' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Unrelated') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + + it 'does not let an intersection satisfy an expectation none of its conjuncts meet' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Integer') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(false) + end + + it 'requires every conjunct to be satisfied to conform to an intersection expectation' do + inf = described_class.parse('Sub') + exp = described_class.parse('Sup & Unrelated') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(false) + end + + it 'conforms to an intersection expectation when every conjunct is satisfied' do + inf = described_class.parse('Sub') + exp = described_class.parse('Sup & Sub') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + + context 'when both the inferred and expected types are intersections' do + # A & B <: C & D iff every conjunct of the expected side is + # satisfied by *some* conjunct of the inferred side - not "one + # single inferred conjunct satisfies the whole expected type." + # Checking a single already-chosen conjunct against the full + # expected intersection (rather than letting different inferred + # conjuncts cover different expected conjuncts) makes an + # intersection fail to conform to an identical copy of itself + # whenever its conjuncts don't already relate to each other. + it 'conforms to an identical intersection with unrelated conjuncts' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Sub & Unrelated') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + + it 'conforms to an identical intersection regardless of conjunct order' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Unrelated & Sub') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + + it 'still requires every expected conjunct to be covered by some inferred conjunct' do + inf = described_class.parse('Sub & Unrelated') + exp = described_class.parse('Sub & Integer') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(false) + end + + it 'lets a wider inferred intersection satisfy a narrower expected one' do + inf = described_class.parse('Sub & Unrelated & Integer') + exp = described_class.parse('Sub & Unrelated') + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(true) + end + end + + it 'combines a class and a mix-in as conjuncts' do + inf = described_class.parse('String & Comparable') + expect(inf.conforms_to?(api_map, described_class.parse('Comparable'), :method_call)).to be(true) + expect(inf.conforms_to?(api_map, described_class.parse('Enumerable'), :method_call)).to be(false) + end + + it 'lets a value satisfy a class-and-mix-in intersection expectation' do + exp = described_class.parse('Comparable & String') + expect(described_class.parse('String').conforms_to?(api_map, exp, :method_call)).to be(true) + expect(described_class.parse('Integer').conforms_to?(api_map, exp, :method_call)).to be(false) + end + + context 'with a duck-typed conjunct in the expectation' do + let(:source) do + Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + class Unrelated; end + + class Quacker + def to_str + '' + end + end + )) + end + + it 'structurally verifies a duck-typed conjunct alongside a nominal one' do + exp = described_class.parse('Object & #to_str') + expect(described_class.parse('Quacker').conforms_to?(api_map, exp, :method_call)).to be(true) + end + + it 'still requires the nominal conjunct even if the duck-typed one matches' do + exp = described_class.parse('Comparable & #to_str') + expect(described_class.parse('Quacker').conforms_to?(api_map, exp, :method_call)).to be(false) + end + end + end + + context 'with RBS interface types' do + it 'structurally validates a type that satisfies the interface, without any rule' do + exp = described_class.parse('Hash::_Key') + inf = described_class.parse('Symbol') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'structurally invalidates a type that does not satisfy the interface, even with allow_unmatched_interface' do + exp = described_class.parse('_ToAry') + inf = described_class.parse('Integer') + match = inf.conforms_to?(api_map, exp, :method_call, [:allow_unmatched_interface]) + expect(match).to be(false) + end + + it 'rejects a type that does not satisfy the interface when the rule is absent' do + exp = described_class.parse('_ToAry') + inf = described_class.parse('Integer') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'validates a type that satisfies the interface via a core fill include' do + exp = described_class.parse('_ToAry') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'falls back to allow_unmatched_interface when the interface pin cannot be found' do + exp = described_class::UniqueType.new('_NoSuchInterface', rooted: true) + inf = described_class.parse('Integer') + expect(inf.conforms_to?(api_map, exp, :method_call, [:allow_unmatched_interface])).to be(true) + expect(inf.conforms_to?(api_map, exp, :method_call)).to be(false) + end + + it 'rejects a same-named method with the wrong return type' do + # https://github.com/castwide/solargraph/issues/1267 + # + # The structural check only confirms a `to_ary` method exists; it + # doesn't verify it actually returns an Array. + pending 'structural interface conformance does not yet check method return types (issue #1267)' + source = Solargraph::Source.load_string(%( + class BadToAry + # @return [String] + def to_ary + 'not an array' + end + end + )) + api_map.map source + exp = described_class.parse('_ToAry') + inf = described_class.parse('BadToAry') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'rejects a same-named method with the wrong arity' do + # https://github.com/castwide/solargraph/issues/1267 + # + # The structural check only confirms an `eql?` method exists; it + # doesn't verify it accepts the argument Hash::_Key#eql? requires. + pending 'structural interface conformance does not yet check method parameters (issue #1267)' + source = Solargraph::Source.load_string(%( + class BadKey + def eql? + true + end + + def hash + 1 + end + end + )) + api_map.map source + exp = described_class.parse('Hash::_Key') + inf = described_class.parse('BadKey') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + end + context 'with inheritance relationship in allow_reverse_match mode' do let(:api_map) { Solargraph::ApiMap.new } let(:sup) { described_class.parse('String') } diff --git a/spec/complex_type/exclude_spec.rb b/spec/complex_type/exclude_spec.rb new file mode 100644 index 000000000..68a3d9e67 --- /dev/null +++ b/spec/complex_type/exclude_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# ComplexType#exclude already accepts an api_map parameter, but its +# current implementation ignores it and does plain exact-match array +# subtraction. This documents the api_map-aware behavior it would +# ideally have (a third example of api-map-driven type simplification, +# alongside narrow_with's subtype/mix-in reduction and qualify's name +# resolution): excluding a type should also exclude any union member +# already known to be one of its subtypes, since a value that fails +# `is_a?(Sup)` can't be a `Sub` either. +# +# Not implemented - out of scope for the PR that added this file. +# These specs exist so the gap is tracked rather than silently unknown. +describe Solargraph::ComplexType do + let(:api_map) { Solargraph::ApiMap.new } + + let(:source) do + Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + class Unrelated; end + )) + end + + before { api_map.map source } + + it 'excludes known subtypes of an excluded type, not just exact matches' do + pending 'exclude ignores its api_map parameter and only removes exact matches' + type = described_class.parse('Sub, Sup, Unrelated') + result = type.exclude(described_class.parse('Sup'), api_map) + expect(result.tags).to eq('Unrelated') + end + + it 'falls back to UNDEFINED when every member is excluded transitively' do + pending 'exclude ignores its api_map parameter and only removes exact matches' + type = described_class.parse('Sub, Sup') + result = type.exclude(described_class.parse('Sup'), api_map) + expect(result.undefined?).to be(true) + end +end diff --git a/spec/complex_type/narrow_with_spec.rb b/spec/complex_type/narrow_with_spec.rb new file mode 100644 index 000000000..8ebb97fc8 --- /dev/null +++ b/spec/complex_type/narrow_with_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# ComplexType#narrow_with is flow-sensitive type narrowing (e.g. +# refining a declared type using a type learned from an `is_a?` +# guard) - see spec/parser/flow_sensitive_typing_spec.rb for +# end-to-end coverage through real source. These specs exercise the +# narrowing logic directly. +describe Solargraph::ComplexType do + let(:api_map) { Solargraph::ApiMap.new } + + context 'when narrowing a class with an unrelated mix-in' do + let(:source) do + Solargraph::Source.load_string(%( + module M; end + class T; end + )) + end + + before { api_map.map source } + + it 'builds an intersection rather than discarding both facts' do + declared = described_class.parse('T') + learned = described_class.parse('M') + narrowed = declared.narrow_with(learned, api_map) + expect(narrowed.first).to be_a(Solargraph::ComplexType::UniqueType::Intersection) + expect(narrowed.tag).to eq('T & M') + end + + it 'lets the narrowed intersection satisfy either original fact' do + declared = described_class.parse('T') + learned = described_class.parse('M') + narrowed = declared.narrow_with(learned, api_map) + expect(narrowed.conforms_to?(api_map, described_class.parse('T'), :method_call)).to be(true) + expect(narrowed.conforms_to?(api_map, described_class.parse('M'), :method_call)).to be(true) + end + end + + context 'when the mix-in is already known to be included' do + let(:source) do + Solargraph::Source.load_string(%( + module M; end + class T + include M + end + )) + end + + before { api_map.map source } + + it 'simplifies to the already-more-specific type instead of building a redundant intersection' do + declared = described_class.parse('T') + learned = described_class.parse('M') + narrowed = declared.narrow_with(learned, api_map) + expect(narrowed.tag).to eq('T') + end + end +end diff --git a/spec/complex_type/unique_type_spec.rb b/spec/complex_type/unique_type_spec.rb index 2d9812600..d98d76d11 100644 --- a/spec/complex_type/unique_type_spec.rb +++ b/spec/complex_type/unique_type_spec.rb @@ -1,6 +1,25 @@ # frozen_string_literal: true describe Solargraph::ComplexType::UniqueType do + describe '::BOT' do + it 'is a bot type' do + expect(described_class::BOT.bot?).to be true + end + + it 'is rooted, unlike ::UNDEFINED' do + expect(described_class::BOT.rooted?).to be true + end + + it 'is equal to ComplexType::BOT.first' do + # rooted: true is load-bearing here - #bot?/#tag/#to_s all + # match regardless of #rooted, but #== (via Equality's + # equality_fields) also compares the raw @rooted ivar, so a + # rooted: false construction would silently fail this equality + # despite looking identical everywhere else. + expect(described_class::BOT).to eq(Solargraph::ComplexType::BOT.first) + end + end + describe '#any?' do let(:type) { described_class.parse('String') } diff --git a/spec/complex_type_spec.rb b/spec/complex_type_spec.rb index 7064b9df4..1deb5bb7f 100644 --- a/spec/complex_type_spec.rb +++ b/spec/complex_type_spec.rb @@ -223,6 +223,50 @@ expect(type.to_rbs).to eq('Hash[(String | Symbol), (Integer | BigDecimal)]') end + # Regression test for https://github.com/castwide/solargraph/pull/1259#issuecomment-5192210594 + # + # A Hash{} type parsed with a comma-separated (unparenthesized union) + # key keeps key_types split across multiple ComplexType elements (see + # the 'parses multiple key/value types' example above). If that type's + # tag is later regenerated by code that no longer has hash_parameters? + # set (e.g. a UniqueType rebuilt with parameters_type: :list, as can + # happen when generics substitution recreates a Hash-named type from + # a context type's key_types/subtypes), the substring generator must + # not fall back to the 2-parameter Hash notation, since that + # can't represent more than one key or value type and produces a + # string that fails to reparse. + it 'round-trips a Hash tag when key_types has more than one element and hash_parameters? is unset' do + original = Solargraph::ComplexType.parse('Hash{String, nil => Enumerable}').items.first + expect(original.key_types.length).to eq(2) + + mismatched = Solargraph::ComplexType::UniqueType.new( + 'Hash', original.key_types, original.subtypes, rooted: original.rooted?, parameters_type: :list + ) + + expect do + Solargraph::ComplexType.parse(mismatched.tag) + end.not_to raise_error + end + + # A key_types/subtypes array can also have exactly one element that is + # itself a multi-item union ComplexType (as generics substitution can + # produce -- see UniqueType#resolve_generics, which replaces a single + # generic slot with ComplexType.new(context_type.key_types)). + # key_types.length == 1 alone isn't enough to guarantee the + # notation is safe to use. + it 'round-trips a Hash tag when a single key_types slot is itself a union and hash_parameters? is unset' do + union_key = Solargraph::ComplexType.parse('String', 'nil') + value = Solargraph::ComplexType.parse('Enumerable') + + mismatched = Solargraph::ComplexType::UniqueType.new( + 'Hash', [union_key], [value], rooted: false, parameters_type: :list + ) + + expect do + Solargraph::ComplexType.parse(mismatched.tag) + end.not_to raise_error + end + # # Order-Dependent Lists # @@ -341,6 +385,249 @@ xit 'understands reference tags' end + # https://github.com/ruby/rbs/blob/master/docs/syntax.md#intersection-type + context 'when parsing RBS intersection types' do + it 'parses two conjuncts as a single unique type' do + types = Solargraph::ComplexType.parse('String & Comparable') + expect(types.length).to eq(1) + expect(types.first).to be_a(Solargraph::ComplexType::UniqueType::Intersection) + expect(types.first.tag).to eq('String & Comparable') + expect(types.to_rbs).to eq('String & Comparable') + end + + it 'parses more than two conjuncts' do + types = Solargraph::ComplexType.parse('String & Comparable & Enumerable') + expect(types.length).to eq(1) + expect(types.first.conjuncts.map(&:tag)).to eq(%w[String Comparable Enumerable]) + end + + it 'distinguishes intersections from unions in the same list' do + types = Solargraph::ComplexType.parse('String & Comparable, Integer') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('String & Comparable') + expect(types[1].tag).to eq('Integer') + end + + it 'parses intersections nested in subtypes' do + types = Solargraph::ComplexType.parse('Array') + expect(types.first.tag).to eq('Array') + expect(types.to_rbs).to eq('Array[String & Comparable]') + end + + # `&` binds tighter than the top-level `,` (union), matching RBS's + # documented precedence: "A & B | C is (A & B) | C". Our + # single-pass parser doesn't implement precedence via grouping - + # it greedily gathers `&`-separated conjuncts until the next `,` + # or end of string - but that happens to produce the same result + # as real operator precedence for every case reachable through + # this tag-string grammar, since there is no way to write a + # standalone grouped union in it (see below). + context 'with & and , precedence' do + it 'binds & tighter than , when & comes first' do + types = Solargraph::ComplexType.parse('A & B, C') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('A & B') + expect(types[1].tag).to eq('C') + end + + it 'binds & tighter than , when , comes first' do + types = Solargraph::ComplexType.parse('A, B & C') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('A') + expect(types[1].tag).to eq('B & C') + end + + it 'handles multiple intersections in the same union' do + types = Solargraph::ComplexType.parse('A & B, C & D') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('A & B') + expect(types[1].tag).to eq('C & D') + end + + it 'handles intersections of different sizes in the same union' do + types = Solargraph::ComplexType.parse('A & B & C, D & E') + expect(types.length).to eq(2) + expect(types[0].conjuncts.map(&:tags)).to eq(%w[A B C]) + expect(types[1].conjuncts.map(&:tags)).to eq(%w[D E]) + end + end + + context 'with parentheses' do + it 'does not confuse a fixed-tuple-parameter name with a grouped union' do + # `Array(A, B)` is Solargraph's existing fixed-tuple-parameter + # syntax (a tuple `[A, B]`), unrelated to grouping. `&` after + # it still means "intersected with", not "and one more tuple + # element". + types = Solargraph::ComplexType.parse('Array(A, B) & C') + expect(types.length).to eq(1) + intersection = types.first + expect(intersection.conjuncts.map(&:tags)).to eq(['Array(A, B)', 'C']) + end + + it 'reads a bare comma-separated parenthesized group as an anonymous fixed tuple, not a grouped union' do + # `(A, B)` (parentheses, not brackets) is the anonymous form of + # the fixed-tuple-parameter syntax (see "anonymous shorthand" + # specs below) - its name defaults to Array, and its comma + # keeps positional/fixed-arity meaning rather than becoming a + # grouped union. `[...]` (below) is the actual grouping + # syntax; `(...)` never is, with or without a leading name. + types = Solargraph::ComplexType.parse('(A, B) & C') + expect(types.length).to eq(1) + intersection = types.first + expect(intersection.conjuncts.length).to eq(2) + tuple_conjunct = intersection.conjuncts.first.first + expect(tuple_conjunct.name).to eq('Array') + expect(tuple_conjunct.fixed_parameters?).to be(true) + expect(tuple_conjunct.subtypes.map(&:tags)).to eq(%w[A B]) + end + end + + # https://github.com/lsegal/yard/pull/1700 + context 'with the | union operator' do + it 'parses a top-level union the same as a comma-separated one' do + types = Solargraph::ComplexType.parse('String | Integer') + expect(types.length).to eq(2) + expect(types.tags).to eq('String, Integer') + end + + it 'binds looser than &' do + types = Solargraph::ComplexType.parse('A & B | C') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('A & B') + expect(types[1].tag).to eq('C') + end + + it 'binds looser than & on either side' do + types = Solargraph::ComplexType.parse('A | B & C') + expect(types.length).to eq(2) + expect(types[0].tag).to eq('A') + expect(types[1].tag).to eq('B & C') + end + + it 'groups multiple types into a single positional slot of a fixed tuple' do + ut = Solargraph::ComplexType.parse('Array(Foo | Bar, Baz)').first + expect(ut.fixed_parameters?).to be(true) + expect(ut.subtypes.length).to eq(2) + expect(ut.subtypes[0].tags).to eq('Foo, Bar') + expect(ut.to_rbs).to eq('[(Foo | Bar), Baz]') + expect(ut.subtypes[1].tags).to eq('Baz') + end + + it 'groups multiple types into a single positional slot of a generic type parameter list' do + ut = Solargraph::ComplexType.parse('Result').first + expect(ut.subtypes.length).to eq(2) + expect(ut.subtypes[0].tags).to eq('Success, Failure') + expect(ut.to_rbs).to eq('Result[(Success | Failure), Other]') + end + + it 'lands on the same result as a comma inside an implicit-union context (Array<...>)' do + # Both mean "an Array of Foo-or-Bar". They differ in how many + # subtype slots hold the union (one 2-item slot for `|`, two + # 1-item slots for `,`) - implicit_union? treats both the same + # way, so #tags (which doesn't group-render a slot) matches; + # #to_rbs parenthesizes a multi-item slot wherever it appears, + # so it doesn't happen to here, same as any other multi-item + # subtype slot (see the fixed-tuple specs above). + piped = Solargraph::ComplexType.parse('Array').first + commaed = Solargraph::ComplexType.parse('Array').first + expect(piped.tag).to eq(commaed.tag) + end + end + + # https://github.com/lsegal/yard/pull/1700 + context 'with [...] grouping brackets' do + it 'groups a union so it can be one conjunct of an intersection' do + types = Solargraph::ComplexType.parse('[Foo | Bar] & Baz') + expect(types.length).to eq(1) + intersection = types.first + expect(intersection).to be_a(Solargraph::ComplexType::UniqueType::Intersection) + expect(intersection.conjuncts.map(&:tags)).to eq(['Foo, Bar', 'Baz']) + expect(intersection.to_rbs).to eq('(Foo | Bar) & Baz') + end + + it 'groups a union as the second conjunct of an intersection' do + types = Solargraph::ComplexType.parse('Foo & [Bar | Baz]') + intersection = types.first + expect(intersection.conjuncts.map(&:tags)).to eq(['Foo', 'Bar, Baz']) + expect(intersection.to_rbs).to eq('Foo & (Bar | Baz)') + end + + it 'raises on an unclosed bracket' do + expect { Solargraph::ComplexType.parse('[Foo | Bar & Baz') }.to raise_error(Solargraph::ComplexTypeError) + end + end + + # https://github.com/lsegal/yard/pull/1700 + context 'with anonymous shorthand forms' do + it 'defaults to Array' do + ut = Solargraph::ComplexType.parse('').first + expect(ut.name).to eq('Array') + expect(ut.list_parameters?).to be(true) + expect(ut.tag).to eq('Array') + end + + it 'defaults (A) to Array(A)' do + ut = Solargraph::ComplexType.parse('(String)').first + expect(ut.name).to eq('Array') + expect(ut.fixed_parameters?).to be(true) + expect(ut.tag).to eq('Array(String)') + end + + it 'defaults {A=>B} to Hash{A=>B}' do + ut = Solargraph::ComplexType.parse('{String=>Integer}').first + expect(ut.name).to eq('Hash') + expect(ut.hash_parameters?).to be(true) + expect(ut.tag).to eq('Hash{String => Integer}') + expect(ut.to_rbs).to eq('Hash[String, Integer]') + end + + it 'roots an anonymous shorthand the same way its named equivalent would be' do + anonymous = Solargraph::ComplexType.parse('').first + named = Solargraph::ComplexType.parse('Array').first + expect(anonymous.rooted?).to eq(named.rooted?) + end + end + end + + # Redundant-member simplification for plain unions, based on a + # specific api_map's class hierarchy - a second example of + # api-map-driven type simplification, alongside narrow_with's + # subtype/mix-in reduction and (differently) qualify's name + # resolution. `Superclass, Subclass` is logically the same set as + # `Superclass` alone, since every Subclass instance already is a + # Superclass instance - but ComplexType.parse has no api_map to + # check that with, and no such simplification happens anywhere else + # either (verified: nothing in the codebase does this today). + # + # Not implemented - out of scope for the PR that added this file. + # `simplify_redundant_members` below is a proposed/illustrative + # interface, not a settled design; these specs exist so the gap is + # tracked rather than silently unknown. + context 'when simplifying unions of a known superclass and subclass' do + let(:api_map) { Solargraph::ApiMap.new } + + let(:source) do + Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + )) + end + + before { api_map.map source } + + it 'drops a redundant subclass when the superclass is already listed' do + pending 'no api_map-aware union simplification exists yet' + type = described_class.parse('Sup, Sub') + expect(type.simplify_redundant_members(api_map).tags).to eq('Sup') + end + + it 'drops the redundant subclass regardless of listed order' do + pending 'no api_map-aware union simplification exists yet' + type = described_class.parse('Sub, Sup') + expect(type.simplify_redundant_members(api_map).tags).to eq('Sup') + end + end + context 'when given non-sensical types by machine users' do it 'raises ComplexTypeError for unmatched brackets' do expect do @@ -427,7 +714,6 @@ end it 'squashes literal types when simplifying literals of same type' do - pending 'Maybe feasible' api_map = Solargraph::ApiMap.new type = Solargraph::ComplexType.parse('1, 2, 3') type = type.qualify(api_map, '') @@ -501,6 +787,37 @@ expect(type.tag).to eq('Array') end + it 'resolves generic parameters on a tuple using ()' do + return_type = Solargraph::ComplexType.parse('Array(generic, generic)') + generic_class = Solargraph::Pin::Namespace.new(name: 'Foo', + comments: "@generic GenericTypeParam1\n@generic GenericTypeParam2") + called_method = Solargraph::Pin::Method.new( + location: Solargraph::Location.new('file:///foo.rb', Solargraph::Range.from_to(0, 0, 0, 0)), + closure: generic_class, + name: 'bar', + comments: '@return [Foo]' + ) + type = return_type.resolve_generics(generic_class, called_method.return_type) + expect(type.tag).to eq('Array(String, Integer)') + end + + it 'resolves generic parameters on a tuple using <()>' do + return_type = Solargraph::ComplexType.parse('Array<(generic, generic)>') + generic_class = Solargraph::Pin::Namespace.new(name: 'Foo', + comments: "@generic GenericTypeParam1\n@generic GenericTypeParam2") + called_method = Solargraph::Pin::Method.new( + location: Solargraph::Location.new('file:///foo.rb', Solargraph::Range.from_to(0, 0, 0, 0)), + closure: generic_class, + name: 'bar', + comments: '@return [Foo]' + ) + type = return_type.resolve_generics(generic_class, called_method.return_type) + # the anonymous `(...)` tuple defaults its name to Array (see + # "anonymous shorthand" specs below), so it renders with that + # name now instead of bare parentheses + expect(type.tag).to eq('Array') + end + UNIQUE_METHOD_GENERIC_TESTS = [ # tag, context_type_tag, unfrozen_input_map, expected_tag, expected_output_map ['String', 'String', {}, 'String', {}], @@ -533,6 +850,59 @@ end end end + + # Exercises ComplexType#resolve_generics_from_context directly + # (as opposed to UniqueType#resolve_generics_from_context above) + # against union types with more than one member, per + # https://github.com/castwide/solargraph/issues/1298 + # + # Note: the resolved_generic_values assertion below must compare + # with #to_s, not #tag - ComplexType#tag delegates to the first + # union member only (via method_missing), so a binding that's + # incorrectly the *entire* multi-member context union - the + # #1298 bug - can still report the right #tag while silently + # carrying the rest of the union along. #to_s renders every + # member and catches that. + UNION_COMPLEX_TYPE_GENERIC_TESTS = [ + # tag, context_type_tag, unfrozen_input_map, generics_to_resolve, expected_to_s, expected_output_map + # Discriminating: fails pre-fix with A bound to 'String, nil' (the whole context) instead of 'String'. + ['generic, nil', 'String, nil', {}, %w[A], 'String, nil', { 'A' => 'String' }], + # Discriminating: fails pre-fix with A bound to 'String, nil, Symbol' instead of 'String'. + ['generic, nil, Symbol', 'String, nil, Symbol', {}, %w[A], 'String, nil, Symbol', { 'A' => 'String' }], + # Non-discriminating (passes either way): context has only one member, so there's + # nothing to subtract - included as a no-regression check for the single-member-context shape. + ['generic, nil', 'String', {}, %w[A], 'String, nil', { 'A' => 'String' }], + # Non-discriminating: the nested generic is resolved against context.subtypes, and + # ComplexType#method_missing's delegation to the first union member already narrows this + # correctly with or without the fix. Included to confirm the fix doesn't disturb it. + ['Array>, nil', 'Array, nil', {}, %w[A], 'Array, nil', { 'A' => 'String' }], + # Non-discriminating, same reason as the Array case above, but for Hash key/value generics. + ['Hash{generic => generic}, nil', 'Hash{String => Integer}, nil', {}, %w[K V], + 'Hash{String => Integer}, nil', { 'K' => 'String', 'V' => 'Integer' }], + # Non-discriminating: an already-resolved value is never overwritten by context, + # with or without the fix. Included as a no-regression check. + ['generic, nil', 'String, nil', { 'A' => 'Integer' }, %w[A], 'Integer, nil', { 'A' => 'Integer' }], + # Known limitation, unchanged by this fix: with no concrete union member to + # subtract, there's no positional information to tell which generic should + # bind to which context member, so both end up bound to the entire context union. + ['generic, generic', 'String, Integer', {}, %w[A B], 'String, Integer', + { 'A' => 'String, Integer', 'B' => 'String, Integer' }] + ].freeze + + UNION_COMPLEX_TYPE_GENERIC_TESTS.each do |tag, context_type_tag, unfrozen_input_map, generics_to_resolve, expected_to_s, expected_output_map| + context "when resolving union #{tag} with context #{context_type_tag} and existing resolved generics #{unfrozen_input_map}" do + let(:complex_type) { Solargraph::ComplexType.parse(tag) } + let(:context_type) { Solargraph::ComplexType.parse(context_type_tag) } + + it "resolves to #{expected_to_s} with updated map #{expected_output_map}" do + resolved_generic_values = unfrozen_input_map.transform_values { |tag| Solargraph::ComplexType.parse(tag) } + resolved_type = complex_type.resolve_generics_from_context(generics_to_resolve, context_type, + resolved_generic_values: resolved_generic_values) + expect(resolved_type.to_s).to eq(expected_to_s) + expect(resolved_generic_values.transform_values(&:to_s)).to eq(expected_output_map) + end + end + end end end @@ -689,7 +1059,10 @@ def make_bar it 'resolves self keywords in ordered array types' do selfy = Solargraph::ComplexType.parse('Array<(String, Symbol, self)>') type = selfy.self_to_type(Solargraph::ComplexType.parse('Foo')) - expect(type.tag).to eq('Array<(String, Symbol, Foo)>') + # the anonymous `(...)` tuple defaults its name to Array (see + # "anonymous shorthand" specs below), so it renders with that + # name now instead of bare parentheses + expect(type.tag).to eq('Array') expect(type.to_rbs).to eq('Array[[String, Symbol, Foo]]') end @@ -737,11 +1110,31 @@ def make_bar end it 'recognizes a literal conforms with its type' do - pending 'Maybe feasible' api_map = Solargraph::ApiMap.new ptype = Solargraph::ComplexType.parse('Symbol') atype = Solargraph::ComplexType.parse(':foo') expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) end + + it 'recognizes a duck type conforms with an identical duck type' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('#foo') + atype = Solargraph::ComplexType.parse('#foo') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) + end + + it 'recognizes a duck type does not conform with a different duck type' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('#bar') + atype = Solargraph::ComplexType.parse('#foo') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(false) + end + + it 'recognizes a duck type conforms to a duck type method it inherits from Object' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('#to_s') + atype = Solargraph::ComplexType.parse('#foo') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) + end end end diff --git a/spec/convention/class_definition_spec.rb b/spec/convention/class_definition_spec.rb new file mode 100644 index 000000000..b829e7ade --- /dev/null +++ b/spec/convention/class_definition_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +describe Solargraph::Convention::ClassDefinition do + it 'indexes a Class.new assignment as a namespace' do + source = Solargraph::SourceMap.load_string(%( + module Vendor + Specific = Class.new(StandardError) do + # @return [Integer] + def retry_after + 5 + end + end + end + ), 'test.rb') + + pin = source.pins.find { |p| p.path == 'Vendor::Specific' } + expect(pin).to be_a(Solargraph::Pin::Namespace) + expect(pin.type).to be(:class) + end + + it 'attaches block methods to the new class rather than the enclosing module' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + module Vendor + Specific = Class.new(StandardError) do + # @return [Integer] + def retry_after + 5 + end + end + end + ), 'test.rb') + + expect(api_map.get_methods('Vendor::Specific').map(&:name)).to include('retry_after') + expect(api_map.get_methods('Vendor').map(&:name)).not_to include('retry_after') + end + + it 'records the superclass' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + module Vendor + Specific = Class.new(StandardError) + end + ), 'test.rb') + + expect(api_map.super_and_sub?('StandardError', 'Vendor::Specific')).to be(true) + expect(api_map.get_methods('Vendor::Specific').map(&:name)).to include('message') + end + + it 'supports a Class.new class as a superclass of another' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + module Vendor + Base = Class.new(StandardError) + Nested = Class.new(Base) do + # @return [String] + def label + 'x' + end + end + end + ), 'test.rb') + + names = api_map.get_methods('Vendor::Nested').map(&:name) + expect(names).to include('label') + expect(names).to include('message') + end + + it 'handles Class.new with no superclass' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + Anon = Class.new do + # @return [Symbol] + def tag + :t + end + end + ), 'test.rb') + + expect(api_map.get_methods('Anon').map(&:name)).to include('tag') + end + + it 'handles a non-constant superclass expression' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + klass = Object + Dynamic = Class.new(klass) + ), 'test.rb') + + expect(api_map.get_path_pins('Dynamic').first).to be_a(Solargraph::Pin::Namespace) + end + + it 'leaves Struct.new assignments to the struct convention' do + api_map = Solargraph::ApiMap.new + api_map.map Solargraph::Source.load_string(%( + # @param bar [String] + Foo = Struct.new(:bar) + ), 'test.rb') + + expect(api_map.get_methods('Foo').map(&:name)).to include('bar') + end + + it 'resolves calls on a Class.new class in a typecheck' do + checker = Solargraph::TypeChecker.load_string(%( + module Vendor + Specific = Class.new(StandardError) do + # @return [Integer] + def retry_after + 5 + end + end + end + + # @param e [Vendor::Specific] + # @return [void] + def wait_for(e) + e.retry_after + end + ), 'test.rb', :strong) + + expect(checker.problems).to be_empty + end +end diff --git a/spec/convention/struct_definition_spec.rb b/spec/convention/struct_definition_spec.rb index 02786cfe6..b36db2809 100644 --- a/spec/convention/struct_definition_spec.rb +++ b/spec/convention/struct_definition_spec.rb @@ -23,6 +23,19 @@ expect(param_baz.return_type.tag).to eql('Integer') end + it 'treats keyword args as optional, since Ruby defaults omitted members to nil' do + source = Solargraph::SourceMap.load_string(%( + # @param bar [String] + # @param baz [Integer] + Foo = Struct.new(:bar, :baz, keyword_init: true) + ), 'test.rb') + + # @type [Array] + params = source.pins.find { |p| p.path == 'Foo#initialize' }.parameters + + expect(params.map(&:decl)).to eql(%i[kwoptarg kwoptarg]) + end + it 'sets closure to method on assignment operator parameters' do source = Solargraph::SourceMap.load_string(%( # @param bar [String] @@ -148,5 +161,19 @@ def type_checker code )) expect { checker.problems }.not_to raise_error end + + it 'does not report a missing keyword argument when a keyword_init member is omitted' do + checker = type_checker(%( + class Watch < Struct.new(:name, :time, keyword_init: true); end + + class Caller + # @return [Watch] + def go + Watch.new(name: 'foo') + end + end + )) + expect(checker.problems.map(&:message)).not_to include(a_string_matching(/Missing keyword argument/)) + end end end diff --git a/spec/doc_map_spec.rb b/spec/doc_map_spec.rb index 2dbe28fb7..8fdf815a2 100644 --- a/spec/doc_map_spec.rb +++ b/spec/doc_map_spec.rb @@ -19,7 +19,7 @@ let(:plain_doc_map) { described_class.new([], workspace, out: nil) } before do - doc_map.cache_all!(nil) if pre_cache + doc_map.cache_doc_map_gems!(nil) if pre_cache end context 'with a require in solargraph test bundle' do @@ -67,18 +67,48 @@ 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 - described_class.new(['set'], workspace) - expect(Solargraph.logger).not_to have_received(:warn).with(/path set/) + context 'when deserialization takes a while' do + let(:pre_cache) { false } + let(:requires) { ['backport'] } + + before do + # proxy this method to simulate a long-running deserialization + allow(Benchmark).to receive(:measure) do |&block| + block.call + 5.0 + end + end + + it 'logs timing' do + # force lazy evaluation + _pins = doc_map.pins + expect(out.string).to include('Deserialized ').and include(' gem pins ').and include(' ms') + end + 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, cache_stdlib_rbs_map: false) + uncached_gemspec = Gem::Specification.new('uncached_gem', '1.0.0') + allow(workspace).to receive(:fetch_dependencies).with(uncached_gemspec, out: out).and_return([]) + allow(workspace).to receive_messages(fresh_pincache: pincache, resolve_require: [uncached_gemspec], + stdlib_dependencies: [], global_environ: Solargraph::Environ.new) + 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) + 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 diff --git a/spec/gem_pins_spec.rb b/spec/gem_pins_spec.rb index 9d8101d17..808097b34 100644 --- a/spec/gem_pins_spec.rb +++ b/spec/gem_pins_spec.rb @@ -6,7 +6,7 @@ let(:pin) { doc_map.pins.find { |pin| pin.path == path } } before do - doc_map.cache_all!(STDERR) # rubocop:disable Style/GlobalStdStream + doc_map.cache_doc_map_gems!(STDERR) # rubocop:disable Style/GlobalStdStream end context 'with a combined method pin' do @@ -26,6 +26,15 @@ end end + context 'with a known-bad YARD pin' do + let(:path) { 'ENV' } + let(:requires) { ['pp'] } + + it 'excludes the pin that misdeclares ENV as a Class' do + expect(doc_map.pins.select { |pin| pin.path == path }).to be_empty + end + end + context 'with a YARD-only pin' do let(:requires) { ['rake'] } let(:path) { 'Rake::Task#prerequisites' } diff --git a/spec/gemspec_spec.rb b/spec/gemspec_spec.rb new file mode 100644 index 000000000..290945f88 --- /dev/null +++ b/spec/gemspec_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +describe Gem::Specification do + describe 'loaded from solargraph.gemspec' do + let(:spec) { described_class.load(File.expand_path('../solargraph.gemspec', __dir__)) } + + it 'does not package a top-level sig/ directory that RBS auto-discovers in installed gems' do + # A prior sig/shims directory collided with consumers' own RBS + # collections (see https://github.com/castwide/solargraph/issues/1144). + # The shims now live under rbs/shims instead, which RBS does not scan + # automatically. + expect(spec.files.grep(%r{^sig/})).to be_empty + end + end +end diff --git a/spec/language_server/host_spec.rb b/spec/language_server/host_spec.rb index f0497b8f3..29c957bf0 100644 --- a/spec/language_server/host_spec.rb +++ b/spec/language_server/host_spec.rb @@ -275,7 +275,7 @@ def initialize(foo); end expect(symbols).not_to be_empty end - it 'opens a file outside of prepared libraries' do + it 'opens a file outside of prepared libraries', time_limit_seconds: 200 do @host.prepare(File.absolute_path(File.join('spec', 'fixtures', 'workspace'))) @host.open('file:///file.rb', 'class Foo; end', 1) symbols = @host.document_symbols('file:///file.rb') diff --git a/spec/language_server/message/extended/check_gem_version_spec.rb b/spec/language_server/message/extended/check_gem_version_spec.rb index 26023f505..02f0b1c4a 100644 --- a/spec/language_server/message/extended/check_gem_version_spec.rb +++ b/spec/language_server/message/extended/check_gem_version_spec.rb @@ -36,6 +36,10 @@ end it 'responds to update actions' do + status = instance_double(Process::Status) + allow(status).to receive(:==).with(0).and_return(true) + allow(Open3).to receive(:capture2).with('gem update solargraph').and_return(['', status]) + host = Solargraph::LanguageServer::Host.new message = described_class.new(host, {}, current: Gem::Version.new('0.0.1')) message.process @@ -52,6 +56,7 @@ } host.receive action end.not_to raise_error + expect(Open3).to have_received(:capture2).with('gem update solargraph') end it 'uses bundler' do diff --git a/spec/language_server/protocol_spec.rb b/spec/language_server/protocol_spec.rb index 25764e6eb..56307e3ef 100644 --- a/spec/language_server/protocol_spec.rb +++ b/spec/language_server/protocol_spec.rb @@ -424,7 +424,7 @@ def bar baz expect(response['result']['available']).to be_a(String) end - it 'handles $/solargraph/documentGems' do + it 'handles $/solargraph/documentGems', time_limit_seconds: 200 do @protocol.request '$/solargraph/documentGems', {} response = @protocol.response expect(response['error']).to be_nil diff --git a/spec/library_spec.rb b/spec/library_spec.rb index a1528163c..be324b88e 100644 --- a/spec/library_spec.rb +++ b/spec/library_spec.rb @@ -59,7 +59,7 @@ def foo(adapter) end it 'returns a Completion' do - library = described_class.new(Solargraph::Workspace.new(Dir.pwd, + library = described_class.new(Solargraph::Workspace.new('', Solargraph::Workspace::Config.new)) library.attach Solargraph::Source.load_string(%( require 'backport' @@ -678,4 +678,14 @@ def bar; end expect { library.send(:sync_catalog) }.not_to raise_error end end + + describe '#cache_next_gemspec' do + it 'does not start a new caching pass while one is already in progress' do + library = described_class.new + library.instance_variable_set(:@cache_progress, Solargraph::LanguageServer::Progress.new('Caching gem')) + allow(library).to receive(:report_cache_progress) + library.send(:cache_next_gemspec) + expect(library).not_to have_received(:report_cache_progress) + end + end end diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index 4c9034873..1acd917cb 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -24,6 +24,50 @@ def verify_repro(repr) expect(clip.infer.to_s).to eq('ReproBase') end + it 'narrows to an intersection when is_a? checks an unrelated mix-in' do + source = Solargraph::Source.load_string(%( + module M; end + class T; end + # @param t [T] + def verify(t) + if t.is_a?(M) + t + else + t + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq('T & M') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.to_s).to eq('T') + end + + it 'does not build a redundant intersection when the mix-in is already included' do + source = Solargraph::Source.load_string(%( + module M; end + class T + include M + end + # @param t [T] + def verify(t) + if t.is_a?(M) + t + else + t + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 10]) + expect(clip.infer.to_s).to eq('T') + + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.to_s).to eq('T') + end + it 'uses is_a? in a simple if() with a union to refine types' do source = Solargraph::Source.load_string(%( class ReproBase; end @@ -94,6 +138,64 @@ def verify_repro(repr) expect(clip.infer.to_s).to eq('ReproBase') end + it 'uses is_a? in a simple if() to refine types on a root-scoped (::-prefixed) class' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + module Foo + class Repro < ReproBase; end + end + # @param repr [ReproBase] + def verify_repro(repr) + if repr.is_a?(::Foo::Repro) + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.to_s).to eq('Foo::Repro') + + clip = api_map.clip_at('test.rb', [10, 10]) + expect(clip.infer.to_s).to eq('ReproBase') + end + + it 'uses is_a? with a ::-prefixed class combined via && in a guard clause to refine types' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro < ReproBase; end + # @param repr [ReproBase] + # @return [void] + def verify_repro(repr) + return unless repr.is_a?(::Repro) && repr.respond_to?(:foo) + repr + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 8]) + expect(clip.infer.to_s).to eq('Repro') + end + + it 'uses is_a? with a ::-prefixed class in an elsif to refine types in the branch body' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + class Repro2 < ReproBase; end + # @param repr [ReproBase] + def verify_repro(repr) + if repr.is_a?(Repro1) + repr + elsif repr.is_a?(::Repro2) && repr.respond_to?(:foo) + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.to_s).to eq('Repro2') + end + it 'uses is_a? in a simple unless statement to refine types' do source = Solargraph::Source.load_string(%( class ReproBase; end @@ -163,6 +265,96 @@ def verify_repro(repr) expect(clip.infer.to_s).to eq('ReproBase') end + it 'narrows a case/when subject to the matched class inside each branch' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + class Repro2 < ReproBase; end + # @param repr [ReproBase] + def verify_repro(repr) + case repr + when Repro1 + repr + when Repro2 + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.to_s).to eq('Repro1') + + clip = api_map.clip_at('test.rb', [10, 10]) + expect(clip.infer.to_s).to eq('Repro2') + + clip = api_map.clip_at('test.rb', [12, 10]) + expect(clip.infer.to_s).to eq('ReproBase') + end + + it 'narrows a case/when subject to a union when a when clause has multiple values' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + class Repro2 < ReproBase; end + class Repro3 < ReproBase; end + # @param repr [ReproBase] + def verify_repro(repr) + case repr + when Repro1, Repro2 + repr + when Repro3 + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.to_s).to eq('Repro1, Repro2') + end + + it 'narrows a case/when subject to the matched class for an ivar subject' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + class Foo + # @param repr [ReproBase] + def initialize(repr) + @repr = repr + end + + # @return [void] + def verify + case @repr + when Repro1 + @repr + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [13, 12]) + expect(clip.infer.to_s).to eq('Repro1') + end + + it 'does not narrow a case/when subject when a when clause value is not a simple constant' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + # @param repr [ReproBase] + def verify_repro(repr) + case repr + when Repro1, some_dynamic_value + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 10]) + expect(clip.infer.to_s).to eq('ReproBase') + end + it 'uses is_a? in a "break unless" statement in an .each block to refine types' do source = Solargraph::Source.load_string(%( class ReproBase; end @@ -314,6 +506,22 @@ def baz; end expect(clip.infer.to_s).to eq('Foo') end + it 'keeps a definite reassignment visible inside a subsequent if-guard' do + source = Solargraph::Source.load_string(%( + def m + x = nil + x = 1 + if x + y = x * 2 + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 14]) + expect(clip.infer.rooted_tags).to eq('1') + end + it 'skips is_a? without a receiver' do source = Solargraph::Source.load_string(%( if is_a? Object @@ -611,14 +819,13 @@ def verify_repro(repr = nil) ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [3, 8]) - expect(clip.infer.rooted_tags).to eq('nil, ::Integer') + expect(clip.infer.rooted_tags).to eq('nil, 10') clip = api_map.clip_at('test.rb', [5, 10]) - expect(clip.infer.rooted_tags).to eq('::Integer') + expect(clip.infer.rooted_tags).to eq('10') clip = api_map.clip_at('test.rb', [7, 10]) - # @todo `false` might be acceptable here - expect(clip.infer.rooted_tags).to eq('nil, ::Boolean') + expect(clip.infer.rooted_tags).to eq('nil, false') end it 'uses .nil? in a return if() in an if to refine types using nil checks' do @@ -660,6 +867,81 @@ def bar(baz: nil) expect(clip.infer.rooted_tags).to eq('::Boolean') end + it 'uses .nil? in a raise if() in a method to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + raise 'baz required' if baz.nil? + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + end + + it 'uses .nil? in a raise if() to refine a type used as a call argument' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + raise 'baz required' if baz.nil? + accepts_boolean(baz) + end + + # @param b [::Boolean] + # @return [void] + def accepts_boolean(b); end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [6, 27]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + end + + it 'uses .nil? in a raise if() to refine a type used as a call receiver' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [String, nil] + # @return [void] + def bar(baz: nil) + raise 'baz required' if baz.nil? + baz.length + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [6, 12]) + expect(clip.infer.rooted_tags).to eq('::String') + end + + it 'uses .nil? in a raise if() with a multi-statement branch to refine types' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [String, nil] + # @return [void] + def bar(baz: nil) + if baz.nil? + valid = %w[a b c] + raise "baz required. Valid: \#{valid.inspect}" + end + baz.length + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [9, 12]) + expect(clip.infer.rooted_tags).to eq('::String') + end + it 'uses .nil? in a return if() in a block to refine types using nil checks' do source = Solargraph::Source.load_string(%( class Foo @@ -899,6 +1181,44 @@ def bar(baz: nil) expect(clip.infer.rooted_tags).to eq('::Boolean') end + it 'narrows a plain ||= assignment on an lvar to eliminate nil' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro < ReproBase; end + # @param repr [Repro, nil] + # @return [void] + def verify_repro(repr) + repr ||= Repro.new + repr + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 8]) + expect(clip.infer.to_s).to eq('Repro') + end + + it 'narrows a ||= assignment on a keyword param to eliminate nil, with no nested nil check' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + baz ||= true + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + + clip = api_map.clip_at('test.rb', [7, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + end + it 'uses .nil? in a return if() in a try / rescue / ensure to refine types using nil checks' do source = Solargraph::Source.load_string(%( class Foo @@ -1025,4 +1345,263 @@ def check clip = api_map.clip_at('test.rb', [13, 12]) expect(clip.infer.to_s).to eq('ReproBase') end + + it 'narrows a repeated call to the same attr_reader-style accessor after a truthy guard' do + source = Solargraph::Source.load_string(%( + class Location + # @return [String] + def filename; end + end + + class Pin + # @return [Location, nil] + attr_reader :location + end + + # @param pin [Pin] + def bundled_filename(pin) + return nil unless pin.location + pin.location + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [13, 32]) + expect(clip.infer.rooted_tags).to eq('::Location, nil') + + clip = api_map.clip_at('test.rb', [14, 14]) + expect(clip.infer.rooted_tags).to eq('::Location') + end + + it 'narrows a repeated call to the same attr_reader-style accessor after a .nil? guard' do + source = Solargraph::Source.load_string(%( + class Location + # @return [String] + def filename; end + end + + class Pin + # @return [Location, nil] + attr_reader :location + end + + # @param pin [Pin] + def bundled_filename(pin) + return nil if pin.location.nil? + pin.location.filename + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [14, 23]) + expect(clip.infer.rooted_tags).to eq('::String') + end + + it 'narrows a bare, implicit-self attr_reader-style accessor after a .nil? guard' do + source = Solargraph::Source.load_string(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + def identify + return nil if steps.nil? + steps.empty? + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 15]) + expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') + end + + it 'narrows a bare, implicit-self attr_reader-style accessor after a truthy guard' do + source = Solargraph::Source.load_string(%( + class Repro + # @return [Array, nil] + attr_reader :steps + + def identify + return nil unless steps + steps.empty? + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 15]) + expect(clip.infer.rooted_tags).to eq('::Array<::Hash>') + end + + it 'narrows a repeated call to the same attr_reader-style accessor rooted in an ivar' do + source = Solargraph::Source.load_string(%( + class Location + # @return [String] + def filename; end + end + + class Pin + # @return [Location, nil] + attr_reader :location + end + + class Bundler + # @param pin [Pin] + def initialize(pin) + @pin = pin + end + + def bundled_filename + return nil unless @pin.location + @pin.location.filename + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [19, 26]) + expect(clip.infer.rooted_tags).to eq('::String') + end + + it 'uses is_a? with a fully-qualified type name to refine types' do + source = Solargraph::Source.load_string(%( + # @param x [Object] + def verify_repro(x) + if x.is_a?(::Integer) + x + else + x + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Object') + end + + it 'uses != against a literal symbol to refine types out of a union' do + source = Solargraph::Source.load_string(%( + # @param sections [Array, :not_specified] + def verify_repro(sections) + if sections != :not_specified + sections + else + sections + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.to_s).to eq('Array') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq(':not_specified') + end + + it 'uses == against a literal symbol to refine types down to a member of a union' do + source = Solargraph::Source.load_string(%( + # @param sections [Array, :not_specified] + def verify_repro(sections) + if sections == :not_specified + sections + else + sections + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.to_s).to eq(':not_specified') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq('Array') + end + + it 'uses unless with == against a literal symbol to refine types out of a union' do + source = Solargraph::Source.load_string(%( + # @param sections [Array, :not_specified] + def verify_repro(sections) + unless sections == :not_specified + sections + else + sections + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.to_s).to eq('Array') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq(':not_specified') + end + + it 'narrows an opaque receiver to a duck type from a respond_to? guard' do + source = Solargraph::Source.load_string(%( + # @param obj [Object] + def duckish(obj) + if obj.respond_to?(:fetch_thing) + obj + else + obj + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.to_s).to eq('#fetch_thing') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq('Object') + end + + it 'selects the union arms providing the method from a respond_to? guard' do + source = Solargraph::Source.load_string(%( + # @param data [Hash{String => Integer}, Array] + def pick(data) + if data.respond_to?(:key?) + data + else + data + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 10]) + expect(clip.infer.to_s).to eq('Hash{String => Integer}') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.to_s).to eq('Hash{String => Integer}, Array') + end + + it 'narrows through respond_to? combined with && in a guard' do + source = Solargraph::Source.load_string(%( + # @param data [Hash{String => Integer}, Array] + # @param subkey [String] + # @return [Integer, nil] + def flex(data, subkey) + return data[subkey] if data.respond_to?(:key?) && data.key?(subkey) + + nil + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + checker = Solargraph::TypeChecker.new('test.rb', api_map: api_map, level: :strong) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not narrow from respond_to? with a non-literal argument' do + source = Solargraph::Source.load_string(%( + # @param obj [Object] + # @param name [Symbol] + def dynamic(obj, name) + if obj.respond_to?(name) + obj + else + obj + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 10]) + expect(clip.infer.to_s).to eq('Object') + end end diff --git a/spec/parser/node_methods_spec.rb b/spec/parser/node_methods_spec.rb index 1ead0a6b6..c88fda083 100644 --- a/spec/parser/node_methods_spec.rb +++ b/spec/parser/node_methods_spec.rb @@ -121,6 +121,21 @@ def parse source expect(returns.map(&:to_s)).to eq(['(true)', '(int 73)', '(false)', '(nil)', '(false)', '(true)']) end + it 'keeps an or-node whole when it is a conditional branch value' do + node = parse(%( + if m.nil? + fallback + else + m <= expected || fallback + end + )) + returns = described_class.returns_from_method_body(node) + # The or-node must stay intact so chain inference (Chain::Or) can + # strip nil from the left operand when the right operand is not + # nullable; flattening it into both operands loses that. + expect(returns.map(&:type)).to eq(%i[send or]) + end + it 'handles return nodes from case statements with boolean conditions' do node = parse(%( case true @@ -166,9 +181,10 @@ def parse source it "handles nested 'or' nodes" do node = parse('return 1 || "2"') rets = described_class.returns_from_method_body(node) - expect(rets.length).to eq(2) - expect(described_class.infer_literal_node_type(rets[0])).to eq('::Integer') - expect(described_class.infer_literal_node_type(rets[1])).to eq('::String') + # The or-node stays whole so Chain::Or can union the operands and + # strip nil from the left one when the right is not nullable + expect(rets.length).to eq(1) + expect(rets[0].type).to eq(:or) end it 'finds return nodes in blocks' do @@ -192,6 +208,54 @@ def parse source expect(rets.map(&:type)).to eq([:str]) end + it 'uses the body value for a method with an ensure clause' do + node = parse(%( + begin + 'hello' + ensure + nil + end + )) + rets = described_class.returns_from_method_body(node) + expect(rets.map(&:type)).to eq([:str]) + end + + it 'includes explicit returns from an ensure clause' do + node = parse(%( + begin + 'hello' + ensure + return 'goodbye' if foo + end + )) + rets = described_class.returns_from_method_body(node) + expect(rets.map(&:type)).to eq(%i[str str]) + end + + it 'uses the body value for a method-level ensure clause with no begin/end' do + def_node = parse(%( + def foo + 'hello' + ensure + nil + end + )) + rets = described_class.returns_from_method_body(def_node.children[2]) + expect(rets.map(&:type)).to eq([:str]) + end + + it 'includes explicit returns from a method-level ensure clause with no begin/end' do + def_node = parse(%( + def foo + 'hello' + ensure + return 1 if bar + end + )) + rets = described_class.returns_from_method_body(def_node.children[2]) + expect(rets.map(&:type)).to eq(%i[str int]) + end + it 'returns nested return blocks' do node = parse(%( if foo @@ -295,7 +359,7 @@ def parse source it "handles nested 'or' nodes from return" do node = parse('return 1 || "2"') rets = described_class.returns_from_method_body(node) - expect(rets.map(&:type)).to eq(%i[int str]) + expect(rets.map(&:type)).to eq(%i[or]) end it 'handles return nodes from case statements' do diff --git a/spec/parser/node_processor_spec.rb b/spec/parser/node_processor_spec.rb index b32371ff1..54f442ee2 100644 --- a/spec/parser/node_processor_spec.rb +++ b/spec/parser/node_processor_spec.rb @@ -79,4 +79,23 @@ class Foo < Array #[String] expect(map.pins.last.type.to_s).to eq('Array') end + + it 'ignores bracketed comments in the class body' do + map = Solargraph::SourceMap.load_string(%( + class Foo < Array + # [:b, { c: :d }] + end + ), 'test.rb') + + expect(map.pins.last.type.to_s).to eq('Array') + end + + it 'ignores a bracketed comment separated from the hash' do + map = Solargraph::SourceMap.load_string(%( + class Foo < Array # [String] + end + ), 'test.rb') + + expect(map.pins.last.type.to_s).to eq('Array') + end end diff --git a/spec/pin/base_spec.rb b/spec/pin/base_spec.rb index e11566d38..548f3bbc9 100644 --- a/spec/pin/base_spec.rb +++ b/spec/pin/base_spec.rb @@ -52,8 +52,18 @@ end it 'deals well with known closure combination issue' do - Solargraph::Shell.new.uncache('yard') - api_map = Solargraph::ApiMap.load_with_cache('.', $stderr) + # if this fails you might not have an rbs collection installed + api_map = Solargraph::ApiMap.load '' + + # catalog first so doc_map registers 'yard' as required before we + # try to cache it - cache_gem is a no-op for gems doc_map doesn't + # yet know it needs + bench = Solargraph::Bench.new(external_requires: ['yard']) + api_map.catalog bench + spec = Gem::Specification.find_by_name('yard') + api_map.cache_gem(spec) + api_map.catalog bench + pins = api_map.get_method_stack('YARD::Docstring', 'parser', scope: :class) expect(pins.length).to eq(1) parser_method_pin = pins.first diff --git a/spec/pin/base_variable_spec.rb b/spec/pin/base_variable_spec.rb index 0b1fff84b..09fbff255 100644 --- a/spec/pin/base_variable_spec.rb +++ b/spec/pin/base_variable_spec.rb @@ -12,6 +12,37 @@ expect(pin1).not_to eq(pin2) end + it 'treats combine_with results with the same location but different presence as unequal' do + # combine_with results choose the earliest assignment's #location, + # so two combine_with results over a different number of + # assignments to the same variable can share #location while + # covering different #presence ranges. Pin::Base#== only compared + # location, not presence, so these looked equal to any caller + # keying off of #== (e.g. Array#include?, used by Chain's inference + # recursion guard) even though they represent different sets of + # possible values for the variable. + source = Solargraph::Source.load_string(%( + def go(str) + str = str.gsub('a', 'b') + str = str.gsub('c', 'd') + str + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + smap = api_map.source_map('test.rb') + param = smap.locals.find { |p| p.name == 'str' && p.is_a?(Solargraph::Pin::Parameter) } + first_assignment = smap.locals.find { |p| p.name == 'str' && p.location.range.start.line == 2 } + second_assignment = smap.locals.find { |p| p.name == 'str' && p.location.range.start.line == 3 } + + combined_through_first = param.combine_with(first_assignment) + combined_through_second = combined_through_first.combine_with(second_assignment) + + expect(combined_through_first.location).to eq(combined_through_second.location) + expect(combined_through_first.presence).not_to eq(combined_through_second.presence) + expect(combined_through_first).not_to eq(combined_through_second) + end + it 'infers types from variable assignments with unparenthesized parameters' do source = Solargraph::Source.load_string(%( class Container @@ -41,10 +72,10 @@ def bar api_map.map source pin = api_map.get_instance_variable_pins('Foo').first type = pin.probe(api_map) - expect(type.tags).to eq('Integer, nil') - expect(type.simple_tags).to eq('Integer, nil') - expect(type.to_rbs).to eq('(::Integer | nil)') - expect(type.simplify_literals.to_rbs).to eq('(::Integer | nil)') + expect(type.tags).to eq('1, nil') + expect(type.simple_tags).to eq('Integer, NilClass') + expect(type.to_rbs).to eq('(1 | nil)') + expect(type.simplify_literals.to_rbs).to eq('(::Integer | ::NilClass)') end it "understands proc kwarg parameters aren't affected by @type" do diff --git a/spec/pin/compound_statement_spec.rb b/spec/pin/compound_statement_spec.rb new file mode 100644 index 000000000..5a6a1e0d9 --- /dev/null +++ b/spec/pin/compound_statement_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +describe Solargraph::Pin::CompoundStatement do + # Every pin built through Region-threaded node processors still gets + # an explicit `closure:`, so `Pin::Base#closure` returns the stored + # value, not the derived one - the derivation only kicks in as a + # fallback. These specs check the two would agree anyway, so a + # future node processor that updates one threading (closure: or + # compound_statement:) without the other gets caught here instead + # of silently drifting. + def derive_closure pin + cs = pin.compound_statement + cs = cs.compound_statement while cs && !cs.is_a?(Solargraph::Pin::Closure) + cs + end + + it 'agrees with the stored closure for compound statements nested in a method, if, and while' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar(flag) + if flag + while flag + local = 1 + end + end + end + end + )) + + compound_statement_pins = source_map.pins.select { |pin| pin.is_a?(described_class) } + expect(compound_statement_pins).not_to be_empty + + compound_statement_pins.each do |pin| + expect(derive_closure(pin)).to eq(pin.closure), "mismatch for #{pin.inspect}" + end + end + + it 'agrees with the stored closure for compound statements nested in a block' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar + [1].each do |i| + if i + local = i + end + end + end + end + )) + + compound_statement_pins = source_map.pins.select { |pin| pin.is_a?(described_class) } + expect(compound_statement_pins).not_to be_empty + + compound_statement_pins.each do |pin| + expect(derive_closure(pin)).to eq(pin.closure), "mismatch for #{pin.inspect}" + end + end + + it 'derives the enclosing method as closure for a bare CompoundStatement built only with compound_statement:' do + source_map = Solargraph::SourceMap.load_string(%( + class Foo + def bar + 1 + end + end + )) + method_pin = source_map.pins.find { |pin| pin.is_a?(Solargraph::Pin::Method) && pin.name == 'bar' } + + bare_pin = described_class.new( + location: method_pin.location, + compound_statement: method_pin, + source: :parser + ) + + expect(bare_pin.closure).to eq(method_pin) + end + + describe '#combine_with' do + let(:earlier_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(1, 0, 3, 0)) } + let(:later_location) { Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 0, 7, 0)) } + + it 'prefers the compound_statement with the earlier location' do + earlier_cs = described_class.new(location: earlier_location, source: :parser) + later_cs = described_class.new(location: later_location, source: :parser) + pin1 = described_class.new(location: earlier_location, compound_statement: earlier_cs, source: :parser) + pin2 = described_class.new(location: later_location, compound_statement: later_cs, source: :parser) + + expect(pin1.combine_with(pin2).compound_statement).to eq(earlier_cs) + expect(pin2.combine_with(pin1).compound_statement).to eq(earlier_cs) + end + + it 'prefers a non-nil compound_statement over a nil one' do + cs = described_class.new(location: earlier_location, source: :parser) + pin1 = described_class.new(location: earlier_location, compound_statement: nil, source: :parser) + pin2 = described_class.new(location: earlier_location, compound_statement: cs, source: :parser) + + expect(pin1.combine_with(pin2).compound_statement).to eq(cs) + expect(pin2.combine_with(pin1).compound_statement).to eq(cs) + end + end +end diff --git a/spec/pin/method_spec.rb b/spec/pin/method_spec.rb index a520fdb24..45006958c 100644 --- a/spec/pin/method_spec.rb +++ b/spec/pin/method_spec.rb @@ -315,6 +315,32 @@ def bar expect(kwrestarg_overload.parameters.first.decl).to eq(:kwrestarg) end + it 'applies yieldparam tags from the matching overload only' do + pin = described_class.new(name: 'build', comments: %( +@overload build + @return [String] +@overload build + @yieldparam widget [String] + @return [void] + )) + expect(pin.signatures.length).to eq(2) + plain, block_form = pin.signatures + expect(plain.block).to be_nil + expect(block_form.block).not_to be_nil + expect(block_form.block.parameters.first.return_type.tag).to eq('String') + end + + it 'does not leak a method-level yieldparam into an overload that declares no block' do + pin = described_class.new(name: 'foo', comments: %( +@yieldparam bing [Integer] +@overload foo(bar) + @param bar [Integer] + @return [String] + )) + expect(pin.signatures.length).to eq(1) + expect(pin.signatures.first.block).to be_nil + end + it 'infers from nil return nodes' do source = Solargraph::Source.load_string(%( class Foo @@ -329,9 +355,9 @@ def bar api_map.map source pin = api_map.get_path_pins('Foo#bar').first type = pin.probe(api_map) - expect(type.rooted_tags).to eq('::Integer, nil') - expect(type.to_rbs).to eq('(::Integer | nil)') - expect(type.simple_tags).to eq('Integer, nil') + expect(type.rooted_tags).to eq('1, nil') + expect(type.to_rbs).to eq('(1 | nil)') + expect(type.simple_tags).to eq('Integer, NilClass') end it 'infers from chains' do @@ -365,6 +391,38 @@ def bar expect(type.simple_tags).to eq('Integer') end + it 'infers from literal array dereference' do + source = Solargraph::Source.load_string(%( + class Foo + def bar + arr = ['a', 'b'] + arr[0] + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('Foo#bar').first + type = pin.probe(api_map) + expect(type.to_s).to eq('String, nil') + end + + it 'infers from multiple-assignment chains' do + source = Solargraph::Source.load_string(%( + class Foo + def bar + a, b = ['a', 'b'] + b + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('Foo#bar').first + type = pin.probe(api_map) + expect(type.to_s).to eq('String') + end + it 'typifies from super methods' do source = Solargraph::Source.load_string(%( class Sup @@ -528,7 +586,9 @@ class Foo # on type. Let's make sure we combine those with anything else # found (e.g., additions from the BigDecimal RBS collection) # without collapsing signatures - api_map = Solargraph::ApiMap.load_with_cache(Dir.pwd, nil) + api_map = Solargraph::ApiMap.load(Dir.pwd) + bench = Solargraph::Bench.new external_requires: ['bigdecimal'] + api_map.catalog(bench) method = api_map.get_method_stack('Integer', '+', scope: :instance).first expect(method.signatures.count).to be > 3 end @@ -645,6 +705,100 @@ def foo; end expect(pin.return_type.to_s).to eq('Boolean') end + it 'sets intersection return types' do + source = Solargraph::Source.load_string(%( + #: () -> (String & Comparable) + def foo; end + )) + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('#foo').first + expect(pin.return_type.to_s).to eq('String & Comparable') + expect(pin.return_type.first).to be_a(Solargraph::ComplexType::UniqueType::Intersection) + end + + # RBS allows a union as one member of an intersection - `&` and + # `|` nest freely in either direction, unlike the YARD/tag-string + # grammar (see complex_type_spec.rb "with parentheses"), so these + # go through RbsTranslator directly instead of round-tripping + # through a tag string. + context 'with a union nested inside an intersection' do + it 'preserves a union as the first conjunct' do + source = Solargraph::Source.load_string(%( + #: () -> ((String | Integer) & Comparable) + def foo; end + )) + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('#foo').first + intersection = pin.return_type.first + expect(intersection).to be_a(Solargraph::ComplexType::UniqueType::Intersection) + expect(intersection.conjuncts.length).to eq(2) + expect(intersection.conjuncts.first.length).to eq(2) + expect(intersection.conjuncts.first.tags).to eq('String, Integer') + expect(intersection.to_rbs).to eq('(::String | ::Integer) & ::Comparable') + end + + it 'preserves a union as the second conjunct' do + source = Solargraph::Source.load_string(%( + #: () -> (Comparable & (String | Integer)) + def foo; end + )) + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('#foo').first + intersection = pin.return_type.first + expect(intersection.conjuncts.last.length).to eq(2) + expect(intersection.to_rbs).to eq('::Comparable & (::String | ::Integer)') + end + + it 'flattens a nested intersection into the same conjunct list' do + source = Solargraph::Source.load_string(%( + #: () -> (String & (Comparable & Enumerable)) + def foo; end + )) + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('#foo').first + intersection = pin.return_type.first + expect(intersection.to_rbs).to eq('::String & ::Comparable & ::Enumerable') + end + + it 'is not round-trippable through the tag string, unlike to_rbs' do + # The object model built directly by RbsTranslator is + # correct (verified above), but there is no way to express a + # grouped union as a plain tag *string* (see + # complex_type_spec.rb). Re-parsing the informal `tag`/`to_s` + # output therefore does not reproduce the same structure - + # this is a known, documented limitation, not a silent + # inconsistency. `to_rbs` uses real RBS grouping syntax and + # does round-trip correctly. + source = Solargraph::Source.load_string(%( + #: () -> ((String | Integer) & Comparable) + def foo; end + )) + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.get_path_pins('#foo').first + original = pin.return_type + expect(original.tag).to eq('String, Integer & Comparable') + + reparsed = Solargraph::ComplexType.parse(original.tag) + expect(reparsed.length).to eq(2) + expect(reparsed[0].tag).to eq('String') + expect(reparsed[1].tag).to eq('Integer & Comparable') + + # to_rbs, by contrast, produces real RBS syntax and round-trips + # correctly through RBS's own parser (not Solargraph's tag parser, + # which speaks a different dialect and doesn't understand `|` or + # bare grouping parens). + rbs_type = RBS::Parser.parse_type(original.to_rbs) + reparsed_via_rbs = Solargraph::RbsTranslator.to_complex_type(rbs_type) + expect(reparsed_via_rbs.length).to eq(1) + expect(reparsed_via_rbs.first.conjuncts.map(&:tags)).to eq(['String, Integer', 'Comparable']) + end + end + it 'sets required positional parameters' do source = Solargraph::Source.load_string(%( #: (String) -> bool @@ -687,7 +841,10 @@ def foo(*bar); end expect(pin.signatures.first.parameters).to be_one expect(pin.signatures.first.parameters.first.name).to eq('bar') expect(pin.signatures.first.parameters.first.decl).to eq(:restarg) - expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Array') + # `bar` here is the restarg's declared per-element type (RBS's + # inline shorthand identifies it by position, not name), now + # tracked instead of being erased to a bare `Array` + expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Array') end it 'sets required keyword parameters' do @@ -732,7 +889,9 @@ def foo(**bar); end expect(pin.signatures.first.parameters).to be_one expect(pin.signatures.first.parameters.first.name).to eq('bar') expect(pin.signatures.first.parameters.first.decl).to eq(:kwrestarg) - expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Hash{Symbol => Object}') + # `bar` here is the kwrestarg's declared per-value type, now + # tracked instead of being erased to `Hash{Symbol => Object}` + expect(pin.signatures.first.parameters.first.return_type.to_s).to eq('Hash{Symbol => bar}') end it 'sets block parameters' do @@ -760,4 +919,10 @@ def foo(**bar); end expect { pin.signatures }.not_to raise_error end end + + it 'typifies a DuckMethod pin with no closure without raising' do + api_map = Solargraph::ApiMap.new + pin = Solargraph::Pin::DuckMethod.new(name: 'to_s', source: :api_map) + expect { pin.typify(api_map) }.not_to raise_error + end end diff --git a/spec/pin_cache_spec.rb b/spec/pin_cache_spec.rb new file mode 100644 index 000000000..a55df3281 --- /dev/null +++ b/spec/pin_cache_spec.rb @@ -0,0 +1,235 @@ +# 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 '#combined_pins_in_memory' do + # @param yard_plugins [Array] + # @return [Solargraph::PinCache] + def new_pin_cache yard_plugins: ['activesupport-concern'] + described_class.new(rbs_collection_path: '.gem_rbs_collection', + rbs_collection_config_path: 'rbs_collection.yaml', + directory: Dir.pwd, + yard_plugins: yard_plugins) + end + + it 'is shared across PinCache instances with the same yard_plugins, rather than memoized per instance' do + cache1 = new_pin_cache + cache2 = new_pin_cache + key = ['some-gem', Gem::Version.new('1.0.0'), 'some-rbs-cache-key'] + pins = [instance_double(Solargraph::Pin::Base)] + + cache1.send(:combined_pins_in_memory)[key] = pins + + expect(cache2.send(:combined_pins_in_memory)[key]).to equal(pins) + ensure + cache1.send(:combined_pins_in_memory).delete(key) + end + + it 'is not shared across PinCache instances with different yard_plugins' do + cache1 = new_pin_cache(yard_plugins: ['activesupport-concern']) + cache2 = new_pin_cache(yard_plugins: []) + key = ['some-gem', Gem::Version.new('1.0.0'), 'some-rbs-cache-key'] + pins = [instance_double(Solargraph::Pin::Base)] + + cache1.send(:combined_pins_in_memory)[key] = pins + + expect(cache2.send(:combined_pins_in_memory)[key]).to be_nil + ensure + cache1.send(:combined_pins_in_memory).delete(key) + end + 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 + pin_cache.uncache_gem(Gem::Specification.find_by_name('parser'), out: nil) + 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 + pin_cache.cache_gem(gemspec: Gem::Specification.find_by_name('kramdown'), out: nil) + 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 + pin_cache.uncache_gem(Gem::Specification.find_by_name(gem_name), out: nil) + 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 + pin_cache.uncache_gem(Gem::Specification.find_by_name(gem_name), out: nil) + 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/position_spec.rb b/spec/position_spec.rb index d61b05ce5..300973a15 100644 --- a/spec/position_spec.rb +++ b/spec/position_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - describe Solargraph::Position do it 'normalizes arrays into positions' do pos = described_class.normalize([0, 1]) diff --git a/spec/rbs_map/conversions_spec.rb b/spec/rbs_map/conversions_spec.rb index 50f4b0b1a..0785ec6a4 100644 --- a/spec/rbs_map/conversions_spec.rb +++ b/spec/rbs_map/conversions_spec.rb @@ -93,11 +93,125 @@ def bar: () -> untyped expect(method_pin.return_type.tag).to eq('undefined') end end + + # https://github.com/castwide/solargraph/issues/1255 + context 'with a type alias used as a parameter type' do + subject(:parameter) { method_pin.signatures.first.parameters.first } + + let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :instance).first } + + let(:rbs) do + <<~RBS + type path = String | Integer + + class Foo + def bar: (path src) -> void + end + RBS + end + + it 'expands the alias to its underlying union instead of a nominal tag' do + expect(parameter.return_type.rooted_tags).to eq('::String, ::Integer') + end + end + + # https://github.com/castwide/solargraph/issues/1255 + context 'with a type alias declared after the class that references it' do + subject(:parameter) { method_pin.signatures.first.parameters.first } + + let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :instance).first } + + let(:rbs) do + <<~RBS + class Foo + def bar: (path src) -> void + end + + type path = String | Integer + RBS + end + + it 'still expands the alias to its underlying union' do + expect(parameter.return_type.rooted_tags).to eq('::String, ::Integer') + end + end + + # https://github.com/castwide/solargraph/pull/1281#issuecomment-5270350329 + context 'with a type alias that references a name declared only in RBS core' do + subject(:parameter) { method_pin.signatures.first.parameters.first } + + let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :instance).first } + + let(:rbs) do + <<~RBS + type wrapped_path = ::path + + class Foo + def bar: (wrapped_path src) -> void + end + RBS + end + + it 'expands the alias instead of falling back to a self-referential nominal tag' do + expect(parameter.return_type.rooted_tags).to eq('::String, ::_ToStr, ::_ToPath') + end + end + + # https://github.com/castwide/solargraph/issues/1255 + context 'with a recursive type alias' do + subject(:parameter) { method_pin.signatures.first.parameters.first } + + let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :instance).first } + + let(:rbs) do + <<~RBS + type json = String | Array[json] + + class Foo + def bar: (json src) -> void + end + RBS + end + + it 'does not crash expanding it' do + expect { conversions.pins }.not_to raise_error + end + + it 'falls back to the nominal alias tag once a cycle is detected' do + expect(parameter.return_type.rooted_tags).to eq('::String, ::Array') + end + end + + # https://github.com/castwide/solargraph/issues/1255 + context 'with a generic type alias' do + subject(:parameter) { method_pin.signatures.first.parameters.first } + + let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :instance).first } + + let(:rbs) do + <<~RBS + type box[T] = Array[T] | nil + + class Foo + def bar: (box[String] src) -> void + end + RBS + end + + it 'falls back to the nominal alias tag instead of leaking an unbound generic' do + expect(parameter.return_type.rooted_tags).not_to include('generic<') + end + end end context 'with standard loads for solargraph project' do before :all do # rubocop:disable RSpec/BeforeAfterAll - @api_map = Solargraph::ApiMap.load_with_cache('.') + @api_map = Solargraph::ApiMap.load('.') + gems = %w[parser ast open3] + bench = Solargraph::Bench.new(workspace: @api_map.workspace, external_requires: gems) + @api_map.catalog(bench) + @api_map.cache_all_for_doc_map! + @api_map.catalog(bench) end let(:api_map) { @api_map } @@ -160,7 +274,9 @@ class Sub < Hash[Symbol, untyped] if Gem::Version.new(RBS::VERSION) >= Gem::Version.new('3.9.1') context 'with method pin for Open3.capture2e' do it 'accepts chdir kwarg' do - api_map = Solargraph::ApiMap.load_with_cache('.', $stdout) + api_map = Solargraph::ApiMap.load('.') + bench = Solargraph::Bench.new(external_requires: ['open3']) + api_map.catalog(bench) method_pin = api_map.pins.find do |pin| pin.is_a?(Solargraph::Pin::Method) && pin.path == 'Open3.capture2e' diff --git a/spec/rbs_map/core_map_spec.rb b/spec/rbs_map/core_map_spec.rb index 94cd8395b..79878c572 100644 --- a/spec/rbs_map/core_map_spec.rb +++ b/spec/rbs_map/core_map_spec.rb @@ -82,7 +82,7 @@ # correctly. It would be better to test RbsMap or RbsMap::Conversions # with an RBS fixture. core_map = described_class.new - pins = core_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::Reference::Include) && pin.name == 'Enumerable' } + pins = core_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::Reference::Include) && pin.name == '::Enumerable' } expect(pins.map(&:closure).map(&:namespace)).to include('Enumerator') end @@ -107,8 +107,8 @@ class Foo ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [3, 6]) - expect(clip.infer.to_s).to eq('String') - expect(clip.infer.to_rbs).to eq('::String') + expect(clip.infer.to_s).to eq('""') + expect(clip.infer.to_rbs).to eq('""') end it 'treats literal nil as NilClass for method resolution' do @@ -118,6 +118,6 @@ class Foo ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [2, 6]) - expect(clip.infer.to_s).to eq('String') + expect(clip.infer.to_s).to eq('""') end end diff --git a/spec/rbs_map/stdlib_map_spec.rb b/spec/rbs_map/stdlib_map_spec.rb index 4364fcfef..9f76b3d08 100644 --- a/spec/rbs_map/stdlib_map_spec.rb +++ b/spec/rbs_map/stdlib_map_spec.rb @@ -6,7 +6,7 @@ # @todo Unlike the YardMap stdlib, the RBS version reports the correct # return type for Pathname#Join. Delete or modify this test depending # on how StdLibFills will be handled going forward. - rbs_map = Solargraph::RbsMap::StdlibMap.load('pathname') + rbs_map = described_class.load('pathname') pin = rbs_map.path_pin('Pathname#join') expect(pin.signatures.first.return_type.tag).to eq('Pathname') end @@ -25,7 +25,7 @@ it 'processes RBS class variables' do pending 'rbs not in stdlib?' - map = Solargraph::RbsMap::StdlibMap.load('rbs') + map = described_class.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| @@ -38,7 +38,7 @@ it 'processes RBS class instance variables' do pending 'rbs not in stdlib?' - map = Solargraph::RbsMap::StdlibMap.load('rbs') + map = described_class.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| @@ -50,7 +50,7 @@ end it 'processes RBS module aliases' do - map = Solargraph::RbsMap::StdlibMap.load('yaml') + map = described_class.load('yaml') store = Solargraph::ApiMap::Store.new(map.pins) constant_pins = store.get_constants('') yaml_pins = constant_pins.select do |pin| @@ -63,7 +63,7 @@ end it 'pins are marked as coming from RBS parsing' do - map = Solargraph::RbsMap::StdlibMap.load('yaml') + map = described_class.load('yaml') store = Solargraph::ApiMap::Store.new(map.pins) constant_pins = store.get_constants('') pin = constant_pins.first diff --git a/spec/rbs_translator_spec.rb b/spec/rbs_translator_spec.rb new file mode 100644 index 000000000..6525e8ea0 --- /dev/null +++ b/spec/rbs_translator_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rbs' + +# RbsTranslator.to_complex_type builds ComplexType::UniqueType::Intersection +# directly from the RBS AST for a *top-level* RBS::Types::Intersection, +# rather than flattening it through a joined tag string - see +# spec/pin/method_spec.rb "with a union nested inside an intersection". +# That fix only covers the entry point used for method return types and +# parameter types. Every other place a type gets recursively translated +# - RBS::Types::Optional, RBS::Types::Union members, RBS::Types::Tuple +# elements, and generic type arguments (Array[...], Hash[...], and any +# other name with type args, via build_type/type_tag) - still goes +# through the same flattening `type_to_tag` string join that caused the +# original bug, whenever the nested type is an intersection with a union +# conjunct. A plain (non-union-conjunct) intersection nested anywhere is +# fine; only the combination of "intersection containing a union" nested +# below the top level is affected. +# +# This spec covers that whole class of position, not just the one +# reported. +describe Solargraph::RbsTranslator do + # @param rbs_string [String] + # @return [Solargraph::ComplexType] + def translate rbs_string + described_class.to_complex_type(RBS::Parser.parse_type(rbs_string)) + end + + context 'when translating at the top level (already correct)' do + it 'builds a real intersection from a union conjunct' do + type = translate('(Integer | String) & Comparable') + expect(type.length).to eq(1) + expect(type.to_rbs).to eq('(::Integer | ::String) & ::Comparable') + end + end + + context 'with a plain intersection (no union conjunct) nested anywhere' do + it 'translates correctly inside a generic argument' do + type = translate('Array[Integer & Comparable]') + expect(type.to_rbs).to eq('::Array[::Integer & ::Comparable]') + end + end + + context 'with a union-in-intersection nested below the top level' do + it 'preserves grouping inside a generic argument (Array)' do + type = translate('Array[(Integer | String) & Comparable]') + expect(type.to_rbs).to eq('::Array[(::Integer | ::String) & ::Comparable]') + end + + it 'preserves grouping inside a Hash value' do + type = translate('Hash[Symbol, (Integer | String) & Comparable]') + expect(type.to_rbs).to eq('::Hash[::Symbol, (::Integer | ::String) & ::Comparable]') + end + + it 'preserves grouping inside a Hash key' do + type = translate('Hash[(Integer | String) & Comparable, Symbol]') + expect(type.to_rbs).to eq('::Hash[(::Integer | ::String) & ::Comparable, ::Symbol]') + end + + it 'preserves grouping when doubly nested (Array of Hash values)' do + type = translate('Array[Hash[Symbol, (Integer | String) & Comparable]]') + expect(type.to_rbs).to eq('::Array[::Hash[::Symbol, (::Integer | ::String) & ::Comparable]]') + end + + it 'preserves grouping under an optional (nilable) wrapper' do + type = translate('((Integer | String) & Comparable)?') + # T? is T | nil - a 2-item union of [the intersection, nil], not a + # 3-item union that leaks the intersection's own first conjunct + # out to the top level. + expect(type.length).to eq(2) + expect(type.to_rbs).to eq('((::Integer | ::String) & ::Comparable | nil)') + end + + it 'preserves grouping as one member of an outer union' do + # NilClass renders as the literal `nil` tag everywhere in this + # codebase (see RBS_TO_YARD_TYPE), independent of this fix. + type = translate('((Integer | String) & Comparable) | NilClass') + expect(type.length).to eq(2) + expect(type.to_rbs).to eq('((::Integer | ::String) & ::Comparable | nil)') + end + + it 'preserves grouping as a tuple element' do + type = translate('[(Integer | String) & Comparable, Integer]') + # a 2-element tuple - the intersection, then Integer - not a + # 3-element tuple that leaks the intersection's first conjunct out + # as its own element. + expect(type.to_rbs).to eq('[(::Integer | ::String) & ::Comparable, ::Integer]') + end + end +end diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index 3d8a254bf..3f1725104 100644 --- a/spec/shell_spec.rb +++ b/spec/shell_spec.rb @@ -129,6 +129,35 @@ def bundle_exec(*cmd) 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 diff --git a/spec/source/chain/call_spec.rb b/spec/source/chain/call_spec.rb index 0ecb8aee5..0b784765c 100644 --- a/spec/source/chain/call_spec.rb +++ b/spec/source/chain/call_spec.rb @@ -96,7 +96,6 @@ def self.new; end end it 'infers types from macros' do - pending 'WIP' source = Solargraph::Source.load_string(%( class Foo # @!macro @@ -168,6 +167,19 @@ def self.bar expect(type.tag).to eq('Array') end + it 'infers generic parameterized types through module inclusion via RBS definition of module' do + source = Solargraph::Source.load_string(%( + foo = ['bar'].to_set + + foo + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(3, 9)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Set') + end + it 'infers generic-class method return values with self reference' do source = Solargraph::Source.load_string(%( # @generic GenericTypeParam @@ -194,6 +206,33 @@ def self.bar expect(type.tag).to eq('Hash>') end + it 'infers generic-class method return values with self reference through RBS definition' do + source = Solargraph::Source.load_string(%( + a = ['bar'] + # @param item [String] + foo = a.to_set.classify do |item| + item.class + end + + foo + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(3, 12)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Array') + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(3, 20)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Set') + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(4, 17)) + block_pin = api_map.source_map('test.rb').pins.find { |p| p.is_a?(Solargraph::Pin::Block) } + type = chain.infer(api_map, block_pin, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Class') + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(7, 9)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Hash{Class => Set}') + end + it 'infers method return types' do source = Solargraph::Source.load_string(%( def bar @@ -280,6 +319,31 @@ def baz expect(type.tag).to eq('String') end + it 'infers generic return types from @generic tag when method also takes an unused block param' do + source = Solargraph::Source.load_string(%( + class Foo + def initialize; end + def foo_method; 1; end + end + + class Repro + # @generic T + # @param clazz [Class>] + # @return [generic] + def create_object(clazz, &unused) + clazz.new + end + end + + Repro.new.create_object(Foo) + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(15, 20)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Foo') + end + it 'infers generic return types from block from yield being a return node' do pending('deeper inference support') @@ -374,6 +438,30 @@ def bar; end expect(type.tag).to eq('String') end + it 'resolves same-class generics from a union independently of declaration order' do + # https://github.com/castwide/solargraph/issues/1272 + ['Box, Box', 'Box, Box'].each do |union_tag| + source = Solargraph::Source.load_string(%( + # @generic T + class Box + # @return [generic] + def get; end + end + + # @type [#{union_tag}] + b = boxed + c = b.get + c + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(10, 7)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.items.map(&:tag).sort).to eq(%w[Integer String]) + end + end + it 'denies calls off of nilable objects when loose union mode is off' do source = Solargraph::Source.load_string(%( # @type [String, nil] @@ -389,6 +477,37 @@ def bar; end expect(type.tag).to eq('undefined') end + it 'denies calls on a two-class union when loose union mode is off and only one class defines the method' do + # The nilable spec above exercises this same "every union member must + # define the method, unless loose_unions" rule, but only via + # nullable?/without_nil stripping nil out first - it never actually + # tests the general two-real-class case. Found while checking whether + # the Call#resolve fix for intersections (A & B <: A, A & B <: B) + # regressed real union semantics (A, B calls require both) - it + # doesn't (this reproduces identically on unmodified HEAD, before that + # fix), but this specific shape was never covered. + pending 'strict union mode does not deny a call when only one of two plain classes defines it' + source = Solargraph::Source.load_string(%( + class A + # @return [void] + def foo; end + end + + class B + end + + # @type [A, B] + x = make + x.foo + ), 'test.rb') + api_map = Solargraph::ApiMap.new(loose_unions: false) + api_map.map source + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(10, 8)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('undefined') + end + it 'preserves unions in value position in Hash' do source = Solargraph::Source.load_string(%( # @param params [Hash{String => Array, Hash{String => undefined}, String, Integer}] @@ -428,6 +547,186 @@ def foo(params) expect(type.rooted_tags).to eq('undefined') end + it 'does not infer undefined types when declared ones exist' do + source = Solargraph::Source.load_string(%( + # @return [Array] + def other; end + def foo + parts = [''] + other + parts + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new + api_map.map source + + foo_pin = api_map.source_map('test.rb').pins.find { |p| p.name == 'foo' } + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(5, 8)) + type = chain.infer(api_map, foo_pin, api_map.source_map('test.rb').locals) + expect(type.rooted_tags).to eq('::Array<::String>') + end + + it 'understands types in an Array#+ scenario' do + source = Solargraph::Source.load_string(%( + module A + class B + def c + ([B.new] + [B.new]).each do |d| + d + end + end + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new + api_map.map source + + closure_pin = api_map.source_map('test.rb').pins.find do |p| + p.is_a?(Solargraph::Pin::Block) && p.location.range.start.line == 4 + end + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(5, 14)) + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) + expect(type.tags).to eq('A::B') + end + + it 'qualifies types in an Array#+ scenario' do + source = Solargraph::Source.load_string(%( + module A + class B + def c + ([B.new] + [B.new]).each do |d| + d + end + end + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new + api_map.map source + + closure_pin = api_map.source_map('test.rb').pins.find do |p| + p.is_a?(Solargraph::Pin::Block) && p.location.range.start.line == 4 + end + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(5, 14)) + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) + expect(type.rooted_tags).to eq('::A::B') + end + + it 'handles subclass and superclass issues in Array#+' do + source = Solargraph::Source.load_string(%( + module A + class B; end + class C < B + def c + ([B.new] + [C.new]).each do |d| + d + end + end + def d + ([C.new] + [B.new]).each do |d| + d + end + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(6, 14)) + closure_pin = api_map.source_map('test.rb').pins.find do |p| + p.is_a?(Solargraph::Pin::Block) && p.location.range.start.line == 5 + end + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) + expect(type.rooted_tags).to eq('::A::B').or eq('::A::B, ::A::C').or eq('::A::C, ::A::B') + + closure_pin = api_map.source_map('test.rb').pins.find do |p| + p.is_a?(Solargraph::Pin::Block) && p.location.range.start.line == 10 + end + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(11, 14)) + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) + # valid options here: + # * emit type checker warning when adding [B.new] and type whole thing as '::A::B' + # * type whole thing as '::A::B, A::C' + # * type as undefined + expect(type.rooted_tags).to eq('::A::B, ::A::C').or eq('::A::C, ::A::B').or be_undefined + expect(type.rooted_tags).not_to eq('::A::C') + end + + it 'qualifies types in a second Array#+' do + source = Solargraph::Source.load_string(%( + module A1 + class B1 + # @return [Array] + def foo; end + end + end + module A + module D + class E; end + end + class B; end + class C < B + def e + ([D::E.new] + [D::E.new]).each do |d| + d + end + end + def f + de1 = [D::E.new] + de2 = [D::E.new] + (de1 + de2).each do |d| + d + end + end + # @return [Array] + attr_reader :g + # @return [Array] + attr_reader :h + def i + de1 = [D::E.new] + (g + de1).each do |d| + d + end + end + def j + (g + h).each do |d| + d + end + end + def k + arr1 = A1::B1.new.foo + h + arr1 + arr1.each do |d1| + d1 + end + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + clip = api_map.clip_at('test.rb', [15, 14]) + expect(clip.infer.rooted_tags).to eq('::A::D::E') + + clip = api_map.clip_at('test.rb', [22, 14]) + expect(clip.infer.rooted_tags).to eq('::A::D::E') + + clip = api_map.clip_at('test.rb', [32, 14]) + expect(clip.infer.rooted_tags).to eq('::A::D::E') + + clip = api_map.clip_at('test.rb', [37, 14]) + expect(clip.infer.rooted_tags).to eq('::A::D::E') + + clip = api_map.clip_at('test.rb', [42, 12]) + expect(clip.infer.rooted_tags).to eq('::Array<::A::D::E>') + end + it 'correctly looks up civars' do source = Solargraph::Source.load_string(%( class Foo @@ -497,4 +796,126 @@ def objects_by_class klass clip = api_map.clip_at('test.rb', [14, 14]) expect(clip.infer.rooted_tags).to eq('::Set<::Foo::Bar::Symbol>') end + + it 'accepts a word with no location, for external callers that omit it' do + expect { described_class.new('foo') }.not_to raise_error + end + + context 'with an RBS-declared generic block-form overload accepting a kwrest parameter' do + # create a temporary directory with the scope of the spec + around do |example| + require 'tmpdir' + Dir.mktmpdir('rspec-solargraph-') do |dir| + @temp_dir = dir + example.run + end + end + + attr_reader :temp_dir + + let(:rbs) do + <<~RBS + class Box + def self.start: (Integer val, ?String opt1, ?String opt2) -> Box + | [T] (Integer val, ?String opt1, ?String opt2, **untyped opts) { (Integer v) -> T } -> T + end + RBS + end + + let(:conversions) do + loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) + loader.add(path: Pathname(temp_dir)) + Solargraph::RbsMap::Conversions.new(loader: loader) + end + + before do + File.write(File.join(temp_dir, 'box.rbs'), rbs) + end + + # Simulates a gem/stdlib method (loaded via `convention_pins`, the + # same mechanism DocMap uses for a resolved `require`) being called + # with a keyword argument that should bind to the overload's kwrest + # parameter, not the next positional parameter. + # + # @param code [String] + # @return [Solargraph::ComplexType] + # @param [Object] position + def infer_at code, position + api_map = Solargraph::ApiMap.new + source = Solargraph::Source.load_string(code, 'test.rb') + source_map = Solargraph::SourceMap.map(source) + source_map.send(:convention_pins=, conversions.pins) + api_map.catalog(Solargraph::Bench.new(source_maps: [source_map], live_map: source_map)) + api_map.clip_at('test.rb', position).infer + end + + it 'matches a trailing keyword argument to a kwrest parameter instead of the next positional parameter' do + type = infer_at(%( + Box.start(1, "x", foo: true) do |v| + v + end + ), [2, 10]) + expect(type.rooted_tags).to eq('::Integer') + end + + it 'still resolves the block-form overload when no keyword argument is passed' do + type = infer_at(%( + Box.start(1, "x") do |v| + v + end + ), [2, 10]) + expect(type.rooted_tags).to eq('::Integer') + end + end + + context 'with overloads that differ only in which keyword(s) they accept' do + around do |example| + require 'tmpdir' + Dir.mktmpdir('rspec-solargraph-') do |dir| + @temp_dir = dir + example.run + end + end + + attr_reader :temp_dir + + let(:rbs) do + <<~RBS + class Box + def self.add: (path: String) -> Integer + | (library: String, ?resolve_dependencies: untyped) -> String + end + RBS + end + + let(:conversions) do + loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) + loader.add(path: Pathname(temp_dir)) + Solargraph::RbsMap::Conversions.new(loader: loader) + end + + before do + File.write(File.join(temp_dir, 'box.rbs'), rbs) + end + + # @param code [String] + # @param position [Array(Integer, Integer)] + # @return [Solargraph::ComplexType] + def infer_at code, position + api_map = Solargraph::ApiMap.new + source = Solargraph::Source.load_string(code, 'test.rb') + source_map = Solargraph::SourceMap.map(source) + source_map.send(:convention_pins=, conversions.pins) + api_map.catalog(Solargraph::Bench.new(source_maps: [source_map], live_map: source_map)) + api_map.clip_at('test.rb', position).infer + end + + it 'matches a call by the keyword it actually passes, not an earlier overload with an untyped keyword param' do + code = %( + Box.add(path: "x") + ) + type = infer_at(code, [1, code.lines[1].chomp.length]) + expect(type.rooted_tags).to eq('::Integer') + end + end end diff --git a/spec/source/chain/or_spec.rb b/spec/source/chain/or_spec.rb index 4a36dfe7c..af54125e5 100644 --- a/spec/source/chain/or_spec.rb +++ b/spec/source/chain/or_spec.rb @@ -30,4 +30,37 @@ def foo clip = api_map.clip_at('test.rb', [3, 8]) expect(clip.infer.simplify_literals.rooted_tags).to eq('::String') end + + it 'infers just the lhs type when the rhs always raises' do + source = Solargraph::Source.load_string(%( + class Example + # @param argv [Array] + # @return [String] + def first_arg(argv) + argv[0] || raise('missing first argument') + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + checker = Solargraph::TypeChecker.new('test.rb', api_map: api_map) + expect(checker.problems).to be_empty + end + + it 'infers just the lhs type when the rhs always fails via ||=' do + source = Solargraph::Source.load_string(%( + class Example + # @param name [String, nil] + # @return [String] + def resolved_name(name) + name ||= raise('name required') + name + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + checker = Solargraph::TypeChecker.new('test.rb', api_map: api_map) + expect(checker.problems).to be_empty + end end diff --git a/spec/source/chain_spec.rb b/spec/source/chain_spec.rb index a6b29686e..5393c5869 100644 --- a/spec/source/chain_spec.rb +++ b/spec/source/chain_spec.rb @@ -235,7 +235,8 @@ class NotCorrect; end # chain = Solargraph::Source::NodeChainer.chain(node, 'test.rb') chain = Solargraph::Parser.chain(node, 'test.rb') type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, []) - expect(type.tag).to eq('Boolean') + expect(type.tag).to eq('true') + expect(type.simplify_literals.tag).to eq('Boolean') end it 'infers self from Object#freeze' do @@ -363,8 +364,6 @@ class Bar; end end it 'infers instance variables from sequential assignments' do - pending('sequential assignment support') - source = Solargraph::Source.load_string(%( def foo @foo = nil diff --git a/spec/source/source_chainer_spec.rb b/spec/source/source_chainer_spec.rb index 467934665..272cbb6ef 100644 --- a/spec/source/source_chainer_spec.rb +++ b/spec/source/source_chainer_spec.rb @@ -270,6 +270,31 @@ class Inner2 expect(chain.links.last.arguments.length).to eq(2) end + it 'infers specific array type when child types identical' do + source = Solargraph::Source.load_string(%( + a = 'a' + [a, 'b'] + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = described_class.chain(source, Solargraph::Position.new(2, 9)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Array') + end + + it 'infers tuple type when types in literal differ' do + source = Solargraph::Source.load_string(%( + ['a', 123] + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = described_class.chain(source, Solargraph::Position.new(1, 16)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Array(String, Integer)') + end + it 'allows Array methods when tuple type in literal inferred' do source = Solargraph::Source.load_string(%( b = ['a', 'b', 123] @@ -284,6 +309,18 @@ class Inner2 expect(type.rooted_tag).to eq('::Boolean') end + it 'infers specific array type when types in literal identical' do + source = Solargraph::Source.load_string(%( + ['a', 'b'] + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = described_class.chain(source, Solargraph::Position.new(1, 16)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('Array') + end + it 'extracts correct node from repaired source' do source = Solargraph::Source.load_string(%( # @return [Array] diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index b30002967..3f46e0d0a 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -450,7 +450,6 @@ def foo; end end it 'infers return types from local variables' do - pending 'Probably redundant' source = Solargraph::Source.load_string(%( def foo x = 1 @@ -1110,7 +1109,6 @@ def foo end it 'infers complex variable type from ternary operator' do - pending 'Probably redundant' source = Solargraph::Source.load_string(%( def foo a type = (a == 123 ? 'foo' : 456) @@ -1783,6 +1781,23 @@ def bar(t) expect(type.to_s).to eq('Hash{String => Integer}') end + it 'picks correct overload in Enumerable#max_by' do + source = Solargraph::Source.load_string(%( + a = [1, 2, 3] + a + b = a.max_by(&:abs) + b + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array') + + clip = api_map.clip_at('test.rb', [4, 6]) + type = clip.infer + expect(type.to_s).to eq('Integer, nil') + end + it 'preserves duplicated types in tuple' do source = Solargraph::Source.load_string(%( # @type [Array(Array(Symbol, String, Array(Integer, Integer)))] @@ -1868,6 +1883,17 @@ def foo; end expect(type.to_s).to eq('Gem::Specification') end + it 'infers block-pass symbols from generics' do + source = Solargraph::Source.load_string(%( + array = [0, 1, 2] + array.max_by(&:abs) + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 13]) + type = clip.infer + expect(type.to_s).to eq('Integer, nil') + end + it 'picks correct overload in Hash#each_with_object and resolves return type' do source = Solargraph::Source.load_string(%( # @param klass [Class] @@ -1917,6 +1943,90 @@ def bad_passthrough; yield; end expect(type.to_s).to eq('undefined') end + it 'infers block-pass symbols with variant yields' do + source = Solargraph::Source.load_string(%( + array = [0] + array.map(&:to_s) + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 13]) + type = clip.infer + expect(type.to_s).to eq('Array') + end + + it 'resolves literal arrays in the face of identical names' do + source = Solargraph::Source.load_string(%( + module Foo; class Array; end; end + foo = ['foo'] + foo + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [3, 6]) + type = clip.infer + expect(type.tag).to eq('Array') + expect(type.rooted?).to be true + expect(type.all_rooted?).to be true + end + + it 'infers block parameter type for Array#select' do + source = Solargraph::Source.load_string(%( + a = [1,2,3] + a.select { |i| i } + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 21]) + type = clip.infer + expect(type.to_s).to eq('Integer') + end + + it 'binds generics through a cross-file @!parse stub that adds @generic to an existing class' do + # The plain implementation (e.g., as it would be defined in a gem) is + # unaware of any generics. It's mapped first, simulating a gem's pins + # being loaded before the workspace's. + plain_impl = Solargraph::SourceMap.load_string(%( + module Widgetbox + class Collection + def self.make + new + end + + def last + nil + end + end + + class Widget + # @return [String, nil] + def resource_subtype; end + end + end + ), 'widgetbox.rb') + # A `@!parse` stub in a separate file adds `@generic T` to the existing + # class and overrides the return types of its methods. + parse_stub = Solargraph::SourceMap.load_string(%( + # @!parse + # module Widgetbox + # # @generic T + # class Collection + # class << self + # # @return [Widgetbox::Collection] + # def make; end + # end + # # @return [generic] + # def last; end + # end + # end + ), 'annotations.rb') + caller_source = Solargraph::Source.load_string(%( + Widgetbox::Collection.make.last.resource_subtype + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.catalog Solargraph::Bench.new(source_maps: [plain_impl, parse_stub, Solargraph::SourceMap.map(caller_source)]) + clip = api_map.clip_at('test.rb', [1, 40]) + type = clip.infer + expect(type.tag).to eq('String') + end + it 'uses simple return value of block to infer return value of Enumerable#map' do source = Solargraph::Source.load_string(%( a = ['a'].map { 123 } @@ -1925,11 +2035,41 @@ def bad_passthrough; yield; end api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [2, 6]) type = clip.infer - expect(type.tags).to eq('Array') + expect(type.tags).to eq('Array<123>') expect(type.simple_tags).to eq('Array') # @todo more root-safety to be done - expect(type.rooted?).to be true end + it 'infers type of block argument of map and return value dependent on it' do + source = Solargraph::Source.load_string(%( + def foo + a = [1,2,3] + a + b = a.map do |i| + i + i.to_f + end + b + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [3, 8]) + type = clip.infer + expect(type.tag).to eq('Array') + + # api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 10]) + type = clip.infer + expect(type.tag).to eq('Integer') + + clip = api_map.clip_at('test.rb', [8, 8]) + type = clip.infer + expect(type.tag).to eq('Array') + + # @todo more root-safety to be done - expect(type.rooted?).to be true + end + it 'calculates visibility into variable in root closure from block' do source = Solargraph::Source.load_string(%( mutex = Thread::Mutex.new @@ -1943,7 +2083,7 @@ def bad_passthrough; yield; end clip = api_map.clip_at('test.rb', [6, 6]) type = clip.infer - expect(type.tags).to eq('Integer') + expect(type.tags).to eq('123') expect(type.simple_tags).to eq('Integer') # @todo more root-safety to be done - expect(type.rooted?).to be true @@ -2037,8 +2177,15 @@ def signatures_at # @todo more root-safety to be done - expect(type.rooted?).to be true end + # Tuples deliberately do not give index-specific types for [], #at, + # or #fetch (see https://github.com/castwide/solargraph/issues/1196): + # once a variable holding a tuple has been reassigned, indexed with a + # non-literal, or mutated, there's no reliable way to know which + # position is actually being read, so a precise-looking but + # potentially wrong answer is worse than the union of all element + # types. This applies uniformly regardless of which literal index is + # used. it 'resolves declared tuple types correctly' do - pending 'We might eliminate the Tuple fill' source = Solargraph::Source.load_string(%( # @type [::Solargraph::Fills::Tuple(String, Integer)] a = nil @@ -2067,7 +2214,6 @@ def signatures_at xit 'does not pay attention to method signatures which have been redefind by subclass' it 'understands #at for tuples' do - pending 'We might eliminate the Tuple fill' source = Solargraph::Source.load_string(%( # @type [::Solargraph::Fills::Tuple(String, Integer)] a = nil @@ -2094,7 +2240,6 @@ def signatures_at end it 'understands #fetch for tuples with no default' do - pending 'We might eliminate the Tuple fill' source = Solargraph::Source.load_string(%( # @type [::Solargraph::Fills::Tuple(String, Integer)] a = nil @@ -2121,7 +2266,6 @@ def signatures_at end it 'understands #fetch for tuples with a default' do - pending 'We might eliminate the Tuple fill' source = Solargraph::Source.load_string(%( # @type [::Solargraph::Fills::Tuple(String, Integer)] a = nil @@ -2148,7 +2292,6 @@ def signatures_at end it 'understands #fetch for tuples with a block' do - pending 'We might eliminate the Tuple fill' source = Solargraph::Source.load_string(%( # @type [::Solargraph::Fills::Tuple(String, Integer)] a = nil @@ -2202,7 +2345,6 @@ def meth arg, arg2 end it 'dereferences tuple types with [](idx) via literals' do - pending 'Probably not feasible' source = Solargraph::Source.load_string(%( # @type [Array(String, Integer)] a = foo @@ -2220,8 +2362,211 @@ def meth arg, arg2 expect(type.to_s).to eq('Integer') end + it 'tracks a literal value through reassignment for tuple indexing (#1196)' do + source = Solargraph::Source.load_string(%( + array = [1, 'two'] + index = 0 + b = array[index] + b + index += 1 + c = array[index] + c + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [4, 6]) + expect(clip.infer.to_s).to eq('Integer') + + # Before the reassignment fix, this returned the stale pre-`+=` + # answer (`'Integer'`, i.e. array[0]'s type) instead of reflecting + # that `index` is now 1 - a wrong, specious answer. It must never + # be wrong; since `index`'s type after `+=` widens to plain + # Integer (RBS's Integer#+ doesn't preserve literal values), the + # safe union of all element types is the correct, precise-as- + # possible result here. + clip = api_map.clip_at('test.rb', [7, 6]) + expect(clip.infer.to_s).to eq('Integer, String, nil') + end + + it 'safely handles a nil-typed index into a tuple (#1196)' do + source = Solargraph::Source.load_string(%( + array = [1, 'two'] + # @type [Integer] + index = nil + e = array[index] + e + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [5, 6]) + expect(clip.infer.to_s).to eq('Integer, String, nil') + end + + it 'does not track a tuple through a mutating call (documented #1196 limitation)' do + # Unlike reassignment (tracked, see the spec above), mutating + # calls like #unshift are NOT tracked - Solargraph has no way to + # know the tuple's positions shifted, so a literal index still + # returns array[0]'s *original* element type, which is now wrong + # (the actual index-0 value is 'zero', a String). This is a + # known, deliberate limitation - see the tuple.rbs top comment + # and https://github.com/castwide/solargraph/issues/1196 (scenario + # 4). If this spec ever starts failing because the result became + # safe/correct, update it - that would mean mutation tracking got + # implemented. + source = Solargraph::Source.load_string(%( + array = [1, 'two'] + array.unshift 'zero' + d = array[0] + d + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [4, 6]) + expect(clip.infer.to_s).to eq('Integer') + end + + it 'widens a tuple to the safe union when a mutating call result is reassigned (#1223)' do + # Unlike the bare-statement form above (still an unfixed, + # documented limitation), explicitly capturing a + # position-shifting mutator's result via reassignment is now + # safe: tuple.rbs gives #unshift (and the other calls that can + # shift/replace/reorder positions - see the top-of-file @note) + # a widened, position-erased `Array[...]` return type instead of + # `self`. Combined with this PR's reassignment-tracking fix, that + # means `array = array.unshift(x)` falls back to the safe union + # instead of preserving the stale Tuple type. + source = Solargraph::Source.load_string(%( + array = [1, 'two'] + array = array.unshift('zero') + d = array[0] + d + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [4, 6]) + expect(clip.infer.to_s).to eq('Integer, String, nil') + end + + it 'drops a reassigned literal from the union once a wider assignment subsumes it (#1223)' do + # Reported by @castwide on PR #1223: https://github.com/castwide/solargraph/pull/1223#issuecomment-3138551901 + # + # x = 0 + # x += 1 + # x # => inferred as 0 (well, "0, Integer" as of this PR's + # reassignment-tracking fix, before the union was simplified) + # + # A variable pin's type is the union of the return types of *all* + # its assignments in scope, not just the one nearest the + # reference (narrowing to only the most recent assignment is + # general "sequential assignment" flow narrowing - a separate, + # still-open, pre-existing limitation since PR #863; see the + # pending 'replaces type with reassignments' spec above). But + # when one of those assignments' types is a literal (`0`, from + # `x = 0`) and another is that literal's own non-literal base + # type (`Integer`, from `x += 1`, which correctly widens away the + # literal per the #1223 reassignment fix - see "tracks a literal + # value through reassignment for tuple indexing" above), the + # literal adds no information beyond what the base type already + # says - `Integer` alone is precise-as-possible and doesn't + # misleadingly suggest `0` is still reachable after the + # increment. `probe` now drops such redundant literal items. + source = Solargraph::Source.load_string(%( + x = 0 + x += 1 + x + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [3, 6]) + expect(clip.infer.to_s).to eq('Integer') + end + + it 'does not track a plain array through a mutating call like #push (pre-existing, documented limitation)' do + # Reported by @castwide on PR #1223: https://github.com/castwide/solargraph/pull/1223#issuecomment-3138551901 + # + # y = [1] + # y.push 'two' + # y # => inferred as Array + # + # Same root cause as "does not track a tuple through a mutating + # call" above (#unshift on a Tuple), just for a plain Array + # literal's inferred element type instead of a Tuple's positional + # types: Solargraph has no mutation tracking, so `y`'s type stays + # `Array` (inferred from the `[1]` literal at + # assignment) even though `#push 'two'` means `y` can now also + # hold a String. This reproduces identically on master, before + # any of #1223/#1196's changes, and via a wholly separate code + # path (plain array literal inference, not tuple.rbs) - it's a + # pre-existing, general limitation, not something #1196 covers or + # this PR regresses. This spec exists so the exact case Fred + # raised has a regression test. + source = Solargraph::Source.load_string(%( + y = [1] + y.push 'two' + y + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [3, 6]) + expect(clip.infer.to_s).to eq('Array') + end + + it 'infers array types from single element literal arrays' do + source = Solargraph::Source.load_string(%( + a = [123] + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array') + end + + it 'infers array types from multi element homogenous literal arrays' do + source = Solargraph::Source.load_string(%( + a = [123, 456] + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.rooted_tags).to eq('::Array<::Integer>') + end + + it 'infers tuple types from diverse literal arrays' do + source = Solargraph::Source.load_string(%( + a = [123, 'foo'] + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array(Integer, String)') + end + + it 'infers shallow literal diverse arrays into tuples' do + source = Solargraph::Source.load_string(%( + h = ['foo', 1] + h + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array(String, Integer)') + end + + it 'infers literal diverse array of diverse arrays into tuple of tuples' do + source = Solargraph::Source.load_string(%( + h = [['foo', 1], ['bar', :baz]] + h + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array(Array(String, Integer), Array(String, Symbol))') + end + it 'resolves block parameter types from Hash#each' do - pending 'Maybe feasible' source = Solargraph::Source.load_string(%( # @type [Hash{String => Integer}] h = { 'foo' => 1 } @@ -2242,7 +2587,6 @@ def meth arg, arg2 end it 'resolves block parameter types from Array(A, B)#each' do - pending 's and i are undefined' source = Solargraph::Source.load_string(%( # @type [Array] h = [['foo', 1], ['bar', 2]] @@ -2268,6 +2612,17 @@ def meth arg, arg2 expect(type.to_s).to eq('Integer') end + it 'infers literal heterogeneous arrays into tuples' do + source = Solargraph::Source.load_string(%( + h = [['foo', 1], ['bar', 2]] + h + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [2, 6]) + type = clip.infer + expect(type.to_s).to eq('Array') + end + it 'excludes Kernel singleton methods from chained methods' do source = Solargraph::Source.load_string('[].put', 'test.rb') api_map = Solargraph::ApiMap.new.map(source) @@ -2283,8 +2638,65 @@ def meth arg, arg2 expect(clip.infer.to_s).to eq('nil') end + it 'uses types to determine overload to match' do + source = Solargraph::Source.load_string(%( + # @generic A + # @generic B + class Foo + # @overload find(index) + # @param [String] index + # @return [generic] + # @overload find(index) + # @param [Symbol] index + # @return [generic] + def find(index); end + end + + # @type [Foo(String, Integer)] + m = blah + mb = m.find('foo') + mb + mc = m.find(:bar) + mc +), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [16, 6]) + expect(clip.infer.to_s).to eq('String') + + clip = api_map.clip_at('test.rb', [18, 6]) + expect(clip.infer.to_s).to eq('Integer') + end + + it 'uses types to determine overload of [] to match' do + source = Solargraph::Source.load_string(%( + # @generic A + # @generic B + class Foo + # @overload [](index) + # @param [String] index + # @return [generic] + # @overload [](index) + # @param [Symbol] index + # @return [generic] + def [](index); end + end + + # @type [Foo(String, Integer)] + m = blah + mb = m['foo'] + mb + mc = m[:bar] + mc +), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [16, 6]) + expect(clip.infer.to_s).to eq('String') + + clip = api_map.clip_at('test.rb', [18, 6]) + expect(clip.infer.to_s).to eq('Integer') + end + it 'uses literal types to determine overload of [] to match' do - pending 'Might be feasible' source = Solargraph::Source.load_string(%( # @generic A # @generic B @@ -2381,8 +2793,6 @@ def bar; end end it 'replaces nil with reassignments' do - pending 'sequential assignment support' - source = Solargraph::Source.load_string(%( bar = nil bar @@ -2394,12 +2804,10 @@ def bar; end expect(clip.infer.to_s).to eq('nil') clip = api_map.clip_at('test.rb', [4, 6]) - expect(clip.infer.to_s).to eq('Integer') + expect(clip.infer.to_s).to eq('123') end it 'replaces type with reassignments' do - pending 'sequential assignment support' - source = Solargraph::Source.load_string(%( bar = 'a' bar @@ -2411,7 +2819,7 @@ def bar; end expect(clip.infer.to_s).to eq('String') clip = api_map.clip_at('test.rb', [4, 6]) - expect(clip.infer.to_s).to eq('Integer') + expect(clip.infer.to_s).to eq('123') end it 'expands nil type with conditional reassignments' do @@ -2473,7 +2881,7 @@ def bar; end ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [7, 6]) - expect(clip.infer.to_s).to eq('nil, Integer, Symbol') + expect(clip.infer.to_s).to eq('nil, 123, :foo') end it 'expands type with conditional reassignments' do @@ -2489,7 +2897,7 @@ def bar; end api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [7, 6]) # The order of the types can vary between platforms - expect(clip.infer.items.map(&:to_s).sort).to match_array(%w[Integer String Symbol]) + expect(clip.infer.items.map(&:to_s).sort).to eq(['123', ':foo', 'String']) end it 'does not map Module methods into an Object' do @@ -2565,7 +2973,6 @@ def foo end it 'handles mass assignment into instance variables' do - pending 'Should be feasible' source = Solargraph::Source.load_string(%( class Blah def initialize @@ -2648,16 +3055,16 @@ def baz api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [20, 10]) - expect(clip.infer.to_s).to eq('Array') + expect(clip.infer.to_s).to eq('Array<456>') clip = api_map.clip_at('test.rb', [22, 10]) - expect(clip.infer.to_s).to eq('Array') + expect(clip.infer.to_s).to eq('Array<456>') clip = api_map.clip_at('test.rb', [24, 10]) - expect(clip.infer.to_s).to eq('Array') + expect(clip.infer.to_s).to eq('Array<456>') clip = api_map.clip_at('test.rb', [26, 10]) - expect(clip.infer.to_s).to eq('Array') + expect(clip.infer.to_s).to eq('Array<456>') end it 'resolves overloads based on kwarg existence' do @@ -2699,7 +3106,6 @@ def foo end it 'preserves hash value when it is a union without brackets' do - pending 'Inferred type contains NilClass' source = Solargraph::Source.load_string(%( # @type [Hash{String => Array, Hash, Integer, nil}] raw_data = {} @@ -2727,8 +3133,6 @@ def foo a end it 'preserves hash value when it is a union with brackets' do - pending 'union in bracket support' - source = Solargraph::Source.load_string(%( # @type [Hash{String => [Array, Hash, Integer, nil]}] raw_data = {} @@ -3095,12 +3499,12 @@ def foo; end api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [4, 12]) - expect(clip.infer.to_s).to eq('String, Integer, nil') + expect(clip.infer.to_s).to eq('String') clip = api_map.clip_at('test.rb', [4, 13]) paths = clip.complete.pins.map(&:path) expect(paths).to include('String#upcase') - expect(paths).to include('Integer#abs') + expect(paths).not_to include('Integer#abs') clip = api_map.clip_at('test.rb', [7, 12]) paths = clip.complete.pins.map(&:path) diff --git a/spec/type_checker/levels/alpha_spec.rb b/spec/type_checker/levels/alpha_spec.rb index aca95b9c3..b2c7ac67e 100644 --- a/spec/type_checker/levels/alpha_spec.rb +++ b/spec/type_checker/levels/alpha_spec.rb @@ -193,6 +193,19 @@ def bing expect(checker.problems.map(&:message)).to eq([]) end + it 'resolves calls to a duck type param\'s own declared method' do + checker = type_checker(%( + class Foo + # @param baz [#read_body] + # @return [void] + def bar(baz) + baz.read_body + end + end + )) + expect(checker.problems).to be_empty + end + it 'resolves self correctly in arguments (second case)' do checker = type_checker(%( class Blah diff --git a/spec/type_checker/levels/destructuring_spec.rb b/spec/type_checker/levels/destructuring_spec.rb new file mode 100644 index 000000000..2a2f288f7 --- /dev/null +++ b/spec/type_checker/levels/destructuring_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +describe Solargraph::TypeChecker do + context 'with level set to strong, destructuring' do + def type_checker code + Solargraph::TypeChecker.load_string(code, 'test.rb', :strong) + end + + it 'destructures tuple elements onto flat block parameters' do + checker = type_checker(%( + class TupleBlocks + # @return [Array] + def pairs + [['a', 1]] + end + + # @return [Array] + def flat_params + pairs.map { |name, count| "\#{name.upcase} \#{count.succ}" } + end + end + )) + expect(checker.problems.map(&:message)).not_to include('Unresolved call to upcase') + expect(checker.problems.map(&:message)).not_to include('Unresolved call to succ') + end + + it 'projects Hash#each pair types onto block parameters' do + checker = type_checker(%( + class HashPairs + # @return [Hash{Symbol => Array}] + def dict + { a: ['x'] } + end + + # @return [void] + def each_pair + dict.each { |key, vals| puts "\#{key.to_proc} \#{vals.length}" } + end + end + )) + expect(checker.problems.map(&:message)).not_to include('Unresolved call to to_proc') + expect(checker.problems.map(&:message)).not_to include('Unresolved call to length') + end + + it 'projects tuple element types into a destructured parameter group' do + source = Solargraph::Source.load_string(%( + class MlhsGroup + # @return [Array] + def pairs + [['a', 1]] + end + + # @return [Hash{String => Integer}] + def grouped + pairs.each_with_object({}) do |(name, count), memo| + memo[name.upcase] = count.succ + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + locals = api_map.source_map('test.rb').locals + name_pin = locals.find { |l| l.name == 'name' } + count_pin = locals.find { |l| l.name == 'count' } + # without mlhs support the variables inside the group do not exist + # as local pins at all + expect(name_pin).not_to be_nil + expect(count_pin).not_to be_nil + expect(name_pin.typify(api_map).tag).to eq('String') + expect(count_pin.typify(api_map).tag).to eq('Integer') + end + + it 'keeps a union-typed pair element in one tuple position (Hash#each with union key)' do + checker = type_checker(%( + class UnionKeyDict + # @return [Hash{String, Symbol => Integer}] + def dict + { 'a' => 1, b: 2 } + end + + # @return [void] + def each_pair + dict.each { |key, count| puts "\#{key.to_s} \#{count.succ}" } + end + end + )) + expect(checker.problems).to be_empty + end + + it 'keeps a nilable pair element in one tuple position' do + source = Solargraph::Source.load_string(%( + class NilableKeyDict + # @return [Hash{String, nil => Array}] + def dict + { 'a' => ['x'], nil => [] } + end + + # @return [void] + def each_pair + dict.each do |section, tasks| + puts tasks.length if section.nil? + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + locals = api_map.source_map('test.rb').locals + section = locals.find { |l| l.name == 'section' } + tasks = locals.find { |l| l.name == 'tasks' } + expect(section.typify(api_map).to_s).to match(/\AString, (nil|NilClass)\z/) + expect(tasks.typify(api_map).to_s).to eq('Array') + end + + it 'projects a union element through a destructured parameter group' do + source = Solargraph::Source.load_string(%( + class UnionMlhs + # @return [Hash{String, Symbol => Integer}] + def dict + { 'a' => 1 } + end + + # @return [Hash{String => Integer}] + def grouped + dict.each_with_object({}) do |(key, count), memo| + memo[key.to_s] = count + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + locals = api_map.source_map('test.rb').locals + key = locals.find { |l| l.name == 'key' } + count = locals.find { |l| l.name == 'count' } + expect(key.typify(api_map).to_s).to eq('String, Symbol') + expect(count.typify(api_map).to_s).to eq('Integer') + end + end +end diff --git a/spec/type_checker/levels/normal_spec.rb b/spec/type_checker/levels/normal_spec.rb index 8dec2892c..21243d161 100644 --- a/spec/type_checker/levels/normal_spec.rb +++ b/spec/type_checker/levels/normal_spec.rb @@ -223,6 +223,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') + workspace.cache_gem(gemspec) checker = type_checker(%( require 'kramdown-parser-gfm' diff --git a/spec/type_checker/levels/strict_spec.rb b/spec/type_checker/levels/strict_spec.rb index 9f5367138..582ebbc61 100644 --- a/spec/type_checker/levels/strict_spec.rb +++ b/spec/type_checker/levels/strict_spec.rb @@ -49,6 +49,77 @@ def foo str; end .to eq(['Wrong argument type for #foo: str expected String, received Class']) end + it 'catches a bad #push argument against an inferred Array element type (#1223)' do + # Reported by @castwide on PR #1223: https://github.com/castwide/solargraph/pull/1223#issuecomment-3138551901 + # + # y = [1] + # y.push 'two' + # y # => inferred as Array, silently missing the pushed String + # + # Inference still can't track the mutation (see the "does not + # track a plain array through a mutating call" spec in + # clip_spec.rb), but type checking can now catch the bad + # argument at the call site itself, since Array#push's restarg + # is checked against the receiver's element type instead of + # being skipped entirely. + checker = type_checker(%( + y = [1] + y.push 'two' + )) + # The restarg's parameter name (`objects`, `obj`, etc.) varies + # across core RBS versions, so match on the substance of the + # message rather than the exact name. + expect(checker.problems.map(&:message)) + .to contain_exactly(a_string_matching(/\AWrong argument type for Array#push: \w+ expected Integer, received String\z/)) + end + + it 'does not report a bogus problem for a restarg typed as RBS `untyped` (#1223)' do + # Reported at https://github.com/castwide/solargraph/pull/1223#issuecomment-5198820509 - + # BasicObject#instance_exec is declared `(*untyped, **untyped)`, so its + # restarg has no per-element type to check arguments against. + # #restarg_problems_for used to unwrap that down to a + # ComplexType with zero items, which #undefined? failed to + # recognize (ComplexType#method_missing only delegates to + # #items.first, so it returns nil - not true - when #items is + # empty), so the empty type fell through to the error message + # instead of being skipped, rendering as "expected , received". + checker = type_checker(%( + 1.instance_exec(2) { } + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not leak an unresolved generic from a literal-keyed Hash#fetch (#1223)' do + # Reported at https://github.com/castwide/solargraph/pull/1223#issuecomment-5296594623 - + # on RBS 4.0.x, a single-argument Hash#fetch call resolved K to the + # receiver's literal key type (e.g. "Index" from a + # `Hash{"Index" => Float}` @param tag), giving Hash#fetch a single + # candidate overload with no non-literal sibling to fall back to. + # The overload-selection gate that requires an exact-literal match + # (added to make tuple's literal-indexed overloads win over its + # safe catch-all) rejected that candidate outright, since the + # call's plain string argument isn't itself literal-typed - so no + # overload matched at all, and inference fell back to the union of + # every overload's declared return type instead of the one real + # match. + checker = type_checker(%( + class ReproLeak + # @param period [Hash{"Index" => Float}] + # @return [Float] + def literal_key(period) + period.fetch('Index') + end + + # @param period [Hash{String => Float}] + # @return [Float] + def class_key(period) + period.fetch('Index') + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'handles compatible interfaces with self types on call' do checker = type_checker(%( # @param a [Enumerable] @@ -768,7 +839,7 @@ def baz foo(123) end )) - expect(checker.problems.map(&:message)).to eq(['Wrong argument type for #foo: bar expected String, received Integer']) + expect(checker.problems.map(&:message)).to eq(['Wrong argument type for #foo: bar expected String, received 123']) end it 'validates inferred return types with complex tags' do @@ -855,6 +926,24 @@ def foo expect(checker.problems.map(&:message)).to eq([]) end + it 'resolves a call on an exhaustively-excluded (bot-typed) receiver' do + checker = type_checker(%( + class Box + # @return [Integer] + def length + 0 + end + end + + # @param subs [Box] + # @return [void] + def check(subs) + return unless !subs.is_a?(Box) && subs.length == 2 + end + )) + expect(checker.problems).to be_empty + end + it 'interprets self references correctly' do checker = type_checker(%( class Bar @@ -926,7 +1015,6 @@ def bar expect(checker.problems.map(&:message)).to eq([]) end - # @todo Possibly redundant it 'understands tuple superclass' do checker = type_checker(%( b = ['a', 'b', 123] @@ -1021,7 +1109,6 @@ def foo(a); end end it 'does not complain when passing NilClass to nil parameter' do - pending 'should be feasible' checker = type_checker(%( # @param a [nil] def foo(a); end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 1043a192d..a27909606 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -128,6 +128,58 @@ def global_config_path expect(checker.problems.map(&:message)).to be_empty end + it 'does not leak nil from an earlier &. into an unrelated later call in the same chain' do + checker = type_checker(%( + class Repro + # @param x [String, nil] + # @return [Boolean] + def process(x) + x&.to_s == '1' + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'still flags a chain ending in a safe navigation call as nullable' do + checker = type_checker(%( + class Repro + # @param x [String, nil] + # @return [String] + def process(x) + x&.to_s + end + end + )) + expect(checker.problems.map(&:message)).not_to be_empty + end + + it 'still flags nil leaking through a self-returning call after an earlier &.' do + checker = type_checker(%( + class Repro + # @param x [String, nil] + # @return [String] + def process(x) + x&.to_s.itself + end + end + )) + expect(checker.problems.map(&:message)).not_to be_empty + end + + it 'does not flag a call after &. whose result on NilClass is a fixed non-nil type' do + checker = type_checker(%( + class Repro + # @param x [String, nil] + # @return [Integer] + def process(x) + x&.to_s.to_i + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'is able to probe type over an assignment' do checker = type_checker(%( # @return [String] @@ -219,6 +271,26 @@ def quux(baz) 'Unresolved call to upcase on String, nil']) end + it 'applies a yieldparam type declared on a block-form @overload' do + checker = type_checker(%( + # @overload build + # @return [String] + # @overload build + # @yieldparam widget [String] + # @return [void] + def build + return 'hi' unless block_given? + + yield 'hi' + end + + build do |w| + w.upcase + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'does not complain on array dereference' do checker = type_checker(%( # @param idx [Integer] an index @@ -299,6 +371,29 @@ def bar; end expect(checker.problems.first.message).to include('Missing @return tag') end + it 'strips nil from an or-expression fallback in a conditional branch' do + checker = type_checker(%( + class Container + # @param m [Module, nil] + # @param expected [Module] + # @return [Boolean] + def check(m, expected) + if m.nil? + fallback + else + m <= expected || fallback + end + end + + # @return [Boolean] + def fallback + true + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'calls out keyword issues even when required arg count matches' do checker = type_checker(%( # @param a [String] @@ -589,6 +684,20 @@ def block_pins expect(checker.problems.map(&:message)).to be_empty end + it 'ignores unresolved method-scoped generics returned from a bare yield' do + checker = type_checker(%( + class Repro + # @generic T + # @yieldreturn [generic] + # @return [generic] + def call + yield + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'ignores generic resolution failures with only one arg' do checker = type_checker(%( # @generic T @@ -612,6 +721,19 @@ def downcast_arr(arr) expect(checker.problems.map(&:message)).to be_empty end + it 'narrows a literal-equality guard against a literal union member' do + checker = type_checker(%( + # @param sections [Array, :not_specified] + # @return [void] + def not_equal_guard(sections) + if sections != :not_specified + sections.each { |s| puts s } + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'does not complain on adding nil to types via return value' do checker = type_checker(%( # @param bar [Integer] @@ -657,6 +779,22 @@ def meth arg end it 'understands Open3 methods' do + # https://github.com/castwide/solargraph/pull/1292#issuecomment-5279286724 + # + # match_overload_type's loop stops at the first overload where + # positional_arguments_match? returns true, and that check has a + # pre-existing bypass (param.compatible_arg?(atype, api_map) || + # param.restarg?) accepting any argument type against a *rest + # param regardless of fit. Open3.capture2e's first overload + # ((*arg_0 Array[String], ...)) "matches" foo (a Hash) via that + # bypass before the loop ever reaches the later, correct + # (env Hash[...], *cmds, ...) overload, so it gets narrowed to + # the wrong signature. Fixed for the keyword-matching half by + # #1292's second commit, but the underlying restarg-leniency + + # first-match-wins overload selection predates #1292 and is a + # separate, larger fix. + pending('restarg-leniency lets an earlier wrong overload win before a later correct one is tried - castwide/solargraph#1292') + checker = type_checker(%( require 'open3' @@ -707,6 +845,104 @@ def objects_by_class klass end end + it 'resolves a generic type variable against a union @param type' do + checker = type_checker(%( + # @generic A + # @param arg [generic, nil] + # @return [generic] + def must_nilable(arg) + raise if arg.nil? + + arg + end + + # @param arg [String, nil] + # @return [Integer] + def via_nilable(arg) = must_nilable(arg).length + )) + # The remaining "Declared return type generic does not match + # inferred type generic, nil for #must_nilable" problem is + # caused by #1276 (`raise` does not narrow the type), which is + # independent of the generic resolution behavior under test here. + messages = checker.problems.map(&:message) + expect(messages).not_to include('#via_nilable return type could not be inferred') + expect(messages).not_to include('Unresolved call to length on String, nil') + end + + # NOTE: this scenario doesn't distinguish fixed from unfixed + # behavior - a union return type of the exact same shape as the + # union @param type flattens/dedupes an incorrectly-too-wide + # binding back down to the right answer by coincidence (the union + # already contains 'nil' as a sibling member either way). It's + # included as a no-regression check for union return types, not as + # a regression test for #1298 itself. + it 'resolves a generic type variable when both the @param and @return types are the same union' do + checker = type_checker(%( + # @generic A + # @param arg [generic, nil] + # @return [generic, nil] + def maybe(arg) + arg + end + + # @param arg [String, nil] + # @return [String, nil] + def via(arg) = maybe(arg) + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves a generic type variable against a union @param type with more than two members' do + checker = type_checker(%( + # @generic A + # @param arg [generic, nil, Symbol] + # @return [generic] + def must_not_nil_or_symbol(arg) + raise if arg.nil? || arg.is_a?(Symbol) + + arg + end + + # @param arg [String, nil, Symbol] + # @return [Integer] + def via_triple(arg) = must_not_nil_or_symbol(arg).length + )) + # As with the two-member case above, the remaining "Declared + # return type...does not match inferred type" problem is #1276 + # (`raise` does not narrow the type), independent of this test. + messages = checker.problems.map(&:message) + expect(messages).not_to include('#via_triple return type could not be inferred') + expect(messages).not_to include('Unresolved call to length on String, nil, Symbol') + end + + it 'resolves a generic type variable against a union @param type through two layers of generic methods' do + checker = type_checker(%( + # @generic A + # @param arg [generic, nil] + # @return [generic] + def layer1(arg) + raise if arg.nil? + + arg + end + + # @generic A + # @param arg [generic, nil] + # @return [generic] + def layer2(arg) = layer1(arg) + + # @param arg [String, nil] + # @return [Integer] + def via_layers(arg) = layer2(arg).length + )) + # As above, any "Declared return type...does not match inferred + # type" problems here are #1276 (`raise` does not narrow the + # type), independent of this test. + messages = checker.problems.map(&:message) + expect(messages).not_to include('#via_layers return type could not be inferred') + expect(messages).not_to include('Unresolved call to length on String, nil') + end + it 'resolves constants inside modules inside classes' do checker = type_checker(%( class Bar @@ -882,6 +1118,536 @@ def maybe_bar? expect(checker.problems.map(&:message)).to eq([]) end + it 'updates a parameter type after reassignment to a different non-literal type' do + checker = type_checker(%( + class Position + # @return [Integer] + def line + 1 + end + end + + module PositionNormalizer + # @param position [Position, Array(Integer, Integer)] + # @return [Position] + def self.normalize(position) + Position.new + end + end + + # @param position [Position, Array(Integer, Integer)] + # @return [Integer] + def describe(position) + position = PositionNormalizer.normalize(position) + position.line + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not treat a parameter reassignment inside a block as guaranteed to have run' do + checker = type_checker(%( + class Position + # @return [Integer] + def line + 1 + end + end + + module PositionNormalizer + # @param position [Position, Array(Integer, Integer)] + # @return [Position] + def self.normalize(position) + Position.new + end + end + + # @param position [Position, Array(Integer, Integer)] + # @return [Integer] + def describe(position) + [1].each { position = PositionNormalizer.normalize(position) } + position.line + end + )) + expect(checker.problems.map(&:message)).to eq([ + '#describe return type could not be inferred', + 'Unresolved call to line on Position, Array(Integer, Integer)' + ]) + end + + it 'still treats a conditional reassignment as guaranteed to have run for a use site inside the same branch' do + checker = type_checker(%( + # @param str [String] + # @param num [Integer] + # @param flag [Boolean] + # @return [void] + def conditional_reassign(str, num, flag) + local = num + if flag + local = str + local.upcase + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a variable assigned in the if condition' do + checker = type_checker(%( + # @param name [String] + # @return [Integer] + def limit_of(name) + if (md = name.match(/\\[(.*)\\]/)) + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a variable assigned in the right side of an && condition' do + checker = type_checker(%( + # @param name [String, nil] + # @return [Integer] + def limit_of(name) + if !name.nil? && (md = name.match(/\\[(.*)\\]/)) + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not narrow a variable assigned in the left side of an || condition' do + checker = type_checker(%( + # @param name [String] + # @param fallback [Boolean] + # @return [Integer] + def limit_of(name, fallback) + if (md = name.match(/\\[(.*)\\]/)) || fallback + md[1].to_i + else + 0 + end + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to []']) + end + + it 'treats a variable assigned in the if condition as falsy in the else clause' do + checker = type_checker(%( + # @param name [String] + # @return [Integer] + def limit_of(name) + if (md = name.match(/\\[(.*)\\]/)) + 0 + else + md[1].to_i + end + end + )) + # the falsy-only receiver renders as either `nil, false` or + # `nil, Boolean` depending on literal handling; both mean narrowed + expect(checker.problems.map(&:message)) + .to contain_exactly(a_string_matching(/\AUnresolved call to \[\] on nil, (false|Boolean)\z/)) + end + + it 'uses a branch-local reassignment at a use site later in the same branch' do + checker = type_checker(%( + # @param items [Array, nil] + # @return [void] + def clean(items) + if items.nil? + items = fetch_items + items.reject! { |i| i.empty? } + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not use a reassignment made in a nested branch that may not run' do + checker = type_checker(%( + # @param items [Array, nil] + # @param flag [Boolean] + # @return [void] + def clean(items, flag) + if items.nil? + if flag + items = fetch_items + end + items.reject! { |i| i.empty? } + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to reject! on nil']) + end + + it 'does not use a branch-local reassignment at a use site before it' do + checker = type_checker(%( + # @param items [Array, nil] + # @return [void] + def clean(items) + if items.nil? + items.reject! { |i| i.empty? } + items = fetch_items + end + end + + # @return [Array] + def fetch_items; ['x']; end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to reject! on nil']) + end + + it 'accumulates every fact an or-guard asserts about the same value' do + checker = type_checker(%( + # @param name [String, Integer, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.is_a?(Integer) + a + end + + # @param name [String, Integer, nil] + # @return [String, Integer, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a nil-guarded default behind an or-guard with three operands' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.empty? || a == 'x' + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a nil-guarded default behind an or-guard with four operands' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + a = 'd' if a.nil? || a.empty? || a == 'x' || a == 'y' + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + # Soundness controls for the or-guard narrowing above. `¬(x || y)` implies + # every operand is false, so the guard's false path may narrow any variable + # it tests - but the guard's TRUE path only reassigns `a`, so nothing may be + # concluded about a second variable the guard happens to mention. + it 'does not narrow a second variable an or-guard tests but never reassigns' do + checker = type_checker(%( + # @param name [String, nil] + # @return [String] + def f(name) + a = lookup(name) + b = lookup(name) + a = 'd' if a.nil? || b.nil? + b + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)) + .to include(a_string_matching(/Declared return type ::String does not match/)) + end + + it 'does not narrow when the or-guard never tests the variable at all' do + checker = type_checker(%( + # @param name [String, nil] + # @param flag [Boolean] + # @return [String] + def f(name, flag) + a = lookup(name) + a = 'd' if flag || name.nil? + a + end + + # @param name [String, nil] + # @return [String, nil] + def lookup(name); end + )) + expect(checker.problems.map(&:message)) + .to include(a_string_matching(/Declared return type ::String does not match/)) + end + + it 'applies a modifier-if guard after the variable was reassigned' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = lookup(name) + return got.length if got + + got = lookup(name) + got.length if got + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'applies a modifier-if guard after a reassignment whose block shadows the name' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = candidates.find { |got| got == name } + return got.length if got + + got = candidates.find { |got| got != name } + got.length if got + end + + # @return [Array] + def candidates; []; end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'keeps a guard fact in force until the variable is reassigned' do + checker = type_checker(%( + # @param name [String] + # @return [Integer, nil] + def find(name) + got = lookup(name) + return got.length if got + + got.length + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)) + .to contain_exactly(a_string_matching(/\AUnresolved call to length on nil, (false|Boolean)\z/)) + end + + it 'does not apply a guard fact past a reassignment that only runs in a branch' do + checker = type_checker(%( + # @param name [String] + # @param flag [Boolean] + # @return [Integer, nil] + def find(name, flag) + got = lookup(name) + return got.length if got + + if flag + got = lookup(name) + end + got.length + end + + # @param name [String] + # @return [String, nil] + def lookup(name); name; end + )) + expect(checker.problems.map(&:message)) + .to contain_exactly(a_string_matching(/\AUnresolved call to length on nil, (false|Boolean)\z/)) + end + + it 'narrows a nil-guarded default after the modifier if' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + tasks = ['a'] if tasks.nil? + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a nil-guarded default after a non-modifier if' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + if tasks.nil? + tasks = ['a'] + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a guarded default assigned in an unless modifier' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + tasks = ['a'] unless tasks + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows a guarded default assigned in an else clause' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def guarded_default(tasks) + if !tasks.nil? + puts 'have tasks' + else + tasks = ['a'] + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'narrows only the reassigned variable when an or-condition guards it' do + checker = type_checker(%( + # @param xs [Array, nil] + # @param ys [Array, nil] + # @return [void] + def or_guard(xs, ys) + xs = ['a'] if xs.nil? || ys.nil? + xs.each { |t| puts t } + ys.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the guard tests something other than the variable' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @param flag [Boolean] + # @return [void] + def unrelated_guard(tasks, flag) + tasks = ['a'] if flag + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the nil guard does not reassign the variable' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @return [void] + def no_reassignment(tasks) + puts 'hi' if tasks.nil? + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'keeps nil in the type when the guarded assignment is itself conditional' do + checker = type_checker(%( + # @param tasks [Array, nil] + # @param flag [Boolean] + # @return [void] + def nested_conditional_assign(tasks, flag) + if tasks.nil? + tasks = ['a'] if flag + end + tasks.each { |t| puts t } + end + )) + expect(checker.problems.map(&:message)).to eq(['Unresolved call to each on Array, nil']) + end + + it 'does not let a loop-body reassignment override a reference textually before it' do + checker = type_checker(%( + # @param str [String] + # @param num [Integer] + # @param flag [Boolean] + # @return [void] + def loop_reassign(str, num, flag) + local = num + while flag + local.abs + local = str + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'updates a local variable type after reassignment to a different literal type' do + checker = type_checker(%( + # @return [void] + def run + local = 5 + local = 'hello' + local.upcase + nil + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'updates an instance variable type after reassignment in the same method' do + checker = type_checker(%( + class Foo + # @return [void] + def run + @ivar = 5 + @ivar = 'hello' + @ivar.upcase + nil + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'resolves a self-referential reassignment against the pre-assignment type' do + checker = type_checker(%( + class Repro + # @param x [String] + # @return [Integer] + def foo(x) + x = x.length + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'supports !@x.nil && @x.y' do checker = type_checker(%( class Bar @@ -898,6 +1664,17 @@ def foo? expect(checker.problems.map(&:message)).to eq([]) end + it 'infers a Boolean return from !!(x.nil? || x < n) on a nilable param' do + checker = type_checker(%( + # @param val [Integer, nil] + # @return [Boolean] + def check?(val) + !!(val.nil? || val < 5) + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'uses cast type instead of defined type' do checker = type_checker(%( # frozen_string_literal: true @@ -925,5 +1702,517 @@ def baz(bases) # an error when trying to declare sub as Subclass expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base') end + + it 'resolves Hash#fetch to the value type with no intersection involved' do + # Regression coverage for a leak that used to make this report + # 'Declared type Float does not match inferred type Float, generic'. + # Not #1231-specific: https://github.com/castwide/solargraph/pull/1231#issuecomment-5207523909 + # reported it against an intersection of two Hash instantiations, but it + # reproduced identically for a single, non-intersected generic Hash. + # + # Pin::Parameter#compatible_arg? rejected Hash#fetch's exact-arity + # overload, so inference fell through to the pin's raw combined + # signature type, which still carried the unresolved generic X from + # fetch's default-value and block overloads. + # + # castwide/solargraph#1266 fixed that for RBS >= 4.1.x, and + # castwide/solargraph#1223's non-literal overload fallback + # (a1e844414) closed the remaining pre-4.1 case, so this now holds on + # every supported RBS version. Both are merged into this branch; the + # assertion was version-conditional until then. + checker = type_checker(%( + class Repro + # @param period [Hash{"Index" => Float}] + # @return [void] + def process(period) + # @type [Float] + index = period.fetch("Index") + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'always dispatches a same-class generic method through the first union member, not #1231-specific' do + # Not an intersection-types bug at all: a *union* of two Hash + # instantiations shows the identical "always the first one, regardless + # of order or argument" dispatch bug that the same-class intersection + # specs below track. Confirmed on unmodified castwide/solargraph + # master (8fda63384, no & or | involved) with a minimal @generic + # class (no Hash, no literal keys, no #1266 dependency): + # + # # @generic T + # class Box + # # @return [generic] + # def get; end + # end + # + # # @param b [Box, Box] + # b.get # infers Integer + # # @param b [Box, Box] + # b.get # infers String - order picked the type, not the call + # + # Root cause: Call#inferred_pins resolves the class's generic + # parameter against `self_type = name_pin.binder` - the *whole* + # union/intersection type - rather than per-member, so it binds + # against whichever member it structurally matches first. + # + # Filing this as its own issue/PR against master, independent of + # #1231 and #1266, so the Hash intersection specs below inherit the + # fix whichever order the PRs land in rather than stacking one PR on + # top of another. + pending 'Call#inferred_pins binds a generic against the first union/intersection member only' + checker = type_checker(%( + class Repro + # @param period [Hash{"Index" => Float}, Hash{"Triggers" => Array String}>}] + # @return [void] + def process(period) + # @type [Float] + index = period.fetch("Index") + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + context 'with intersection types' do + it 'accepts an intersection-typed argument where any one conjunct is expected' do + checker = type_checker(%( + class Asana; class Resources; class Project; end; end; end + class Mocha; class Mock; end; end + + class Consumer + # @param project_obj [Asana::Resources::Project] + # @return [void] + def project_to_h(project_obj); end + end + + class MockFactory + # @sg-ignore Mocha::Mock configured with responds_like_instance_of + # duck-types as Asana::Resources::Project at every call site. + # @return [Mocha::Mock & Asana::Resources::Project] + def make_mock + Mocha::Mock.new + end + end + + Consumer.new.project_to_h(MockFactory.new.make_mock) + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'still rejects a plain conjunct type that does not satisfy the expected type' do + checker = type_checker(%( + class Asana; class Resources; class Project; end; end; end + class Mocha; class Mock; end; end + + class Consumer + # @param project_obj [Asana::Resources::Project] + # @return [void] + def project_to_h(project_obj); end + end + + Consumer.new.project_to_h(Mocha::Mock.new) + )) + expect(checker.problems.map(&:message)) + .to include('Wrong argument type for Consumer#project_to_h: project_obj expected Asana::Resources::Project, received Mocha::Mock') + end + + it 'accepts an intersection-typed argument where the duck-typed conjunct is expected (#1231)' do + # https://github.com/castwide/solargraph/pull/1231#issuecomment-5280196737 - + # ComplexType#duck_types_match? used to check the duck-typed + # expectation against ComplexType#namespace/#scope, which for an + # Intersection just delegates to its *first* conjunct + # (Intersection#namespace/#scope). That rejected an intersection + # whenever the duck-typed conjunct wasn't the first one, even + # though duck-typed subtyping only needs *some* conjunct to + # satisfy it - the same "any one conjunct" rule + # Intersection#conforms_to? already applies elsewhere. Fixed by + # checking each conjunct instead of just the first. + checker = type_checker(%( + # @param callback [#quack] + # @return [void] + def notify(callback); end + + # @param x [String & #quack] + # @return [void] + def relay(x) + notify(x) + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'still rejects an intersection-typed argument when no conjunct satisfies the duck-typed expectation' do + checker = type_checker(%( + # @param callback [#quack] + # @return [void] + def notify(callback); end + + # @param x [String & Integer] + # @return [void] + def relay(x) + notify(x) + end + )) + expect(checker.problems.map(&:message)) + .to include('Wrong argument type for #notify: callback expected #quack, received String & Integer') + end + + it 'dispatches generic methods per-conjunct when intersecting two instantiations of the same generic class (#1231)' do + # https://github.com/castwide/solargraph/pull/1231#issuecomment-5207523909 - + # #fetch on Hash{K1=>V1} & Hash{K2=>V2} used to always resolve through + # the *first* conjunct's #fetch signature regardless of which key was + # passed. Root cause (shared with castwide/solargraph#1272): both + # conjuncts resolve to a pin with the same path (Hash#fetch) but + # different, already-correctly-resolved return types, and dedup was + # keying on path alone - fixed here the same way + # castwide/solargraph#1273 fixed it for real unions, applied to + # Call#method_stack_pins's Intersection branch too. + # + # That made dispatch order-independent and sound (both conjuncts' + # possible return types show up), but not yet *precise*: it returned + # a union of every conjunct's result rather than narrowing to the one + # whose key actually matches. RBS's own `Hash#fetch: (_Key key) -> V` + # can't do this itself - `_Key` is a structural hash/eql? interface, + # not literally `K`, so the key argument is never connected to the + # return type by ordinary overload resolution. Call#method_stack_pins + # now detects any `_Key`-shaped parameter on a conjunct's method + # (generalizing past #fetch/#[] to #dig, #delete, etc. for free) and, + # when every conjunct yields a positive verdict for or against the + # call's own literal argument, keeps only the matching conjunct(s) - + # conservatively falling back to today's full union whenever even one + # conjunct can't be verified one way or the other. + # + # Key-parameter detection has to handle two shapes, because RBS's + # own core/hash.rbs changed in 4.1.0: `(_Key key)` from 4.1.0 on, + # `(K arg0)` before it. Pin::Signature#key_param_index recognizes + # both - see its comment. Without the pre-4.1 shape this spec + # passes on RBS >= 4.1 and fails on 3.10.x/4.0.x, which is what + # apiology/solargraph#49 CI was reporting before that was fixed. + # + # Both castwide/solargraph#1223 (restores literal type inference, + # without which the literal "Index"/"Triggers" key_types get widened + # to plain String before the narrowing above ever sees them) and + # castwide/solargraph#1266 are merged into this branch. Neither is + # specific to intersections; #1223 is a genuine prerequisite for this + # spec to observe the fix working, #1266 is not involved at all. + # + # This spec was `skip`ped for a while as "flaky - fails or + # unexpectedly passes depending on run". That was a misreading of CI, + # not a real flake. apiology/solargraph#49 run 1 (commit 82f464e0e) + # was reported as failing every matrix leg but one; in fact exactly + # one leg failed (`rspec (4.0, 4.0.3)`) and the other twelve were + # `cancelled` by fail-fast. Run 2 (commit 92b638667) was reported as + # an unexplained opposite result on `rspec (4.0, 4.1.1)`; in fact + # that leg's only "failure" was two `FIXED` markers - the specs + # passed, but that run's pending guard was gated to Ruby 3.2, so a + # pass on Ruby 4.0 registered as unexpected. The behavior was + # deterministic throughout, splitting purely on RBS version. + checker = type_checker(%( + class Repro + # @param period [Hash{"Index" => Float} & Hash{"Triggers" => Array String}>}] + # @return [void] + def process(period) + # @type [Float] + index = period.fetch("Index") + + # @type [Array String}>] + triggers = period.fetch("Triggers") + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'dispatches generic methods per-conjunct regardless of conjunct order (#1231)' do + # https://github.com/castwide/solargraph/pull/1231#issuecomment-5207523909 - + # this used to demonstrate order-*dependence*: swapping the conjunct + # order flipped which (wrong) type both fetches reported. The + # dedup-key fix described in the sibling spec above makes this + # order-independent now - same per-key-narrowed result either way, + # dispatched via the same literal-key matching described there. + # Same prerequisite as that spec, already merged into this branch: + # castwide/solargraph#1223. Was `skip`ped alongside its sibling for a + # flake that turned out not to exist - see that spec's comment. + checker = type_checker(%( + class Repro + # @param period [Hash{"Triggers" => Array String}>} & Hash{"Index" => Float}] + # @return [void] + def process(period) + # @type [Array String}>] + triggers = period.fetch("Triggers") + + # @type [Float] + index = period.fetch("Index") + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'dispatches generic methods per-conjunct for symbol keys (#1231)' do + # Symbol keys take a different path to the same bug than the string + # keys above: symbols already infer as literal types, so per-overload + # matching correctly rejects the non-matching conjunct - but a pin + # whose overloads all fail to match is not dropped, it just falls + # through to its declared return type, so the union survives anyway. + # Only key_verified_conjuncts can actually remove a conjunct. Before + # Pin::Signature#key_param_index learned RBS < 4.1's `(K arg0)` + # shape, this reported the union plus an unresolved generic plus + # three spurious "Wrong argument type for Hash#fetch: arg0 expected + # :Index, received :Triggers" errors on RBS 3.10.x/4.0.x - the arg + # check was resolving against the conjunct that narrowing should + # have dropped. castwide/solargraph#1223 (restores literal type + # inference) is a prerequisite too, already merged into this + # branch. + checker = type_checker(%( + class Repro + # @param period [Hash{:Index => Float} & Hash{:Triggers => Array String}>}] + # @return [void] + def process(period) + # @type [Float] + index = period.fetch(:Index) + + # @type [Array String}>] + triggers = period.fetch(:Triggers) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves a non-generic method shared by both conjuncts of a same-class intersection' do + checker = type_checker(%( + class Repro + # @param period [Hash{"Index" => Float} & Hash{"Triggers" => Array String}>}] + # @return [void] + def process(period) + # @type [Integer] + n = period.size + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves a call to a method defined on just one conjunct of an intersection-typed receiver (#1231)' do + # https://github.com/castwide/solargraph/pull/1231#issuecomment-5207595119 - + # method-call resolution used to only try the first conjunct's method + # stack, per unique type, and required every one of them to define the + # method the way a real union would. Fixed in Call#method_stack_pins by + # giving Intersection conjuncts "any one is enough" semantics instead. + checker = type_checker(%( + class A + # @return [void] + def foo; end + end + + class B + # @return [void] + def bar; end + end + + class Factory + # @sg-ignore A.new duck-types as A & B for this repro + # @return [A & B] + def make + A.new + end + end + + Factory.new.make.foo + Factory.new.make.bar + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves a call to a method inherited from a common ancestor of both conjuncts' do + checker = type_checker(%( + class A + # @return [void] + def foo; end + end + + class B + # @return [void] + def bar; end + end + + class Factory + # @sg-ignore A.new duck-types as A & B for this repro + # @return [A & B] + def make + A.new + end + end + + # @type [String] + s = Factory.new.make.to_s + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves a conjunct method on an intersection-typed local variable, not just a call chain (#1231)' do + # Same fix as the sibling spec above applies to any intersection-typed + # value, not just method-return-value call chains. + checker = type_checker(%( + class A + # @return [void] + def foo; end + end + + class B + # @return [void] + def bar; end + end + + # @sg-ignore A.new duck-types as A & B for this repro + # @type [A & B] + value = A.new + + value.foo + value.bar + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves conjunct methods on a three-way intersection' do + # Same fix as the sibling specs above; each conjunct is checked + # independently regardless of how many there are. + checker = type_checker(%( + class A + # @return [void] + def foo; end + end + + class B + # @return [void] + def bar; end + end + + class C + # @return [void] + def baz; end + end + + class Factory + # @sg-ignore A.new duck-types as A & B & C for this repro + # @return [A & B & C] + def make + A.new + end + end + + Factory.new.make.foo + Factory.new.make.bar + Factory.new.make.baz + )) + expect(checker.problems.map(&:message)).to be_empty + end + end + + it 'resolves Hash#fetch return type on Hash{Symbol => Class} without leaking a generic placeholder' do + # https://github.com/castwide/solargraph/issues/1227 + # + # As of RBS 4.1.0, Hash#fetch's single-argument overload takes + # its key as the ::Hash::_Key duck-type interface instead of the + # generic K (see ruby/rbs core/hash.rbs). Solargraph couldn't + # prove a Symbol argument satisfies that interface, so it fell + # back to merging the return types of all of Hash#fetch's + # overloads (including the unresolved block-form's `generic`) + # instead of picking the single-argument overload. + checker = type_checker(%( + class Foo; end + + class Holder + # @return [Hash{Symbol => Class}] + def registry + { x: Foo } + end + + # @return [Foo] + def use_it + # @type [Class] + clazz = registry.fetch(:x) + clazz.new + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'resolves a repeated core-method call on a var reassigned mid-method after a reopened-class call' do + # https://github.com/castwide/solargraph/pull/1288#issuecomment-5273022388 + # + # Not currently known to be reachable on master: it depends on + # flow-sensitive-typing pin combination behavior that only exists + # once certain in-flight branches land (as of this writing, + # https://github.com/castwide/solargraph/pull/1258 and + # https://github.com/castwide/solargraph/pull/1282 - verified + # neither reproduces it alone, only the two combined). Kept here + # as a standing regression guard so that whatever combination of + # future changes reintroduces the failure mode gets caught, + # regardless of merge order. + checker = type_checker(%( + class String + # @return [String] + def depunctuate + self + end + end + + # @param str [String] + # @param other [String] + # @return [String] + def go(str, other) + str = str.gsub(other.depunctuate, other) + str = str.gsub(other, other) + str.gsub(other, other) + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'rebinds self to the new class in Class.new blocks' do + checker = type_checker(%( + # @return [void] + def make_class + Class.new do + define_method(:foo) { nil } + end + nil + end + )) + expect(checker.problems.map(&:message)).not_to include('Unresolved call to define_method') + end + + it 'keeps the rebound self in blocks nested inside Class.new blocks' do + checker = type_checker(%( + # @param names [Array] + # @return [void] + def make_class(names) + Class.new do + names.each { |m| define_method(m) { nil } } + end + nil + end + )) + expect(checker.problems.map(&:message)).not_to include('Unresolved call to define_method') + end + + it 'keeps the rebound self in blocks nested inside class_eval blocks' do + checker = type_checker(%( + # @param names [Array] + # @return [void] + def decorate(names) + String.class_eval do + names.each { |m| define_method(m) { nil } } + end + nil + end + )) + expect(checker.problems.map(&:message)).not_to include('Unresolved call to define_method') + end end end diff --git a/spec/type_checker/levels/typed_spec.rb b/spec/type_checker/levels/typed_spec.rb index 561ff54cf..eef9f367b 100644 --- a/spec/type_checker/levels/typed_spec.rb +++ b/spec/type_checker/levels/typed_spec.rb @@ -223,7 +223,7 @@ def foo def foo(bar = 123); end )) expect(checker.problems.map(&:message)) - .to eq(['Declared type String does not match inferred type Integer for variable bar']) + .to eq(['Declared type String does not match inferred type 123 for variable bar']) end it 'validates string default values of parameters' do @@ -443,5 +443,42 @@ def nil_assignment? )) expect(checker.problems).to be_empty end + + it 'accepts a method body that only ever raises against any declared return type' do + checker = type_checker(%( + class Foo + # @return [Boolean] + def matches? + raise 'Override me!' + end + end + )) + expect(checker.problems).to be_empty + end + + it 'accepts a method body that only ever aborts against any declared return type' do + checker = type_checker(%( + class Foo + # @return [String] + def name + abort('Override me!') + end + end + )) + expect(checker.problems).to be_empty + end + + it 'accepts a method body that conditionally raises alongside a conforming return' do + checker = type_checker(%( + class Foo + # @return [String] + def maybe cond + return 'x' if cond + raise 'nope' + end + end + )) + expect(checker.problems).to be_empty + end end end diff --git a/spec/type_checker_spec.rb b/spec/type_checker_spec.rb index 3113896d2..3a478a431 100644 --- a/spec/type_checker_spec.rb +++ b/spec/type_checker_spec.rb @@ -22,6 +22,31 @@ expect(checker.problems).to be_one end + # Regression test for https://github.com/castwide/solargraph/pull/1259#issuecomment-5192216269 + # + # call_problems iterates every call node in a file and infers each one + # with no rescue around it, so any exception raised deep inside a single + # call's type inference (a ComplexTypeError from a malformed type, or any + # other internal error) aborts TypeChecker#problems entirely instead of + # being reported as a problem for that one call site, silently losing + # every diagnostic for the rest of the file (and, when called per-file + # across a workspace, every file after it). + it 'reports a problem instead of aborting the whole run when inferring a single call raises' do + checker = described_class.load_string(%( + boom_call + another_undefined_call + ), nil, :strict) + allow(Solargraph::Parser).to receive(:chain).and_wrap_original do |original, *args| + chain = original.call(*args) + allow(chain).to receive(:infer).and_raise(Solargraph::ComplexTypeError, 'boom') if chain.links.last.word == 'boom_call' + chain + end + + problems = nil + expect { problems = checker.problems }.not_to raise_error + expect(problems.map(&:message).join).to include('another_undefined_call') + end + it 'uses caching in Solargraph::Chain to handle a degenerate case' do checker = described_class.load_string(%( def documentation diff --git a/spec/workspace/gemspecs_fetch_dependencies_spec.rb b/spec/workspace/gemspecs_fetch_dependencies_spec.rb index 56504e7dd..47aba4ddb 100644 --- a/spec/workspace/gemspecs_fetch_dependencies_spec.rb +++ b/spec/workspace/gemspecs_fetch_dependencies_spec.rb @@ -77,7 +77,7 @@ context 'with gem that exists in our bundle' do let(:gem_name) { 'undercover' } - it 'finds dependencies' do + it 'finds dependencies', time_limit_seconds: 200 do expect(deps.map(&:name)).to include('ast') end end @@ -85,7 +85,7 @@ context 'with gem does not exist in our bundle' do let(:gem_name) { 'activerecord' } - it 'gives a useful message' do + it 'gives a useful message', time_limit_seconds: 200 do dep_names = nil output = capture_both { dep_names = deps.map(&:name) } expect(output).to include('Please install the gem activerecord') diff --git a/spec/workspace/gemspecs_resolve_require_spec.rb b/spec/workspace/gemspecs_resolve_require_spec.rb index 8deba9ff8..abf1d2df5 100644 --- a/spec/workspace/gemspecs_resolve_require_spec.rb +++ b/spec/workspace/gemspecs_resolve_require_spec.rb @@ -153,6 +153,36 @@ def configure_bundler_spec stub_value end end + context 'with a require path that does not textually match the gem name' do + # e.g. activesupport ships as 'active_support' - neither + # require.tr('/', '-') nor require.split('/').first can guess + # 'activesupport' from 'active_support', so this can only be + # resolved via Gem::Specification.find_by_path, and only if the + # require path itself (not "lib/#{require}.rb") is passed to it + let(:require) { 'active_support' } + let(:mismatched_spec) do + Gem::Specification.new.tap do |spec| + spec.name = 'activesupport' + spec.files = [] + end + end + + before do + allow(Gem::Specification).to receive(:find_by_path).and_call_original + allow(Gem::Specification).to receive(:find_by_path).with(require).and_return(mismatched_spec) + allow(gemspecs).to receive(:all_gemspecs_from_bundle).and_return([mismatched_spec]) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['activesupport']) + end + + it 'passes the require path directly to find_by_path, not prefixed with lib/' do + specs + expect(Gem::Specification).to have_received(:find_by_path).with(require) + end + end + context 'with Bundler.require' do let(:require) { 'bundler/require' } diff --git a/spec/workspace_spec.rb b/spec/workspace_spec.rb index ddb5c7f01..f44f6d9da 100644 --- a/spec/workspace_spec.rb +++ b/spec/workspace_spec.rb @@ -145,4 +145,43 @@ described_class.new('./path', config) end.not_to raise_error end + + describe '#cache_all_for_workspace!' do + let(:pin_cache) { instance_double(Solargraph::PinCache) } + let(:gemspecs) { instance_double(Solargraph::Workspace::Gemspecs) } + + 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) + allow(Solargraph::Workspace::Gemspecs).to receive(:new).and_return(gemspecs) + gemspec = instance_double(Gem::Specification, name: 'test_gem', version: '1.0.0') + allow(gemspecs).to receive(:all_gemspecs_from_bundle).and_return([gemspec]) + 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 + allow(pin_cache).to receive(:cached?).and_return(false) + + allow(pin_cache).to receive(:cache_all_stdlibs).with(out: nil, rebuild: false) + + 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) + end + end end diff --git a/spec/yard_map/mapper/to_class_definition_spec.rb b/spec/yard_map/mapper/to_class_definition_spec.rb new file mode 100644 index 000000000..0ecd1a779 --- /dev/null +++ b/spec/yard_map/mapper/to_class_definition_spec.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +describe Solargraph::YardMap::Mapper::ToClassDefinition do + around do |example| + YARD::Registry.clear + example.run + YARD::Registry.clear + end + + # Maps a source string the way a gem's yardoc would arrive: YARD parses it, + # and the Mapper converts the resulting code objects into pins. + # + # @param code [String] + # @return [Array] + def map code + YARD.parse_string(code) + Solargraph::YardMap::Mapper.new(YARD::Registry.all).map + end + + # @param pins [Array] + # @param path [String] + # @return [Array] + def pins_at pins, path + pins.select { |pin| pin.path == path } + end + + it 'indexes the class defined by a Class.new block' do + pins = map(<<~RUBY) + Foo = Class.new(StandardError) do + def bar; end + end + RUBY + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Namespace]) + expect(pins_at(pins, 'Foo#bar').map(&:class)).to eq([Solargraph::Pin::Method]) + end + + it 'emits a superclass reference instead of a constant pin' do + pins = map(<<~RUBY) + Foo = Class.new(StandardError) do + def bar; end + end + RUBY + expect(pins).not_to include(an_instance_of(Solargraph::Pin::Constant)) + superclass = pins.grep(Solargraph::Pin::Reference::Superclass).first + expect(superclass.name).to eq('StandardError') + expect(superclass.closure.path).to eq('Foo') + end + + it 'keeps YARD tags written inside the block' do + pins = map(<<~RUBY) + Foo = Class.new(StandardError) do + # @return [Integer] + def bar; end + end + RUBY + expect(pins_at(pins, 'Foo#bar').first.return_type.to_s).to eq('Integer') + end + + it 'gives the class the documentation written on the constant' do + pins = map(<<~RUBY) + # An erroneous condition. + Foo = Class.new(StandardError) + RUBY + expect(pins_at(pins, 'Foo').first.comments).to include('An erroneous condition.') + end + + it 'resolves a superclass named relative to the enclosing namespace' do + pins = map(<<~RUBY) + module Errors + class Base < StandardError; end + Specific = Class.new(Base) + end + RUBY + api_map = Solargraph::ApiMap.new(pins: pins) + expect(api_map.super_and_sub?('Errors::Base', 'Errors::Specific')).to be(true) + expect(api_map.super_and_sub?('StandardError', 'Errors::Specific')).to be(true) + end + + it 'finds methods from the block through the api map' do + pins = map(<<~RUBY) + module Errors + Specific = Class.new(StandardError) do + attr_accessor :retry_after_seconds + end + end + RUBY + api_map = Solargraph::ApiMap.new(pins: pins) + stack = api_map.get_method_stack('Errors::Specific', 'retry_after_seconds') + expect(stack.map(&:path)).to eq(['Errors::Specific#retry_after_seconds']) + end + + it 'handles Class.new with a superclass and no block' do + pins = map('Foo = Class.new(StandardError)') + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Namespace]) + expect(pins.grep(Solargraph::Pin::Reference::Superclass).map(&:name)).to eq(['StandardError']) + end + + it 'handles Class.new with no superclass and no block' do + pins = map('Foo = Class.new') + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Namespace]) + expect(pins.grep(Solargraph::Pin::Reference::Superclass)).to be_empty + end + + it 'handles Class.new with no superclass and a block' do + pins = map(<<~RUBY) + Foo = Class.new do + def bar; end + end + RUBY + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Namespace]) + expect(pins_at(pins, 'Foo#bar')).not_to be_empty + end + + it 'leaves a conditional Class.new as a constant' do + pins = map('Foo = (Class.new(StandardError) if RUBY_VERSION)') + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Constant]) + end + + it 'leaves Class.new used as an ordinary value as a constant' do + pins = map('Foo = Class.new(StandardError).new') + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Constant]) + end + + it 'leaves an unrelated constant alone' do + pins = map("Foo = 'a string'") + expect(pins_at(pins, 'Foo').map(&:class)).to eq([Solargraph::Pin::Constant]) + end + + it 'falls back to a constant pin when the value cannot be parsed' do + code_object = YARD::CodeObjects::ConstantObject.new(YARD::Registry.root, :Foo) do |obj| + obj.value = 'Class.new(StandardError) do' + end + pins = Solargraph::YardMap::Mapper.new([code_object]).map + expect(pins.map(&:class)).to eq([Solargraph::Pin::Constant]) + expect(pins.first.name).to eq('Foo') + end + + it 'emits no parser nodes' do + pins = map(<<~RUBY) + module Errors + Specific = Class.new(StandardError) do + attr_accessor :retry_after_seconds + + def initialize(retry_after_seconds) + @retry_after_seconds = retry_after_seconds + end + end + end + RUBY + expect(nodes_reachable_from(pins)).to be_empty + end + + it 'gives every emitted pin the location of the constant' do + pins = map(<<~RUBY) + Foo = Class.new(StandardError) do + def bar; end + end + RUBY + expected = Solargraph::YardMap::Mapper::ToConstant.make(YARD::Registry.at('Foo')).location + expect(pins.map(&:location).compact.uniq).to eq([expected]) + end + + # A codebase that documented one of these constants by hand keeps its + # `@!parse` stub in the workspace pinset while the gem now contributes real + # pins at the same path. Catalog order is core, gem, conventions, workspace, + # so the gem's pins come first. + # + # @param gem_pins [Array] + # @param workspace_code [String] + # @return [Solargraph::ApiMap] + def api_map_with gem_pins, workspace_code + workspace = Solargraph::SourceMap.map(Solargraph::Source.load_string(workspace_code, 'stub.rb')) + api_map = Solargraph::ApiMap.new + core = Solargraph::ApiMap.class_variable_get(:@@core_map).pins + api_map.send(:store).update(core, gem_pins, [], workspace.pins, []) + api_map.send(:cache).clear + api_map + end + + context 'with a hand-written @!parse stub at the same path' do + let(:gem_pins) do + map(<<~RUBY) + module Errors + Specific = Class.new(StandardError) do + attr_accessor :retry_after_seconds + end + end + RUBY + end + + let(:stub) do + <<~RUBY + # @!parse + # module Errors + # class Specific < ::StandardError + # # @return [Integer] + # attr_accessor :retry_after_seconds + # end + # end + RUBY + end + + it 'resolves the method with the stub present' do + api_map = api_map_with(gem_pins, stub) + stack = api_map.get_method_stack('Errors::Specific', 'retry_after_seconds') + expect(stack).not_to be_empty + expect(stack.map(&:path)).to all(eq('Errors::Specific#retry_after_seconds')) + end + + it 'keeps the superclass chain intact' do + api_map = api_map_with(gem_pins, stub) + expect(api_map.super_and_sub?('StandardError', 'Errors::Specific')).to be(true) + end + + it 'holds two namespace pins and no constant pin at the path' do + api_map = api_map_with(gem_pins, stub) + expect(api_map.get_path_pins('Errors::Specific').map(&:class)) + .to eq([Solargraph::Pin::Namespace, Solargraph::Pin::Namespace]) + end + + # How far the stub's tag gets depends on the base. Where + # ApiMap::Store#combine_duplicate_method_pins exists, the two pins merge + # into one typed pin and the tag reaches the call site. Where it does not, + # the stack keeps both, the gem's untyped pin sorts first, and + # Source::Chain::Call#resolve infers from `stack.first` -- so the tag is + # shadowed and inference is undefined. Either way the stub is redundant: + # the call resolves without it. + it 'keeps the stub return tag in the method stack' do + api_map = api_map_with(gem_pins, stub) + stack = api_map.get_method_stack('Errors::Specific', 'retry_after_seconds') + expect(stack.map { |pin| pin.return_type.to_s }).to include('Integer') + end + + it 'lets an @!override on the method path type the gem pin' do + api_map = api_map_with(gem_pins, <<~RUBY) + # @!override Errors::Specific#retry_after_seconds + # @return [Integer] + RUBY + stack = api_map.get_method_stack('Errors::Specific', 'retry_after_seconds') + expect(stack.map { |pin| pin.return_type.to_s }).to eq(['Integer']) + end + end + + # Nodes hold a reference to the buffer they were parsed from, which both + # bloats the marshalled gem cache and makes inference reach for a source map + # that does not exist. + # + # @param object [Object] + # @param seen [Set] + # @return [Array] + def nodes_reachable_from object, seen = Set.new + return [] unless seen.add?(object.object_id) + + case object + when Parser::AST::Node, Parser::Source::Buffer, Parser::Source::Map, YARD::Docstring + [object.class.to_s] + when Array + object.flat_map { |item| nodes_reachable_from(item, seen) } + when Hash + object.each_value.flat_map { |value| nodes_reachable_from(value, seen) } + when String, Symbol, Numeric, nil, true, false + [] + else + object.instance_variables.flat_map do |ivar| + nodes_reachable_from(object.instance_variable_get(ivar), seen) + end + end + end +end diff --git a/spec/yard_map/mapper_spec.rb b/spec/yard_map/mapper_spec.rb index b2efd4cec..573056de6 100644 --- a/spec/yard_map/mapper_spec.rb +++ b/spec/yard_map/mapper_spec.rb @@ -7,7 +7,7 @@ def pins_with require doc_map = Solargraph::DocMap.new([require], @api_map.workspace, out: nil) - doc_map.cache_all!(nil) + doc_map.cache_doc_map_gems!(nil) doc_map.pins end @@ -37,13 +37,6 @@ def pins_with require 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 - 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 pin = pins_with('rspec/expectations').find { |pin| pin.path == 'RSpec::Matchers#expect' } 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