diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index b4ef26bfe..f473ece4e 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -8,7 +8,7 @@ name: Linting on: workflow_dispatch: {} pull_request: - branches: [ master ] + branches: ['*'] push: branches: - 'main' @@ -33,7 +33,7 @@ jobs: ruby-version: 3.4 bundler: latest bundler-cache: true - cache-version: 2025-06-06 + cache-version: 2025-10-25 - name: Update to best available RBS run: | diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index c7ad72cb4..f73126b5d 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -9,7 +9,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: ['*'] permissions: contents: read @@ -23,7 +23,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4 + ruby-version: 3.4 # keep same as typecheck.yml bundler-cache: true - uses: awalsh128/cache-apt-pkgs-action@latest with: @@ -43,7 +43,7 @@ jobs: - name: Install gem types run: bundle exec rbs collection update - name: Ensure typechecking still works - run: bundle exec solargraph typecheck --level typed + run: bundle exec solargraph typecheck --level strong - name: Ensure specs still run run: bundle exec rake spec rails: @@ -54,7 +54,9 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4 + ruby-version: 3.4 # keep same as typecheck.yml + # See https://github.com/castwide/solargraph/actions/runs/19000135777/job/54265647107?pr=1119 + rubygems: latest bundler-cache: false - uses: awalsh128/cache-apt-pkgs-action@latest with: @@ -72,7 +74,7 @@ jobs: - name: Install gem types run: bundle exec rbs collection update - name: Ensure typechecking still works - run: bundle exec solargraph typecheck --level typed + run: bundle exec solargraph typecheck --level strong - name: Ensure specs still run run: bundle exec rake spec rspec: @@ -83,7 +85,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4 + ruby-version: 3.4 # keep same as typecheck.yml bundler-cache: false - uses: awalsh128/cache-apt-pkgs-action@latest with: @@ -101,7 +103,7 @@ jobs: - name: Install gem types run: bundle exec rbs collection update - name: Ensure typechecking still works - run: bundle exec solargraph typecheck --level typed + run: bundle exec solargraph typecheck --level strong - name: Ensure specs still run run: bundle exec rake spec @@ -116,39 +118,35 @@ jobs: - name: clone https://github.com/lekemula/solargraph-rspec/ run: | cd .. - # git clone https://github.com/lekemula/solargraph-rspec.git - - # pending https://github.com/lekemula/solargraph-rspec/pull/30 - git clone https://github.com/apiology/solargraph-rspec.git + git clone https://github.com/lekemula/solargraph-rspec.git cd solargraph-rspec - git checkout reset_closures - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.1' + ruby-version: 3.4 rubygems: latest bundler-cache: false - name: Install gems run: | - set -x + set -x - cd ../solargraph-rspec - echo "gem 'solargraph', path: '../solargraph'" >> Gemfile - bundle config path ${{ env.BUNDLE_PATH }} - bundle install --jobs 4 --retry 3 - bundle exec appraisal install - # @todo some kind of appraisal/bundle conflict? - # https://github.com/castwide/solargraph/actions/runs/19038710934/job/54369767122?pr=1116 - # /home/runner/work/solargraph/solargraph-rspec/vendor/bundle/ruby/3.1.0/gems/bundler-2.6.9/lib/bundler/runtime.rb:317:in - # `check_for_activated_spec!': You have already activated date - # 3.5.0, but your Gemfile requires date 3.4.1. Prepending - # `bundle exec` to your command may solve - # this. (Gem::LoadError) - bundle exec appraisal update date - # For some reason on ruby 3.1 it defaults to an old version: 1.3.2 - # https://github.com/lekemula/solargraph-rspec/actions/runs/17814581205/job/50645370316?pr=22 - # We update manually to the latest - bundle exec appraisal update rspec-rails + cd ../solargraph-rspec + echo "gem 'solargraph', path: '../solargraph'" >> Gemfile + bundle config path ${{ env.BUNDLE_PATH }} + bundle install --jobs 4 --retry 3 + bundle exec appraisal install + # @todo some kind of appraisal/bundle conflict? + # https://github.com/castwide/solargraph/actions/runs/19038710934/job/54369767122?pr=1116 + # /home/runner/work/solargraph/solargraph-rspec/vendor/bundle/ruby/3.1.0/gems/bundler-2.6.9/lib/bundler/runtime.rb:317:in + # `check_for_activated_spec!': You have already activated date + # 3.5.0, but your Gemfile requires date 3.4.1. Prepending + # `bundle exec` to your command may solve + # this. (Gem::LoadError) + bundle exec appraisal update date + # For some reason on ruby 3.1 it defaults to an old version: 1.3.2 + # https://github.com/lekemula/solargraph-rspec/actions/runs/17814581205/job/50645370316?pr=22 + # We update manually to the latest + bundle exec appraisal update rspec-rails - name: Configure .solargraph.yml run: | cd ../solargraph-rspec @@ -180,6 +178,8 @@ jobs: # solargraph-rails supports Ruby 3.0+ ruby-version: '3.0' bundler-cache: false + # https://github.com/apiology/solargraph/actions/runs/19400815835/job/55508092473?pr=17 + rubygems: latest bundler: latest env: MATRIX_RAILS_VERSION: "7.0" diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index c82ade49b..f761b61aa 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -21,19 +21,50 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4', '4.0'] - rbs-version: ['3.6.1', '3.9.5', '4.0.0.dev.4'] + ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4', '4.0', 'head'] + rbs-version: ['3.6.1', '3.8.1', '3.9.5', '3.10.0', '4.0.0.dev.4'] # Ruby 3.0 doesn't work with RBS 3.9.4 or 4.0.0.dev.4 exclude: + # only include the 3.0 variants we include later - ruby-version: '3.0' - rbs-version: '3.9.5' - - ruby-version: '3.0' - rbs-version: '4.0.0.dev.4' - # Missing require in 'rbs collection update' - hopefully - # fixed in next RBS release + # only include the 3.1 variants we include later + - ruby-version: '3.1' + # only include the 3.2 variants we include later + - ruby-version: '3.2' + # only include the 3.3 variants we include later + - ruby-version: '3.3' + # only include the 3.4 variants we include later + - ruby-version: '3.4' + # only include the 4.0 variants we include later - ruby-version: '4.0' + # Don't exclude 'head' - let's test all RBS versions we + # can there. + # + # + # Just exclude some odd-ball compatibility issues we can't + # work around: + # + # https://github.com/castwide/solargraph/actions/runs/20627923548/job/59241444380?pr=1102 + - ruby-version: 'head' rbs-version: '3.6.1' - - ruby-version: '4.0' + - ruby-version: 'head' + rbs-version: '3.8.1' + - ruby-version: 'head' + rbs-version: '4.0.0.dev.4' + include: + - ruby-version: '3.0' + rbs-version: '3.6.1' + - ruby-version: '3.1' + rbs-version: '3.6.1' + - ruby-version: '3.2' + rbs-version: '3.8.1' + - ruby-version: '3.3' + rbs-version: '3.9.5' + - ruby-version: '3.3' + rbs-version: '3.10.0' + - ruby-version: '3.4' + rbs-version: '3.10.0' + - ruby-version: '3.4' rbs-version: '4.0.0.dev.4' steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f40977acf..e1bf05d7c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -11,7 +11,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + branches: ['*'] permissions: contents: read diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 962ac9bb6..6b1483356 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -215,11 +215,6 @@ Layout/SpaceAfterComma: Layout/SpaceAroundEqualsInParameterDefault: Enabled: false -# This cop supports safe autocorrection (--autocorrect). -Layout/SpaceAroundKeyword: - Exclude: - - 'spec/rbs_map/conversions_spec.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator, EnforcedStyleForRationalLiterals. # SupportedStylesForExponentOperator: space, no_space @@ -457,7 +452,7 @@ Metrics/AbcSize: # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. # AllowedMethods: refine Metrics/BlockLength: - Max: 56 + Max: 57 # Configuration parameters: CountBlocks, CountModifierForms. Metrics/BlockNesting: @@ -468,6 +463,7 @@ Metrics/ClassLength: Exclude: - 'lib/solargraph/api_map.rb' - 'lib/solargraph/language_server/host.rb' + - 'lib/solargraph/pin/method.rb' - 'lib/solargraph/rbs_map/conversions.rb' - 'lib/solargraph/type_checker.rb' @@ -490,7 +486,6 @@ Metrics/ParameterLists: - 'lib/solargraph/pin/callable.rb' - 'lib/solargraph/type_checker.rb' - 'lib/solargraph/yard_map/mapper/to_method.rb' - - 'lib/solargraph/yard_map/to_method.rb' # Configuration parameters: AllowedMethods, AllowedPatterns, Max. Metrics/PerceivedComplexity: @@ -522,7 +517,12 @@ Naming/MemoizedInstanceVariableName: # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to Naming/MethodParameterName: - Enabled: false + Exclude: + - 'lib/solargraph/parser/parser_gem/node_chainer.rb' + - 'lib/solargraph/pin/base.rb' + - 'lib/solargraph/range.rb' + - 'lib/solargraph/source.rb' + - 'lib/solargraph/yard_map/mapper/to_method.rb' # Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates. # AllowedMethods: call @@ -623,7 +623,6 @@ RSpec/ExampleWording: # This cop supports safe autocorrection (--autocorrect). RSpec/ExcessiveDocstringSpacing: Exclude: - - 'spec/rbs_map/conversions_spec.rb' - 'spec/source/chain/call_spec.rb' # This cop supports safe autocorrection (--autocorrect). @@ -639,21 +638,10 @@ RSpec/ExpectActual: RSpec/HookArgument: Enabled: false -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: . -# SupportedStyles: is_expected, should -RSpec/ImplicitExpect: - EnforcedStyle: should - # Configuration parameters: AssignmentOnly. RSpec/InstanceVariable: Enabled: false -# This cop supports safe autocorrection (--autocorrect). -RSpec/LeadingSubject: - Exclude: - - 'spec/rbs_map/conversions_spec.rb' - RSpec/LeakyConstantDeclaration: Exclude: - 'spec/complex_type_spec.rb' @@ -760,7 +748,6 @@ Style/AndOr: # RedundantBlockArgumentNames: blk, block, proc Style/ArgumentsForwarding: Exclude: - - 'lib/solargraph/api_map.rb' - 'lib/solargraph/complex_type.rb' # This cop supports safe autocorrection (--autocorrect). @@ -942,7 +929,6 @@ Style/MapIntoArray: Exclude: - 'lib/solargraph/diagnostics/update_errors.rb' - 'lib/solargraph/parser/parser_gem/node_chainer.rb' - - 'lib/solargraph/type_checker/param_def.rb' # This cop supports unsafe autocorrection (--autocorrect-all). Style/MapToHash: @@ -1016,7 +1002,6 @@ Style/Next: - 'lib/solargraph/parser/parser_gem/node_processors/send_node.rb' - 'lib/solargraph/pin/signature.rb' - 'lib/solargraph/source_map/clip.rb' - - 'lib/solargraph/type_checker/checks.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Strict, AllowedNumbers, AllowedPatterns. @@ -1153,7 +1138,12 @@ Style/SlicingWithRange: # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowModifier. Style/SoleNestedConditional: - Enabled: false + Exclude: + - 'lib/solargraph/complex_type/unique_type.rb' + - 'lib/solargraph/pin/parameter.rb' + - 'lib/solargraph/source.rb' + - 'lib/solargraph/source/source_chainer.rb' + - 'lib/solargraph/type_checker.rb' # This cop supports safe autocorrection (--autocorrect). Style/StderrPuts: @@ -1175,7 +1165,6 @@ Style/StringLiterals: # This cop supports safe autocorrection (--autocorrect). Style/SuperArguments: Exclude: - - 'lib/solargraph/pin/base_variable.rb' - 'lib/solargraph/pin/callable.rb' - 'lib/solargraph/pin/method.rb' - 'lib/solargraph/pin/signature.rb' @@ -1217,7 +1206,12 @@ Style/TrailingCommaInArrayLiteral: # Configuration parameters: EnforcedStyleForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma Style/TrailingCommaInHashLiteral: - Enabled: false + Exclude: + - 'lib/solargraph/pin/callable.rb' + - 'lib/solargraph/pin/closure.rb' + - 'lib/solargraph/pin/parameter.rb' + - 'lib/solargraph/pin/local_variable.rb' + - 'lib/solargraph/rbs_map/conversions.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, IgnoreClassMethods, AllowedMethods. @@ -1265,12 +1259,7 @@ YARD/MismatchName: Enabled: false YARD/TagTypeSyntax: - Exclude: - - 'lib/solargraph/api_map/constants.rb' - - 'lib/solargraph/language_server/host.rb' - - 'lib/solargraph/parser/comment_ripper.rb' - - 'lib/solargraph/pin/method.rb' - - 'lib/solargraph/type_checker.rb' + Enabled: false # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. diff --git a/README.md b/README.md index 7f344c712..2382a25f3 100755 --- a/README.md +++ b/README.md @@ -132,9 +132,7 @@ See [https://solargraph.org/guides](https://solargraph.org/guides) for more tips ### Development -To see more logging when typechecking or running specs, set the -`SOLARGRAPH_LOG` environment variable to `debug` or `info`. `warn` is -the default value. +To see more logging when typechecking or running specs, set the `SOLARGRAPH_LOG` environment variable to `debug` or `info`. `warn` is the default value. Code contributions are always appreciated. Feel free to fork the repo and submit pull requests. Check for open issues that could use help. Start new issues to discuss changes that have a major impact on the code or require large time commitments. diff --git a/Rakefile b/Rakefile index c83d9ab6b..f27abfeb6 100755 --- a/Rakefile +++ b/Rakefile @@ -63,6 +63,7 @@ def undercover status rescue StandardError => e warn "hit error: #{e.message}" + # @sg-ignore Need to add nil check here warn "Backtrace:\n#{e.backtrace.join("\n")}" warn "output: #{output}" puts "Flushing" diff --git a/lib/solargraph.rb b/lib/solargraph.rb index 038e7bccf..2a313c5c1 100755 --- a/lib/solargraph.rb +++ b/lib/solargraph.rb @@ -57,6 +57,7 @@ class InvalidRubocopVersionError < RuntimeError; end # @param type [Symbol] Type of assert. def self.asserts_on?(type) + # @sg-ignore Translate to something flow sensitive typing understands if ENV['SOLARGRAPH_ASSERTS'].nil? || ENV['SOLARGRAPH_ASSERTS'].empty? false elsif ENV['SOLARGRAPH_ASSERTS'] == 'on' diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index cc3031ea5..72effa759 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -24,9 +24,17 @@ class ApiMap attr_reader :missing_docs # @param pins [Array] - def initialize pins: [] + # @param loose_unions [Boolean] if true, a potential type can be + # inferred if ANY of the UniqueTypes in the base chain's + # ComplexType match it. If false, every single UniqueTypes in + # the base must be ALL able to independently provide this + # type. The former is useful during completion, but the + # latter is best for typechecking at higher levels. + # + def initialize pins: [], loose_unions: true @source_map_hash = {} @cache = Cache.new + @loose_unions = loose_unions index pins end @@ -52,6 +60,8 @@ def hash equality_fields.hash end + attr_reader :loose_unions + def to_s self.class.to_s end @@ -114,7 +124,7 @@ def catalog bench # that this overload of 'protected' will typecheck @sg-ignore # @sg-ignore protected def equality_fields - [self.class, @source_map_hash, conventions_environ, @doc_map, @unresolved_requires] + [self.class, @source_map_hash, conventions_environ, @doc_map, @unresolved_requires, @missing_docs, @loose_unions] end # @return [DocMap] @@ -142,9 +152,10 @@ def core_pins @@core_map.pins end - # @param name [String] + # @param name [String, nil] # @return [YARD::Tags::MacroDirective, nil] def named_macro name + # @sg-ignore Need to add nil check here store.named_macros[name] end @@ -180,10 +191,11 @@ def clip_at filename, position # Create an ApiMap with a workspace in the specified directory. # # @param directory [String] + # @param loose_unions [Boolean] See #initialize # # @return [ApiMap] - def self.load directory - api_map = new + def self.load directory, loose_unions: true + api_map = new(loose_unions: loose_unions) workspace = Solargraph::Workspace.new(directory) # api_map.catalog Bench.new(workspace: workspace) library = Library.new(workspace) @@ -192,7 +204,7 @@ def self.load directory api_map end - # @param out [IO, nil] + # @param out [StringIO, IO, nil] # @return [void] def cache_all!(out) @doc_map.cache_all!(out) @@ -200,7 +212,7 @@ def cache_all!(out) # @param gemspec [Gem::Specification] # @param rebuild [Boolean] - # @param out [IO, nil] + # @param out [StringIO, IO, nil] # @return [void] def cache_gem(gemspec, rebuild: false, out: nil) @doc_map.cache(gemspec, rebuild: rebuild, out: out) @@ -215,18 +227,19 @@ class << self # # # @param directory [String] - # @param out [IO] The output stream for messages + # @param out [IO, StringIO, nil] The output stream for messages + # @param loose_unions [Boolean] See #initialize # # @return [ApiMap] - def self.load_with_cache directory, out - api_map = load(directory) + def self.load_with_cache directory, out = $stdout, loose_unions: true + api_map = load(directory, loose_unions: loose_unions) if api_map.uncached_gemspecs.empty? logger.info { "All gems cached for #{directory}" } return api_map end api_map.cache_all!(out) - load(directory) + load(directory, loose_unions: loose_unions) end # @return [Array] @@ -334,16 +347,34 @@ def get_instance_variable_pins(namespace, scope = :instance) result.concat store.get_instance_variables(namespace, scope) sc_fqns = namespace while (sc = store.get_superclass(sc_fqns)) + # @sg-ignore flow sensitive typing needs to handle "if foo = bar" sc_fqns = store.constants.dereference(sc) result.concat store.get_instance_variables(sc_fqns, scope) end result end - # @sg-ignore Missing @return tag for Solargraph::ApiMap#visible_pins - # @see Solargraph::Parser::FlowSensitiveTyping#visible_pins - def visible_pins(*args, **kwargs, &blk) - Solargraph::Parser::FlowSensitiveTyping.visible_pins(*args, **kwargs, &blk) + # Find a variable pin by name and where it is used. + # + # Resolves our most specific view of this variable's type by + # preferring pins created by flow-sensitive typing when we have + # them based on the Closure and Location. + # + # @param candidates [Array] + # @param name [String] + # @param closure [Pin::Closure] + # @param location [Location] + # + # @return [Pin::BaseVariable, nil] + def var_at_location(candidates, name, closure, location) + with_correct_name = candidates.select { |pin| pin.name == name} + vars_at_location = with_correct_name.reject do |pin| + # visible_at? excludes the starting position, but we want to + # include it for this purpose + (!pin.visible_at?(closure, location) && !pin.starts_at?(location)) + end + + vars_at_location.inject(&:combine_with) end # Get an array of class variable pins for a namespace. @@ -589,6 +620,7 @@ def locate_pins location # @param cursor [Source::Cursor] # @return [SourceMap::Clip] def clip cursor + # @sg-ignore Need to add nil check here raise FileNotFoundError, "ApiMap did not catalog #{cursor.filename}" unless source_map_hash.key?(cursor.filename) SourceMap::Clip.new(self, cursor) @@ -636,12 +668,16 @@ 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 We should understand reassignment of variable to new type return false if sup.literal? && sub.literal? && sup.to_s != sub.to_s + # @sg-ignore We should understand reassignment of variable to new type sup = sup.simplify_literals.to_s + # @sg-ignore We should understand reassignment of variable to new type sub = sub.simplify_literals.to_s return true if sup == sub sc_fqns = sub while (sc = store.get_superclass(sc_fqns)) + # @sg-ignore flow sensitive typing needs to handle "if foo = bar" sc_new = store.constants.dereference(sc) # Cyclical inheritance is invalid return false if sc_new == sc_fqns @@ -669,6 +705,7 @@ def resolve_method_aliases pins, visibility = [: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 @@ -769,6 +806,7 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false if scope == :instance store.get_includes(fqns).reverse.each do |ref| in_tag = dereference(ref) + # @sg-ignore Need to add nil check here result.concat inner_get_methods_from_reference(in_tag, namespace_pin, rooted_type, scope, visibility, deep, skip, true) end rooted_sc_tag = qualify_superclass(rooted_tag) @@ -847,6 +885,7 @@ def prefer_non_nil_variables pins # @return [Pin::Method, nil] def resolve_method_alias(alias_pin) ancestors = store.get_ancestors(alias_pin.full_context.reduce_class_type.tag) + # @type [Pin::Method, nil] original = nil # Search each ancestor for the original method @@ -870,7 +909,6 @@ def resolve_method_alias(alias_pin) break if original end - # @sg-ignore ignore `received nil` for original create_resolved_alias_pin(alias_pin, original) if original end diff --git a/lib/solargraph/api_map/cache.rb b/lib/solargraph/api_map/cache.rb index 0052d91ea..c69d223b4 100644 --- a/lib/solargraph/api_map/cache.rb +++ b/lib/solargraph/api_map/cache.rb @@ -8,7 +8,7 @@ def initialize @methods = {} # @type [Hash{String, Array => Array}] @constants = {} - # @type [Hash{String => String}] + # @type [Hash{String => String, nil}] @qualified_namespaces = {} # @type [Hash{String => Pin::Method}] @receiver_definitions = {} @@ -61,14 +61,14 @@ def set_constants namespace, contexts, value # @param name [String] # @param context [String] - # @return [String] + # @return [String, nil] def get_qualified_namespace name, context @qualified_namespaces["#{name}|#{context}"] end # @param name [String] # @param context [String] - # @param value [String] + # @param value [String, nil] # @return [void] def set_qualified_namespace name, context, value @qualified_namespaces["#{name}|#{context}"] = value diff --git a/lib/solargraph/api_map/constants.rb b/lib/solargraph/api_map/constants.rb index 8dcaf1945..e6512f5d3 100644 --- a/lib/solargraph/api_map/constants.rb +++ b/lib/solargraph/api_map/constants.rb @@ -27,9 +27,11 @@ 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) + # @sg-ignore Need to add nil check here return store.get_path_pins(name[2..]).first&.path if name.start_with?('::') flat = gates.flatten @@ -86,6 +88,7 @@ def qualify_type type, *gates return unless fqns pin = store.get_path_pins(fqns).first if pin.is_a?(Pin::Constant) + # @sg-ignore Need to add nil check here const = Solargraph::Parser::NodeMethods.unpack_name(pin.assignment) return unless const fqns = resolve(const, *pin.gates) @@ -105,6 +108,7 @@ def clear # @param name [String] # @param gates [Array] + # @sg-ignore Should handle redefinition of types in simple contexts # @return [String, nil] def resolve_and_cache name, gates cached_resolve[[name, gates]] = :in_process @@ -125,6 +129,7 @@ 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 @@ -138,7 +143,7 @@ def resolve_uncached name, gates # @param name [String] # @param gates [Array] # @param internal [Boolean] True if the name is not the last in the namespace - # @return [Array(Object, Array)] + # @return [Array(String, Array), Array(nil, Array), String] def complex_resolve name, gates, internal resolved = nil gates.each.with_index do |gate, idx| @@ -165,6 +170,7 @@ def simple_resolve name, gate, internal here = "#{gate}::#{name}".sub(/^::/, '').sub(/::$/, '') pin = store.get_path_pins(here).first if pin.is_a?(Pin::Constant) && internal + # @sg-ignore Need to add nil check here const = Solargraph::Parser::NodeMethods.unpack_name(pin.assignment) return unless const resolve(const, pin.gates) @@ -197,13 +203,14 @@ def cached_collect # will start the search in the specified context until it finds a # match for the namespace. # - # @param namespace [String, nil] The namespace to + # @param namespace [String] The namespace to # match # @param context_namespace [String] The context namespace in which the # tag was referenced; start from here to resolve the name # @return [String, nil] fully qualified namespace def qualify_namespace namespace, context_namespace = '' if namespace.start_with?('::') + # @sg-ignore Need to add nil check here inner_qualify(namespace[2..], '', Set.new) else inner_qualify(namespace, context_namespace, Set.new) @@ -249,7 +256,7 @@ def inner_qualify name, root, skip end end - # @param fqns [String] + # @param fqns [String, nil] # @param visibility [Array] # @param skip [Set] # @return [Array] @@ -259,17 +266,20 @@ def inner_get_constants fqns, visibility, skip result = [] store.get_prepends(fqns).each do |pre| + # @sg-ignore Need to add nil check here pre_fqns = resolve(pre.name, pre.closure.gates - skip.to_a) result.concat inner_get_constants(pre_fqns, [:public], skip) end result.concat(store.get_constants(fqns, visibility).sort { |a, b| a.name <=> b.name }) store.get_includes(fqns).each do |pin| + # @sg-ignore Need to add nil check here inc_fqns = resolve(pin.name, pin.closure.gates - skip.to_a) result.concat inner_get_constants(inc_fqns, [:public], skip) end sc_ref = store.get_superclass(fqns) if sc_ref fqsc = dereference(sc_ref) + # @sg-ignore Need to add nil check here result.concat inner_get_constants(fqsc, [:public], skip) unless %w[Object BasicObject].include?(fqsc) end result diff --git a/lib/solargraph/api_map/index.rb b/lib/solargraph/api_map/index.rb index 944f02c79..48cf05706 100644 --- a/lib/solargraph/api_map/index.rb +++ b/lib/solargraph/api_map/index.rb @@ -40,13 +40,13 @@ def path_pin_hash # @param klass [Class>] # @return [Set>] def pins_by_class klass - # @type [Set] + # @type [Set>] s = Set.new # @sg-ignore need to support destructured args in blocks @pin_select_cache[klass] ||= pin_class_hash.each_with_object(s) { |(key, o), n| n.merge(o) if key <= klass } end - # @return [Hash{String => Array}] + # @return [Hash{String => Array}] def include_references # @param h [String] # @param k [Array] @@ -60,21 +60,21 @@ def include_reference_pins @include_reference_pins ||= Hash.new { |h, k| h[k] = [] } end - # @return [Hash{String => Array}] + # @return [Hash{String => Array}] def extend_references # @param h [String] # @param k [Array] @extend_references ||= Hash.new { |h, k| h[k] = [] } end - # @return [Hash{String => Array}] + # @return [Hash{String => Array}] def prepend_references # @param h [String] # @param k [Array] @prepend_references ||= Hash.new { |h, k| h[k] = [] } end - # @return [Hash{String => Array}] + # @return [Hash{String => Array}] def superclass_references # @param h [String] # @param k [Array] @@ -138,7 +138,7 @@ def catalog new_pins # @generic T # @param klass [Class>] - # @param hash [Hash{String => generic}] + # @param hash [Hash{String => Array>}] # # @return [void] def map_references klass, hash @@ -150,6 +150,7 @@ def map_references klass, hash # @return [void] def map_overrides + # @todo should complain when type for 'ovr' is not provided # @param ovr [Pin::Reference::Override] pins_by_class(Pin::Reference::Override).each do |ovr| logger.debug { "ApiMap::Index#map_overrides: Looking at override #{ovr} for #{ovr.name}" } @@ -160,16 +161,27 @@ def map_overrides path_pin_hash[pin.path.sub(/#initialize/, '.new')].first end (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 + # @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 new_pin.docstring.delete_tags tag if new_pin end ovr.tags.each do |tag| pin.docstring.add_tag(tag) redefine_return_type pin, tag - if new_pin - new_pin.docstring.add_tag(tag) - redefine_return_type new_pin, tag - end + pin.reset_generated! + + next unless new_pin + + new_pin.docstring.add_tag(tag) + redefine_return_type new_pin, tag + new_pin.reset_generated! end end end @@ -186,7 +198,6 @@ def redefine_return_type pin, tag pin.signatures.each do |sig| sig.instance_variable_set(:@return_type, ComplexType.try_parse(tag.type)) end - pin.reset_generated! end end end diff --git a/lib/solargraph/api_map/source_to_yard.rb b/lib/solargraph/api_map/source_to_yard.rb index 55452582b..05010c636 100644 --- a/lib/solargraph/api_map/source_to_yard.rb +++ b/lib/solargraph/api_map/source_to_yard.rb @@ -6,6 +6,9 @@ module SourceToYard # Get the YARD CodeObject at the specified path. # + # @sg-ignore Declared return type generic, nil does not match + # inferred type ::YARD::CodeObjects::Base, nil for + # Solargraph::ApiMap::SourceToYard#code_object_at # @generic T # @param path [String] # @param klass [Class>] @@ -34,13 +37,17 @@ def rake_yard store if pin.type == :class # @param obj [YARD::CodeObjects::RootObject] code_object_map[pin.path] ||= YARD::CodeObjects::ClassObject.new(root_code_object, pin.path) { |obj| + # @sg-ignore flow sensitive typing needs to handle attrs next if pin.location.nil? || pin.location.filename.nil? + # @sg-ignore flow sensitive typing needs to handle attrs obj.add_file(pin.location.filename, pin.location.range.start.line, !pin.comments.empty?) } else # @param obj [YARD::CodeObjects::RootObject] code_object_map[pin.path] ||= YARD::CodeObjects::ModuleObject.new(root_code_object, pin.path) { |obj| + # @sg-ignore flow sensitive typing needs to handle attrs next if pin.location.nil? || pin.location.filename.nil? + # @sg-ignore flow sensitive typing needs to handle attrs obj.add_file(pin.location.filename, pin.location.range.start.line, !pin.comments.empty?) } end @@ -57,7 +64,6 @@ def rake_yard store code_object = code_object_map[ref.type.to_s] next unless code_object extend_object.class_mixins.push code_object - # @todo add spec showing why this next line is necessary extend_object.instance_mixins.push code_object end end @@ -67,14 +73,20 @@ def rake_yard store next end + # @sg-ignore Need to add nil check here # @param obj [YARD::CodeObjects::RootObject] code_object_map[pin.path] ||= YARD::CodeObjects::MethodObject.new(code_object_at(pin.namespace, YARD::CodeObjects::NamespaceObject), pin.name, pin.scope) { |obj| + # @sg-ignore flow sensitive typing needs to handle attrs next if pin.location.nil? || pin.location.filename.nil? + # @sg-ignore flow sensitive typing needs to handle attrs obj.add_file pin.location.filename, pin.location.range.start.line } method_object = code_object_at(pin.path, YARD::CodeObjects::MethodObject) + # @sg-ignore Need to add nil check here method_object.docstring = pin.docstring + # @sg-ignore Need to add nil check here method_object.visibility = pin.visibility || :public + # @sg-ignore Need to add nil check here method_object.parameters = pin.parameters.map do |p| [p.full_name, p.asgn_code] end diff --git a/lib/solargraph/api_map/store.rb b/lib/solargraph/api_map/store.rb index bc829ba5a..122b37666 100644 --- a/lib/solargraph/api_map/store.rb +++ b/lib/solargraph/api_map/store.rb @@ -34,6 +34,7 @@ def update *pinsets @fqns_pins_map = nil return catalog(pinsets) if changed == 0 + # @sg-ignore Need to add nil check here pinsets[changed..].each_with_index do |pins, idx| @pinsets[changed + idx] = pins @indexes[changed + idx] = if pins.empty? @@ -71,7 +72,6 @@ def get_constants fqns, visibility = [:public] # @return [Enumerable] def get_methods fqns, scope: :instance, visibility: [:public] all_pins = namespace_children(fqns).select do |pin| - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 pin.is_a?(Pin::Method) && pin.scope == scope && visibility.include?(pin.visibility) end GemPins.combine_method_pins_by_path(all_pins) @@ -80,8 +80,8 @@ def get_methods fqns, scope: :instance, visibility: [:public] BOOLEAN_SUPERCLASS_PIN = Pin::Reference::Superclass.new(name: 'Boolean', closure: Pin::ROOT_PIN, source: :solargraph) OBJECT_SUPERCLASS_PIN = Pin::Reference::Superclass.new(name: 'Object', closure: Pin::ROOT_PIN, source: :solargraph) - # @param fqns [String] - # @return [Pin::Reference::Superclass] + # @param fqns [String, nil] + # @return [Pin::Reference::Superclass, nil] def get_superclass fqns return nil if fqns.nil? || fqns.empty? return BOOLEAN_SUPERCLASS_PIN if %w[TrueClass FalseClass].include?(fqns) @@ -126,7 +126,7 @@ def get_path_pins path index.path_pin_hash[path] end - # @param fqns [String] + # @param fqns [String, nil] # @param scope [Symbol] :class or :instance # @return [Enumerable] def get_instance_variables(fqns, scope = :instance) @@ -199,7 +199,7 @@ def pins_by_class klass index.pins_by_class klass end - # @param fqns [String] + # @param fqns [String, nil] # @return [Array] def fqns_pins fqns return [] if fqns.nil? @@ -244,8 +244,11 @@ 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 @@ -275,7 +278,7 @@ def index # @param pinsets [Array>] # - # @return [void] + # @return [true] def catalog pinsets @pinsets = pinsets # @type [Array] @@ -308,7 +311,7 @@ def symbols index.pins_by_class(Pin::Symbol) end - # @return [Hash{String => Array}] + # @return [Hash{String => Array}] def superclass_references index.superclass_references end diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 1b9f96dfa..269912ab0 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -9,6 +9,7 @@ class ComplexType # include TypeMethods include Equality + autoload :Conformance, 'solargraph/complex_type/conformance' autoload :TypeMethods, 'solargraph/complex_type/type_methods' autoload :UniqueType, 'solargraph/complex_type/unique_type' @@ -19,13 +20,15 @@ def initialize types = [UniqueType::UNDEFINED] items = types.flat_map(&:items).uniq(&:to_s) if items.any? { |i| i.name == 'false' } && items.any? { |i| i.name == 'true' } items.delete_if { |i| i.name == 'false' || i.name == 'true' } - items.unshift(ComplexType::BOOLEAN) + items.unshift(UniqueType::BOOLEAN) end + # @type [Array] items = [UniqueType::UNDEFINED] if items.any?(&:undefined?) + # @todo shouldn't need this cast - if statement above adds an 'Array' type + # @type [Array] @items = items end - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields [self.class, items] end @@ -44,7 +47,7 @@ def qualify api_map, *gates end # @param generics_to_resolve [Enumerable]] - # @param context_type [UniqueType, nil] + # @param context_type [ComplexType, ComplexType::UniqueType, nil] # @param resolved_generic_values [Hash{String => ComplexType}] Added to as types are encountered or resolved # @return [self] def resolve_generics_from_context generics_to_resolve, context_type, resolved_generic_values: {} @@ -65,7 +68,7 @@ def to_rbs (@items.length > 1 ? ')' : '')) end - # @param dst [ComplexType] + # @param dst [ComplexType, ComplexType::UniqueType] # @return [ComplexType] def self_to_type dst object_type_dst = dst.reduce_class_type @@ -76,9 +79,13 @@ def self_to_type dst end # @yieldparam [UniqueType] + # @yieldreturn [UniqueType] # @return [Array] - def map &block - @items.map &block + # @sg-ignore Declared return type + # ::Array<::Solargraph::ComplexType::UniqueType> does not match + # inferred type ::Array<::Proc> for Solargraph::ComplexType#map + def map(&block) + @items.map(&block) end # @yieldparam [UniqueType] @@ -194,6 +201,60 @@ def desc rooted_tags end + # @param api_map [ApiMap] + # @param expected [ComplexType, ComplexType::UniqueType] + # @param situation [:method_call, :return_type, :assignment] + # @param allow_subtype_skew [Boolean] if false, check if any + # subtypes of the expected type match the inferred type + # @param allow_reverse_match [Boolean] if true, check if any subtypes + # of the expected type match the inferred type + # @param allow_empty_params [Boolean] if true, allow a general + # inferred type without parameters to conform to a more specific + # expected type + # @param allow_any_match [Boolean] if true, any unique type + # matched in the inferred qualifies as a match + # @param allow_undefined [Boolean] if true, treat undefined as a + # wildcard that matches anything + # @param rules [Array<:allow_subtype_skew, :allow_empty_params, :allow_reverse_match, :allow_any_match, :allow_undefined, :allow_unresolved_generic, :allow_unmatched_interface>] + # @param variance [:invariant, :covariant, :contravariant] + # @return [Boolean] + def conforms_to?(api_map, expected, + situation, + rules = [], + variance: erased_variance(situation)) + 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? + + if rules.include? :allow_any_match + inferred.any? do |inf| + inf.conforms_to?(api_map, expected, situation, rules, + variance: variance) + end + else + inferred.all? do |inf| + inf.conforms_to?(api_map, expected, situation, rules, + variance: variance) + end + end + end + + # @param api_map [ApiMap] + # @param expected [ComplexType, UniqueType] + # @param inferred [ComplexType, UniqueType] + # @return [Boolean] + def duck_types_match? api_map, expected, inferred + raise ArgumentError, 'Expected type must be duck type' unless expected.duck_type? + 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? + end + true + end + # @return [String] def rooted_tags map(&:rooted_tag).join(', ') @@ -252,6 +313,13 @@ def nullable? @items.any?(&:nil_type?) end + # @return [ComplexType] + def without_nil + new_items = @items.reject(&:nil_type?) + return ComplexType::UNDEFINED if new_items.empty? + ComplexType.new(new_items) + end + # @return [Array] def all_params @items.first.all_params || [] @@ -274,6 +342,13 @@ def all_rooted? all?(&:all_rooted?) end + # @param other [ComplexType, UniqueType] + def erased_version_of?(other) + return false if items.length != 1 || other.items.length != 1 + + @items.first.erased_version_of?(other.items.first) + end + # every top-level type has resolved to be fully qualified; see # #all_rooted? to check their subtypes as well def rooted? @@ -286,6 +361,40 @@ def rooted? @items.all?(&:rooted?) end + # @param exclude_types [ComplexType, nil] + # @param api_map [ApiMap] + # @return [ComplexType, self] + def exclude exclude_types, api_map + return self if exclude_types.nil? + + types = items - exclude_types.items + types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + ComplexType.new(types) + end + + # @see https://en.wikipedia.org/wiki/Intersection_type + # + # @param intersection_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? + 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) + types << ut + end + end + end + types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + ComplexType.new(types) + end + protected # @return [ComplexType] @@ -320,17 +429,18 @@ class << self # # @todo Need ability to use a literal true as a type below # # @param partial [Boolean] True if the string is part of a another type # # @return [Array] - # @todo To be able to select the right signature above, + # @sg-ignore To be able to select the right signature above, # Chain::Call needs to know the decl type (:arg, :optarg, # :kwarg, etc) of the arguments given, instead of just having # an array of Chains as the arguments. def parse *strings, partial: false - # @type [Hash{Array => ComplexType}] + # @type [Hash{Array => ComplexType, Array}] @cache ||= {} unless partial cached = @cache[strings] return cached unless cached.nil? end + # @types [Array] types = [] key_types = nil strings.each do |type_string| @@ -351,6 +461,7 @@ def parse *strings, partial: false 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 diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb new file mode 100644 index 000000000..c2a48b255 --- /dev/null +++ b/lib/solargraph/complex_type/conformance.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module Solargraph + class ComplexType + # Checks whether a type can be used in a given situation + class Conformance + # @param api_map [ApiMap] + # @param inferred [ComplexType::UniqueType] + # @param expected [ComplexType::UniqueType] + # @param situation [:method_call, :return_type] + # @param rules [Array<:allow_subtype_skew, :allow_empty_params, :allow_reverse_match, + # :allow_any_match, :allow_undefined, :allow_unresolved_generic, + # :allow_unmatched_interface>] + # @param variance [:invariant, :covariant, :contravariant] + def initialize api_map, inferred, expected, + situation = :method_call, rules = [], + variance: inferred.erased_variance(situation) + @api_map = api_map + @inferred = inferred + @expected = expected + @situation = situation + @rules = rules + @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 + + def conforms_to_unique_type? + unless expected.is_a?(UniqueType) + # :nocov: + raise "Expected type must be a UniqueType, got #{expected.class} in #{expected.inspect}" + # :nocov: + end + + return true if ignore_interface? + return true if conforms_via_reverse_match? + + downcast_inferred = inferred.downcast_to_literal_if_possible + downcast_expected = expected.downcast_to_literal_if_possible + if (downcast_inferred.name != inferred.name) || (downcast_expected.name != expected.name) + return with_new_types(downcast_inferred, downcast_expected).conforms_to_unique_type? + end + + if rules.include?(:allow_subtype_skew) && !expected.all_params.empty? + # parameters are not considered in this case + return with_new_types(inferred, expected.erase_parameters).conforms_to_unique_type? + end + + return with_new_types(inferred.erase_parameters, expected).conforms_to_unique_type? if only_inferred_parameters? + + return conforms_via_stripped_expected_parameters? if can_strip_expected_parameters? + + return true if inferred == expected + + return false unless erased_type_conforms? + + return true if inferred.all_params.empty? && rules.include?(:allow_empty_params) + + # at this point we know the erased type is fine - time to look at parameters + + # there's an implicit 'any' on the expectation parameters + # if there are none specified + return true if expected.all_params.empty? + + return false unless key_types_conform? + + subtypes_conform? + end + + private + + def only_inferred_parameters? + !expected.parameters? && inferred.parameters? + end + + 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)) + end + + def can_strip_expected_parameters? + expected.parameters? && !inferred.parameters? && rules.include?(:allow_empty_params) + end + + def conforms_via_reverse_match? + return false unless rules.include? :allow_reverse_match + + expected.conforms_to?(api_map, inferred, situation, + rules - [:allow_reverse_match], + variance: variance) + end + + def erased_type_conforms? + case variance + when :invariant + return false unless inferred.name == expected.name + when :covariant + # covariant: we can pass in a more specific type + # we contain the expected mix-in, or we have a more specific type + return false unless api_map.type_include?(inferred.name, expected.name) || + api_map.super_and_sub?(expected.name, inferred.name) || + inferred.name == expected.name + when :contravariant + # contravariant: we can pass in a more general type + # we contain the expected mix-in, or we have a more general type + return false unless api_map.type_include?(inferred.name, expected.name) || + api_map.super_and_sub?(inferred.name, expected.name) || + inferred.name == expected.name + else + # :nocov: + raise "Unknown variance: #{variance.inspect}" + # :nocov: + end + true + end + + def key_types_conform? + return true if expected.key_types.empty? + + return false if inferred.key_types.empty? + + unless ComplexType.new(inferred.key_types).conforms_to?(api_map, + ComplexType.new(expected.key_types), + situation, + rules, + variance: inferred.parameter_variance(situation)) + return false + end + + true + end + + def subtypes_conform? + return true if expected.subtypes.empty? + + return true if expected.subtypes.any?(&:undefined?) && rules.include?(:allow_undefined) + + return true if inferred.subtypes.any?(&:undefined?) && rules.include?(:allow_undefined) + + return true if inferred.subtypes.all?(&:generic?) && rules.include?(:allow_unresolved_generic) + + return true if expected.subtypes.all?(&:generic?) && rules.include?(:allow_unresolved_generic) + + return false if inferred.subtypes.empty? + + ComplexType.new(inferred.subtypes).conforms_to?(api_map, + ComplexType.new(expected.subtypes), + situation, + rules, + variance: inferred.parameter_variance(situation)) + end + + # @return [self] + # @param inferred [ComplexType::UniqueType] + # @param expected [ComplexType::UniqueType] + def with_new_types inferred, expected + self.class.new(api_map, inferred, expected, situation, rules, variance: variance) + end + + attr_reader :api_map, :inferred, :expected, :situation, :rules, :variance + end + end +end diff --git a/lib/solargraph/complex_type/type_methods.rb b/lib/solargraph/complex_type/type_methods.rb index a0e99cdee..687d6b705 100644 --- a/lib/solargraph/complex_type/type_methods.rb +++ b/lib/solargraph/complex_type/type_methods.rb @@ -73,6 +73,18 @@ def undefined? name == 'undefined' end + # Variance of the type ignoring any type parameters + # @return [Symbol] + # @param situation [Symbol] The situation in which the variance is being considered. + def erased_variance situation = :method_call + # :nocov: + unless %i[method_call return_type assignment].include?(situation) + raise "Unknown situation: #{situation.inspect}" + end + # :nocov: + :covariant + end + # @param generics_to_erase [Enumerable] # @return [self] def erase_generics(generics_to_erase) @@ -218,7 +230,9 @@ def qualify api_map, context = '' end # @yieldparam [UniqueType] - # @return [Enumerator] + # @return [void] + # @overload each_unique_type() + # @return [Enumerator] def each_unique_type &block return enum_for(__method__) unless block_given? yield self diff --git a/lib/solargraph/complex_type/unique_type.rb b/lib/solargraph/complex_type/unique_type.rb index e490bff35..86f6c28bf 100644 --- a/lib/solargraph/complex_type/unique_type.rb +++ b/lib/solargraph/complex_type/unique_type.rb @@ -11,7 +11,6 @@ class UniqueType attr_reader :all_params, :subtypes, :key_types - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields [@name, @all_params, @subtypes, @key_types] end @@ -46,6 +45,7 @@ def self.parse name, substring = '', make_rooted: nil parameters_type = nil unless substring.empty? 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]) if parameters_type == :hash raise ComplexTypeError, "Bad hash type: name=#{name}, substring=#{substring}" unless !subs.is_a?(ComplexType) and subs.length == 2 and !subs[0].is_a?(UniqueType) and !subs[1].is_a?(UniqueType) @@ -62,6 +62,7 @@ def self.parse name, substring = '', make_rooted: nil 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 @@ -109,6 +110,44 @@ def simplify_literals end end + # @param exclude_types [ComplexType, nil] + # @param api_map [ApiMap] + # @return [ComplexType, self] + def exclude exclude_types, api_map + return self if exclude_types.nil? + + types = items - exclude_types.items + types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + ComplexType.new(types) + end + + # @see https://en.wikipedia.org/wiki/Intersection_type + # + # @param intersection_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? + types = [] + # try to find common types via conformance + items.each do |ut| + intersection_type.each do |int_type| + if ut.can_assign?(api_map, int_type) + types << int_type + elsif int_type.can_assign?(api_map, ut) + types << ut + end + end + end + types = [ComplexType::UniqueType::UNDEFINED] if types.empty? + ComplexType.new(types) + end + + def simplifyable_literal? + literal? && name != 'nil' + end + def literal? non_literal_name != name end @@ -118,6 +157,13 @@ def non_literal_name @non_literal_name ||= determine_non_literal_name end + # @return [self] + def without_nil + return UniqueType::UNDEFINED if nil_type? + + self + end + # @return [String] def determine_non_literal_name # https://github.com/ruby/rbs/blob/master/docs/syntax.md @@ -131,6 +177,7 @@ def determine_non_literal_name return 'NilClass' if name == 'nil' return 'Boolean' if ['true', 'false'].include?(name) return 'Symbol' if name[0] == ':' + # @sg-ignore Need to add nil check here return 'String' if ['"', "'"].include?(name[0]) return 'Integer' if name.match?(/^-?\d+$/) name @@ -156,10 +203,86 @@ def ==(other) eql?(other) end + # https://www.playfulpython.com/type-hinting-covariance-contra-variance/ + + # "[Expected] type variables that are COVARIANT can be substituted with + # a more specific [inferred] type without causing errors" + # + # "[Expected] type variables that are CONTRAVARIANT can be substituted + # with a more general [inferred] type without causing errors" + # + # "[Expected] types where neither is possible are INVARIANT" + # + # @param _situation [:method_call, :return_type] + # @param default [Symbol] The default variance to return if the type is not one of the special cases + # + # @return [:invariant, :covariant, :contravariant] + def parameter_variance _situation, default = :covariant + # @todo RBS can specify variance - maybe we can use that info + # and also let folks specify? + # + # Array/Set: ideally invariant, since we don't know if user is + # going to add new stuff into it or read it. But we don't + # have a way to specify, so we use covariant + # Enumerable: covariant: can't be changed, so we can pass + # in more specific subtypes + # Hash: read-only would be covariant, read-write would be + # invariant if we could distinguish that - should default to + # covariant + # contravariant?: Proc - can be changed, so we can pass + # in less specific super types + if ['Hash', 'Tuple', 'Array', 'Set', 'Enumerable'].include?(name) && fixed_parameters? + :covariant + else + default + end + end + + # Whether this is an RBS interface like _ToAry or _Each. + def interface? + name.start_with?('_') + end + + # @param other [UniqueType] + def erased_version_of?(other) + name == other.name && (all_params.empty? || all_params.all?(&:undefined?)) + end + + # @param api_map [ApiMap] + # @param expected [ComplexType::UniqueType, ComplexType] + # @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] + def conforms_to?(api_map, expected, situation, rules = [], + variance: erased_variance(situation)) + return true if undefined? && rules.include?(:allow_undefined) + + # @todo teach this to validate duck types as inferred type + return true if duck_type? + + # complex types as expectations are unions - we only need to + # match one of their unique types + expected.any? do |expected_unique_type| + # :nocov: + unless expected_unique_type.instance_of?(UniqueType) + raise "Expected type must be a UniqueType, got #{expected_unique_type.class} in #{expected.inspect}" + end + # :nocov: + conformance = Conformance.new(api_map, self, expected_unique_type, situation, + rules, variance: variance) + conformance.conforms_to_unique_type? + end + end + def hash [self.class, @name, @key_types, @sub_types, @rooted, @all_params, @parameters_type].hash end + # @return [self] + def erase_parameters + UniqueType.new(name, rooted: rooted?, parameters_type: parameters_type) + end + # @return [Array] def items [self] @@ -181,6 +304,7 @@ def desc rooted_tags end + # @sg-ignore Need better if/elseanalysis # @return [String] def to_rbs if duck_type? @@ -190,7 +314,7 @@ def to_rbs elsif name.downcase == 'nil' 'nil' elsif name == GENERIC_TAG_NAME - all_params.first.name + all_params.first&.name elsif ['Class', 'Module'].include?(name) rbs_name elsif ['Tuple', 'Array'].include?(name) && fixed_parameters? @@ -242,8 +366,12 @@ def generic? name == GENERIC_TAG_NAME || all_params.any?(&:generic?) end + def nullable? + nil_type? + end + # @param api_map [ApiMap] The ApiMap that performs qualification - # @param atype [ComplexType] type which may be assigned to this type + # @param atype [ComplexType, self] type which may be assigned to this type def can_assign?(api_map, atype) logger.debug { "UniqueType#can_assign?(self=#{rooted_tags.inspect}, atype=#{atype.rooted_tags.inspect})" } downcasted_atype = atype.downcast_to_literal_if_possible @@ -254,21 +382,29 @@ def can_assign?(api_map, atype) out end + # @yieldreturn [Boolean] + def all? &block + block.yield self + end + # @return [UniqueType] def downcast_to_literal_if_possible SINGLE_SUBTYPE.fetch(rooted_tag, self) end # @param generics_to_resolve [Enumerable] - # @param context_type [UniqueType, nil] + # @param context_type [ComplexType, UniqueType, nil] # @param resolved_generic_values [Hash{String => ComplexType, ComplexType::UniqueType}] Added to as types are encountered or resolved # @return [UniqueType, ComplexType] def resolve_generics_from_context generics_to_resolve, context_type, resolved_generic_values: {} if name == ComplexType::GENERIC_TAG_NAME type_param = subtypes.first&.name + # @sg-ignore flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) 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) resolved_generic_values[type_param] = context_type end if new_binding @@ -276,6 +412,7 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge complex_type.resolve_generics_from_context(generics_to_resolve, nil, 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 @@ -286,7 +423,7 @@ def resolve_generics_from_context generics_to_resolve, context_type, resolved_ge end # @param generics_to_resolve [Enumerable] - # @param context_type [UniqueType, nil] + # @param context_type [UniqueType, ComplexType, nil] # @param resolved_generic_values [Hash{String => ComplexType}] # @yieldreturn [Array] # @return [Array] @@ -337,6 +474,7 @@ def resolve_generics definitions, context_type ComplexType::UNDEFINED end else + # @sg-ignore Need to add nil check here context_type.all_params[idx] || definitions.generic_defaults[generic_name] || ComplexType::UNDEFINED end else @@ -352,6 +490,13 @@ def map &block [block.yield(self)] end + # @yieldparam t [self] + # @yieldreturn [self] + # @return [Enumerable] + def each &block + [self].each &block + end + # @return [Array] def to_a [self] @@ -370,6 +515,7 @@ 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 @@ -443,6 +589,22 @@ def self_to_type dst end end + # @yieldreturn [Boolean] + def any? &block + block.yield self + end + + # @return [ComplexType] + def reduce_class_type + new_items = items.flat_map do |type| + next type unless ['Module', 'Class'].include?(type.name) + next type if type.all_params.empty? + + type.all_params + end + ComplexType.new(new_items) + end + def all_rooted? return true if name == GENERIC_TAG_NAME rooted? && all_params.all?(&:rooted?) diff --git a/lib/solargraph/convention/data_definition.rb b/lib/solargraph/convention/data_definition.rb index 8efe27932..193364061 100644 --- a/lib/solargraph/convention/data_definition.rb +++ b/lib/solargraph/convention/data_definition.rb @@ -17,6 +17,7 @@ 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, @@ -39,6 +40,7 @@ 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( @@ -51,6 +53,7 @@ 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( @@ -78,7 +81,7 @@ def process private - # @return [DataDefintionNode, nil] + # @return [DataDefinition::DataDefintionNode, DataDefinition::DataAssignmentNode, nil] def data_definition_node @data_definition_node ||= if DataDefintionNode.match?(node) DataDefintionNode.new(node) diff --git a/lib/solargraph/convention/data_definition/data_definition_node.rb b/lib/solargraph/convention/data_definition/data_definition_node.rb index e86161c2d..49cf210a7 100644 --- a/lib/solargraph/convention/data_definition/data_definition_node.rb +++ b/lib/solargraph/convention/data_definition/data_definition_node.rb @@ -66,7 +66,7 @@ def attributes end.compact end - # @return [Parser::AST::Node] + # @return [Parser::AST::Node, nil] def body_node node.children[2] end @@ -81,8 +81,10 @@ def data_node node.children[1] end + # @sg-ignore Need to add nil check here # @return [Array] def data_attribute_nodes + # @sg-ignore Need to add nil check here data_node.children[2..-1] end end diff --git a/lib/solargraph/convention/struct_definition.rb b/lib/solargraph/convention/struct_definition.rb index b34ae5494..7871dec00 100644 --- a/lib/solargraph/convention/struct_definition.rb +++ b/lib/solargraph/convention/struct_definition.rb @@ -17,6 +17,7 @@ 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, @@ -39,6 +40,7 @@ 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( @@ -52,6 +54,7 @@ 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 } @@ -102,7 +105,7 @@ def process private - # @return [StructDefintionNode, StructAssignmentNode, nil] + # @return [StructDefinition::StructDefintionNode, StructDefinition::StructAssignmentNode, nil] def struct_definition_node @struct_definition_node ||= if StructDefintionNode.match?(node) StructDefintionNode.new(node) @@ -121,6 +124,7 @@ def docstring # @return [YARD::Docstring] def parse_comments struct_comments = comments_for(node) || '' + # @sg-ignore Need to add nil check here struct_definition_node.attributes.each do |attr_node, attr_name| comment = comments_for(attr_node) next if comment.nil? diff --git a/lib/solargraph/convention/struct_definition/struct_assignment_node.rb b/lib/solargraph/convention/struct_definition/struct_assignment_node.rb index 141abf599..2816de6ed 100644 --- a/lib/solargraph/convention/struct_definition/struct_assignment_node.rb +++ b/lib/solargraph/convention/struct_definition/struct_assignment_node.rb @@ -22,6 +22,7 @@ class << self # s(:def, :foo, # s(:args), # s(:send, nil, :bar)))) + # # @param node [Parser::AST::Node] def match?(node) return false unless node&.type == :casgn diff --git a/lib/solargraph/convention/struct_definition/struct_definition_node.rb b/lib/solargraph/convention/struct_definition/struct_definition_node.rb index 725e4227f..8f03f1fcb 100644 --- a/lib/solargraph/convention/struct_definition/struct_definition_node.rb +++ b/lib/solargraph/convention/struct_definition/struct_definition_node.rb @@ -92,6 +92,7 @@ def struct_node node.children[1] end + # @sg-ignore Need to add nil check here # @return [Array] def struct_attribute_nodes struct_node.children[2..-1] diff --git a/lib/solargraph/diagnostics/require_not_found.rb b/lib/solargraph/diagnostics/require_not_found.rb index 757849e08..df42da2e5 100644 --- a/lib/solargraph/diagnostics/require_not_found.rb +++ b/lib/solargraph/diagnostics/require_not_found.rb @@ -10,6 +10,7 @@ def diagnose source, api_map return [] unless source.parsed? && source.synchronized? result = [] refs = {} + # @sg-ignore Need to add nil check here map = api_map.source_map(source.filename) map.requires.each { |ref| refs[ref.name] = ref } api_map.missing_docs.each do |r| diff --git a/lib/solargraph/diagnostics/rubocop.rb b/lib/solargraph/diagnostics/rubocop.rb index 0a55d0367..5eade7592 100644 --- a/lib/solargraph/diagnostics/rubocop.rb +++ b/lib/solargraph/diagnostics/rubocop.rb @@ -25,6 +25,7 @@ class Rubocop < Base def diagnose source, _api_map @source = source require_rubocop(rubocop_version) + # @sg-ignore Need to add nil check here options, paths = generate_options(source.filename, source.code) store = RuboCop::ConfigStore.new runner = RuboCop::Runner.new(options, store) diff --git a/lib/solargraph/diagnostics/rubocop_helpers.rb b/lib/solargraph/diagnostics/rubocop_helpers.rb index fc458956e..b306f638a 100644 --- a/lib/solargraph/diagnostics/rubocop_helpers.rb +++ b/lib/solargraph/diagnostics/rubocop_helpers.rb @@ -18,6 +18,7 @@ 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] @@ -50,6 +51,7 @@ def generate_options filename, code # @return [String] def fix_drive_letter path return path unless path.match(/^[a-z]:/) + # @sg-ignore Need to add nil check here path[0].upcase + path[1..-1] end diff --git a/lib/solargraph/diagnostics/type_check.rb b/lib/solargraph/diagnostics/type_check.rb index 80f53eb7c..ea833860b 100644 --- a/lib/solargraph/diagnostics/type_check.rb +++ b/lib/solargraph/diagnostics/type_check.rb @@ -11,6 +11,7 @@ def diagnose source, api_map # return [] unless args.include?('always') || api_map.workspaced?(source.filename) severity = Diagnostics::Severities::ERROR level = (args.reverse.find { |a| ['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.problems .sort { |a, b| a.location.range.start.line <=> b.location.range.start.line } diff --git a/lib/solargraph/doc_map.rb b/lib/solargraph/doc_map.rb index e45ff0b65..4a3543fb8 100644 --- a/lib/solargraph/doc_map.rb +++ b/lib/solargraph/doc_map.rb @@ -60,7 +60,7 @@ def initialize(requires, preferences, workspace = nil) pins.concat @environ.pins end - # @param out [IO] + # @param out [StringIO, IO, nil] # @return [void] def cache_all!(out) # if we log at debug level: @@ -80,7 +80,7 @@ def cache_all!(out) end # @param gemspec [Gem::Specification] - # @param out [IO] + # @param out [StringIO, IO, nil] # @return [void] def cache_yard_pins(gemspec, out) pins = GemPins.build_yard_pins(yard_plugins, gemspec) @@ -89,7 +89,7 @@ def cache_yard_pins(gemspec, out) end # @param gemspec [Gem::Specification] - # @param out [IO] + # @param out [StringIO, IO, nil] # @return [void] def cache_rbs_collection_pins(gemspec, out) rbs_map = RbsMap.from_gemspec(gemspec, rbs_collection_path, rbs_collection_config_path) @@ -103,7 +103,7 @@ def cache_rbs_collection_pins(gemspec, out) # @param gemspec [Gem::Specification] # @param rebuild [Boolean] whether to rebuild the pins even if they are cached - # @param out [IO, nil] output stream for logging + # @param out [StringIO, IO, nil] output stream for logging # @return [void] def cache(gemspec, rebuild: false, out: nil) build_yard = uncached_yard_gemspecs.include?(gemspec) || rebuild @@ -145,6 +145,7 @@ def yard_pins_in_memory # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version def rbs_collection_pins_in_memory + # @sg-ignore Need to add nil check here self.class.all_rbs_collection_gems_in_memory[rbs_collection_path] ||= {} end @@ -177,10 +178,9 @@ def load_serialized_gem_pins @uncached_yard_gemspecs = [] @uncached_rbs_collection_gemspecs = [] with_gemspecs, without_gemspecs = required_gems_map.partition { |_, v| v } - # @sg-ignore Need support for RBS duck interfaces like _ToHash + # @sg-ignore Need better typing for Hash[] # @type [Array] paths = Hash[without_gemspecs].keys - # @sg-ignore Need support for RBS duck interfaces like _ToHash # @type [Array] gemspecs = Hash[with_gemspecs].values.flatten.compact + dependencies.to_a @@ -346,7 +346,7 @@ def gemspec_or_preference gemspec end # @param gemspec [Gem::Specification] - # @param version [Gem::Version] + # @param version [Gem::Version, String] # @return [Gem::Specification] def change_gemspec_version gemspec, version Gem::Specification.find_by_name(gemspec.name, "= #{version}") @@ -387,8 +387,11 @@ def inspect # @return [Array, nil] def gemspecs_required_from_bundler # @todo Handle projects with custom Bundler/Gemfile setups + # @sg-ignore Need to add nil check here return unless workspace.gemfile? + # @todo: redundant check + # @sg-ignore Need to add nil check here if workspace.gemfile? && Bundler.definition&.lockfile&.to_s&.start_with?(workspace.directory) # Find only the gems bundler is now using Bundler.definition.locked_gems.specs.flat_map do |lazy_spec| @@ -432,6 +435,7 @@ def gemspecs_required_from_external_bundle end.compact else Solargraph.logger.warn "Failed to load gems from bundle at #{workspace&.directory}: #{e}" + nil end end end diff --git a/lib/solargraph/language_server/host.rb b/lib/solargraph/language_server/host.rb index b228bdba6..57f09e830 100644 --- a/lib/solargraph/language_server/host.rb +++ b/lib/solargraph/language_server/host.rb @@ -105,6 +105,7 @@ def receive request message.process unless cancel?(request['id']) rescue StandardError => e logger.warn "Error processing request: [#{e.class}] #{e.message}" + # @sg-ignore Need to add nil check here logger.warn e.backtrace.join("\n") message.set_error Solargraph::LanguageServer::ErrorCodes::INTERNAL_ERROR, "[#{e.class}] #{e.message}" end @@ -300,8 +301,11 @@ def prepare directory, name = nil end end + # @sg-ignore Need to validate config # @return [String] + # @sg-ignore Need detailed hash types def command_path + # @type [String] options['commandPath'] || 'solargraph' end @@ -729,9 +733,11 @@ def requests end # @param path [String] + # @sg-ignore Need to be able to choose signature on String#gsub # @return [String] def normalize_separators path return path if File::ALT_SEPARATOR.nil? + # @sg-ignore flow sensitive typing needs to handle constants path.gsub(File::ALT_SEPARATOR, File::SEPARATOR) end diff --git a/lib/solargraph/language_server/host/dispatch.rb b/lib/solargraph/language_server/host/dispatch.rb index 1ff1227b8..f64b4ac96 100644 --- a/lib/solargraph/language_server/host/dispatch.rb +++ b/lib/solargraph/language_server/host/dispatch.rb @@ -33,6 +33,7 @@ def libraries # @return [void] def update_libraries uri src = sources.find(uri) + # @sg-ignore Need to add nil check here using = libraries.select { |lib| lib.contain?(src.filename) } using.push library_for(uri) if using.empty? using.each { |lib| lib.merge src } diff --git a/lib/solargraph/language_server/host/message_worker.rb b/lib/solargraph/language_server/host/message_worker.rb index ec426b99f..b0878b154 100644 --- a/lib/solargraph/language_server/host/message_worker.rb +++ b/lib/solargraph/language_server/host/message_worker.rb @@ -28,7 +28,7 @@ def initialize(host) end # pending handle messages - # @return [Array] + # @return [Array undefined}>] def messages @messages ||= [] end @@ -66,6 +66,7 @@ def tick @resource.wait(@mutex) if messages.empty? next_message end + # @sg-ignore Need to add nil check here handler = @host.receive(message) handler&.send_response end diff --git a/lib/solargraph/language_server/host/sources.rb b/lib/solargraph/language_server/host/sources.rb index da0c63b93..cdbaa8feb 100644 --- a/lib/solargraph/language_server/host/sources.rb +++ b/lib/solargraph/language_server/host/sources.rb @@ -55,6 +55,7 @@ def update uri, updater # @raise [FileNotFoundError] if the URI does not match an open source. # # @param uri [String] + # @sg-ignore Flow-sensitive 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/extended/check_gem_version.rb b/lib/solargraph/language_server/message/extended/check_gem_version.rb index ead1eeaf2..b4f64df22 100644 --- a/lib/solargraph/language_server/message/extended/check_gem_version.rb +++ b/lib/solargraph/language_server/message/extended/check_gem_version.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true -# @todo PR the RBS gem to add this -# @!parse -# module ::Gem -# class SpecFetcher; end -# end - module Solargraph module LanguageServer module Message @@ -64,6 +58,7 @@ def process end elsif fetched? Solargraph::Logging.logger.warn error + # @sg-ignore Need to add nil check here host.show_message(error, MessageTypes::ERROR) if params['verbose'] end set_result({ @@ -78,6 +73,7 @@ def process attr_reader :current # @return [Gem::Version] + # @sg-ignore Need to add nil check here def available if !@available && !@fetched @fetched = true diff --git a/lib/solargraph/language_server/message/extended/document.rb b/lib/solargraph/language_server/message/extended/document.rb index 836fc005e..f379d0a6b 100644 --- a/lib/solargraph/language_server/message/extended/document.rb +++ b/lib/solargraph/language_server/message/extended/document.rb @@ -14,6 +14,7 @@ def process ) rescue StandardError => e Solargraph.logger.warn "Error processing document: [#{e.class}] #{e.message}" + # @sg-ignore Need to add nil check here Solargraph.logger.debug e.backtrace.join("\n") end end diff --git a/lib/solargraph/language_server/message/text_document/completion.rb b/lib/solargraph/language_server/message/text_document/completion.rb index ef7ad1be4..5b9acec33 100644 --- a/lib/solargraph/language_server/message/text_document/completion.rb +++ b/lib/solargraph/language_server/message/text_document/completion.rb @@ -15,6 +15,7 @@ def process items = [] last_context = nil idx = -1 + # @sg-ignore Need to add nil check here completion.pins.each do |pin| idx += 1 if last_context != pin.context items.push pin.completion_item.merge({ @@ -37,6 +38,7 @@ def process end rescue FileNotFoundError => e Logging.logger.warn "[#{e.class}] #{e.message}" + # @sg-ignore Need to add nil check here Logging.logger.warn e.backtrace.join("\n") set_result empty_result end diff --git a/lib/solargraph/language_server/message/text_document/definition.rb b/lib/solargraph/language_server/message/text_document/definition.rb index ea0942dd5..96c1e988a 100644 --- a/lib/solargraph/language_server/message/text_document/definition.rb +++ b/lib/solargraph/language_server/message/text_document/definition.rb @@ -13,7 +13,9 @@ def process # @return [Array, nil] def code_location suggestions = host.definitions_at(params['textDocument']['uri'], @line, @column) + # @sg-ignore Need to add nil check here return nil if suggestions.empty? + # @sg-ignore Need to add nil check here suggestions.reject { |pin| pin.best_location.nil? || pin.best_location.filename.nil? }.map do |pin| { uri: file_to_uri(pin.best_location.filename), diff --git a/lib/solargraph/language_server/message/text_document/document_symbol.rb b/lib/solargraph/language_server/message/text_document/document_symbol.rb index 2490f5c6d..3d3cccbde 100644 --- a/lib/solargraph/language_server/message/text_document/document_symbol.rb +++ b/lib/solargraph/language_server/message/text_document/document_symbol.rb @@ -13,7 +13,9 @@ def process containerName: pin.namespace, kind: pin.symbol_kind, location: { + # @sg-ignore Need to add nil check here uri: file_to_uri(pin.best_location.filename), + # @sg-ignore Need to add nil check here range: pin.best_location.range.to_hash }, deprecated: pin.deprecated? diff --git a/lib/solargraph/language_server/message/text_document/formatting.rb b/lib/solargraph/language_server/message/text_document/formatting.rb index d67a0b414..7010de741 100644 --- a/lib/solargraph/language_server/message/text_document/formatting.rb +++ b/lib/solargraph/language_server/message/text_document/formatting.rb @@ -98,9 +98,11 @@ def formatter_class(config) end # @param value [Array, String] + # # @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 diff --git a/lib/solargraph/language_server/message/text_document/hover.rb b/lib/solargraph/language_server/message/text_document/hover.rb index 72eff4296..57a9161a3 100644 --- a/lib/solargraph/language_server/message/text_document/hover.rb +++ b/lib/solargraph/language_server/message/text_document/hover.rb @@ -11,6 +11,7 @@ def process contents = [] suggestions = host.definitions_at(params['textDocument']['uri'], line, col) last_link = nil + # @sg-ignore Need to add nil check here suggestions.each do |pin| parts = [] this_link = host.options['enablePages'] ? pin.link_documentation : pin.text_documentation @@ -31,6 +32,7 @@ def process ) rescue FileNotFoundError => e Logging.logger.warn "[#{e.class}] #{e.message}" + # @sg-ignore Need to add nil check here Logging.logger.warn e.backtrace.join("\n") set_result nil end diff --git a/lib/solargraph/language_server/message/text_document/signature_help.rb b/lib/solargraph/language_server/message/text_document/signature_help.rb index e4e8795db..a56b56edd 100644 --- a/lib/solargraph/language_server/message/text_document/signature_help.rb +++ b/lib/solargraph/language_server/message/text_document/signature_help.rb @@ -14,6 +14,7 @@ def process }) rescue FileNotFoundError => e Logging.logger.warn "[#{e.class}] #{e.message}" + # @sg-ignore Need to add nil check here Logging.logger.warn e.backtrace.join("\n") set_result nil end diff --git a/lib/solargraph/language_server/message/text_document/type_definition.rb b/lib/solargraph/language_server/message/text_document/type_definition.rb index adb24038b..3a2431659 100644 --- a/lib/solargraph/language_server/message/text_document/type_definition.rb +++ b/lib/solargraph/language_server/message/text_document/type_definition.rb @@ -13,7 +13,9 @@ def process # @return [Array, nil] def code_location suggestions = host.type_definitions_at(params['textDocument']['uri'], @line, @column) + # @sg-ignore Need to add nil check here return nil if suggestions.empty? + # @sg-ignore Need to add nil check here suggestions.reject { |pin| pin.best_location.nil? || pin.best_location.filename.nil? }.map do |pin| { uri: file_to_uri(pin.best_location.filename), diff --git a/lib/solargraph/language_server/message/workspace/workspace_symbol.rb b/lib/solargraph/language_server/message/workspace/workspace_symbol.rb index 780e4aa0b..fe7e5efa5 100644 --- a/lib/solargraph/language_server/message/workspace/workspace_symbol.rb +++ b/lib/solargraph/language_server/message/workspace/workspace_symbol.rb @@ -6,6 +6,7 @@ class Solargraph::LanguageServer::Message::Workspace::WorkspaceSymbol < Solargra def process pins = host.query_symbols(params['query']) info = pins.map do |pin| + # @sg-ignore Need to add nil check here uri = file_to_uri(pin.best_location.filename) { name: pin.path, @@ -13,6 +14,7 @@ def process kind: pin.symbol_kind, location: { uri: uri, + # @sg-ignore Need to add nil check here range: pin.best_location.range.to_hash }, deprecated: pin.deprecated? diff --git a/lib/solargraph/library.rb b/lib/solargraph/library.rb index 5c7851201..129d23f0d 100644 --- a/lib/solargraph/library.rb +++ b/lib/solargraph/library.rb @@ -57,8 +57,11 @@ def synchronized? # @param source [Source, nil] # @return [void] def attach source + # @sg-ignore flow sensitive typing needs to handle ivars if @current && (!source || @current.filename != source.filename) && source_map_hash.key?(@current.filename) && !workspace.has_file?(@current.filename) + # @sg-ignore flow sensitive typing needs to handle ivars source_map_hash.delete @current.filename + # @sg-ignore flow sensitive typing needs to handle ivars source_map_external_require_hash.delete @current.filename @external_requires = nil end @@ -72,7 +75,9 @@ def attach source # # @param filename [String] # @return [Boolean] + # @sg-ignore flow sensitive typing needs to handle ivars def attached? filename + # @sg-ignore flow sensitive typing needs to handle ivars !@current.nil? && @current.filename == filename end alias open? attached? @@ -82,6 +87,7 @@ def attached? filename # @param filename [String] # @return [Boolean] True if the specified file was detached def detach filename + # @sg-ignore flow sensitive typing needs to handle ivars return false if @current.nil? || @current.filename != filename attach nil true @@ -182,9 +188,14 @@ def definitions_at filename, line, column if cursor.comment? source = read(filename) offset = Solargraph::Position.to_offset(source.code, Solargraph::Position.new(line, column)) + # @sg-ignore Need to add nil check here + # @type [MatchData, nil] lft = source.code[0..offset-1].match(/\[[a-z0-9_:<, ]*?([a-z0-9_:]*)\z/i) + # @sg-ignore Need to add nil check here + # @type [MatchData, nil] rgt = source.code[offset..-1].match(/^([a-z0-9_]*)(:[a-z0-9_:]*)?[\]>, ]/i) if lft && rgt + # @sg-ignore Need to add nil check here tag = (lft[1] + rgt[1]).sub(/:+$/, '') clip = mutex.synchronize { api_map.clip(cursor) } clip.translate tag @@ -255,15 +266,19 @@ def references_from filename, line, column, strip: false, only: false files.uniq(&:filename).each do |source| found = source.references(pin.name) found.select! do |loc| + # @sg-ignore Need to add nil check here + # @type [Solargraph::Pin::Base, nil] referenced = definitions_at(loc.filename, loc.range.ending.line, loc.range.ending.character).first 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 @@ -273,6 +288,7 @@ 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 Need to add nil check here 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 @@ -299,6 +315,7 @@ def locate_pins location def locate_ref location map = source_map_hash[location.filename] return if map.nil? + # @sg-ignore Need to add nil check here pin = map.requires.select { |p| p.location.range.contain?(location.range.start) }.first return nil if pin.nil? # @param full [String] @@ -403,6 +420,7 @@ def diagnose filename workspace.config.reporters.each do |line| if line == 'all!' Diagnostics.reporters.each do |reporter_name| + # @sg-ignore Need to add nil check here repargs[Diagnostics.reporter(reporter_name)] ||= [] end else @@ -410,7 +428,9 @@ def diagnose filename name = args.shift reporter = Diagnostics.reporter(name) raise DiagnosticsError, "Diagnostics reporter #{name} does not exist" if reporter.nil? + # @sg-ignore flow sensitive typing needs to handle 'raise if' repargs[reporter] ||= [] + # @sg-ignore flow sensitive typing needs to handle 'raise if' repargs[reporter].concat args end end @@ -433,6 +453,7 @@ def bench source_maps: source_map_hash.values, workspace: workspace, external_requires: external_requires, + # @sg-ignore flow sensitive typing needs to handle ivars live_map: @current ? source_map_hash[@current.filename] : nil ) end @@ -469,10 +490,13 @@ def mapped? # @return [SourceMap, Boolean] def next_map return false if mapped? + # @sg-ignore Need to add nil check here src = workspace.sources.find { |s| !source_map_hash.key?(s.filename) } if src Logging.logger.debug "Mapping #{src.filename}" + # @sg-ignore Need to add nil check here source_map_hash[src.filename] = Solargraph::SourceMap.map(src) + # @sg-ignore Need to add nil check here source_map_hash[src.filename] else false @@ -482,7 +506,9 @@ def next_map # @return [self] def map! workspace.sources.each do |src| + # @sg-ignore Need to add nil check here source_map_hash[src.filename] = Solargraph::SourceMap.map(src) + # @sg-ignore Need to add nil check here find_external_requires source_map_hash[src.filename] end self @@ -513,6 +539,7 @@ def find_external_requires source_map # return if new_set == source_map_external_require_hash[source_map.filename] _filenames = nil filenames = ->{ _filenames ||= workspace.filenames.to_set } + # @sg-ignore Need to add nil check here source_map_external_require_hash[source_map.filename] = new_set.reject do |path| workspace.require_paths.any? do |base| full = File.join(base, path) @@ -539,8 +566,10 @@ def api_map # # @raise [FileNotFoundError] if the file does not exist # @param filename [String] + # @sg-ignore flow sensitive typing needs to handle ivars # @return [Solargraph::Source] def read filename + # @sg-ignore flow sensitive typing needs to handle ivars return @current if @current && @current.filename == filename raise FileNotFoundError, "File not found: #{filename}" unless workspace.has_file?(filename) workspace.source(filename) @@ -562,11 +591,15 @@ def handle_file_not_found filename, error # @return [void] def maybe_map source return unless source + # @sg-ignore Need to add nil check here return unless @current == source || workspace.has_file?(source.filename) + # @sg-ignore Need to add nil check here if source_map_hash.key?(source.filename) new_map = Solargraph::SourceMap.map(source) + # @sg-ignore Need to add nil check here source_map_hash[source.filename] = new_map else + # @sg-ignore Need to add nil check here source_map_hash[source.filename] = Solargraph::SourceMap.map(source) end end @@ -632,24 +665,31 @@ def queued_gemspec_cache # @return [void] def report_cache_progress gem_name, pending @total ||= pending + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars @total = pending if pending > @total + # @sg-ignore flow sensitive typing needs to handle ivars finished = @total - pending + # @sg-ignore flow sensitive typing needs to handle ivars pct = if @total.zero? 0 else + # @sg-ignore flow sensitive typing needs to handle ivars ((finished.to_f / @total.to_f) * 100).to_i end message = "#{gem_name}#{pending > 0 ? " (+#{pending})" : ''}" # " if @cache_progress + # @sg-ignore flow sensitive typing needs to handle ivars @cache_progress.report(message, pct) else @cache_progress = LanguageServer::Progress.new('Caching gem') # If we don't send both a begin and a report, the progress notification # might get stuck in the status bar forever + # @sg-ignore Should handle redefinition of types in simple contexts @cache_progress.begin(message, pct) changed notify_observers @cache_progress + # @sg-ignore Should handle redefinition of types in simple contexts @cache_progress.report(message, pct) end changed diff --git a/lib/solargraph/location.rb b/lib/solargraph/location.rb index df92668bf..a7b15e42f 100644 --- a/lib/solargraph/location.rb +++ b/lib/solargraph/location.rb @@ -6,6 +6,7 @@ module Solargraph # class Location include Equality + include Comparable # @return [String] attr_reader :filename @@ -13,14 +14,15 @@ class Location # @return [Solargraph::Range] attr_reader :range - # @param filename [String] + # @param filename [String, nil] # @param range [Solargraph::Range] def initialize filename, range + raise "Use nil to represent no-file" if filename&.empty? + @filename = filename @range = range end - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields [filename, range] end @@ -64,14 +66,17 @@ def to_hash # @return [Location, nil] def self.from_node(node) return nil if node.nil? || node.loc.nil? + filename = node.loc.expression.source_buffer.name + # @sg-ignore flow sensitive typing needs to create separate ranges for postfix if + filename = nil if filename.empty? range = Range.from_node(node) - self.new(node.loc.expression.source_buffer.name, range) + # @sg-ignore Need to add nil check here + self.new(filename, range) end # @param other [BasicObject] def == other return false unless other.is_a?(Location) - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 filename == other.filename and range == other.range end diff --git a/lib/solargraph/logging.rb b/lib/solargraph/logging.rb index 8f3edaba2..30bdc06c2 100644 --- a/lib/solargraph/logging.rb +++ b/lib/solargraph/logging.rb @@ -29,9 +29,29 @@ module Logging module_function + # override this in your class to temporarily set a custom + # filtering log level for the class (e.g., suppress any debug + # message by setting it to :info even if it is set elsewhere, or + # show existing debug messages by setting to :debug). + # + # @return [Symbol] + def log_level + :warn + end + # @return [Logger] def logger - @@logger + if LOG_LEVELS[log_level.to_s] == DEFAULT_LOG_LEVEL + @@logger + else + new_log_level = LOG_LEVELS[log_level.to_s] + logger = Logger.new(STDERR, level: new_log_level) + + # @sg-ignore Wrong argument type for Logger#formatter=: arg_0 + # expected nil, received Logger::_Formatter, nil + logger.formatter = @@logger.formatter + logger + end end end end diff --git a/lib/solargraph/parser/comment_ripper.rb b/lib/solargraph/parser/comment_ripper.rb index 92373df20..8bfe166f5 100644 --- a/lib/solargraph/parser/comment_ripper.rb +++ b/lib/solargraph/parser/comment_ripper.rb @@ -23,6 +23,7 @@ def on_comment *args # @sg-ignore # @type [Array(Symbol, String, Array([Integer, nil], [Integer, nil]))] result = super + # @sg-ignore Need to add nil check here if @buffer_lines[result[2][0]][0..result[2][1]].strip =~ /^#/ chomped = result[1].chomp if result[2][0] == 0 && chomped.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '').match(/^#\s*frozen_string_literal:/) @@ -40,20 +41,26 @@ def create_snippet(result) @comments[result[2][0]] = Snippet.new(Range.from_to(result[2][0] || 0, result[2][1] || 0, result[2][0] || 0, (result[2][1] || 0) + chomped.length), chomped) end + # @sg-ignore @override is adding, not overriding def on_embdoc_beg *args result = super + # @sg-ignore @override is adding, not overriding create_snippet(result) result end + # @sg-ignore @override is adding, not overriding def on_embdoc *args result = super + # @sg-ignore @override is adding, not overriding create_snippet(result) result end + # @sg-ignore @override is adding, not overriding def on_embdoc_end *args result = super + # @sg-ignore @override is adding, not overriding create_snippet(result) result end diff --git a/lib/solargraph/parser/flow_sensitive_typing.rb b/lib/solargraph/parser/flow_sensitive_typing.rb index 41ce6eeaf..dfe58b478 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -5,16 +5,21 @@ class FlowSensitiveTyping # @param locals [Array] # @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil] - def initialize(locals, enclosing_breakable_pin = nil) + # @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil] + def initialize(locals, enclosing_breakable_pin, enclosing_compound_statement_pin) @locals = locals @enclosing_breakable_pin = enclosing_breakable_pin + @enclosing_compound_statement_pin = enclosing_compound_statement_pin end # @param and_node [Parser::AST::Node] # @param true_ranges [Array] + # @param false_ranges [Array] # # @return [void] - def process_and(and_node, true_ranges = []) + def process_and(and_node, true_ranges = [], false_ranges = []) + return unless and_node.type == :and + # @type [Parser::AST::Node] lhs = and_node.children[0] # @type [Parser::AST::Node] @@ -25,13 +30,64 @@ def process_and(and_node, true_ranges = []) rhs_presence = Range.new(before_rhs_pos, get_node_end_position(rhs)) - process_isa(lhs, true_ranges + [rhs_presence]) + + # can't assume if an and is false that every single condition + # is false, so don't provide any false ranges to assert facts + # on + process_expression(lhs, true_ranges + [rhs_presence], []) + process_expression(rhs, true_ranges, []) + end + + # @param or_node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] + # + # @return [void] + def process_or(or_node, true_ranges = [], false_ranges = []) + return unless or_node.type == :or + + # @type [Parser::AST::Node] + lhs = or_node.children[0] + # @type [Parser::AST::Node] + rhs = or_node.children[1] + + before_rhs_loc = rhs.location.expression.adjust(begin_pos: -1) + before_rhs_pos = Position.new(before_rhs_loc.line, before_rhs_loc.column) + + rhs_presence = Range.new(before_rhs_pos, + get_node_end_position(rhs)) + + # can assume if an or is false that every single condition is + # false, so't provide false ranges to assert facts on + + # can't assume if an or is true that every single condition is + # true, so don't provide true ranges to assert facts on + + process_expression(lhs, [], false_ranges + [rhs_presence]) + process_expression(rhs, [], false_ranges) + end + + # @param node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_calls(node, true_presences, false_presences) + return unless node.type == :send + + process_isa(node, true_presences, false_presences) + process_nilp(node, true_presences, false_presences) + process_bang(node, true_presences, false_presences) end # @param if_node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] # # @return [void] - def process_if(if_node) + def process_if(if_node, true_ranges = [], false_ranges = []) + return if if_node.type != :if + # # See if we can refine a type based on the result of 'if foo.nil?' # @@ -44,23 +100,45 @@ def process_if(if_node) # s(:send, nil, :bar)) # [4] pry(main)> conditional_node = if_node.children[0] - # @type [Parser::AST::Node] + # @type [Parser::AST::Node, nil] then_clause = if_node.children[1] - # @type [Parser::AST::Node] + # @type [Parser::AST::Node, nil] else_clause = if_node.children[2] - true_ranges = [] - if always_breaks?(else_clause) - unless enclosing_breakable_pin.nil? - rest_of_breakable_body = Range.new(get_node_end_position(if_node), - get_node_end_position(enclosing_breakable_pin.node)) + unless enclosing_breakable_pin.nil? + rest_of_breakable_body = Range.new(get_node_end_position(if_node), + get_node_end_position(enclosing_breakable_pin.node)) + + if always_breaks?(then_clause) + false_ranges << rest_of_breakable_body + end + + if always_breaks?(else_clause) true_ranges << rest_of_breakable_body end end + unless enclosing_compound_statement_pin.node.nil? + rest_of_returnable_body = Range.new(get_node_end_position(if_node), + get_node_end_position(enclosing_compound_statement_pin.node)) + + # + # if one of the clauses always leaves the compound + # statement, we can assume things about the rest of the + # compound statement + # + if always_leaves_compound_statement?(then_clause) + false_ranges << rest_of_returnable_body + end + + if always_leaves_compound_statement?(else_clause) + true_ranges << rest_of_returnable_body + end + end + unless then_clause.nil? # - # Add specialized locals for the then clause range + # If the condition is true we can assume things about the then clause # before_then_clause_loc = then_clause.location.expression.adjust(begin_pos: -1) before_then_clause_pos = Position.new(before_then_clause_loc.line, before_then_clause_loc.column) @@ -68,51 +146,56 @@ def process_if(if_node) get_node_end_position(then_clause)) end - process_conditional(conditional_node, true_ranges) - end + unless else_clause.nil? + # + # If the condition is true we can assume things about the else clause + # + before_else_clause_loc = else_clause.location.expression.adjust(begin_pos: -1) + before_else_clause_pos = Position.new(before_else_clause_loc.line, before_else_clause_loc.column) + false_ranges << Range.new(before_else_clause_pos, + get_node_end_position(else_clause)) + end - class << self - include Logging + process_expression(conditional_node, true_ranges, false_ranges) end - # Find a variable pin by name and where it is used. - # - # Resolves our most specific view of this variable's type by - # preferring pins created by flow-sensitive typing when we have - # them based on the Closure and Location. - # - # @param pins [Array] - # @param name [String] - # @param closure [Pin::Closure] - # @param location [Location] + # @param while_node [Parser::AST::Node] + # @param true_ranges [Array] + # @param false_ranges [Array] # - # @return [Array] - def self.visible_pins(pins, name, closure, location) - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location})" } - pins_with_name = pins.select { |p| p.name == name } - if pins_with_name.empty? - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location}) => [] - no pins with name" } - return [] - end - pins_with_specific_visibility = pins.select { |p| p.name == name && p.presence && p.visible_at?(closure, location) } - if pins_with_specific_visibility.empty? - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location}) => #{pins_with_name} - no pins with specific visibility" } - return pins_with_name - end - visible_pins_specific_to_this_closure = pins_with_specific_visibility.select { |p| p.closure == closure } - if visible_pins_specific_to_this_closure.empty? - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location}) => #{pins_with_specific_visibility} - no visible pins specific to this closure (#{closure})}" } - return pins_with_specific_visibility - end - flow_defined_pins = pins_with_specific_visibility.select { |p| p.presence_certain? } - if flow_defined_pins.empty? - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location}) => #{visible_pins_specific_to_this_closure} - no flow-defined pins" } - return visible_pins_specific_to_this_closure + # @return [void] + def process_while(while_node, true_ranges = [], false_ranges = []) + return if while_node.type != :while + + # + # See if we can refine a type based on the result of 'if foo.nil?' + # + # [3] pry(main)> Parser::CurrentRuby.parse("while a; b; c; end") + # => s(:while, + # s(:send, nil, :a), + # s(:begin, + # s(:send, nil, :b), + # s(:send, nil, :c))) + # [4] pry(main)> + conditional_node = while_node.children[0] + # @type [Parser::AST::Node, nil] + do_clause = while_node.children[1] + + unless do_clause.nil? + # + # If the condition is true we can assume things about the do clause + # + before_do_clause_loc = do_clause.location.expression.adjust(begin_pos: -1) + before_do_clause_pos = Position.new(before_do_clause_loc.line, before_do_clause_loc.column) + true_ranges << Range.new(before_do_clause_pos, + get_node_end_position(do_clause)) end - logger.debug { "FlowSensitiveTyping#visible_pins(name=#{name}, closure=#{closure}, location=#{location}) => #{flow_defined_pins}" } + process_expression(conditional_node, true_ranges, false_ranges) + end - flow_defined_pins + class << self + include Logging end include Logging @@ -120,27 +203,20 @@ def self.visible_pins(pins, name, closure, location) private # @param pin [Pin::LocalVariable] - # @param downcast_type_name [String] # @param presence [Range] + # @param downcast_type [ComplexType, nil] + # @param downcast_not_type [ComplexType, nil] # # @return [void] - def add_downcast_local(pin, downcast_type_name, presence) - # @todo Create pin#update method - new_pin = Solargraph::Pin::LocalVariable.new( - location: pin.location, - closure: pin.closure, - name: pin.name, - assignment: pin.assignment, - comments: pin.comments, - presence: presence, - return_type: ComplexType.try_parse(downcast_type_name), - presence_certain: true, - source: :flow_sensitive_typing - ) + def add_downcast_local(pin, presence:, downcast_type:, downcast_not_type:) + new_pin = pin.downcast(exclude_return_type: downcast_not_type, + intersection_return_type: downcast_type, + source: :flow_sensitive_typing, + presence: presence) locals.push(new_pin) end - # @param facts_by_pin [Hash{Pin::LocalVariable => Array String}>}] + # @param facts_by_pin [Hash{Pin::LocalVariable => Array ComplexType}>}] # @param presences [Array] # # @return [void] @@ -150,50 +226,67 @@ def process_facts(facts_by_pin, presences) # facts_by_pin.each_pair do |pin, facts| facts.each do |fact| - downcast_type_name = fact.fetch(:type) + downcast_type = fact.fetch(:type, nil) + downcast_not_type = fact.fetch(:not_type, nil) presences.each do |presence| - add_downcast_local(pin, downcast_type_name, presence) + add_downcast_local(pin, + presence: presence, + downcast_type: downcast_type, + downcast_not_type: downcast_not_type) end end end end - # @param conditional_node [Parser::AST::Node] + # @param expression_node [Parser::AST::Node] # @param true_ranges [Array] + # @param false_ranges [Array] # # @return [void] - def process_conditional(conditional_node, true_ranges) - if conditional_node.type == :send - process_isa(conditional_node, true_ranges) - elsif conditional_node.type == :and - process_and(conditional_node, true_ranges) - end + 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_variable(expression_node, true_ranges, false_ranges) end - # @param isa_node [Parser::AST::Node] - # @return [Array(String, String), nil] - def parse_isa(isa_node) - return unless isa_node&.type == :send && isa_node.children[1] == :is_a? + # @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 + 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: # s(:send, # s(:send, nil, :foo), :is_a?, # s(:const, nil, :Baz)), - isa_receiver = isa_node.children[0] - isa_type_name = type_name(isa_node.children[2]) - return unless isa_type_name + # + call_receiver = call_node.children[0] + call_arg = type_name(call_node.children[2]) - # check if isa_receiver looks like this: + # check if call_receiver looks like this: # s(:send, nil, :foo) # and set variable_name to :foo - if isa_receiver&.type == :send && isa_receiver.children[0].nil? && isa_receiver.children[1].is_a?(Symbol) - variable_name = isa_receiver.children[1].to_s + 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 = isa_receiver.children[0].to_s if isa_receiver&.type == :lvar + variable_name = call_receiver.children[0].to_s if call_receiver&.type == :lvar return unless variable_name - [isa_type_name, variable_name] + [call_arg, variable_name] + end + + # @param isa_node [Parser::AST::Node] + # @return [Array(String, String), nil] + def parse_isa(isa_node) + call_type_name, variable_name = parse_call(isa_node, :is_a?) + + return unless call_type_name + + [call_type_name, variable_name] end # @param variable_name [String] @@ -201,27 +294,135 @@ def parse_isa(isa_node) # # @return [Solargraph::Pin::LocalVariable, nil] def find_local(variable_name, position) + # @sg-ignore Need to add nil check here pins = locals.select { |pin| pin.name == variable_name && pin.presence.include?(position) } - return unless pins.length == 1 pins.first end # @param isa_node [Parser::AST::Node] # @param true_presences [Array] + # @param false_presences [Array] # # @return [void] - def process_isa(isa_node, true_presences) + 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? + # @sg-ignore Need to add nil check here isa_position = Range.from_node(isa_node).start pin = find_local(variable_name, isa_position) return unless pin + # @type Hash{Pin::LocalVariable => Array ComplexType}>} + if_true = {} + if_true[pin] ||= [] + if_true[pin] << { type: ComplexType.parse(isa_type_name) } + process_facts(if_true, true_presences) + + # @type Hash{Pin::LocalVariable => Array ComplexType}>} + if_false = {} + if_false[pin] ||= [] + if_false[pin] << { not_type: ComplexType.parse(isa_type_name) } + process_facts(if_false, false_presences) + end + + # @param nilp_node [Parser::AST::Node] + # @return [Array(String, String), nil] + def parse_nilp(nilp_node) + parse_call(nilp_node, :nil?) + end + + # @param nilp_node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @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? + # if .nil? got an argument, move on, this isn't the situation + # we're looking for and typechecking will cover any invalid + # ones + return unless nilp_arg.nil? + # @sg-ignore Need to add nil check here + nilp_position = Range.from_node(nilp_node).start + + pin = find_local(variable_name, nilp_position) + return unless pin + + # @type Hash{Pin::LocalVariable => Array ComplexType}>} if_true = {} if_true[pin] ||= [] - if_true[pin] << { type: isa_type_name } + if_true[pin] << { type: ComplexType::NIL } process_facts(if_true, true_presences) + + # @type Hash{Pin::LocalVariable => Array ComplexType}>} + if_false = {} + if_false[pin] ||= [] + if_false[pin] << { not_type: ComplexType::NIL } + process_facts(if_false, false_presences) + end + + # @param bang_node [Parser::AST::Node] + # @return [Array(String, String), nil] + def parse_bang(bang_node) + parse_call(bang_node, :!) + end + + # @param bang_node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + # + # @return [void] + def process_bang(bang_node, true_presences, false_presences) + # pry(main)> require 'parser/current'; Parser::CurrentRuby.parse("!2") + # => s(:send, + # s(:int, 2), :!) + # end + return unless bang_node.type == :send && bang_node.children[1] == :! + + receiver = bang_node.children[0] + + # swap the two presences + process_expression(receiver, false_presences, true_presences) + end + + # @param var_node [Parser::AST::Node] + # + # @return [String, nil] Variable name referenced + def parse_variable(var_node) + return if var_node.children.length != 1 + + var_node.children[0]&.to_s + end + + # @return [void] + # @param node [Parser::AST::Node] + # @param true_presences [Array] + # @param false_presences [Array] + def process_variable(node, true_presences, false_presences) + return unless [:lvar, :ivar, :cvar, :gvar].include?(node.type) + + variable_name = parse_variable(node) + return if variable_name.nil? + + # @sg-ignore Need to add nil check here + var_position = Range.from_node(node).start + + pin = find_local(variable_name, var_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] @@ -231,7 +432,9 @@ def type_name(node) # e.g., # s(:const, nil, :Baz) return unless node&.type == :const + # @type [Parser::AST::Node, nil] module_node = node.children[0] + # @type [Parser::AST::Node, nil] class_node = node.children[1] return class_node.to_s if module_node.nil? @@ -242,14 +445,22 @@ def type_name(node) "#{module_type_name}::#{class_node}" end - # @param clause_node [Parser::AST::Node] + # @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 + # @sg-ignore Need to look at Tuple#include? handling + [:return, :raise, :next, :redo, :retry].include?(clause_node&.type) + end + attr_reader :locals - attr_reader :enclosing_breakable_pin + attr_reader :enclosing_breakable_pin, :enclosing_compound_statement_pin end end end diff --git a/lib/solargraph/parser/node_processor.rb b/lib/solargraph/parser/node_processor.rb index dbe0b7cd5..3d9e9b846 100644 --- a/lib/solargraph/parser/node_processor.rb +++ b/lib/solargraph/parser/node_processor.rb @@ -35,7 +35,7 @@ def deregister type, cls # @param node [Parser::AST::Node] # @param region [Region] # @param pins [Array] - # @param locals [Array] + # @param locals [Array] # @return [Array(Array, Array)] def self.process node, region = Region.new, pins = [], locals = [] if pins.empty? diff --git a/lib/solargraph/parser/node_processor/base.rb b/lib/solargraph/parser/node_processor/base.rb index fad31e95b..39f67cbe3 100644 --- a/lib/solargraph/parser/node_processor/base.rb +++ b/lib/solargraph/parser/node_processor/base.rb @@ -40,6 +40,28 @@ def process private + # @return [Solargraph::Location] + def location + get_node_location(node) + end + + # @return [Solargraph::Position] + def position + Position.new(node.loc.line, node.loc.column) + end + + # @sg-ignore downcast output of Enumerable#select + # @return [Solargraph::Pin::Breakable, nil] + def enclosing_breakable_pin + pins.select{|pin| pin.is_a?(Pin::Breakable) && pin.location&.range&.contain?(position)}.last + end + + # @todo downcast output of Enumerable#select + # @return [Solargraph::Pin::CompoundStatement, nil] + def enclosing_compound_statement_pin + pins.select{|pin| pin.is_a?(Pin::CompoundStatement) && pin.location&.range&.contain?(position)}.last + end + # @param subregion [Region] # @return [void] def process_children subregion = region @@ -68,6 +90,7 @@ def comments_for(node) # @return [Pin::Closure, nil] def named_path_pin position pins.select do |pin| + # @sg-ignore Need to add nil check here pin.is_a?(Pin::Closure) && pin.path && !pin.path.empty? && pin.location.range.contain?(position) end.last end @@ -77,6 +100,7 @@ def named_path_pin position # @return [Pin::Closure, nil] def block_pin position # @todo determine if this can return a Pin::Block + # @sg-ignore Need to add nil check here pins.select { |pin| pin.is_a?(Pin::Closure) && pin.location.range.contain?(position) }.last end @@ -84,6 +108,7 @@ def block_pin position # @param position [Solargraph::Position] # @return [Pin::Closure, nil] def closure_pin position + # @sg-ignore Need to add nil check here pins.select { |pin| pin.is_a?(Pin::Closure) && pin.location.range.contain?(position) }.last end end diff --git a/lib/solargraph/parser/parser_gem/class_methods.rb b/lib/solargraph/parser/parser_gem/class_methods.rb index 2daf22fc7..5b32c19d7 100644 --- a/lib/solargraph/parser/parser_gem/class_methods.rb +++ b/lib/solargraph/parser/parser_gem/class_methods.rb @@ -8,19 +8,23 @@ module ParserGem module ClassMethods # @param code [String] # @param filename [String, nil] + # @param starting_line [Integer] must be provided so that we + # can find relevant local variables later even if this is just + # a subset of the file in question # @return [Array(Parser::AST::Node, Hash{Integer => Solargraph::Parser::Snippet})] - def parse_with_comments code, filename = nil - node = parse(code, filename) + def parse_with_comments code, filename = nil, starting_line = 0 + node = parse(code, filename, starting_line) comments = CommentRipper.new(code, filename, 0).parse [node, comments] end # @param code [String] # @param filename [String, nil] - # @param line [Integer] + # @param starting_line [Integer] + # @sg-ignore need to understand that raise does not return # @return [Parser::AST::Node] - def parse code, filename = nil, line = 0 - buffer = ::Parser::Source::Buffer.new(filename, line) + def parse code, filename = nil, starting_line = 0 + buffer = ::Parser::Source::Buffer.new(filename, starting_line) buffer.source = code parser.parse(buffer) rescue ::Parser::SyntaxError, ::Parser::UnknownEncodingInMagicComment => e @@ -38,6 +42,7 @@ def parser # @param source [Source] # @return [Array(Array, Array)] def map source + # @sg-ignore Need to add nil check here NodeProcessor.process(source.node, Region.new(source: source)) end @@ -50,15 +55,18 @@ def references source, name # @param code [String] # @param offset [Integer] # @return [Array(Integer, Integer), Array(nil, nil)] + # @sg-ignore Need to add nil check here extract_offset = ->(code, offset) { reg.match(code, offset).offset(0) } else # @param code [String] # @param offset [Integer] # @return [Array(Integer, Integer), Array(nil, nil)] + # @sg-ignore Need to add nil check here extract_offset = ->(code, offset) { [soff = code.index(name, offset), soff + name.length] } end inner_node_references(name, source.node).map do |n| rng = Range.from_node(n) + # @sg-ignore Need to add nil check here offset = Position.to_offset(source.code, rng.start) soff, eoff = extract_offset[source.code, offset] Location.new( @@ -99,7 +107,7 @@ def process_node *args Solargraph::Parser::NodeProcessor.process *args end - # @param node [Parser::AST::Node] + # @param node [Parser::AST::Node, nil] # @return [String, nil] def infer_literal_node_type node NodeMethods.infer_literal_node_type node @@ -110,7 +118,7 @@ def version parser.version end - # @param node [BasicObject] + # @param node [BasicObject, nil] # @return [Boolean] def is_ast_node? node node.is_a?(::Parser::AST::Node) @@ -124,19 +132,25 @@ def node_range node Range.new(st, en) end - # @param node [Parser::AST::Node] + # @param node [Parser::AST::Node, nil] # @return [Array] def string_ranges node return [] unless is_ast_node?(node) result = [] + # @sg-ignore Translate to something flow sensitive typing understands result.push Range.from_node(node) if node.type == :str + # @sg-ignore Translate to something flow sensitive typing understands node.children.each do |child| result.concat string_ranges(child) end + # @sg-ignore Translate to something flow sensitive typing understands if node.type == :dstr && node.children.last.nil? + # @sg-ignore Translate to something flow sensitive typing understands last = node.children[-2] + # @sg-ignore Need to add nil check here unless last.nil? rng = Range.from_node(last) + # @sg-ignore Need to add nil check here pos = Position.new(rng.ending.line, rng.ending.column - 1) result.push Range.new(pos, pos) end diff --git a/lib/solargraph/parser/parser_gem/node_chainer.rb b/lib/solargraph/parser/parser_gem/node_chainer.rb index d8d46319b..bc04c2855 100644 --- a/lib/solargraph/parser/parser_gem/node_chainer.rb +++ b/lib/solargraph/parser/parser_gem/node_chainer.rb @@ -35,9 +35,12 @@ def chain node, filename = nil, parent = nil end # @param code [String] + # @param filename [String] + # @param starting_line [Integer] + # # @return [Source::Chain] - def load_string(code) - node = Parser.parse(code.sub(/\.$/, '')) + def load_string(code, filename, starting_line) + node = Parser.parse(code.sub(/\.$/, ''), filename, starting_line) chain = NodeChainer.new(node).chain chain.links.push(Chain::Link.new) if code.end_with?('.') chain @@ -61,6 +64,7 @@ def generate_links n 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? args = [] + # @sg-ignore Need to add nil check here n.children[2..-1].each do |c| args.push NodeChainer.chain(c, @filename, n) end @@ -93,14 +97,22 @@ def generate_links n elsif [:lvar, :lvasgn].include?(n.type) result.push Chain::Call.new(n.children[0].to_s, Location.from_node(n)) elsif [:ivar, :ivasgn].include?(n.type) - result.push Chain::InstanceVariable.new(n.children[0].to_s) + result.push Chain::InstanceVariable.new(n.children[0].to_s, n, Location.from_node(n)) elsif [:cvar, :cvasgn].include?(n.type) result.push Chain::ClassVariable.new(n.children[0].to_s) elsif [:gvar, :gvasgn].include?(n.type) result.push Chain::GlobalVariable.new(n.children[0].to_s) elsif n.type == :or_asgn - new_node = n.updated(n.children[0].type, n.children[0].children + [n.children[1]]) - result.concat generate_links new_node + # @bar ||= 123 translates to: + # + # s(:or_asgn, + # s(:ivasgn, :@bar), + # s(:int, 123)) + 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]) + # this is just for a call chain, so we don't need to record the assignment + result.push(or_link) elsif [:class, :module, :def, :defs].include?(n.type) # @todo Undefined or what? result.push Chain::UNDEFINED_CALL @@ -109,7 +121,17 @@ def generate_links n elsif n.type == :or result.push Chain::Or.new([NodeChainer.chain(n.children[0], @filename), NodeChainer.chain(n.children[1], @filename, n)]) elsif n.type == :if - result.push Chain::If.new([NodeChainer.chain(n.children[1], @filename), NodeChainer.chain(n.children[2], @filename, n)]) + then_clause = if n.children[1] + 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] + 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 [:begin, :kwbegin].include?(n.type) result.concat generate_links(n.children.last) elsif n.type == :block_pass @@ -150,12 +172,15 @@ def hash_is_splatted? node def passed_block node return unless node == @node && @parent&.type == :block + # @sg-ignore Need to add nil check here NodeChainer.chain(@parent.children[2], @filename) end # @param node [Parser::AST::Node] + # @sg-ignore Need to add nil check here # @return [Array] def node_args node + # @sg-ignore Need to add nil check here node.children[2..-1].map do |child| NodeChainer.chain(child, @filename, node) end diff --git a/lib/solargraph/parser/parser_gem/node_methods.rb b/lib/solargraph/parser/parser_gem/node_methods.rb index b77c4cd47..9b7d94827 100644 --- a/lib/solargraph/parser/parser_gem/node_methods.rb +++ b/lib/solargraph/parser/parser_gem/node_methods.rb @@ -37,7 +37,7 @@ def pack_name(node) parts end - # @param node [Parser::AST::Node] + # @param node [Parser::AST::Node, nil] # @return [String, nil] def infer_literal_node_type node return nil unless node.is_a?(AST::Node) @@ -105,21 +105,24 @@ def drill_signature node, signature signature end - # @param node [Parser::AST::Node] + # @param node [Parser::AST::Node, nil] # @return [Hash{Symbol => Chain}] def convert_hash node return {} unless Parser.is_ast_node?(node) + # @sg-ignore Translate to something flow sensitive typing understands return convert_hash(node.children[0]) if node.type == :kwsplat + # @sg-ignore Translate to something flow sensitive typing understands return convert_hash(node.children[0]) if Parser.is_ast_node?(node.children[0]) && node.children[0].type == :kwsplat + # @sg-ignore Translate to something flow sensitive typing understands return {} unless node.type == :hash result = {} + # @sg-ignore Translate to something flow sensitive typing understands node.children.each do |pair| result[pair.children[0].children[0]] = Solargraph::Parser.chain(pair.children[1]) end result end - # @sg-ignore Wrong argument type for AST::Node.new: type expected AST::_ToSym, received :nil NIL_NODE = ::Parser::AST::Node.new(:nil) # @param node [Parser::AST::Node] @@ -161,12 +164,15 @@ def call_nodes_from node if node.type == :block result.push node 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..-1].each { |child| result.concat call_nodes_from(child) } end + # @sg-ignore Need to add nil check here node.children[1..-1].each { |child| result.concat call_nodes_from(child) } elsif node.type == :send result.push node result.concat call_nodes_from(node.children.first) + # @sg-ignore Need to add nil check here node.children[2..-1].each { |child| result.concat call_nodes_from(child) } elsif [:super, :zsuper].include?(node.type) result.push node @@ -211,8 +217,10 @@ def find_recipient_node cursor position = cursor.position offset = cursor.offset tree = if source.synchronized? + # @sg-ignore Need to add nil check here match = source.code[0..offset-1].match(/,\s*\z/) if match + # @sg-ignore Need to add nil check here source.tree_at(position.line, position.column - match[0].length) else source.tree_at(position.line, position.column) @@ -225,7 +233,9 @@ def find_recipient_node cursor tree.each do |node| if node.type == :send args = node.children[2..-1] + # @sg-ignore Need to add nil check here if !args.empty? + # @sg-ignore Need to add nil check here return node if prev && args.include?(prev) else if source.synchronized? @@ -303,7 +313,6 @@ def repaired_find_recipient_node cursor module DeepInference class << self CONDITIONAL_ALL_BUT_FIRST = [:if, :unless] - CONDITIONAL_ALL = [:or] ONLY_ONE_CHILD = [:return] FIRST_TWO_CHILDREN = [:rescue] COMPOUND_STATEMENTS = [:begin, :kwbegin] @@ -334,7 +343,7 @@ def value_position_nodes_only(node) # Look at known control statements and use them to find # more specific return nodes. # - # @param node [Parser::AST::Node] Statement which is in + # @param node [AST::Node] Statement which is in # value position for a method body # @param include_explicit_returns [Boolean] If true, # include the value nodes of the parameter of the @@ -348,10 +357,9 @@ def from_value_position_statement node, include_explicit_returns: true if COMPOUND_STATEMENTS.include?(node.type) result.concat from_value_position_compound_statement node elsif CONDITIONAL_ALL_BUT_FIRST.include?(node.type) + # @sg-ignore Need to add nil check here result.concat reduce_to_value_nodes(node.children[1..-1]) # result.push NIL_NODE unless node.children[2] - elsif CONDITIONAL_ALL.include?(node.type) - result.concat reduce_to_value_nodes(node.children) elsif ONLY_ONE_CHILD.include?(node.type) result.concat reduce_to_value_nodes([node.children[0]]) elsif FIRST_TWO_CHILDREN.include?(node.type) @@ -364,6 +372,7 @@ def from_value_position_statement node, include_explicit_returns: true # that the function is executed here. result.concat explicit_return_values_from_compound_statement(node.children[2]) if include_explicit_returns elsif CASE_STATEMENT.include?(node.type) + # @sg-ignore Need to add nil check here node.children[1..-1].each do |cc| if cc.nil? result.push NIL_NODE @@ -460,17 +469,28 @@ 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 result.concat reduce_to_value_nodes(node.children[1..-1]) + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif node.type == :return + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check result.concat reduce_to_value_nodes([node.children[0]]) + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif node.type == :or + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check result.concat reduce_to_value_nodes(node.children) + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif node.type == :block + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check result.concat explicit_return_values_from_compound_statement(node.children[2]) + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check elsif node.type == :resbody + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check result.concat reduce_to_value_nodes([node.children[2]]) else result.push node diff --git a/lib/solargraph/parser/parser_gem/node_processors.rb b/lib/solargraph/parser/parser_gem/node_processors.rb index e2cb828da..5f1634bba 100644 --- a/lib/solargraph/parser/parser_gem/node_processors.rb +++ b/lib/solargraph/parser/parser_gem/node_processors.rb @@ -27,8 +27,10 @@ module NodeProcessors autoload :SymNode, 'solargraph/parser/parser_gem/node_processors/sym_node' autoload :ResbodyNode, 'solargraph/parser/parser_gem/node_processors/resbody_node' autoload :UntilNode, 'solargraph/parser/parser_gem/node_processors/until_node' + autoload :WhenNode, 'solargraph/parser/parser_gem/node_processors/when_node' autoload :WhileNode, 'solargraph/parser/parser_gem/node_processors/while_node' autoload :AndNode, 'solargraph/parser/parser_gem/node_processors/and_node' + autoload :OrNode, 'solargraph/parser/parser_gem/node_processors/or_node' end end @@ -63,8 +65,10 @@ module NodeProcessor register :op_asgn, ParserGem::NodeProcessors::OpasgnNode register :sym, ParserGem::NodeProcessors::SymNode register :until, ParserGem::NodeProcessors::UntilNode + register :when, ParserGem::NodeProcessors::WhenNode register :while, ParserGem::NodeProcessors::WhileNode register :and, ParserGem::NodeProcessors::AndNode + register :or, ParserGem::NodeProcessors::OrNode end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb index a761ae38c..085c0c68a 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -10,10 +10,9 @@ class AndNode < Parser::NodeProcessor::Base def process process_children - position = get_node_start_position(node) - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 - enclosing_breakable_pin = pins.select{|pin| pin.is_a?(Pin::Breakable) && pin.location.range.contain?(position)}.last - FlowSensitiveTyping.new(locals, enclosing_breakable_pin).process_and(node) + FlowSensitiveTyping.new(locals, + enclosing_breakable_pin, + enclosing_compound_statement_pin).process_and(node) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/args_node.rb b/lib/solargraph/parser/parser_gem/node_processors/args_node.rb index 8d601bf6e..ef7630921 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/args_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/args_node.rb @@ -20,6 +20,7 @@ def process name: u.children[0].to_s, assignment: u.children[1], asgn_code: u.children[1] ? region.code_for(u.children[1]) : nil, + # @sg-ignore Need to add nil check here presence: callable.location.range, decl: get_decl(u), source: :parser @@ -40,6 +41,7 @@ def forward(callable) locals.push Solargraph::Pin::Parameter.new( location: loc, closure: callable, + # @sg-ignore Need to add nil check here presence: region.closure.location.range, decl: get_decl(node), source: :parser diff --git a/lib/solargraph/parser/parser_gem/node_processors/begin_node.rb b/lib/solargraph/parser/parser_gem/node_processors/begin_node.rb index b52b9d3c6..19e53a681 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/begin_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/begin_node.rb @@ -6,6 +6,15 @@ module ParserGem module NodeProcessors class BeginNode < Parser::NodeProcessor::Base def process + # We intentionally don't create a CompoundStatement pin + # here, as this is not necessarily a control flow block - + # e.g., a begin...end without rescue or ensure should be + # treated by flow-sensitive typing as if the begin and end + # didn't exist at all. As such, we create the + # CompoundStatement pins around the things which actually + # result in control flow changes - like + # if/while/rescue/etc + process_children 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 d773e8e50..b16bde064 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/block_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/block_node.rb @@ -9,23 +9,22 @@ class BlockNode < Parser::NodeProcessor::Base def process location = get_node_location(node) - parent = if other_class_eval? - Solargraph::Pin::Namespace.new( - location: location, - type: :class, - name: unpack_name(node.children[0].children[0]), - source: :parser, - ) - else - region.closure + scope = region.scope || region.closure.context.scope + if other_class_eval? + clazz_name = unpack_name(node.children[0].children[0]) + # instance variables should come from the Class type + # - i.e., treated as class instance variables + context = ComplexType.try_parse("Class<#{clazz_name}>") + scope = :class end block_pin = Solargraph::Pin::Block.new( location: location, - closure: parent, + closure: region.closure, node: node, + context: context, receiver: node.children[0], comments: comments_for(node), - scope: region.scope || region.closure.context.scope, + scope: scope, source: :parser ) pins.push block_pin @@ -37,6 +36,7 @@ def process def other_class_eval? node.children[0].type == :send && node.children[0].children[1] == :class_eval && + # @sg-ignore Need to add nil check here [:cbase, :const].include?(node.children[0].children[0]&.type) end end 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 47c01e728..1b9fd442d 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/def_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/def_node.rb @@ -8,10 +8,15 @@ class DefNode < Parser::NodeProcessor::Base def process name = node.children[0].to_s scope = region.scope || (region.closure.is_a?(Pin::Singleton) ? :class : :instance) + # specify context explicitly instead of relying on + # closure, as they may differ (e.g., defs inside + # class_eval) + method_context = scope == :instance ? region.closure.binder.namespace_type : region.closure.binder methpin = Solargraph::Pin::Method.new( location: get_node_location(node), closure: region.closure, name: name, + context: method_context, comments: comments_for(node), scope: scope, visibility: scope == :instance && name == 'initialize' ? :private : region.visibility, @@ -23,6 +28,7 @@ def process location: methpin.location, closure: methpin.closure, name: methpin.name, + context: method_context, comments: methpin.comments, scope: :class, visibility: :public, @@ -34,6 +40,7 @@ def process location: methpin.location, closure: methpin.closure, name: methpin.name, + context: method_context, comments: methpin.comments, scope: :instance, visibility: :private, 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 2452b9cc5..9b0bbd978 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/if_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/if_node.rb @@ -8,13 +8,42 @@ class IfNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - process_children + FlowSensitiveTyping.new(locals, + 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, + node: condition_node, + source: :parser, + ) + NodeProcessor.process(condition_node, region, pins, locals) + end + then_node = node.children[1] + if then_node + pins.push Solargraph::Pin::CompoundStatement.new( + location: get_node_location(then_node), + closure: region.closure, + node: then_node, + source: :parser, + ) + NodeProcessor.process(then_node, region, pins, locals) + end - position = get_node_start_position(node) - # @sg-ignore - # @type [Solargraph::Pin::Breakable, nil] - enclosing_breakable_pin = pins.select{|pin| pin.is_a?(Pin::Breakable) && pin.location.range.contain?(position)}.last - FlowSensitiveTyping.new(locals, enclosing_breakable_pin).process_if(node) + else_node = node.children[2] + if else_node + pins.push Solargraph::Pin::CompoundStatement.new( + location: get_node_location(else_node), + closure: region.closure, + node: else_node, + source: :parser, + ) + NodeProcessor.process(else_node, region, pins, locals) + end + + true end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb index 021ae0ab1..d05ecc41c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb @@ -19,6 +19,7 @@ def process ) if region.visibility == :module_function here = get_node_start_position(node) + # @type [Pin::Closure, nil] named_path = named_path_pin(here) if named_path.is_a?(Pin::Method) pins.push Solargraph::Pin::InstanceVariable.new( 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 938483652..63e2c55dc 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/lvasgn_node.rb @@ -9,6 +9,7 @@ class LvasgnNode < Parser::NodeProcessor::Base def process here = get_node_start_position(node) + # @sg-ignore Need to add nil check here presence = Range.new(here, region.closure.location.range.ending) loc = get_node_location(node) locals.push Solargraph::Pin::LocalVariable.new( diff --git a/lib/solargraph/parser/parser_gem/node_processors/or_node.rb b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb new file mode 100644 index 000000000..c77bad1d6 --- /dev/null +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Solargraph + module Parser + module ParserGem + module NodeProcessors + class OrNode < Parser::NodeProcessor::Base + include ParserGem::NodeMethods + + def process + process_children + + FlowSensitiveTyping.new(locals, + enclosing_breakable_pin, + enclosing_compound_statement_pin).process_or(node) + end + end + end + 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 21e32bd22..c59c2ee04 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb @@ -11,6 +11,7 @@ class ResbodyNode < Parser::NodeProcessor::Base def process if node.children[1] # Exception local variable name 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) loc = get_node_location(node.children[1]) types = if node.children[0].nil? 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 1b573ed93..d3d2cef4f 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb @@ -5,6 +5,7 @@ module Parser module ParserGem module NodeProcessors class SclassNode < Parser::NodeProcessor::Base + # @sg-ignore @override is adding, not overriding def process sclass = node.children[0] # @todo Changing Parser::AST::Node to AST::Node below will 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 861d6b157..bbb210012 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb @@ -7,6 +7,7 @@ module NodeProcessors class SendNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods + # @sg-ignore @override is adding, not overriding def process # @sg-ignore Variable type could not be inferred for method_name # @type [Symbol] @@ -53,6 +54,7 @@ def process # @return [void] def process_visibility if (node.children.length > 2) + # @sg-ignore Need to add nil check here node.children[2..-1].each do |child| # @sg-ignore Variable type could not be inferred for method_name # @type [Symbol] @@ -82,6 +84,7 @@ def process_visibility # @return [void] def process_attribute + # @sg-ignore Need to add nil check here node.children[2..-1].each do |a| loc = get_node_location(node) clos = region.closure @@ -122,6 +125,7 @@ def process_attribute def process_include if node.children[2].is_a?(AST::Node) && node.children[2].type == :const cp = region.closure + # @sg-ignore Need to add nil check here node.children[2..-1].each do |i| type = region.scope == :class ? Pin::Reference::Extend : Pin::Reference::Include pins.push type.new( @@ -138,6 +142,7 @@ def process_include def process_prepend if node.children[2].is_a?(AST::Node) && node.children[2].type == :const cp = region.closure + # @sg-ignore Need to add nil check here node.children[2..-1].each do |i| pins.push Pin::Reference::Prepend.new( location: get_node_location(i), @@ -151,6 +156,7 @@ def process_prepend # @return [void] def process_extend + # @sg-ignore Need to add nil check here node.children[2..-1].each do |i| loc = get_node_location(node) if i.type == :self @@ -193,6 +199,7 @@ def process_module_function # @todo Smelly instance variable access region.instance_variable_set(:@visibility, :module_function) elsif node.children[2].type == :sym || node.children[2].type == :str + # @sg-ignore Need to add nil check here node.children[2..-1].each do |x| cn = x.children[0].to_s # @type [Pin::Method, nil] @@ -228,7 +235,6 @@ def process_module_function closure: cm, name: ivar.name, comments: ivar.comments, - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 assignment: ivar.assignment, source: :parser ) @@ -237,7 +243,6 @@ def process_module_function closure: mm, name: ivar.name, comments: ivar.comments, - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 assignment: ivar.assignment, source: :parser ) diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb new file mode 100644 index 000000000..b2b11dec1 --- /dev/null +++ b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Solargraph + module Parser + module ParserGem + module NodeProcessors + class WhenNode < Parser::NodeProcessor::Base + include ParserGem::NodeMethods + + def process + pins.push Solargraph::Pin::CompoundStatement.new( + location: get_node_location(node), + closure: region.closure, + node: node, + source: :parser, + ) + process_children + end + end + end + 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 c9211448e..4b025121e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/while_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/while_node.rb @@ -8,7 +8,10 @@ class WhileNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - location = get_node_location(node) + FlowSensitiveTyping.new(locals, + enclosing_breakable_pin, + enclosing_compound_statement_pin).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 diff --git a/lib/solargraph/parser/region.rb b/lib/solargraph/parser/region.rb index a6559bc8a..c1906e646 100644 --- a/lib/solargraph/parser/region.rb +++ b/lib/solargraph/parser/region.rb @@ -22,7 +22,6 @@ class Region attr_reader :lvars # @param source [Source] - # @param namespace [String] # @param closure [Pin::Closure, nil] # @param scope [Symbol, nil] # @param visibility [Symbol] @@ -30,14 +29,13 @@ class Region def initialize source: Solargraph::Source.load_string(''), closure: nil, scope: nil, visibility: :public, lvars: [] @source = source - # @closure = closure @closure = closure || Pin::Namespace.new(name: '', location: source.location, source: :parser) @scope = scope @visibility = visibility @lvars = lvars end - # @return [String] + # @return [String, nil] def filename source.filename end diff --git a/lib/solargraph/parser/snippet.rb b/lib/solargraph/parser/snippet.rb index 081dec3e0..1ea6bd6d9 100644 --- a/lib/solargraph/parser/snippet.rb +++ b/lib/solargraph/parser/snippet.rb @@ -1,7 +1,7 @@ module Solargraph module Parser class Snippet - # @return [Range] + # @return [Solargraph::Range] attr_reader :range # @return [String] attr_reader :text diff --git a/lib/solargraph/pin.rb b/lib/solargraph/pin.rb index 526ac6fc3..6cd6fcaf9 100644 --- a/lib/solargraph/pin.rb +++ b/lib/solargraph/pin.rb @@ -38,6 +38,8 @@ module Pin autoload :Until, 'solargraph/pin/until' autoload :While, 'solargraph/pin/while' autoload :Callable, 'solargraph/pin/callable' + autoload :CompoundStatement, + 'solargraph/pin/compound_statement' ROOT_PIN = Pin::Namespace.new(type: :class, name: '', closure: nil, source: :pin_rb) end diff --git a/lib/solargraph/pin/base.rb b/lib/solargraph/pin/base.rb index 57d083453..b6b3a2fb3 100644 --- a/lib/solargraph/pin/base.rb +++ b/lib/solargraph/pin/base.rb @@ -41,7 +41,7 @@ def presence_certain? # @param type_location [Solargraph::Location, nil] # @param closure [Solargraph::Pin::Closure, nil] # @param name [String] - # @param comments [String] + # @param comments [String, nil] # @param source [Symbol, nil] # @param docstring [YARD::Docstring, nil] # @param directives [::Array, nil] @@ -57,6 +57,9 @@ def initialize location: nil, type_location: nil, closure: nil, source: nil, nam @docstring = docstring @directives = directives @combine_priority = combine_priority + # @type [ComplexType, ComplexType::UniqueType, nil] + @binder = nil + assert_source_provided assert_location_provided @@ -72,7 +75,6 @@ def assert_location_provided # @return [Pin::Closure, nil] def closure Solargraph.assert_or_log(:closure, "Closure not set on #{self.class} #{name.inspect} from #{source.inspect}") unless @closure - # @type [Pin::Closure, nil] @closure end @@ -81,7 +83,6 @@ def closure # # @return [self] def combine_with(other, attrs={}) - raise "tried to combine #{other.class} with #{self.class}" unless other.class == self.class priority_choice = choose_priority(other) return priority_choice unless priority_choice.nil? @@ -92,7 +93,7 @@ def combine_with(other, attrs={}) location: location, type_location: type_location, name: combined_name, - closure: choose_pin_attr_with_same_name(other, :closure), + closure: combine_closure(other), comments: choose_longer(other, :comments), source: :combined, docstring: choose(other, :docstring), @@ -140,14 +141,22 @@ def choose_longer(other, attr) end # @param other [self] + # # @return [::Array, nil] def combine_directives(other) return self.directives if other.directives.empty? return other.directives if directives.empty? - [directives + other.directives].uniq + (directives + other.directives).uniq + end + + # @param other [self] + # @return [Pin::Closure, nil] + def combine_closure(other) + choose_pin_attr_with_same_name(other, :closure) end # @param other [self] + # @sg-ignore @type should override probed type # @return [String] def combine_name(other) if needs_consistent_name? || other.needs_consistent_name? @@ -170,6 +179,9 @@ def reset_generated! # Same with @directives, @macros, @maybe_directives, which # regenerate docstring @deprecated = nil + @context = nil + @binder = nil + @path = nil reset_conversions end @@ -189,6 +201,10 @@ def combine_return_type(other) other.return_type elsif other.return_type.undefined? return_type + elsif return_type.erased_version_of?(other.return_type) + other.return_type + elsif other.return_type.erased_version_of?(return_type) + return_type elsif dodgy_return_type_source? && !other.dodgy_return_type_source? other.return_type elsif other.dodgy_return_type_source? && !dodgy_return_type_source? @@ -203,9 +219,12 @@ def combine_return_type(other) end end + # @sg-ignore need boolish support for ? methods def dodgy_return_type_source? # uses a lot of 'Object' instead of 'self' - location&.filename&.include?('core_ext/object/') + location&.filename&.include?('core_ext/object/') || + # ditto + location&.filename&.include?('stdlib/date/0/date.rbs') end # when choices are arbitrary, make sure the choice is consistent @@ -213,7 +232,8 @@ def dodgy_return_type_source? # @param other [Pin::Base] # @param attr [::Symbol] # - # @return [Object, nil] + # @sg-ignore + # @return [undefined, nil] def choose(other, attr) results = [self, other].map(&attr).compact # true and false are different classes and can't be sorted @@ -250,6 +270,7 @@ def prefer_rbs_location(other, attr) end end + # @sg-ignore need boolish support for ? methods def rbs_location? type_location&.rbs? end @@ -309,7 +330,11 @@ def assert_same_count(other, attr) # @sg-ignore # @return [undefined] def assert_same(other, attr) - return false if other.nil? + if other.nil? + Solargraph.assert_or_log("combine_with_#{attr}_nil".to_sym, + "Other was passed in nil in assert_same on #{self}") + return send(attr) + end val1 = send(attr) val2 = other.send(attr) return val1 if val1 == val2 @@ -358,8 +383,11 @@ def choose_pin_attr(other, attr) [ # maximize number of gates, as types in other combined pins may # depend on those gates + + # @sg-ignore Need better handling of #compact closure.gates.length, # use basename so that results don't vary system to system + # @sg-ignore Need better handling of #compact File.basename(closure.best_location.to_s) ] end @@ -376,7 +404,7 @@ def comments end # @param generics_to_resolve [Enumerable] - # @param return_type_context [ComplexType, nil] + # @param return_type_context [ComplexType, ComplexType::UniqueType, nil] # @param context [ComplexType] # @param resolved_generic_values [Hash{String => ComplexType}] # @return [self] @@ -418,6 +446,7 @@ 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 @@ -453,11 +482,16 @@ def best_location # @return [Boolean] def nearly? other self.class == other.class && + # @sg-ignore Translate to something flow sensitive typing understands name == other.name && + # @sg-ignore flow sensitive typing needs to handle attrs (closure == other.closure || (closure && closure.nearly?(other.closure))) && + # @sg-ignore Translate to something flow sensitive typing understands (comments == other.comments || + # @sg-ignore Translate to something flow sensitive typing understands (((maybe_directives? == false && other.maybe_directives? == false) || compare_directives(directives, other.directives)) && - compare_docstring_tags(docstring, other.docstring)) + # @sg-ignore Translate to something flow sensitive typing understands + compare_docstring_tags(docstring, other.docstring)) ) end @@ -484,6 +518,7 @@ def docstring @docstring ||= Solargraph::Source.parse_docstring('').to_docstring end + # @sg-ignore parse_comments will always set @directives # @return [::Array] def directives parse_comments unless @directives @@ -504,6 +539,7 @@ def macros # # @return [Boolean] def maybe_directives? + # @sg-ignore flow sensitive typing needs to handle ivars return !@directives.empty? if defined?(@directives) && @directives @maybe_directives ||= comments.include?('@!') end @@ -520,7 +556,7 @@ def deprecated? # provided ApiMap. # # @param api_map [ApiMap] - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def typify api_map return_type.qualify(api_map, *(closure&.gates || [''])) end @@ -528,14 +564,14 @@ def typify api_map # Infer the pin's return type via static code analysis. # # @param api_map [ApiMap] - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def probe api_map typify api_map end # @deprecated Use #typify and/or #probe instead # @param api_map [ApiMap] - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def infer api_map Solargraph::Logging.logger.warn "WARNING: Pin #infer methods are deprecated. Use #typify or #probe instead." type = typify(api_map) @@ -568,7 +604,7 @@ def realize api_map # the return type and the #proxied? setting, the proxy should be a clone # of the original. # - # @param return_type [ComplexType] + # @param return_type [ComplexType, ComplexType::UniqueType, nil] # @return [self] def proxy return_type result = dup @@ -618,7 +654,7 @@ def type_desc # @return [String] def inner_desc - closure_info = closure&.desc + closure_info = closure&.name.inspect binder_info = binder&.desc "name=#{name.inspect} return_type=#{type_desc}, context=#{context.rooted_tags}, closure=#{closure_info}, binder=#{binder_info}" end @@ -646,10 +682,6 @@ def all_location_text end end - # @return [void] - def reset_generated! - end - protected # @return [Boolean] @@ -658,7 +690,7 @@ def reset_generated! # @return [Boolean] attr_writer :proxied - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType, nil] attr_writer :return_type attr_writer :docstring diff --git a/lib/solargraph/pin/base_variable.rb b/lib/solargraph/pin/base_variable.rb index ab7bd3961..3ec34c775 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -6,31 +6,130 @@ class BaseVariable < Base # include Solargraph::Source::NodeMethods include Solargraph::Parser::NodeMethods - # @return [Parser::AST::Node, nil] - attr_reader :assignment + # @return [Array] + attr_reader :assignments attr_accessor :mass_assignment + # @return [Range, nil] + attr_reader :presence + # @param return_type [ComplexType, nil] + # @param assignment [Parser::AST::Node, nil] First assignment + # that was made to this variable + # @param assignments [Array] Possible + # assignments that may have been made to this variable # @param mass_assignment [::Array(Parser::AST::Node, Integer), nil] # @param assignment [Parser::AST::Node, nil] - def initialize assignment: nil, return_type: nil, mass_assignment: nil, **splat + # @param assignments [::Array] + # @param exclude_return_type [ComplexType, nil] Ensure any + # return type returned will never include any of these unique + # types in the unique types of its complex type. + # + # 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 + # 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. + # + # Example: If a return type is 'Numeric | nil' and the + # intersection_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 + # @param presence [Range, nil] + # @param presence_certain [Boolean] + def initialize assignment: nil, assignments: [], mass_assignment: nil, return_type: nil, + intersection_return_type: nil, exclude_return_type: nil, + presence: nil, presence_certain: false, + **splat super(**splat) - @assignment = assignment + @assignments = (assignment.nil? ? [] : [assignment]) + assignments # @type [nil, ::Array(Parser::AST::Node, Integer)] - @mass_assignment = nil + @mass_assignment = mass_assignment @return_type = return_type + @intersection_return_type = intersection_return_type + @exclude_return_type = exclude_return_type + @presence = presence + @presence_certain = presence_certain + end + + # @param presence [Range] + # @param exclude_return_type [ComplexType, nil] + # @param intersection_return_type [ComplexType, nil] + # @param source [::Symbol] + # + # @return [self] + def downcast presence:, exclude_return_type: nil, intersection_return_type: nil, + source: self.source + result = dup + result.exclude_return_type = exclude_return_type + result.intersection_return_type = intersection_return_type + result.source = source + result.presence = presence + result.reset_generated! + result + end + + def reset_generated! + @assignment = nil + super end def combine_with(other, attrs={}) + new_assignments = combine_assignments(other) new_attrs = attrs.merge({ - assignment: assert_same(other, :assignment), - mass_assignment: assert_same(other, :mass_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), + presence_certain: combine_presence_certain(other) }) super(other, new_attrs) end + def inner_desc + super + ", intersection_return_type=#{intersection_return_type&.rooted_tags.inspect}, exclude_return_type=#{exclude_return_type&.rooted_tags.inspect}, presence=#{presence.inspect}, assignments=#{assignments}" + end + + # @param other [self] + # + # @return [Array(AST::Node, Integer), nil] + def combine_mass_assignment(other) + # @todo pick first non-nil arbitrarily - we don't yet support + # mass assignment merging + mass_assignment || other.mass_assignment + end + + # If a certain pin is being combined with an uncertain pin, we + # end up with a certain result + # + # @param other [self] + # + # @return [Boolean] + def combine_presence_certain(other) + presence_certain? || other.presence_certain? + end + + # @return [Parser::AST::Node, nil] + def assignment + @assignment ||= assignments.last + end + + # @param other [self] + # + # @return [::Array] + def combine_assignments(other) + (other.assignments + assignments).uniq + end + def completion_item_kind Solargraph::LanguageServer::CompletionItemKinds::VARIABLE end @@ -40,10 +139,6 @@ def symbol_kind Solargraph::LanguageServer::SymbolKinds::VARIABLE end - def return_type - @return_type ||= generate_complex_type - end - def nil_assignment? # this will always be false - should it be return_type == # ComplexType::NIL or somesuch? @@ -67,10 +162,12 @@ def return_types_from_node(parent_node, api_map) rng = Range.from_node(node) next if rng.nil? pos = rng.ending + # @sg-ignore Need to add nil check here clip = api_map.clip_at(location.filename, pos) # 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) + # @sg-ignore Need to add nil check here result = chain.infer(api_map, closure, clip.locals).self_to_type(closure.context) types.push result unless result.undefined? end @@ -79,13 +176,15 @@ def return_types_from_node(parent_node, api_map) end # @param api_map [ApiMap] - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def probe api_map - unless @assignment.nil? - types = return_types_from_node(@assignment, api_map) - return ComplexType.new(types.uniq) unless types.empty? - end + 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? + return adjust_type api_map, type_from_assignment unless type_from_assignment.nil? + # @todo should handle merging types from mass assignments as + # well so that we can do better flow sensitive typing with + # multiple assignments unless @mass_assignment.nil? mass_node, index = @mass_assignment types = return_types_from_node(mass_node, api_map) @@ -96,7 +195,10 @@ def probe api_map type.all_params.first end end.compact! - return ComplexType.new(types.uniq) unless types.empty? + + return ComplexType::UNDEFINED if types.empty? + + return adjust_type api_map, ComplexType.new(types.uniq).qualify(api_map, *gates) end ComplexType::UNDEFINED @@ -113,13 +215,194 @@ def type_desc "#{super} = #{assignment&.type.inspect}" end + # @return [ComplexType, nil] + def return_type + generate_complex_type || @return_type || intersection_return_type || ComplexType::UNDEFINED + end + + def typify api_map + raw_return_type = super + + adjust_type(api_map, raw_return_type) + end + + # @sg-ignore need boolish support for ? methods + def presence_certain? + exclude_return_type || intersection_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 + + # Narrow the presence range to the intersection of both. + # + # @param other [self] + # + # @return [Range, nil] + def combine_presence(other) + return presence || other.presence if presence.nil? || other.presence.nil? + + # @sg-ignore flow sensitive typing needs to handle attrs + Range.new([presence.start, other.presence.start].max, [presence.ending, other.presence.ending].min) + end + + # @param other [self] + # @return [Pin::Closure, nil] + def combine_closure(other) + return closure if self.closure == other.closure + + # choose first defined, as that establishes the scope of the variable + if closure.nil? || other.closure.nil? + Solargraph.assert_or_log(:varible_closure_missing) do + "One of the local variables being combined is missing a closure: " \ + "#{self.inspect} vs #{other.inspect}" + end + 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 + end + + # @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 + location.filename == other_loc.filename && + # @sg-ignore flow sensitive typing needs to handle attrs + (!presence || presence.include?(other_loc.range.start)) && + visible_in_closure?(other_closure) + end + + def presence_certain? + @presence_certain + end + + protected + + attr_accessor :exclude_return_type, :intersection_return_type + + # @return [Range] + attr_writer :presence + private - # @return [ComplexType] + # @param api_map [ApiMap] + # @param raw_return_type [ComplexType, ComplexType::UniqueType] + # + # @return [ComplexType, ComplexType::UniqueType] + 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 + end + + # @param other [self] + # @return [Pin::Closure, nil] + def combine_closure(other) + return closure if self.closure == other.closure + + # choose first defined, as that establishes the scope of the variable + if closure.nil? || other.closure.nil? + Solargraph.assert_or_log(:varible_closure_missing) do + "One of the local variables being combined is missing a closure: " \ + "#{self.inspect} vs #{other.inspect}" + end + return closure || other.closure + end + + # @sg-ignore Need to add nil check here + if closure.location.nil? || other.closure.location.nil? + # @sg-ignore Need to add nil check here + 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 + end + + # See if this variable is visible within 'viewing_closure' + # + # @param viewing_closure [Pin::Closure] + # @return [Boolean] + def visible_in_closure? viewing_closure + return false if closure.nil? + + # 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 + # in their parent closure, so stop here + return false if scope == :instance && viewing_closure.is_a?(Pin::Namespace) + + parent_of_viewing_closure = viewing_closure.closure + + return false if parent_of_viewing_closure.nil? + + visible_in_closure?(parent_of_viewing_closure) + end + + # @param other [self] + # @return [ComplexType, nil] + def combine_return_type(other) + combine_types(other, :return_type) + end + + # @param other [self] + # @param attr [::Symbol] + # + # @return [ComplexType, nil] + def combine_types(other, attr) + # @type [ComplexType, nil] + type1 = send(attr) + # @type [ComplexType, nil] + type2 = other.send(attr) + if type1 && type2 + types = (type1.items + type2.items).uniq + ComplexType.new(types) + else + type1 || type2 + end + end + + # @return [::Symbol] + def scope + :instance + end + + # @return [ComplexType, nil] def generate_complex_type tag = docstring.tag(:type) return ComplexType.try_parse(*tag.types) unless tag.nil? || tag.types.nil? || tag.types.empty? - ComplexType.new + nil end end end diff --git a/lib/solargraph/pin/block.rb b/lib/solargraph/pin/block.rb index 0c6ecd258..39f0d6495 100644 --- a/lib/solargraph/pin/block.rb +++ b/lib/solargraph/pin/block.rb @@ -21,6 +21,7 @@ def initialize receiver: nil, args: [], context: nil, node: nil, **splat @context = context @return_type = ComplexType.parse('::Proc') @node = node + @name = '' end # @param api_map [ApiMap] @@ -30,7 +31,13 @@ def rebind api_map end def binder - @rebind&.defined? ? @rebind : closure.binder + out = @rebind if @rebind&.defined? + out ||= super + end + + def context + @context = @rebind if @rebind&.defined? + super end # @param yield_types [::Array] @@ -50,14 +57,17 @@ def destructure_yield_types(yield_types, parameters) # @return [::Array] def typify_parameters(api_map) chain = Parser.chain(receiver, filename, node) + # @sg-ignore Need to add nil check here clip = api_map.clip_at(location.filename, location.range.start) locals = clip.locals - [self] + # @sg-ignore Need to add nil check here meths = chain.define(api_map, closure, locals) # @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 @@ -67,6 +77,7 @@ def typify_parameters(api_map) 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 arg_type.resolve_generics(namespace_pin, param_type) else @@ -86,16 +97,27 @@ def typify_parameters(api_map) def maybe_rebind api_map return ComplexType::UNDEFINED unless receiver - chain = Parser.chain(receiver, location.filename) + # @sg-ignore Need to add nil check here + chain = Parser.chain(receiver, location.filename, node) + # @sg-ignore Need to add nil check here locals = api_map.source_map(location.filename).locals_at(location) + # @sg-ignore Need to add nil check here receiver_pin = chain.define(api_map, closure, locals).first return ComplexType::UNDEFINED unless receiver_pin types = receiver_pin.docstring.tag(:yieldreceiver)&.types return ComplexType::UNDEFINED unless types&.any? - target = chain.base.infer(api_map, receiver_pin, locals) - target = full_context unless target.defined? + name_pin = self + # if we have Foo.bar { |x| ... }, and the bar method references self... + target = if chain.base.defined? + # figure out Foo + chain.base.infer(api_map, name_pin, locals) + else + # if not, any self there must be the context of our closure + # @sg-ignore Need to add nil check here + closure.full_context + end ComplexType.try_parse(*types).qualify(api_map, *receiver_pin.gates).self_to_type(target) end diff --git a/lib/solargraph/pin/breakable.rb b/lib/solargraph/pin/breakable.rb index 05907b1bb..7cf6df9ab 100644 --- a/lib/solargraph/pin/breakable.rb +++ b/lib/solargraph/pin/breakable.rb @@ -1,9 +1,13 @@ module Solargraph module Pin - # Mix-in for pins which enclose code which the 'break' statement works with-in - e.g., blocks, when, until, ... + # Mix-in for pins which enclose code which the 'break' statement + # works with-in - e.g., blocks, when, until, ... module Breakable # @return [Parser::AST::Node] attr_reader :node + + # @return [Location, nil] + attr_reader :location end end end diff --git a/lib/solargraph/pin/callable.rb b/lib/solargraph/pin/callable.rb index edbc3f941..550d008f6 100644 --- a/lib/solargraph/pin/callable.rb +++ b/lib/solargraph/pin/callable.rb @@ -21,8 +21,10 @@ def initialize block: nil, return_type: nil, parameters: [], **splat @parameters = parameters end + # @sg-ignore Need to add nil check here # @return [String] def method_namespace + # @sg-ignore Need to add nil check here closure.namespace end @@ -80,6 +82,7 @@ def choose_parameters(other) end end + # @sg-ignore Need to add nil check here # @return [Array] def blockless_parameters if parameters.last&.block? @@ -89,11 +92,33 @@ def blockless_parameters end end - # @return [Array] + # e.g., [["T"], "", "?", "foo:"] - parameter arity declarations, + # ignoring positional names. Used to match signatures. + # + # @return [Array, String, nil>] def arity [generics, blockless_parameters.map(&:arity_decl), block&.arity] end + # e.g., [["T"], "1", "?3", "foo:5"] - parameter arity + # declarations, including the number of unique types in each + # parameter. Used to determine whether combining two + # signatures has lost useful information mapping specific + # parameter types to specific return types. + # + # @return [Array] + def type_arity + [generics, blockless_parameters.map(&:type_arity_decl), block&.type_arity] + end + + # Same as type_arity, but includes return type arity at the front. + # + # @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 + # @param generics_to_resolve [Enumerable] # @param arg_types [Array, nil] # @param return_type_context [ComplexType, nil] @@ -101,6 +126,7 @@ def arity # @param yield_return_type_context [ComplexType, nil] # @param context [ComplexType, nil] # @param resolved_generic_values [Hash{String => ComplexType}] + # # @return [self] def resolve_generics_from_context(generics_to_resolve, arg_types = nil, @@ -137,9 +163,11 @@ def typify api_map end end + # @sg-ignore Need to add nil check here # @return [String] def method_name raise "closure was nil in #{self.inspect}" if closure.nil? + # @sg-ignore Need to add nil check here @method_name ||= closure.name end @@ -150,6 +178,7 @@ def method_name # @param yield_return_type_context [ComplexType, nil] # @param context [ComplexType, nil] # @param resolved_generic_values [Hash{String => ComplexType}] + # # @return [self] def resolve_generics_from_context_until_complete(generics_to_resolve, arg_types = nil, @@ -184,7 +213,6 @@ def resolve_generics_from_context_until_complete(generics_to_resolve, resolved_generic_values: resolved_generic_values) end - # @return [Array] # @yieldparam [ComplexType] # @yieldreturn [ComplexType] # @return [self] @@ -215,8 +243,14 @@ def mandatory_positional_param_count parameters.count(&:arg?) end + # @return [String] + def parameters_to_rbs + # @sg-ignore Need to add nil check here + rbs_generics + '(' + parameters.map { |param| param.to_rbs }.join(', ') + ') ' + (block.nil? ? '' : '{ ' + block.to_rbs + ' } ') + end + def to_rbs - rbs_generics + '(' + parameters.map { |param| param.to_rbs }.join(', ') + ') ' + (block.nil? ? '' : '{ ' + block.to_rbs + ' } ') + '-> ' + return_type.to_rbs + parameters_to_rbs + '-> ' + (return_type&.to_rbs || 'untyped') end def block? diff --git a/lib/solargraph/pin/closure.rb b/lib/solargraph/pin/closure.rb index a7b37e01b..8df7167e9 100644 --- a/lib/solargraph/pin/closure.rb +++ b/lib/solargraph/pin/closure.rb @@ -2,12 +2,12 @@ module Solargraph module Pin - class Closure < Base + class Closure < CompoundStatement # @return [::Symbol] :class or :instance attr_reader :scope # @param scope [::Symbol] :class or :instance - # @param generics [::Array, nil] + # @param generics [::Array, nil] # @param generic_defaults [Hash{String => ComplexType}] def initialize scope: :class, generics: nil, generic_defaults: {}, **splat super(**splat) @@ -44,6 +44,7 @@ def context end end + # @return [ComplexType, ComplexType::UniqueType] def binder @binder || context end diff --git a/lib/solargraph/pin/common.rb b/lib/solargraph/pin/common.rb index 062099ee4..658c983e2 100644 --- a/lib/solargraph/pin/common.rb +++ b/lib/solargraph/pin/common.rb @@ -6,12 +6,24 @@ module Common # @!method source # @abstract # @return [Source, nil] + # @!method reset_generated! + # @abstract + # @return [void] # @type @closure [Pin::Closure, nil] + # @type @binder [ComplexType, ComplexType::UniqueType, nil] + + # @todo Missed nil violation + # @return [Location, nil] + attr_accessor :location - # @return [Location] - attr_reader :location + # @param value [Pin::Closure] + # @return [void] + def closure=(value) + @closure = value + # remove cached values generated from closure + reset_generated! + end - # @sg-ignore Solargraph::Pin::Common#closure return type could not be inferred # @return [Pin::Closure, nil] def closure Solargraph.assert_or_log(:closure, "Closure not set on #{self.class} #{name.inspect} from #{source.inspect}") unless @closure @@ -23,12 +35,13 @@ def name @name ||= '' end + # @todo redundant with Base#return_type? # @return [ComplexType] def return_type @return_type ||= ComplexType::UNDEFINED end - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def context # Get the static context from the nearest namespace @context ||= find_context @@ -40,7 +53,8 @@ def namespace context.namespace.to_s end - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] + # @sg-ignore https://github.com/castwide/solargraph/pull/1100 def binder @binder || context end @@ -70,6 +84,7 @@ def find_context elsif here.is_a?(Pin::Method) return here.context end + # @sg-ignore Need to add nil check here here = here.closure end ComplexType::ROOT diff --git a/lib/solargraph/pin/compound_statement.rb b/lib/solargraph/pin/compound_statement.rb new file mode 100644 index 000000000..4598d677a --- /dev/null +++ b/lib/solargraph/pin/compound_statement.rb @@ -0,0 +1,55 @@ +module Solargraph + module Pin + # A series of statements where if a given statement executes, /all + # of the previous statements in the sequence must have executed as + # well/. In other words, the statements are run from the top in + # sequence, until interrupted by something like a + # return/break/next/raise/etc. + # + # This mix-in is used in flow sensitive typing to determine how + # far we can assume a given assertion about a type can be trusted + # to be true. + # + # Some examples in Ruby: + # + # * Bodies of methods and Ruby blocks + # * Branches of conditionals and loops - if/elsif/else, + # unless/else, when, until, ||=, ?:, switch/case/else + # * The body of begin-end/try/rescue/ensure statements + # + # Compare/contrast with: + # + # * Scope - a sequence where variables declared are not available + # after the end of the scope. Note that this is not necessarily + # true for a compound statement. + # * Compound statement - synonym + # * Block - in Ruby this has a special meaning (a closure passed to a method), but + # in general parlance this is also a synonym. + # * Closure - a sequence which is also a scope + # * Namespace - a named sequence which is also a scope and a + # closure + # + # See: + # https://cse.buffalo.edu/~regan/cse305/RubyBNF.pdf + # https://ruby-doc.org/docs/ruby-doc-bundle/Manual/man-1.4/syntax.html + # https://en.wikipedia.org/wiki/Block_(programming) + # + # Note: + # + # Just because statement #1 in a sequence is executed, it doesn't + # mean that future ones will. Consider the effect of + # break/next/return/raise/etc. on control flow. + class CompoundStatement < Pin::Base + attr_reader :node + + # @param receiver [Parser::AST::Node, nil] + # @param node [Parser::AST::Node, nil] + # @param context [ComplexType, nil] + # @param args [::Array] + def initialize node: nil, **splat + super(**splat) + @node = node + end + end + end +end diff --git a/lib/solargraph/pin/conversions.rb b/lib/solargraph/pin/conversions.rb index e40cc8990..5ad3573f7 100644 --- a/lib/solargraph/pin/conversions.rb +++ b/lib/solargraph/pin/conversions.rb @@ -43,6 +43,7 @@ def completion_item data: { path: path, return_type: return_type.tag, + # @sg-ignore flow sensitive typing needs to handle attrs location: (location ? location.to_hash : nil), deprecated: deprecated? } @@ -80,7 +81,7 @@ def detail # Get a markdown-flavored link to a documentation page. # - # @return [String] + # @return [String, nil] def link_documentation @link_documentation ||= generate_link end diff --git a/lib/solargraph/pin/delegated_method.rb b/lib/solargraph/pin/delegated_method.rb index bcf5b5912..738bb45a4 100644 --- a/lib/solargraph/pin/delegated_method.rb +++ b/lib/solargraph/pin/delegated_method.rb @@ -13,10 +13,11 @@ class DelegatedMethod < Pin::Method # # @param method [Method, nil] an already resolved method pin. # @param receiver [Source::Chain, nil] the source code used to resolve the receiver for this delegated method. - # @param name [String] - # @param receiver_method_name [String] the method name that will be called on the receiver (defaults to :name). + # @param name [String, nil] + # @param receiver_method_name [String, nil] the method name that will be called on the receiver (defaults to :name). def initialize(method: nil, receiver: nil, name: method&.name, receiver_method_name: name, **splat) raise ArgumentError, 'either :method or :receiver is required' if (method && receiver) || (!method && !receiver) + # @sg-ignore Need to add nil check here super(name: name, **splat) @receiver_chain = receiver @@ -72,27 +73,34 @@ def resolvable?(api_map) def resolve_method api_map return if @resolved_method + # @sg-ignore Need to add nil check here resolver = @receiver_chain.define(api_map, self, []).first unless resolver - Solargraph.logger.warn \ - "Delegated receiver for #{path} was resolved to nil from `#{print_chain(@receiver_chain)}'" + # @sg-ignore Need to add nil check here + Solargraph.logger.warn "Delegated receiver for #{path} was resolved to nil from `#{print_chain(@receiver_chain)}'" return end + # @sg-ignore Need to add nil check here receiver_type = resolver.return_type + # @sg-ignore Need to add nil check here return if receiver_type.undefined? receiver_path, method_scope = + # @sg-ignore Need to add nil check here if @receiver_chain.constant? # HACK: the `return_type` of a constant is Class, but looking up a method expects # the arguments `"Whatever"` and `scope: :class`. + # @sg-ignore Need to add nil check here [receiver_type.to_s.sub(/^Class<(.+)>$/, '\1'), :class] else + # @sg-ignore Need to add nil check here [receiver_type.to_s, :instance] end + # @sg-ignore Need to add nil check here method_stack = api_map.get_method_stack(receiver_path, @receiver_method_name, scope: method_scope) @resolved_method = method_stack.first end diff --git a/lib/solargraph/pin/documenting.rb b/lib/solargraph/pin/documenting.rb index bd8b1fe9a..cbeaf2a0d 100644 --- a/lib/solargraph/pin/documenting.rb +++ b/lib/solargraph/pin/documenting.rb @@ -104,6 +104,7 @@ def self.normalize_indentation text left = text.lines.map do |line| match = line.match(/^ +/) next 0 unless match + # @sg-ignore Need to add nil check here match[0].length end.min return text if left.nil? || left.zero? diff --git a/lib/solargraph/pin/instance_variable.rb b/lib/solargraph/pin/instance_variable.rb index c06fdd93e..b3c69f09c 100644 --- a/lib/solargraph/pin/instance_variable.rb +++ b/lib/solargraph/pin/instance_variable.rb @@ -3,13 +3,17 @@ module Solargraph module Pin class InstanceVariable < BaseVariable - # @return [ComplexType] + # @sg-ignore Need to add nil check here + # @return [ComplexType, ComplexType::UniqueType] def binder + # @sg-ignore Need to add nil check here closure.binder end + # @sg-ignore Need to add nil check here # @return [::Symbol] def scope + # @sg-ignore Need to add nil check here closure.binder.scope end diff --git a/lib/solargraph/pin/local_variable.rb b/lib/solargraph/pin/local_variable.rb index cb2dda140..a159e609f 100644 --- a/lib/solargraph/pin/local_variable.rb +++ b/lib/solargraph/pin/local_variable.rb @@ -3,40 +3,70 @@ module Solargraph module Pin class LocalVariable < BaseVariable - # @return [Range] - attr_reader :presence + # @param api_map [ApiMap] + # @return [ComplexType, ComplexType::UniqueType] + def probe api_map + if presence_certain? && return_type && return_type&.defined? + # flow sensitive typing has already figured out this type + # has been downcast - use the type it figured out + # @sg-ignore Flow-sensitive typing should support ivars + return adjust_type api_map, return_type.qualify(api_map, *gates) + end - def presence_certain? - @presence_certain + super end - # @param assignment [AST::Node, nil] - # @param presence [Range, nil] - # @param presence_certain [Boolean] - # @param splat [Hash] - def initialize assignment: nil, presence: nil, presence_certain: false, **splat - super(**splat) - @assignment = assignment - @presence = presence - @presence_certain = presence_certain + def combine_with(other, attrs={}) + # keep this as a parameter + return other.combine_with(self, attrs) if other.is_a?(Parameter) && !self.is_a?(Parameter) + + super end - def combine_with(other, attrs={}) - new_attrs = { - assignment: assert_same(other, :assignment), - presence_certain: assert_same(other, :presence_certain?), - }.merge(attrs) - new_attrs[:presence] = assert_same(other, :presence) unless attrs.key?(:presence) + # @sg-ignore Flow-sensitive typing should support ivars + # @param other_loc [Location] + def starts_at?(other_loc) + location&.filename == other_loc.filename && + presence && + # @sg-ignore Flow-sensitive typing should support ivars + presence.start == other_loc.range.start + end - super(other, new_attrs) + # @param other [self] + # @return [Pin::Closure, nil] + def combine_closure(other) + return closure if self.closure == other.closure + + # choose first defined, as that establishes the scope of the variable + if closure.nil? || other.closure.nil? + Solargraph.assert_or_log(:varible_closure_missing) do + "One of the local variables being combined is missing a closure: " \ + "#{self.inspect} vs #{other.inspect}" + end + return closure || other.closure + end + + # @sg-ignore Flow-sensitive typing should support ivars + if closure.location.nil? || other.closure.location.nil? + # @sg-ignore Flow-sensitive typing should support ivars + return closure.location.nil? ? other.closure : closure + end + + # if filenames are different, this will just pick one + # @sg-ignore Flow-sensitive typing should support ivars + return closure if closure.location <= other.closure.location + + other.closure end # @param other_closure [Pin::Closure] # @param other_loc [Location] def visible_at?(other_closure, other_loc) + # @sg-ignore Need to add nil check here location.filename == other_loc.filename && - presence.include?(other_loc.range.start) && - match_named_closure(other_closure, closure) + # @sg-ignore Flow-sensitive typing should support || + (!presence || presence.include?(other_loc.range.start)) && + visible_in_closure?(other_closure) end def to_rbs @@ -45,30 +75,18 @@ def to_rbs private - # @param tag1 [String] - # @param tag2 [String] - # @return [Boolean] - def match_tags tag1, tag2 - # @todo This is an unfortunate hack made necessary by a discrepancy in - # how tags indicate the root namespace. The long-term solution is to - # standardize it, whether it's `Class<>`, an empty string, or - # something else. - tag1 == tag2 || - (['', 'Class<>'].include?(tag1) && ['', 'Class<>'].include?(tag2)) - end - - # @param needle [Pin::Base] - # @param haystack [Pin::Base] - # @return [Boolean] - def match_named_closure needle, haystack - return true if needle == haystack || haystack.is_a?(Pin::Block) - cursor = haystack - until cursor.nil? - return true if needle.path == cursor.path - return false if cursor.path && !cursor.path.empty? - cursor = cursor.closure + # @param other [self] + # @return [ComplexType, nil] + def combine_return_type(other) + if presence_certain? && return_type&.defined? + # flow sensitive typing has already figured out this type + # has been downcast - use the type it figured out + return return_type + end + if other.presence_certain? && other.return_type&.defined? + return other.return_type end - false + combine_types(other, :return_type) end end end diff --git a/lib/solargraph/pin/method.rb b/lib/solargraph/pin/method.rb index 86bf1cd09..4bfd0bf46 100644 --- a/lib/solargraph/pin/method.rb +++ b/lib/solargraph/pin/method.rb @@ -22,8 +22,9 @@ class Method < Callable # @param attribute [Boolean] # @param signatures [::Array, nil] # @param anon_splat [Boolean] + # @param context [ComplexType, ComplexType::UniqueType, nil] def initialize visibility: :public, explicit: true, block: :undefined, node: nil, attribute: false, signatures: nil, anon_splat: false, - **splat + context: nil, **splat super(**splat) @visibility = visibility @explicit = explicit @@ -32,26 +33,7 @@ def initialize visibility: :public, explicit: true, block: :undefined, node: nil @attribute = attribute @signatures = signatures @anon_splat = anon_splat - end - - # @param signature_pins [Array] - # @return [Array] - def combine_all_signature_pins(*signature_pins) - # @type [Hash{Array => Array}] - by_arity = {} - signature_pins.each do |signature_pin| - by_arity[signature_pin.arity] ||= [] - by_arity[signature_pin.arity] << signature_pin - end - by_arity.transform_values! do |same_arity_pins| - # @param memo [Pin::Signature, nil] - # @param signature [Pin::Signature] - same_arity_pins.reduce(nil) do |memo, signature| - next signature if memo.nil? - memo.combine_with(signature) - end - end - by_arity.values.flatten + @context = context if context end # @param other [Pin::Method] @@ -66,20 +48,6 @@ def combine_visibility(other) end end - # @param other [Pin::Method] - # @return [Array] - def combine_signatures(other) - all_undefined = signatures.all? { |sig| sig.return_type.undefined? } - other_all_undefined = other.signatures.all? { |sig| sig.return_type.undefined? } - if all_undefined && !other_all_undefined - other.signatures - elsif other_all_undefined && !all_undefined - signatures - else - combine_all_signature_pins(*signatures, *other.signatures) - end - end - def combine_with(other, attrs = {}) priority_choice = choose_priority(other) return priority_choice unless priority_choice.nil? @@ -92,7 +60,6 @@ def combine_with(other, attrs = {}) end new_attrs = { visibility: combine_visibility(other), - # @sg-ignore https://github.com/castwide/solargraph/pull/1050 explicit: explicit? || other.explicit?, block: combine_blocks(other), node: choose_node(other, :node), @@ -160,6 +127,8 @@ def block? !block.nil? end + # @sg-ignore flow-sensitive typing needs to remove literal with + # this unless block # @return [Pin::Signature, nil] def block return @block unless @block == :undefined @@ -179,9 +148,10 @@ def return_type end # @param parameters [::Array] - # @param return_type [ComplexType] + # @param return_type [ComplexType, nil] # @return [Signature] def generate_signature(parameters, return_type) + # @type [Pin::Signature, nil] block = nil yieldparam_tags = docstring.tags(:yieldparam) yieldreturn_tags = docstring.tags(:yieldreturn) @@ -202,6 +172,7 @@ def generate_signature(parameters, return_type) comments: p.text, name: name, decl: decl, + # @sg-ignore flow sensitive typing needs to handle attrs presence: location ? location.range : nil, return_type: ComplexType.try_parse(*p.types), source: source @@ -247,6 +218,7 @@ def detail else "(#{signatures.first.parameters.map(&:full).join(', ')}) " unless signatures.first.parameters.empty? end.to_s + # @sg-ignore Need to add nil check here detail += "=#{probed? ? '~' : (proxied? ? '^' : '>')} #{return_type.to_s}" unless return_type.undefined? detail.strip! return nil if detail.empty? @@ -276,6 +248,7 @@ def to_rbs return nil if signatures.empty? rbs = "def #{name}: #{signatures.first.to_rbs}" + # @sg-ignore Need to add nil check here signatures[1..].each do |sig| rbs += "\n" rbs += (' ' * (4 + name.length)) @@ -294,6 +267,7 @@ def method_name end def typify api_map + # @sg-ignore Need to add nil check here logger.debug { "Method#typify(self=#{self}, binder=#{binder}, closure=#{closure}, context=#{context.rooted_tags}, return_type=#{return_type.rooted_tags}) - starting" } decl = super unless decl.undefined? @@ -303,6 +277,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) logger.debug { "Method#typify(self=#{self}) => #{qualified.rooted_tags.inspect}" } return qualified @@ -396,7 +371,7 @@ def probe api_map attribute? ? infer_from_iv(api_map) : infer_from_return_nodes(api_map) end - # @return [::Array] + # @return [::Array] def overloads # Ignore overload tags with nil parameters. If it's not an array, the # tag's source is likely malformed. @@ -414,6 +389,7 @@ def overloads comments: tag.docstring.all.to_s, name: name, decl: decl, + # @sg-ignore flow sensitive typing needs to handle attrs presence: location ? location.range : nil, return_type: param_type_from_name(tag, src.first), source: :overloads @@ -468,10 +444,12 @@ def rest_of_stack api_map attr_writer :documentation + # @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 # inside 'class << self' blocks, but YARD did OK at it + # @sg-ignore Need to add nil check here source == :rbs && scope == :class && type_location&.filename&.include?('generated') && return_type.undefined? || # YARD's RBS generator seems to miss a lot of should-be protected instance methods source == :rbs && scope == :instance && namespace.start_with?('YARD::') || @@ -485,6 +463,71 @@ def dodgy_visibility_source? private + # @param other [Pin::Method] + # @return [Array] + def combine_signatures(other) + all_undefined = signatures.all? { |sig| !sig.return_type&.defined? } + other_all_undefined = other.signatures.all? { |sig| !sig.return_type&.defined? } + if all_undefined && !other_all_undefined + other.signatures + elsif other_all_undefined && !all_undefined + signatures + else + combine_signatures_by_type_arity(*signatures, *other.signatures) + end + end + + # @param signature_pins [Array] + # + # @return [Array] + def combine_signatures_by_type_arity(*signature_pins) + # @type [Hash{Array => Array}] + by_type_arity = {} + signature_pins.each do |signature_pin| + by_type_arity[signature_pin.type_arity] ||= [] + by_type_arity[signature_pin.type_arity] << signature_pin + end + + by_type_arity.transform_values! do |same_type_arity_signatures| + combine_same_type_arity_signatures same_type_arity_signatures + end + by_type_arity.values.flatten + end + + # @param same_type_arity_signatures [Array] + # + # @return [Array] + def combine_same_type_arity_signatures(same_type_arity_signatures) + # This is an O(n^2) operation, so bail out if n is not small + return same_type_arity_signatures if same_type_arity_signatures.length > 10 + + # @param old_signatures [Array] + # @param new_signature [Pin::Signature] + same_type_arity_signatures.reduce([]) do |old_signatures, new_signature| + next [new_signature] if old_signatures.empty? + + found_merge = false + old_signatures.flat_map do |old_signature| + potential_new_signature = old_signature.combine_with(new_signature) + + if potential_new_signature.type_arity == old_signature.type_arity + # the number of types in each parameter and return type + # match, so we found compatible signatures to merge. If + # we increased the number of types, we'd potentially + # have taken away the ability to use parameter types to + # choose the correct return type (while Ruby doesn't + # dispatch based on type, RBS does distinguish overloads + # based on types, not just arity, allowing for type + # information describing how methods behave based on + # their input types) + old_signatures - [old_signature] + [potential_new_signature] + else + old_signatures + [new_signature] + end + end + end + end + # @param name [String] # @param asgn [Boolean] # @@ -532,19 +575,20 @@ def generate_complex_type end # @param api_map [ApiMap] - # @return [ComplexType, nil] + # @return [ComplexType, ComplexType::UniqueType, nil] def see_reference api_map # This should actually be an intersection type - # @param ref [YARD::Tags::Tag, Solargraph::Yard::Tags::RefTag] + # @param ref [YARD::Tags::Tag, YARD::Tags::RefTag] docstring.ref_tags.each do |ref| # @sg-ignore ref should actually be an intersection type next unless ref.tag_name == 'return' && ref.owner - # @sg-ignore ref should actually be an intersection type + # @sg-ignore should actually be an intersection type result = resolve_reference(ref.owner.to_s, api_map) return result unless result.nil? end match = comments.match(/^[ \t]*\(see (.*)\)/m) return nil if match.nil? + # @sg-ignore Need to add nil check here resolve_reference match[1], api_map end @@ -559,6 +603,7 @@ 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? end nil @@ -566,7 +611,7 @@ def typify_from_super api_map # @param ref [String] # @param api_map [ApiMap] - # @return [ComplexType, nil] + # @return [ComplexType, ComplexType::UniqueType, nil] def resolve_reference ref, api_map parts = ref.split(/[.#]/) if parts.first.empty? || parts.one? @@ -574,6 +619,7 @@ def resolve_reference ref, api_map else fqns = api_map.qualify(parts.first, *gates) return ComplexType::UNDEFINED if fqns.nil? + # @sg-ignore Need to add nil check here path = fqns + ref[parts.first.length] + parts.last end pins = api_map.get_path_pins(path) @@ -609,9 +655,11 @@ def infer_from_return_nodes api_map 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 ) + # @sg-ignore Need to add nil check here chain = Solargraph::Parser.chain(n, location.filename) type = chain.infer(api_map, self, clip.locals) result.push type unless type.undefined? diff --git a/lib/solargraph/pin/namespace.rb b/lib/solargraph/pin/namespace.rb index 95bd1089a..f41a7ae2b 100644 --- a/lib/solargraph/pin/namespace.rb +++ b/lib/solargraph/pin/namespace.rb @@ -26,7 +26,6 @@ def initialize type: :class, visibility: :public, gates: [''], name: '', **splat @type = type @visibility = visibility if name.start_with?('::') - # @type [String] name = name[2..-1] || '' @closure = Solargraph::Pin::ROOT_PIN end @@ -39,6 +38,7 @@ def initialize type: :class, visibility: :public, gates: [''], name: '', **splat closure_name = if [Solargraph::Pin::ROOT_PIN, nil].include?(closure) '' else + # @sg-ignore Need to add nil check here closure.full_context.namespace + '::' end closure_name += parts.join('::') @@ -48,6 +48,12 @@ def initialize type: :class, visibility: :public, gates: [''], name: '', **splat @name = name end + def reset_generated! + @return_type = nil + @full_context = nil + @path = nil + end + def to_rbs "#{@type.to_s} #{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 91c205921..21164a5c3 100644 --- a/lib/solargraph/pin/parameter.rb +++ b/lib/solargraph/pin/parameter.rb @@ -30,12 +30,29 @@ def location end def combine_with(other, attrs={}) - new_attrs = { - decl: assert_same(other, :decl), - presence: choose(other, :presence), - asgn_code: choose(other, :asgn_code), - }.merge(attrs) - super(other, new_attrs) + # Parameters can be combined with local variables + new_attrs = if other.is_a?(Parameter) + { + decl: assert_same(other, :decl), + asgn_code: choose(other, :asgn_code) + } + else + { + decl: decl, + asgn_code: asgn_code + } + end + super(other, new_attrs.merge(attrs)) + end + + def combine_return_type(other) + out = super + if out&.undefined? + # allow our return_type method to provide a better type + # using :param tag + out = nil + end + out end def keyword? @@ -43,6 +60,7 @@ def keyword? end def kwrestarg? + # @sg-ignore flow sensitive typing needs to handle attrs decl == :kwrestarg || (assignment && [:HASH, :hash].include?(assignment.type)) end @@ -72,6 +90,11 @@ def arity_decl end end + # @return [String] + def type_arity_decl + arity_decl + return_type.items.count.to_s + end + def arg? decl == :arg end @@ -80,6 +103,14 @@ def restarg? decl == :restarg end + def mandatory_positional? + decl == :arg + end + + def positional? + !keyword? + end + def rest? decl == :restarg || decl == :kwrestarg end @@ -135,12 +166,14 @@ def full end end + # @sg-ignore super always sets @return_type to something # @return [ComplexType] def return_type if @return_type.nil? @return_type = ComplexType::UNDEFINED found = param_tag @return_type = ComplexType.try_parse(*found.types) unless found.nil? or found.types.nil? + # @sg-ignore Need to add nil check here if @return_type.undefined? if decl == :restarg @return_type = ComplexType.try_parse('::Array') @@ -152,22 +185,29 @@ def return_type end end super - @return_type end # The parameter's zero-based location in the block's signature. # + # @sg-ignore Need to add nil check here # @return [Integer] def index - # @type [Method, Block] method_pin = closure + # @sg-ignore Need to add nil check here method_pin.parameter_names.index(name) end # @param api_map [ApiMap] def typify api_map - return return_type.qualify(api_map, *closure.gates) unless return_type.undefined? - closure.is_a?(Pin::Block) ? typify_block_param(api_map) : typify_method_param(api_map) + new_type = super + return new_type if new_type.defined? + + # sniff based on param tags + new_type = closure.is_a?(Pin::Block) ? typify_block_param(api_map) : typify_method_param(api_map) + + return adjust_type api_map, new_type.self_to_type(full_context) if new_type.defined? + + adjust_type api_map, super.self_to_type(full_context) end # @param atype [ComplexType] @@ -176,9 +216,16 @@ def compatible_arg?(atype, api_map) # make sure we get types from up the method # inheritance chain if we don't have them on this pin ptype = typify api_map - ptype.undefined? || ptype.can_assign?(api_map, atype) || ptype.generic? + return true if ptype.undefined? + + return true if atype.conforms_to?(api_map, + ptype, + :method_call, + [:allow_empty_params, :allow_undefined]) + ptype.generic? end + # @sg-ignore flow sensitive typing needs to handle attrs def documentation tag = param_tag return '' if tag.nil? || tag.text.nil? @@ -187,12 +234,19 @@ def documentation private + def generate_complex_type + nil + end + # @return [YARD::Tags::Tag, nil] def param_tag + # @sg-ignore Need to add nil check here params = closure.docstring.tags(:param) + # @sg-ignore Need to add nil check here params.each do |p| return p if p.name == name end + # @sg-ignore Need to add nil check here params[index] if index && params[index] && (params[index].name.nil? || params[index].name.empty?) end @@ -209,6 +263,7 @@ def typify_block_param api_map # @param api_map [ApiMap] # @return [ComplexType] def typify_method_param api_map + # @sg-ignore Need to add nil check here meths = api_map.get_method_stack(closure.full_context.tag, closure.name, scope: closure.scope) # meths.shift # Ignore the first one meths.each do |meth| @@ -222,6 +277,7 @@ def typify_method_param api_map if found.nil? and !index.nil? found = params[index] if params[index] && (params[index].name.nil? || params[index].name.empty?) end + # @sg-ignore Need to add nil check here return ComplexType.try_parse(*found.types).qualify(api_map, *meth.closure.gates) unless found.nil? || found.types.nil? end ComplexType::UNDEFINED @@ -230,6 +286,7 @@ def typify_method_param api_map # @param heredoc [YARD::Docstring] # @param api_map [ApiMap] # @param skip [::Array] + # # @return [::Array] def see_reference heredoc, api_map, skip = [] # This should actually be an intersection type @@ -237,7 +294,7 @@ def see_reference heredoc, api_map, skip = [] heredoc.ref_tags.each do |ref| # @sg-ignore ref should actually be an intersection type next unless ref.tag_name == 'param' && ref.owner - # @sg-ignore ref should actually be an intersection type + # @todo ref should actually be an intersection type result = resolve_reference(ref.owner.to_s, api_map, skip) return result unless result.nil? end @@ -257,6 +314,7 @@ def resolve_reference ref, api_map, skip else fqns = api_map.qualify(parts.first, namespace) return nil if fqns.nil? + # @sg-ignore Need to add nil check here path = fqns + ref[parts.first.length] + parts.last end pins = api_map.get_path_pins(path) diff --git a/lib/solargraph/pin/proxy_type.rb b/lib/solargraph/pin/proxy_type.rb index 452536834..e856c8833 100644 --- a/lib/solargraph/pin/proxy_type.rb +++ b/lib/solargraph/pin/proxy_type.rb @@ -3,7 +3,7 @@ module Solargraph module Pin class ProxyType < Base - # @param return_type [ComplexType] + # @param return_type [ComplexType, ComplexType::UniqueType] # @param gates [Array, nil] Namespaces to try while resolving non-rooted types # @param binder [ComplexType, ComplexType::UniqueType, nil] # @param gates [Array, nil] @@ -25,6 +25,7 @@ def context def self.anonymous context, closure: nil, binder: nil, **kwargs unless closure parts = context.namespace.split('::') + # @sg-ignore Need to add nil check here namespace = parts[0..-2].join('::').to_s closure = Solargraph::Pin::Namespace.new(name: namespace, source: :proxy_type) end diff --git a/lib/solargraph/pin/reference.rb b/lib/solargraph/pin/reference.rb index d456fbbf8..13e603d6e 100644 --- a/lib/solargraph/pin/reference.rb +++ b/lib/solargraph/pin/reference.rb @@ -30,8 +30,10 @@ def type ) end + # @sg-ignore Need to add nil check here # @return [Array] def reference_gates + # @sg-ignore Need to add nil check here closure.gates end end diff --git a/lib/solargraph/pin/reference/override.rb b/lib/solargraph/pin/reference/override.rb index 878c309db..76711f5dd 100644 --- a/lib/solargraph/pin/reference/override.rb +++ b/lib/solargraph/pin/reference/override.rb @@ -7,7 +7,7 @@ class Override < Reference # @return [::Array] attr_reader :tags - # @return [::Array] + # @return [::Array<::Symbol>] attr_reader :delete def closure diff --git a/lib/solargraph/pin/reference/superclass.rb b/lib/solargraph/pin/reference/superclass.rb index c50f640df..c13522648 100644 --- a/lib/solargraph/pin/reference/superclass.rb +++ b/lib/solargraph/pin/reference/superclass.rb @@ -6,7 +6,9 @@ class Reference # A Superclass reference pin. # class Superclass < Reference + # @sg-ignore Need to add nil check here def reference_gates + # @sg-ignore Need to add nil check here @reference_gates ||= closure.gates - [closure.path] end end diff --git a/lib/solargraph/pin/search.rb b/lib/solargraph/pin/search.rb index 0f9883b65..67bd74d24 100644 --- a/lib/solargraph/pin/search.rb +++ b/lib/solargraph/pin/search.rb @@ -51,6 +51,7 @@ def do_query # @param str1 [String] # @param str2 [String] + # # @return [Float] def fuzzy_string_match str1, str2 return 1.0 + (str2.length.to_f / str1.length.to_f) if str1.downcase.include?(str2.downcase) diff --git a/lib/solargraph/pin/signature.rb b/lib/solargraph/pin/signature.rb index 4c25e028b..0a6dbbafb 100644 --- a/lib/solargraph/pin/signature.rb +++ b/lib/solargraph/pin/signature.rb @@ -10,6 +10,7 @@ def initialize **splat end def generics + # @type [Array<::String, nil>] @generics ||= [].freeze end @@ -19,6 +20,7 @@ def identity attr_writer :closure + # @ sg-ignore need boolish support for ? methods def dodgy_return_type_source? super || closure&.dodgy_return_type_source? end @@ -32,8 +34,11 @@ def location end def typify api_map + # @sg-ignore Need to add nil check here if return_type.defined? + # @sg-ignore Need to add nil check here qualified = return_type.qualify(api_map, closure.namespace) + # @sg-ignore Need to add nil check here logger.debug { "Signature#typify(self=#{self}) => #{qualified.rooted_tags.inspect}" } return qualified end @@ -46,8 +51,11 @@ def typify api_map method_stack.each do |pin| sig = pin.signatures.find { |s| s.arity == self.arity } next unless sig + # @sg-ignore Need to add nil check here unless sig.return_type.undefined? + # @sg-ignore Need to add nil check here qualified = sig.return_type.qualify(api_map, closure.namespace) + # @sg-ignore Need to add nil check here logger.debug { "Signature#typify(self=#{self}) => #{qualified.rooted_tags.inspect}" } return qualified end diff --git a/lib/solargraph/pin/symbol.rb b/lib/solargraph/pin/symbol.rb index 294363f5f..18178e9b9 100644 --- a/lib/solargraph/pin/symbol.rb +++ b/lib/solargraph/pin/symbol.rb @@ -3,7 +3,7 @@ module Solargraph module Pin class Symbol < Base - # @param location [Solargraph::Location] + # @param location [Solargraph::Location, nil] # @param name [String] def initialize(location, name, **kwargs) # @sg-ignore "Unrecognized keyword argument kwargs to Solargraph::Pin::Base#initialize" diff --git a/lib/solargraph/pin/until.rb b/lib/solargraph/pin/until.rb index 67823532b..7e050fea6 100644 --- a/lib/solargraph/pin/until.rb +++ b/lib/solargraph/pin/until.rb @@ -2,7 +2,7 @@ module Solargraph module Pin - class Until < Base + class Until < CompoundStatement include Breakable # @param receiver [Parser::AST::Node, nil] diff --git a/lib/solargraph/pin/while.rb b/lib/solargraph/pin/while.rb index e380aadd9..ac8c31c97 100644 --- a/lib/solargraph/pin/while.rb +++ b/lib/solargraph/pin/while.rb @@ -2,7 +2,7 @@ module Solargraph module Pin - class While < Base + class While < CompoundStatement include Breakable # @param receiver [Parser::AST::Node, nil] diff --git a/lib/solargraph/pin_cache.rb b/lib/solargraph/pin_cache.rb index b3c162a15..402ae7737 100644 --- a/lib/solargraph/pin_cache.rb +++ b/lib/solargraph/pin_cache.rb @@ -175,7 +175,7 @@ def uncache_stdlib end # @param gemspec [Gem::Specification] - # @param out [IO, nil] + # @param out [StringIO, IO, nil] # @return [void] def uncache_gem(gemspec, out: nil) uncache(yardoc_path(gemspec), out: out) @@ -192,6 +192,7 @@ def clear private # @param file [String] + # @sg-ignore Marshal.load evaluates to boolean here which is wrong # @return [Array, nil] def load file return nil unless File.file?(file) @@ -219,6 +220,7 @@ def save file, pins end # @param path_segments [Array] + # @param out [IO, nil] # @return [void] def uncache *path_segments, out: nil path = File.join(*path_segments) @@ -229,7 +231,10 @@ def uncache *path_segments, out: nil end # @return [void] + # @param out [IO, nil] # @param path_segments [Array] + # @param out [StringIO, IO, nil] + # @todo need to warn when no @param exists for 'out' def uncache_by_prefix *path_segments, out: nil path = File.join(*path_segments) glob = "#{path}*" diff --git a/lib/solargraph/position.rb b/lib/solargraph/position.rb index 74606f142..53c7b61ba 100644 --- a/lib/solargraph/position.rb +++ b/lib/solargraph/position.rb @@ -21,7 +21,6 @@ def initialize line, character @character = character end - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields [line, character] end @@ -58,7 +57,6 @@ def inspect # @return [Integer] def self.to_offset text, position return 0 if text.empty? - # @sg-ignore Unresolved call to + on Integer text.lines[0...position.line].sum(&:length) + position.character end @@ -81,6 +79,7 @@ def self.line_char_to_offset text, line, character def self.from_offset text, offset cursor = 0 line = 0 + # @type [Integer, nil] character = nil text.lines.each do |l| line_length = l.length @@ -94,6 +93,7 @@ def self.from_offset text, offset end character = 0 if character.nil? and (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 @@ -112,7 +112,6 @@ def self.normalize object def == other return false unless other.is_a?(Position) - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 line == other.line and character == other.character end end diff --git a/lib/solargraph/range.rb b/lib/solargraph/range.rb index 86452d646..f3bb630a9 100644 --- a/lib/solargraph/range.rb +++ b/lib/solargraph/range.rb @@ -19,7 +19,6 @@ def initialize start, ending @ending = ending end - # @sg-ignore Fix "Not enough arguments to Module#protected" protected def equality_fields [start, ending] end @@ -27,12 +26,9 @@ def initialize start, ending # @param other [BasicObject] def <=>(other) return nil unless other.is_a?(Range) - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 if start == other.start - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 ending <=> other.ending else - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 start <=> other.start end end @@ -54,8 +50,11 @@ def to_hash # @return [Boolean] def contain? position position = Position.normalize(position) + # @sg-ignore We should understand reassignment of variable to new type return false if position.line < start.line || position.line > ending.line + # @sg-ignore We should understand reassignment of variable to new type return false if position.line == start.line && position.character < start.character + # @sg-ignore We should understand reassignment of variable to new type return false if position.line == ending.line && position.character > ending.character true end @@ -63,9 +62,11 @@ 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 Should handle redefinition of types in simple contexts # @return [Boolean] def include? position position = Position.normalize(position) + # @sg-ignore Should handle redefinition of types in simple contexts contain?(position) && !(position.line == start.line && position.character == start.character) end @@ -82,7 +83,7 @@ def self.from_to l1, c1, l2, c2 # Get a range from a node. # - # @param node [Parser::AST::Node] + # @param node [::Parser::AST::Node] # @return [Range, nil] def self.from_node node if node&.loc && node.loc.expression @@ -101,7 +102,6 @@ def self.from_expr expr def == other return false unless other.is_a?(Range) - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 start == other.start && ending == other.ending end diff --git a/lib/solargraph/rbs_map.rb b/lib/solargraph/rbs_map.rb index 803e3677a..94bf65b50 100644 --- a/lib/solargraph/rbs_map.rb +++ b/lib/solargraph/rbs_map.rb @@ -51,6 +51,7 @@ def cache_key # @type [String, nil] data = nil if rbs_collection_config_path + # @sg-ignore flow sensitive typing needs to handle attrs 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 @@ -96,6 +97,9 @@ def pins # @generic T # @param path [String] # @param klass [Class>] + # + # @sg-ignore Need to be able to resolve generics based on a + # Class> param # @return [generic, nil] def path_pin path, klass = Pin::Base pin = pins.find { |p| p.path == path } diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index 54bca0f73..d26b236af 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -96,7 +96,7 @@ def convert_self_type_to_pins decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:to_s) include_pin = Solargraph::Pin::Reference::Include.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, @@ -120,32 +120,44 @@ 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}" @@ -232,12 +244,14 @@ def module_decl_to_pin decl convert_self_types_to_pins decl, module_pin convert_members_to_pins decl, module_pin + raise "Invalid type for module declaration: #{module_pin.class}" unless module_pin.is_a?(Pin::Namespace) + add_mixins decl, module_pin.closure end # @param name [String] # @param tag [String] - # @param comments [String] + # @param comments [String, nil] # @param decl [RBS::AST::Declarations::ClassAlias, RBS::AST::Declarations::Constant, RBS::AST::Declarations::ModuleAlias] # @param base [String, nil] Optional conversion of tag to base # @@ -246,6 +260,7 @@ def create_constant(name, tag, comments, decl, base = nil) parts = name.split('::') if parts.length > 1 name = parts.last + # @sg-ignore Need to add nil check here closure = pins.select { |pin| pin && pin.path == parts[0..-2].join('::') }.first else name = parts.first @@ -347,12 +362,11 @@ def global_decl_to_pin decl ["Rainbow::Presenter", :instance, "wrap_with_sgr"] => :private, } - # @param decl [RBS::AST::Members::MethodDefinition, RBS::AST::Members::AttrReader, RBS::AST::Members::AttrAccessor] + # @param decl [RBS::AST::Members::MethodDefinition, RBS::AST::Members::AttrReader, RBS::AST::Members::AttrWriter, RBS::AST::Members::AttrAccessor] # @param closure [Pin::Closure] # @param context [Context] # @param scope [Symbol] :instance or :class # @param name [String] The name of the method - # @sg-ignore # @return [Symbol] def calculate_method_visibility(decl, context, closure, scope, name) override_key = [closure.path, scope, name] @@ -432,8 +446,9 @@ def method_def_to_sigs decl, pin type_location = location_decl_to_pin_location(overload.method_type.location) generics = overload.method_type.type_params.map(&:name).map(&:to_s) signature_parameters, signature_return_type = parts_of_function(overload.method_type, pin) - block = if overload.method_type.block - block_parameters, block_return_type = parts_of_function(overload.method_type.block, pin) + rbs_block = overload.method_type.block + block = if rbs_block + block_parameters, block_return_type = parts_of_function(rbs_block, pin) Pin::Signature.new(generics: generics, parameters: block_parameters, return_type: block_return_type, source: :rbs, type_location: type_location, closure: pin) end @@ -447,9 +462,12 @@ def method_def_to_sigs decl, pin def location_decl_to_pin_location(location) return nil if location&.name.nil? + # @sg-ignore flow sensitive typing should handle return nil if location&.name.nil? start_pos = Position.new(location.start_line - 1, location.start_column) + # @sg-ignore flow sensitive typing should handle return nil if location&.name.nil? end_pos = Position.new(location.end_line - 1, location.end_column) range = Range.new(start_pos, end_pos) + # @sg-ignore flow sensitve typing should handle return nil if location&.name.nil? Location.new(location.name.to_s, range) end @@ -705,7 +723,7 @@ def alias_to_pin decl, closure 'NilClass' => 'nil' } - # @param type [RBS::MethodType] + # @param type [RBS::MethodType, RBS::Types::Block] # @return [String] def method_type_to_tag type if type_aliases.key?(type.type.return_type.to_s) @@ -737,7 +755,9 @@ def type_tag(type_name, type_args = []) build_type(type_name, type_args).tags end - # @param type [RBS::Types::Bases::Base] + # @param type [RBS::Types::Bases::Base,Object] RBS type object. + # Note: Generally these extend from RBS::Types::Bases::Base, + # but not all. # @return [String] def other_type_to_tag type if type.is_a?(RBS::Types::Optional) @@ -794,13 +814,16 @@ def other_type_to_tag type # e.g., singleton(String) type_tag(type.name) else + # RBS doesn't provide a common base class for its type AST nodes + # + # @sg-ignore all types should include location Solargraph.logger.warn "Unrecognized RBS type: #{type.class} at #{type.location}" 'undefined' end end # @param decl [RBS::AST::Declarations::Class, RBS::AST::Declarations::Module] - # @param namespace [Pin::Namespace] + # @param namespace [Pin::Namespace, nil] # @return [void] def add_mixins decl, namespace # @param mixin [RBS::AST::Members::Include, RBS::AST::Members::Members::Extend, RBS::AST::Members::Members::Prepend] diff --git a/lib/solargraph/rbs_map/core_map.rb b/lib/solargraph/rbs_map/core_map.rb index d2836ffe3..2ecd0b326 100644 --- a/lib/solargraph/rbs_map/core_map.rb +++ b/lib/solargraph/rbs_map/core_map.rb @@ -32,9 +32,10 @@ def pins fill_conversions = Conversions.new(loader: fill_loader) @pins.concat fill_conversions.pins + # add some overrides @pins.concat RbsMap::CoreFills::ALL - - processed = ApiMap::Store.new(pins).pins.reject { |p| p.is_a?(Solargraph::Pin::Reference::Override) } + # process overrides, then remove any which couldn't be resolved + processed = ApiMap::Store.new(@pins).pins.reject { |p| p.is_a?(Solargraph::Pin::Reference::Override) } @pins.replace processed PinCache.serialize_core @pins diff --git a/lib/solargraph/shell.rb b/lib/solargraph/shell.rb index 14a1139ae..a4f22e01d 100755 --- a/lib/solargraph/shell.rb +++ b/lib/solargraph/shell.rb @@ -150,6 +150,11 @@ def gems *names do_cache spec, api_map rescue Gem::MissingSpecError warn "Gem '#{name}' not found" + rescue Gem::Requirement::BadRequirementError => e + warn "Gem '#{name}' failed while loading" + warn e.message + # @sg-ignore Need to add nil check here + warn e.backtrace.join("\n") end STDERR.puts "Documentation cached for #{names.count} gems." end @@ -176,7 +181,10 @@ def typecheck *files workspace = Solargraph::Workspace.new(directory) level = options[:level].to_sym rules = workspace.rules(level) - api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout) + api_map = + Solargraph::ApiMap.load_with_cache(directory, $stdout, + loose_unions: + !rules.require_all_unique_types_match_expected_on_lhs?) probcount = 0 if files.empty? files = api_map.source_maps.map(&:filename) @@ -184,10 +192,9 @@ def typecheck *files files.map! { |file| File.realpath(file) } end filecount = 0 - time = Benchmark.measure { files.each do |file| - checker = TypeChecker.new(file, api_map: api_map, level: options[:level].to_sym, workspace: workspace) + checker = TypeChecker.new(file, api_map: api_map, rules: rules, level: options[:level].to_sym, workspace: workspace) problems = checker.problems next if problems.empty? problems.sort! { |a, b| a.location.range.start.line <=> b.location.range.start.line } @@ -219,19 +226,25 @@ def scan api_map = nil time = Benchmark.measure { api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout) + # @sg-ignore We should understand reassignment of variable to new type api_map.pins.each do |pin| begin puts pin_description(pin) if options[:verbose] pin.typify api_map pin.probe api_map rescue StandardError => e + # @todo to add nil check here + # @todo should warn on nil dereference below STDERR.puts "Error testing #{pin_description(pin)} #{pin.location ? "at #{pin.location.filename}:#{pin.location.range.start.line + 1}" : ''}" STDERR.puts "[#{e.class}]: #{e.message}" + # @todo Need to add nil check here + # @todo Should handle redefinition of types in simple contexts STDERR.puts e.backtrace.join("\n") exit 1 end end } + # @sg-ignore Need to add nil check here puts "Scanned #{directory} (#{api_map.pins.length} pins) in #{time.real} seconds." end @@ -309,6 +322,7 @@ def pin path 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}" @@ -316,6 +330,7 @@ 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 @@ -329,7 +344,7 @@ def do_cache gemspec, api_map api_map.cache_gem(gemspec, rebuild: options.rebuild, out: $stdout) end - # @param type [ComplexType] + # @param type [ComplexType, ComplexType::UniqueType] # @return [void] def print_type(type) if options[:rbs] diff --git a/lib/solargraph/source.rb b/lib/solargraph/source.rb index d8e4e2a3f..f2bf63cdb 100644 --- a/lib/solargraph/source.rb +++ b/lib/solargraph/source.rb @@ -60,6 +60,8 @@ def at range # @param c1 [Integer] # @param l2 [Integer] # @param c2 [Integer] + # + # @sg-ignore Need to add nil check here # @return [String] def from_to l1, c1, l2, c2 b = Solargraph::Position.line_char_to_offset(code, l1, c1) @@ -81,7 +83,7 @@ def node_at(line, column) # # @param line [Integer] # @param column [Integer] - # @return [Array] + # @return [Array] def tree_at(line, column) position = Position.new(line, column) stack = [] @@ -131,20 +133,29 @@ def string_at? position return false if Position.to_offset(code, position) >= code.length string_nodes.each do |node| range = Range.from_node(node) + # @sg-ignore Need to add nil check here next if range.ending.line < position.line + # @sg-ignore Need to add nil check here break if range.ending.line > position.line + # @sg-ignore Need to add nil check here return true if node.type == :str && range.include?(position) && range.start != position + # @sg-ignore Need to add nil check here return true if [:STR, :str].include?(node.type) && range.include?(position) && range.start != position if node.type == :dstr inner = node_at(position.line, position.column) next if inner.nil? inner_range = Range.from_node(inner) + # @sg-ignore Need to add nil check here next unless range.include?(inner_range.ending) return true if inner.type == :str + # @sg-ignore Need to add nil check here inner_code = at(Solargraph::Range.new(inner_range.start, position)) + # @sg-ignore Need to add nil check here return true if (inner.type == :dstr && inner_range.ending.character <= position.character) && !inner_code.end_with?('}') || + # @sg-ignore Need to add nil check here (inner.type != :dstr && inner_range.ending.line == position.line && position.character <= inner_range.ending.character && inner_code.end_with?('}')) end + # @sg-ignore Need to add nil check here break if range.ending.line > position.line end false @@ -181,17 +192,22 @@ def error_ranges # @return [String] def code_for(node) rng = Range.from_node(node) + # @sg-ignore Need to add nil check here b = Position.line_char_to_offset(code, rng.start.line, rng.start.column) + # @sg-ignore Need to add nil check here e = Position.line_char_to_offset(code, rng.ending.line, rng.ending.column) frag = code[b..e-1].to_s frag.strip.gsub(/,$/, '') end - # @param node [Parser::AST::Node] + # @param node [AST::Node] + # # @return [String, nil] def comments_for node rng = Range.from_node(node) + # @sg-ignore Need to add nil check here stringified_comments[rng.start.line] ||= begin + # @sg-ignore Need to add nil check here buff = associated_comments[rng.start.line] buff ? stringify_comment_array(buff) : nil end @@ -219,6 +235,7 @@ class sclass module def defs if str dstr array while unless kwbegin hash block # @return [Array] def folding_ranges @folding_ranges ||= begin + # @type [Array] result = [] inner_folding_ranges node, result result.concat foldable_comment_block_ranges @@ -232,7 +249,7 @@ def synchronized? # Get a hash of comments grouped by the line numbers of the associated code. # - # @return [Hash{Integer => String}] + # @return [Hash{Integer => String, nil}] def associated_comments @associated_comments ||= begin # @type [Hash{Integer => String}] @@ -265,18 +282,23 @@ def first_not_empty_from line cursor end - # @param top [Parser::AST::Node] + # @param top [Parser::AST::Node, nil] # @param result [Array] # @param parent [Symbol, nil] # @return [void] def inner_folding_ranges top, result = [], parent = nil return unless Parser.is_ast_node?(top) + # @sg-ignore Translate to something flow sensitive typing understands if FOLDING_NODE_TYPES.include?(top.type) + # @sg-ignore Translate to something flow sensitive typing understands range = Range.from_node(top) + # @sg-ignore Need to add nil check here if result.empty? || range.start.line > result.last.start.line + # @sg-ignore Need to add nil check here result.push range unless range.ending.line - range.start.line < 2 end end + # @sg-ignore Translate to something flow sensitive typing understands top.children.each do |child| inner_folding_ranges(child, result, top.type) end @@ -298,6 +320,7 @@ def stringify_comment_array comments ctxt.concat p else here = p.index(/[^ \t]/) + # @sg-ignore Should handle redefinition of types in simple contexts skip = here if skip.nil? || here < skip ctxt.concat p[skip..-1] end @@ -308,7 +331,7 @@ def stringify_comment_array comments # A hash of line numbers and their associated comments. # - # @return [Hash{Integer => Array, nil}] + # @return [Hash{Integer => String}] def stringified_comments @stringified_comments ||= {} end @@ -348,9 +371,11 @@ def foldable_comment_block_ranges def string_nodes_in n result = [] if Parser.is_ast_node?(n) + # @sg-ignore Translate to something flow sensitive typing understands if n.type == :str || n.type == :dstr || n.type == :STR || n.type == :DSTR result.push n else + # @sg-ignore Translate to something flow sensitive typing understands n.children.each{ |c| result.concat string_nodes_in(c) } end end @@ -364,6 +389,7 @@ def string_nodes_in n def inner_tree_at node, position, stack return if node.nil? here = Range.from_node(node) + # @sg-ignore Need to add nil check here if here.contain?(position) stack.unshift node node.children.each do |c| @@ -396,7 +422,7 @@ def finalize end @finalized = true begin - @node, @comments = Solargraph::Parser.parse_with_comments(@code, filename) + @node, @comments = Solargraph::Parser.parse_with_comments(@code, filename, 0) @parsed = true @repaired = @code rescue Parser::SyntaxError, EncodingError => e @@ -412,7 +438,7 @@ def finalize end error_ranges.concat(changes.map(&:range)) begin - @node, @comments = Solargraph::Parser.parse_with_comments(@repaired, filename) + @node, @comments = Solargraph::Parser.parse_with_comments(@repaired, filename, 0) @parsed = true rescue Parser::SyntaxError, EncodingError => e @node = nil diff --git a/lib/solargraph/source/chain.rb b/lib/solargraph/source/chain.rb index f7a03b552..a1e439ffb 100644 --- a/lib/solargraph/source/chain.rb +++ b/lib/solargraph/source/chain.rb @@ -71,6 +71,7 @@ def initialize links, node = nil, splat = false # @return [Chain] def base + # @sg-ignore Need to add nil check here @base ||= Chain.new(links[0..-2]) end @@ -78,25 +79,25 @@ def base # # @param api_map [ApiMap] # - # @param name_pin [Pin::Base] A pin - # representing the place in which expression is evaluated (e.g., - # a Method pin, or a Module or Class pin if not run within a - # method - both in terms of the closure around the chain, as well - # as the self type used for any method calls in head position. + # @param name_pin [Pin::Base] A pin representing the closure in + # which expression is evaluated (e.g., a Method pin, or a + # Module or Class pin if not run within a method - both in + # terms of the closure around the chain, as well as the self + # type used for any method calls in head position. # # Requirements for name_pin: # # * name_pin.context: This should be a type representing the - # namespace where we can look up non-local variables and - # method names. If it is a Class, we will look up - # :class scoped methods/variables. + # namespace where we can look up non-local variables. If + # it is a Class, we will look up :class scoped + # instance variables. # # * name_pin.binder: Used for method call lookups only # (Chain::Call links). For method calls as the first # element in the chain, 'name_pin.binder' should be the # same as name_pin.context above. For method calls later - # in the chain (e.g., 'b' in a.b.c), it should represent - # 'a'. + # in the chain, it changes. (e.g., for 'b' in a.b.c, it + # should represent the type of 'a'). # # @param locals [::Array] Any local # variables / method parameters etc visible by the statement @@ -113,6 +114,7 @@ def define api_map, name_pin, locals # # @todo ProxyType uses 'type' for the binder, but ' working_pin = name_pin + # @sg-ignore Need to add nil check here links[0..-2].each do |link| pins = link.resolve(api_map, working_pin, locals) type = infer_from_definitions(pins, working_pin, api_map, locals) @@ -138,7 +140,8 @@ def define api_map, name_pin, locals # @return [ComplexType] # @sg-ignore def infer api_map, name_pin, locals - cache_key = [node, node&.location, links, name_pin&.return_type, locals] + # includes binder as it is mutable in Pin::Block + cache_key = [node, node&.location, links, name_pin&.return_type, name_pin&.binder, locals] if @@inference_invalidation_key == api_map.hash cached = @@inference_cache[cache_key] return cached if cached @@ -154,7 +157,7 @@ def infer api_map, name_pin, locals # @param api_map [ApiMap] # @param name_pin [Pin::Base] # @param locals [::Array] - # @return [ComplexType] + # @return [ComplexType, ComplexType::UniqueType] def infer_uncached api_map, name_pin, locals pins = define(api_map, name_pin, locals) if pins.empty? @@ -209,12 +212,12 @@ def to_s private # @param pins [::Array] - # @param context [Pin::Base] + # @param name_pin [Pin::Base] # @param api_map [ApiMap] # @param locals [::Enumerable] - # @return [ComplexType] - def infer_from_definitions pins, context, api_map, locals - # @type [::Array] + # @return [ComplexType, ComplexType::UniqueType] + def infer_from_definitions pins, name_pin, api_map, locals + # @type [::Array] types = [] unresolved_pins = [] # @todo this param tag shouldn't be needed to probe the type @@ -232,7 +235,8 @@ def infer_from_definitions pins, context, api_map, locals # @todo even at strong, no typechecking complaint # happens when a [Pin::Base,nil] is passed into a method # that accepts only [Pin::Namespace] as an argument - type = type.resolve_generics(pin.closure, context.binder) + # @sg-ignore Need to add nil check here + type = type.resolve_generics(pin.closure, name_pin.binder) end types << type else @@ -271,16 +275,15 @@ def infer_from_definitions pins, context, api_map, locals else ComplexType.new(types) end - if context.nil? || context.return_type.undefined? + if name_pin.nil? || name_pin.context.undefined? # up to downstream to resolve self type return type end - - type.self_to_type(context.return_type) + type.self_to_type(name_pin.context) end - # @param type [ComplexType] - # @return [ComplexType] + # @param type [ComplexType, ComplexType::UniqueType] + # @return [ComplexType, ComplexType::UniqueType] def maybe_nil type return type if type.undefined? || type.void? || type.nullable? return type unless nullable? diff --git a/lib/solargraph/source/chain/call.rb b/lib/solargraph/source/chain/call.rb index 89a28b0fa..7ecd4181c 100644 --- a/lib/solargraph/source/chain/call.rb +++ b/lib/solargraph/source/chain/call.rb @@ -37,6 +37,7 @@ def initialize word, location = nil, arguments = [], block = nil # @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 + [arguments, block] end @@ -50,24 +51,35 @@ def with_block? def resolve api_map, name_pin, locals return super_pins(api_map, name_pin) if word == 'super' return yield_pins(api_map, name_pin) if word == 'yield' - found = if head? - api_map.visible_pins(locals, word, name_pin, location) - else - [] - end - return inferred_pins(found, api_map, name_pin, locals) unless found.empty? - pins = name_pin.binder.each_unique_type.flat_map do |context| + found = api_map.var_at_location(locals, word, name_pin, location) if head? + + return inferred_pins([found], api_map, name_pin, locals) unless found.nil? + binder = name_pin.binder + # this is a q_call - i.e., foo&.bar - assume result of call + # will be nil or result as if binder were not nil - + # 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 + # @sg-ignore literal arrays in this module turn into ::Solargraph::Source::Chain::Array + if !api_map.loose_unions && pin_groups.any? { |pins| pins.empty? } + pin_groups = [] + end + pins = pin_groups.flatten.uniq(&:path) return [] if pins.empty? inferred_pins(pins, api_map, name_pin, locals) end private - # @param pins [::Enumerable] + # @param pins [::Enumerable] # @param api_map [ApiMap] # @param name_pin [Pin::Base] # @param locals [::Array] @@ -84,9 +96,13 @@ def inferred_pins pins, api_map, name_pin, locals # reject it regardless with_block, without_block = overloads.partition(&:block?) + # @sg-ignore Flow-sensitive typing should handle is_a? and next + # @type Array 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 @@ -99,6 +115,7 @@ def inferred_pins pins, api_map, name_pin, locals 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) @@ -110,6 +127,7 @@ def inferred_pins pins, api_map, name_pin, locals if match if ol.block && with_block? block_atypes = ol.block.parameters.map(&:return_type) + # @todo Need to add nil check here if block.links.map(&:class) == [BlockSymbol] # like the bar in foo(&:bar) blocktype = block_symbol_call_type(api_map, name_pin.context, block_atypes, locals) @@ -140,6 +158,7 @@ def inferred_pins pins, api_map, name_pin, locals # # qualify(), however, happens in the namespace where # the docs were written - from the method pin. + # @todo Need to add nil check here type = with_params(new_return_type.self_to_type(self_type), self_type).qualify(api_map, *p.gates) if new_return_type.defined? type ||= ComplexType::UNDEFINED end @@ -149,9 +168,11 @@ def inferred_pins pins, api_map, name_pin, locals next p.proxy(type) if type.defined? if !p.macros.empty? result = process_macro(p, api_map, name_pin.context, locals) + # @sg-ignore We should understand reassignment of variable to new type next result unless result.return_type.undefined? elsif !p.directives.empty? result = process_directive(p, api_map, name_pin.context, locals) + # @sg-ignore We should understand reassignment of variable to new type next result unless result.return_type.undefined? end p @@ -162,8 +183,11 @@ def inferred_pins pins, api_map, name_pin, locals reduced_context = name_pin.binder.reduce_class_type pin.proxy(reduced_context) else + # @sg-ignore Need to add nil check here next pin if pin.return_type.undefined? + # @sg-ignore Need to add nil check here selfy = pin.return_type.self_to_type(name_pin.binder) + # @sg-ignore Need to add nil check here selfy == pin.return_type ? pin : pin.proxy(selfy) end end @@ -171,7 +195,7 @@ def inferred_pins pins, api_map, name_pin, locals # @param pin [Pin::Base] # @param api_map [ApiMap] - # @param context [ComplexType] + # @param context [ComplexType, ComplexType::UniqueType] # @param locals [::Array] # @return [Pin::Base] def process_macro pin, api_map, context, locals @@ -190,7 +214,7 @@ def process_macro pin, api_map, context, locals # @param pin [Pin::Method] # @param api_map [ApiMap] - # @param context [ComplexType] + # @param context [ComplexType, ComplexType::UniqueType] # @param locals [::Array] # @return [Pin::ProxyType] def process_directive pin, api_map, context, locals @@ -206,21 +230,24 @@ def process_directive pin, api_map, context, locals # @param pin [Pin::Base] # @param macro [YARD::Tags::MacroDirective] # @param api_map [ApiMap] - # @param context [ComplexType] + # @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? @@ -246,6 +273,7 @@ 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 @@ -271,13 +299,14 @@ def yield_pins api_map, name_pin # @param signature_pin [Pin::Signature] method_pin.signatures.map(&:block).compact.map do |signature_pin| + # @sg-ignore Need to add nil check here return_type = signature_pin.return_type.qualify(api_map, *name_pin.gates) signature_pin.proxy(return_type) end end # @param type [ComplexType] - # @param context [ComplexType] + # @param context [ComplexType, ComplexType::UniqueType] # @return [ComplexType] def with_params type, context return type unless type.to_s.include?('$') @@ -291,13 +320,14 @@ def fix_block_pass end # @param api_map [ApiMap] - # @param context [ComplexType] + # @param context [ComplexType, ComplexType::UniqueType] # @param block_parameter_types [::Array] # @param locals [::Array] # @return [ComplexType, nil] def block_symbol_call_type(api_map, context, block_parameter_types, locals) # Ruby's shorthand for sending the passed in method name # to the first yield parameter with no arguments + # @sg-ignore Need to add nil check here block_symbol_name = block.links.first.word block_symbol_call_path = "#{block_parameter_types.first}##{block_symbol_name}" callee = api_map.get_path_pins(block_symbol_call_path).first @@ -305,6 +335,7 @@ def block_symbol_call_type(api_map, context, block_parameter_types, locals) # @todo: Figure out why we get unresolved generics at # this point and need to assume method return types # based on the generic type + # @sg-ignore Need to add nil check here return_type ||= api_map.get_path_pins("#{context.subtypes.first}##{block.links.first.word}").first&.return_type return_type || ComplexType::UNDEFINED end @@ -312,9 +343,11 @@ def block_symbol_call_type(api_map, context, block_parameter_types, locals) # @param api_map [ApiMap] # @return [Pin::Block, nil] def find_block_pin(api_map) + # @sg-ignore Need to add nil check here node_location = Solargraph::Location.from_node(block.node) - return if node_location.nil? + return if node_location.nil? block_pins = api_map.get_block_pins + # @sg-ignore Need to add nil check here block_pins.find { |pin| pin.location.contain?(node_location) } end @@ -326,10 +359,12 @@ def find_block_pin(api_map) def block_call_type(api_map, name_pin, locals) return nil unless with_block? - block_context_pin = name_pin block_pin = find_block_pin(api_map) - block_context_pin = block_pin.closure if block_pin - block.infer(api_map, block_context_pin, locals) + # We use the block pin as the closure, as the parameters + # here will only be defined inside the block itself and we + # need to be able to see them + # @sg-ignore Need to add nil check here + block.infer(api_map, block_pin, locals) end end end diff --git a/lib/solargraph/source/chain/constant.rb b/lib/solargraph/source/chain/constant.rb index 2752ec136..b1c25fab9 100644 --- a/lib/solargraph/source/chain/constant.rb +++ b/lib/solargraph/source/chain/constant.rb @@ -17,7 +17,9 @@ def resolve api_map, name_pin, locals base = word gates = name_pin.gates end + # @sg-ignore Need to add nil check here fqns = api_map.resolve(base, gates) + # @sg-ignore Need to add nil check here api_map.get_path_pins(fqns) end end diff --git a/lib/solargraph/source/chain/hash.rb b/lib/solargraph/source/chain/hash.rb index 045a7d116..bf2aa484c 100644 --- a/lib/solargraph/source/chain/hash.rb +++ b/lib/solargraph/source/chain/hash.rb @@ -14,6 +14,7 @@ def initialize type, node, splatted = false # @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 + [@splatted] end diff --git a/lib/solargraph/source/chain/if.rb b/lib/solargraph/source/chain/if.rb index 3a7fa0ca9..db0b2481c 100644 --- a/lib/solargraph/source/chain/if.rb +++ b/lib/solargraph/source/chain/if.rb @@ -15,6 +15,7 @@ def initialize links # @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 + [@links] end diff --git a/lib/solargraph/source/chain/instance_variable.rb b/lib/solargraph/source/chain/instance_variable.rb index ea09f5578..a4d4e5aa1 100644 --- a/lib/solargraph/source/chain/instance_variable.rb +++ b/lib/solargraph/source/chain/instance_variable.rb @@ -4,9 +4,24 @@ module Solargraph class Source class Chain class InstanceVariable < Link + # @param word [String] + # @param node [Parser::AST::Node, nil] The node representing the variable + # @param location [Location, nil] The location of the variable reference in the source + def initialize word, node, location + super(word) + @node = node + @location = location + end + def resolve api_map, name_pin, locals - api_map.get_instance_variable_pins(name_pin.binder.namespace, name_pin.binder.scope).select{|p| p.name == word} + api_map.get_instance_variable_pins(name_pin.context.namespace, name_pin.context.scope).select{|p| p.name == word} end + + private + + # @todo: Missed nil violation + # @return [Location] + attr_reader :location end end end diff --git a/lib/solargraph/source/chain/literal.rb b/lib/solargraph/source/chain/literal.rb index 2e0d65c9e..b4be63910 100644 --- a/lib/solargraph/source/chain/literal.rb +++ b/lib/solargraph/source/chain/literal.rb @@ -16,21 +16,27 @@ def word # @param node [Parser::AST::Node, Object] def initialize type, node 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 [: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 @type = type + # @sg-ignore flow sensitive typing needs to handle ivars @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 diff --git a/lib/solargraph/source/chain/or.rb b/lib/solargraph/source/chain/or.rb index 9264d4107..f7ff10347 100644 --- a/lib/solargraph/source/chain/or.rb +++ b/lib/solargraph/source/chain/or.rb @@ -8,6 +8,8 @@ def word '' end + attr_reader :links + # @param links [::Array] def initialize links @links = links @@ -15,7 +17,13 @@ def initialize links def resolve api_map, name_pin, locals types = @links.map { |link| link.infer(api_map, name_pin, locals) } - [Solargraph::Pin::ProxyType.anonymous(Solargraph::ComplexType.new(types.uniq), source: :chain)] + combined_type = Solargraph::ComplexType.new(types) + unless types.all?(&:nullable?) + # @sg-ignore Unresolved call to without_nil on Solargraph::ComplexType + combined_type = combined_type.without_nil + end + + [Solargraph::Pin::ProxyType.anonymous(combined_type, source: :chain)] end end end diff --git a/lib/solargraph/source/change.rb b/lib/solargraph/source/change.rb index 65c47c7e0..becee5191 100644 --- a/lib/solargraph/source/change.rb +++ b/lib/solargraph/source/change.rb @@ -7,13 +7,13 @@ class Source class Change include EncodingFixes - # @return [Range] + # @return [Range, nil] attr_reader :range # @return [String] attr_reader :new_text - # @param range [Range] The starting and ending positions of the change. + # @param range [Range, nil] The starting and ending positions of the change. # If nil, the original text will be overwritten. # @param new_text [String] The text to be changed. def initialize range, new_text @@ -31,9 +31,11 @@ def write text, nullable = false if nullable and !range.nil? and 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 @@ -58,9 +60,12 @@ 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 Reassignment as a function of itself issue result = result[0, off].sub(/#{match[0]}\z/, ' ' * match[0].length) + result[off..-1] end result @@ -73,7 +78,9 @@ def repair text # @param insert [String] # @return [String] def commit text, insert + # @sg-ignore Need to add nil check here start_offset = Position.to_offset(text, range.start) + # @sg-ignore Need to add nil check here end_offset = Position.to_offset(text, range.ending) (start_offset == 0 ? '' : text[0..start_offset-1].to_s) + normalize(insert) + text[end_offset..-1].to_s end diff --git a/lib/solargraph/source/cursor.rb b/lib/solargraph/source/cursor.rb index a8226eb07..5ee9ac4b8 100644 --- a/lib/solargraph/source/cursor.rb +++ b/lib/solargraph/source/cursor.rb @@ -19,7 +19,7 @@ def initialize source, position @position = Position.normalize(position) end - # @return [String] + # @return [String, nil] def filename source.filename end @@ -35,12 +35,14 @@ def word # The part of the word before the current position. Given the text # `foo.bar`, the start_of_word at position(0, 6) is `ba`. # + # @sg-ignore Need to add nil check here # @return [String] def start_of_word @start_of_word ||= begin match = source.code[0..offset-1].to_s.match(start_word_pattern) result = (match ? match[0] : '') # Including the preceding colon if the word appears to be a symbol + # @sg-ignore Need to add nil check here result = ":#{result}" if source.code[0..offset-result.length-1].end_with?(':') and !source.code[0..offset-result.length-1].end_with?('::') result end @@ -50,6 +52,7 @@ 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..-1].to_s.match(end_word_pattern) @@ -110,6 +113,7 @@ def string? def recipient @recipient ||= begin node = recipient_node + # @sg-ignore Need to add nil check here node ? Cursor.new(source, Range.from_node(node).ending) : nil end end @@ -124,8 +128,10 @@ def node def node_position @node_position ||= begin if start_of_word.empty? + # @sg-ignore Need to add nil check here match = source.code[0, offset].match(/\s*(\.|:+)\s*$/) if match + # @sg-ignore Need to add nil check here Position.from_offset(source.code, offset - match[0].length) else position diff --git a/lib/solargraph/source/source_chainer.rb b/lib/solargraph/source/source_chainer.rb index 5758a9d35..aeffbeeec 100644 --- a/lib/solargraph/source/source_chainer.rb +++ b/lib/solargraph/source/source_chainer.rb @@ -33,25 +33,29 @@ def initialize source, position def chain # Special handling for files that end with an integer and a period return Chain.new([Chain::Literal.new('Integer', Integer(phrase[0..-2])), Chain::UNDEFINED_CALL]) if phrase =~ /^[0-9]+\.$/ + # @sg-ignore Need to add nil check here return Chain.new([Chain::Literal.new('Symbol', phrase[1..].to_sym)]) if phrase.start_with?(':') && !phrase.start_with?('::') return SourceChainer.chain(source, Position.new(position.line, position.character + 1)) if end_of_phrase.strip == '::' && source.code[Position.to_offset(source.code, position)].to_s.match?(/[a-z]/i) begin return Chain.new([]) if phrase.end_with?('..') + # @type [::Parser::AST::Node, nil] node = nil + # @type [::Parser::AST::Node, nil] parent = nil if !source.repaired? && source.parsed? && source.synchronized? tree = source.tree_at(position.line, position.column) node, parent = tree[0..2] elsif source.parsed? && source.repaired? && end_of_phrase == '.' node, parent = source.tree_at(fixed_position.line, fixed_position.column)[0..2] - node = Parser.parse(fixed_phrase) if node.nil? + # provide filename and line so that we can look up local variables there later + node = Parser.parse(fixed_phrase, source.filename, fixed_position.line) if node.nil? elsif source.repaired? - node = Parser.parse(fixed_phrase) + node = Parser.parse(fixed_phrase, source.filename, fixed_position.line) else node, parent = source.tree_at(fixed_position.line, fixed_position.column)[0..2] unless source.error_ranges.any?{|r| r.nil? || r.include?(fixed_position)} # Exception for positions that chain literal nodes in unsynchronized sources node = nil unless source.synchronized? || !Parser.infer_literal_node_type(node).nil? - node = Parser.parse(fixed_phrase) if node.nil? + node = Parser.parse(fixed_phrase, source.filename, fixed_position.line) if node.nil? end rescue Parser::SyntaxError return Chain.new([Chain::UNDEFINED_CALL]) @@ -79,11 +83,13 @@ def chain # @return [Solargraph::Source] attr_reader :source + # @sg-ignore Need to add nil check here # @return [String] def phrase @phrase ||= source.code[signature_data..offset-1] end + # @sg-ignore Need to add nil check here # @return [String] def fixed_phrase @fixed_phrase ||= phrase[0..-(end_of_phrase.length+1)] @@ -95,6 +101,7 @@ 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*$/) @@ -149,9 +156,12 @@ def get_signature_data_at index in_whitespace = true else if brackets.zero? and parens.zero? and squares.zero? and in_whitespace + # @sg-ignore Need to add nil check here unless char == '.' or @source.code[index+1..-1].strip.start_with?('.') old = @source.code[index+1..-1] + # @sg-ignore Need to add nil check here nxt = @source.code[index+1..-1].lstrip + # @sg-ignore Need to add nil check here index += (@source.code[index+1..-1].length - @source.code[index+1..-1].lstrip.length) break end diff --git a/lib/solargraph/source/updater.rb b/lib/solargraph/source/updater.rb index 496d534ab..4fdb78330 100644 --- a/lib/solargraph/source/updater.rb +++ b/lib/solargraph/source/updater.rb @@ -29,6 +29,7 @@ def initialize filename, version, changes # @param text [String] # @param nullable [Boolean] + # @sg-ignore changes doesn't mutate @output, so this can never be nil # @return [String] def write text, nullable = false can_nullify = (nullable and changes.length == 1) @@ -37,6 +38,9 @@ def write text, nullable = false @output = text @did_nullify = can_nullify changes.each do |ch| + # @sg-ignore Wrong argument type for + # Solargraph::Source::Change#write: text expected String, + # received String, nil @output = ch.write(@output, can_nullify) end @output diff --git a/lib/solargraph/source_map.rb b/lib/solargraph/source_map.rb index 15b747760..714970f21 100644 --- a/lib/solargraph/source_map.rb +++ b/lib/solargraph/source_map.rb @@ -34,6 +34,8 @@ def locals # @param source [Source] def initialize source @source = source + # @type [Array, nil] + @convention_pins = nil conventions_environ.merge Convention.for_local(self) unless filename.nil? # FIXME: unmemoizing the document_symbols in case it was called and memoized from any of conventions above @@ -63,7 +65,7 @@ def api_hash @api_hash ||= (pins_by_class(Pin::Constant) + pins_by_class(Pin::Namespace).select { |pin| pin.namespace.to_s > '' } + pins_by_class(Pin::Reference) + pins_by_class(Pin::Method).map(&:node) + locals).hash end - # @return [String] + # @return [String, nil] def filename source.filename end @@ -84,6 +86,7 @@ def conventions_environ end # all pins except Solargraph::Pin::Reference::Reference + # # @return [Array] def document_symbols @document_symbols ||= (pins + convention_pins).select do |pin| @@ -97,7 +100,7 @@ def query_symbols query Pin::Search.new(document_symbols, query).results end - # @param position [Position] + # @param position [Position, Array(Integer, Integer)] # @return [Source::Cursor] def cursor_at position Source::Cursor.new(source, position) @@ -125,7 +128,7 @@ def locate_named_path_pin line, character # @param line [Integer] # @param character [Integer] - # @return [Pin::Namespace,Pin::Method,Pin::Block] + # @return [Pin::Closure] def locate_closure_pin line, character _locate_pin line, character, Pin::Closure end @@ -143,7 +146,7 @@ def references name # @return [Array] def locals_at(location) return [] if location.filename != filename - closure = locate_named_path_pin(location.range.start.line, location.range.start.character) + closure = locate_closure_pin(location.range.start.line, location.range.start.character) locals.select { |pin| pin.visible_at?(closure, location) } end @@ -178,6 +181,7 @@ def map source # @return [Hash{Class => Array}] def pin_class_hash + # @todo Need to support generic resolution in classify and transform_values @pin_class_hash ||= pins.to_set.classify(&:class).transform_values(&:to_a) end @@ -191,10 +195,12 @@ def convention_pins @convention_pins || [] end + # @generic T # @param line [Integer] # @param character [Integer] - # @param klasses [Array] - # @return [Pin::Base, nil] + # @param klasses [Array>>] + # @return [generic, nil] + # @sg-ignore Need better generic inference here def _locate_pin line, character, *klasses position = Position.new(line, character) found = nil @@ -202,7 +208,9 @@ def _locate_pin line, character, *klasses # @todo Attribute pins should not be treated like closures, but # there's probably a better way to handle it next if pin.is_a?(Pin::Method) && pin.attribute? + # @sg-ignore Need to add nil check here found = pin if (klasses.empty? || klasses.any? { |kls| pin.is_a?(kls) } ) && pin.location.range.contain?(position) + # @sg-ignore Need to add nil check here break if pin.location.range.start.line > line end # Assuming the root pin is always valid diff --git a/lib/solargraph/source_map/clip.rb b/lib/solargraph/source_map/clip.rb index 3d198ac1e..b2b4d2b5d 100644 --- a/lib/solargraph/source_map/clip.rb +++ b/lib/solargraph/source_map/clip.rb @@ -12,6 +12,7 @@ def initialize api_map, cursor @api_map = api_map @cursor = cursor closure_pin = closure + # @sg-ignore Need to add nil check here closure_pin.rebind(api_map) if closure_pin.is_a?(Pin::Block) && !Solargraph::Range.from_node(closure_pin.receiver).contain?(cursor.range.start) end @@ -20,6 +21,7 @@ def define return [] if cursor.comment? || cursor.chain.literal? result = cursor.chain.define(api_map, closure, locals) result.concat file_global_methods + # @sg-ignore Need to add nil check here result.concat((source_map.pins + source_map.locals).select{ |p| p.name == cursor.word && p.location.range.contain?(cursor.position) }) if result.empty? result end @@ -78,7 +80,7 @@ def gates # @param phrase [String] # @return [Array] def translate phrase - chain = Parser.chain(Parser.parse(phrase)) + chain = Parser.chain(Parser.parse(phrase, cursor.filename, cursor.position.line)) chain.define(api_map, closure, locals) end @@ -92,6 +94,7 @@ def translate phrase # @return [SourceMap] def source_map + # @sg-ignore Need to add nil check here @source_map ||= api_map.source_map(cursor.filename) end @@ -150,16 +153,23 @@ def package_completions result # @return [Completion] def tag_complete result = [] + # @sg-ignore Need to add nil check here match = source_map.code[0..cursor.offset-1].match(/[\[<, ]([a-z0-9_:]*)\z/i) if match + # @sg-ignore Need to add nil check here full = match[1] + # @sg-ignore Need to add nil check here if full.include?('::') + # @sg-ignore Need to add nil check here if full.end_with?('::') + # @sg-ignore Need to add nil check here result.concat api_map.get_constants(full[0..-3], *gates) else + # @sg-ignore Need to add nil check here result.concat api_map.get_constants(full.split('::')[0..-2].join('::'), *gates) end else + # @sg-ignore Need to add nil check here result.concat api_map.get_constants('', full.end_with?('::') ? '' : context_pin.full_context.namespace, *gates) #.select { |pin| pin.name.start_with?(full) } end end @@ -176,6 +186,7 @@ def code_complete cursor.chain.base.infer(api_map, context_pin, locals) else if full.include?('::') && cursor.chain.links.length == 1 + # @sg-ignore Need to add nil check here ComplexType.try_parse(full.split('::')[0..-2].join('::')) elsif cursor.chain.links.length > 1 ComplexType.try_parse(full) @@ -199,7 +210,7 @@ def code_complete if cursor.word.start_with?('@@') return package_completions(api_map.get_class_variable_pins(context_pin.full_context.namespace)) elsif cursor.word.start_with?('@') - return package_completions(api_map.get_instance_variable_pins(closure.binder.namespace, closure.binder.scope)) + return package_completions(api_map.get_instance_variable_pins(closure.full_context.namespace, closure.context.scope)) elsif cursor.word.start_with?('$') return package_completions(api_map.get_global_variable_pins) end diff --git a/lib/solargraph/source_map/data.rb b/lib/solargraph/source_map/data.rb index 453520414..906273fbc 100644 --- a/lib/solargraph/source_map/data.rb +++ b/lib/solargraph/source_map/data.rb @@ -8,13 +8,16 @@ def initialize source @source = source end + # @sg-ignore Translate to something flow sensitive typing understands # @return [Array] + # @sg-ignore https://github.com/castwide/solargraph/pull/1100 def pins generate @pins || [] end - # @return [Array] + # @sg-ignore Translate to something flow sensitive typing understands + # @return [Array] def locals generate @locals || [] diff --git a/lib/solargraph/source_map/mapper.rb b/lib/solargraph/source_map/mapper.rb index 5fdcb9fe6..5440c65df 100644 --- a/lib/solargraph/source_map/mapper.rb +++ b/lib/solargraph/source_map/mapper.rb @@ -48,6 +48,7 @@ class << self # @param source [Source] # @return [Array] def map source + # @sg-ignore Need to add nil check here return new.unmap(source.filename, source.code) unless source.parsed? new.map source end @@ -62,6 +63,7 @@ def pins # @param position [Solargraph::Position] # @return [Solargraph::Pin::Closure] def closure_at(position) + # @sg-ignore Need to add nil check here pins.select{|pin| pin.is_a?(Pin::Closure) and pin.location.range.contain?(position)}.last end @@ -90,11 +92,13 @@ def process_comment source_position, comment_position, comment def find_directive_line_number comment, tag, start # Avoid overruning the index return start unless start < comment.lines.length + # @sg-ignore Need to add nil check here num = comment.lines[start..-1].find_index do |line| # Legacy method directives might be `@method` instead of `@!method` # @todo Legacy syntax should probably emit a warning line.include?("@!#{tag}") || (tag == 'method' && line.include?("@#{tag}")) end + # @sg-ignore Need to add nil check here num.to_i + start end @@ -103,17 +107,20 @@ def find_directive_line_number comment, tag, start # @param directive [YARD::Tags::Directive] # @return [void] def process_directive source_position, comment_position, directive + # @sg-ignore Need to add nil check here docstring = Solargraph::Source.parse_docstring(directive.tag.text).to_docstring location = Location.new(@filename, Range.new(comment_position, comment_position)) case directive.tag.tag_name when 'method' namespace = closure_at(source_position) || @pins.first + # @sg-ignore Need to add nil check here if namespace.location.range.start.line < comment_position.line namespace = closure_at(comment_position) end begin src = Solargraph::Source.load_string("def #{directive.tag.name};end", @source.filename) region = Parser::Region.new(source: src, closure: namespace) + # @type [Array] method_gen_pins = Parser.process_node(src.node, region).first.select { |pin| pin.is_a?(Pin::Method) } gen_pin = method_gen_pins.last return if gen_pin.nil? @@ -165,10 +172,12 @@ def process_directive source_position, comment_position, directive when 'visibility' kind = directive.tag.text&.to_sym + # @sg-ignore Need to look at Tuple#include? handling return unless [:private, :protected, :public].include?(kind) name = directive.tag.name closure = closure_at(source_position) || @pins.first + # @sg-ignore Need to add nil check here if closure.location.range.start.line < comment_position.line closure = closure_at(comment_position) end @@ -186,6 +195,7 @@ def process_directive source_position, comment_position, directive when 'parse' begin ns = closure_at(source_position) + # @sg-ignore Need to add nil check here src = Solargraph::Source.load_string(directive.tag.text, @source.filename) region = Parser::Region.new(source: src, closure: ns) # @todo These pins may need to be marked not explicit @@ -196,6 +206,7 @@ def process_directive source_position, comment_position, directive comment_position.line end Parser.process_node(src.node, region, @pins) + # @sg-ignore Need to add nil check here @pins[index..-1].each do |p| # @todo Smelly instance variable access p.location.range.start.instance_variable_set(:@line, p.location.range.start.line + loff) @@ -206,8 +217,10 @@ def process_directive source_position, comment_position, directive end when 'domain' namespace = closure_at(source_position) || Pin::ROOT_PIN + # @sg-ignore Should handle redefinition of types in simple contexts namespace.domains.concat directive.tag.types unless directive.tag.types.nil? when 'override' + # @sg-ignore Need to add nil check here pins.push Pin::Reference::Override.new(location, directive.tag.name, docstring.tags, source: :source_map) when 'macro' @@ -217,7 +230,9 @@ def process_directive source_position, comment_position, directive # @param line1 [Integer] # @param line2 [Integer] + # @sg-ignore Need to add nil check here def no_empty_lines?(line1, line2) + # @sg-ignore Need to add nil check here @code.lines[line1..line2].none? { |line| line.strip.empty? } end @@ -235,6 +250,7 @@ def remove_inline_comment_hashes comment started = true elsif started && !p.strip.empty? cur = p.index(/[^ ]/) + # @sg-ignore Need to add nil check here num = cur if cur < num end ctxt += "#{p[num..-1]}" if started @@ -248,6 +264,7 @@ def process_comment_directives code_lines = @code.lines @source.associated_comments.each do |line, comments| src_pos = line ? Position.new(line, code_lines[line].to_s.chomp.index(/[^\s]/) || 0) : Position.new(code_lines.length, 0) + # @sg-ignore Need to add nil check here com_pos = Position.new(line + 1 - comments.lines.length, 0) process_comment(src_pos, com_pos, comments) end diff --git a/lib/solargraph/type_checker.rb b/lib/solargraph/type_checker.rb index c2bb6fc64..c8964ad73 100644 --- a/lib/solargraph/type_checker.rb +++ b/lib/solargraph/type_checker.rb @@ -5,11 +5,10 @@ module Solargraph # class TypeChecker autoload :Problem, 'solargraph/type_checker/problem' - autoload :ParamDef, 'solargraph/type_checker/param_def' autoload :Rules, 'solargraph/type_checker/rules' - autoload :Checks, 'solargraph/type_checker/checks' - include Checks + # @!parse + # include Solargraph::Parser::ParserGem::NodeMethods include Parser::NodeMethods # @return [String] @@ -23,12 +22,12 @@ class TypeChecker # @param filename [String, nil] # @param api_map [ApiMap, nil] + # @param rules [Rules] Type checker rules object # @param level [Symbol] Don't complain about anything above this level # @param workspace [Workspace, nil] Workspace to use for loading # type checker rules modified by user config # @param type_checker_rules [Hash{Symbol => Symbol}] Overrides for # type checker rules - e.g., :report_undefined => :strong - # @param rules [Rules] Type checker rules object def initialize filename, api_map: nil, level: :normal, @@ -36,7 +35,8 @@ def initialize filename, rules: workspace ? workspace.rules(level) : Rules.new(level, {}) @filename = filename # @todo Smarter directory resolution - @api_map = api_map || Solargraph::ApiMap.load(File.dirname(filename)) + @api_map = api_map || Solargraph::ApiMap.load(File.dirname(filename), + loose_unions: !rules.require_all_unique_types_match_expected_on_lhs?) @rules = rules # @type [Array] @marked_ranges = [] @@ -49,7 +49,40 @@ def source_map # @return [Source] def source - @source_map.source + source_map.source + end + + # @param inferred [ComplexType, ComplexType::UniqueType] + # @param expected [ComplexType, ComplexType::UniqueType] + def return_type_conforms_to?(inferred, expected) + conforms_to?(inferred, expected, :return_type) + end + + # @param inferred [ComplexType, ComplexType::UniqueType] + # @param expected [ComplexType, ComplexType::UniqueType] + def arg_conforms_to?(inferred, expected) + conforms_to?(inferred, expected, :method_call) + end + + # @param inferred [ComplexType, ComplexType::UniqueType] + # @param expected [ComplexType, ComplexType::UniqueType] + def assignment_conforms_to?(inferred, expected) + conforms_to?(inferred, expected, :assignment) + end + + # @param inferred [ComplexType, ComplexType::UniqueType] + # @param expected [ComplexType, ComplexType::UniqueType] + # @param scenario [Symbol] + def conforms_to?(inferred, expected, scenario) + rules_arr = [] + rules_arr << :allow_empty_params unless rules.require_inferred_type_params? + rules_arr << :allow_any_match unless rules.require_all_unique_types_match_expected? + rules_arr << :allow_undefined unless rules.require_no_undefined_args? + rules_arr << :allow_unresolved_generic unless rules.require_generics_resolved? + rules_arr << :allow_unmatched_interface unless rules.require_interfaces_resolved? + rules_arr << :allow_reverse_match unless rules.require_downcasts? + inferred.conforms_to?(api_map, expected, scenario, + rules_arr) end # @return [Array] @@ -70,20 +103,26 @@ class << self # @return [self] def load filename, level = :normal source = Solargraph::Source.load(filename) - api_map = Solargraph::ApiMap.new + rules = Rules.new(level, {}) + api_map = Solargraph::ApiMap.new(loose_unions: + !rules.require_all_unique_types_match_expected_on_lhs?) api_map.map(source) - new(filename, api_map: api_map, level: level) + new(filename, api_map: api_map, level: level, rules: rules) end # @param code [String] # @param filename [String, nil] # @param level [Symbol] + # @param api_map [Solargraph::ApiMap, nil] # @return [self] - def load_string code, filename = nil, level = :normal + def load_string code, filename = nil, level = :normal, api_map: nil source = Solargraph::Source.load_string(code, filename) - api_map = Solargraph::ApiMap.new + rules = Rules.new(level, {}) + api_map ||= Solargraph::ApiMap.new(loose_unions: + !rules.require_all_unique_types_match_expected_on_lhs?) + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars api_map.map(source) - new(filename, api_map: api_map, level: level) + new(filename, api_map: api_map, level: level, rules: rules) end end @@ -107,6 +146,7 @@ def method_return_type_problems_for pin result = [] declared = pin.typify(api_map).self_to_type(pin.full_context).qualify(api_map, *pin.gates) if declared.undefined? + # @sg-ignore Need to add nil check here if pin.return_type.undefined? && rules.require_type_tags? if pin.attribute? inferred = pin.probe(api_map).self_to_type(pin.full_context) @@ -114,6 +154,7 @@ def method_return_type_problems_for pin else result.push Problem.new(pin.location, "Missing @return tag for #{pin.path}", pin: pin) end + # @sg-ignore Need to add nil check here elsif pin.return_type.defined? && !resolved_constant?(pin) result.push Problem.new(pin.location, "Unresolved return type #{pin.return_type} for #{pin.path}", pin: pin) elsif rules.must_tag_or_infer? && pin.probe(api_map).undefined? @@ -127,7 +168,7 @@ def method_return_type_problems_for pin result.push Problem.new(pin.location, "#{pin.path} return type could not be inferred", pin: pin) end else - unless (rules.require_all_return_types_match_inferred? ? all_types_match?(api_map, inferred, declared) : any_types_match?(api_map, declared, inferred)) + 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) end end @@ -154,6 +195,7 @@ 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 @@ -201,6 +243,7 @@ def ignored_pins def variable_type_tag_problems result = [] all_variables.each do |pin| + # @sg-ignore Need to add nil check here if pin.return_type.defined? declared = pin.typify(api_map) next if declared.duck_type? @@ -215,7 +258,7 @@ def variable_type_tag_problems result.push Problem.new(pin.location, "Variable type could not be inferred for #{pin.name}", pin: pin) end else - unless any_types_match?(api_map, declared, inferred) + unless assignment_conforms_to?(inferred, declared) result.push Problem.new(pin.location, "Declared type #{declared} does not match inferred type #{inferred} for variable #{pin.name}", pin: pin) end end @@ -247,8 +290,10 @@ def const_problems Solargraph::Parser::NodeMethods.const_nodes_from(source.node).each do |const| rng = Solargraph::Range.from_node(const) chain = Solargraph::Parser.chain(const, filename) + # @sg-ignore Need to add nil check here closure_pin = source_map.locate_closure_pin(rng.start.line, rng.start.column) closure_pin.rebind(api_map) + # @sg-ignore Need to add nil check here location = Location.new(filename, rng) locals = source_map.locals_at(location) pins = chain.define(api_map, closure_pin, locals) @@ -265,34 +310,48 @@ def call_problems result = [] Solargraph::Parser::NodeMethods.call_nodes_from(source.node).each do |call| rng = Solargraph::Range.from_node(call) + # @sg-ignore Need to add nil check here next if @marked_ranges.any? { |d| d.contain?(rng.start) } chain = Solargraph::Parser.chain(call, filename) + # @sg-ignore Need to add nil check here closure_pin = source_map.locate_closure_pin(rng.start.line, rng.start.column) namespace_pin = closure_pin if call.type == :block # blocks in the AST include the method call as well, so the # node returned by #call_nodes_from needs to be backed out # one closure + # @todo Need to add nil check here + # @todo Should warn on nil deference here closure_pin = closure_pin.closure end + # @sg-ignore Need to add nil check here closure_pin.rebind(api_map) + # @sg-ignore Need to add nil check here location = Location.new(filename, rng) locals = source_map.locals_at(location) + # @sg-ignore Need to add nil check here type = chain.infer(api_map, closure_pin, locals) if type.undefined? && !rules.ignore_all_undefined? base = chain missing = chain + # @type [Solargraph::Pin::Base, nil] found = nil + # @type [Array] + all_found = [] closest = ComplexType::UNDEFINED until base.links.first.undefined? - found = base.define(api_map, closure_pin, locals).first + # @sg-ignore Need to add nil check here + all_found = base.define(api_map, closure_pin, locals) + found = all_found.first break if found missing = base base = base.base end - closest = found.typify(api_map) if found + all_closest = all_found.map { |pin| pin.typify(api_map) } + closest = ComplexType.new(all_closest.flat_map(&:items).uniq) # @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)) + # @sg-ignore Need to add nil check here unless closest.generic? || ignored_pins.include?(found) if closest.defined? result.push Problem.new(location, "Unresolved call to #{missing.links.last.word} on #{closest}") @@ -303,6 +362,7 @@ def call_problems end end end + # @sg-ignore Need to add nil check here result.concat argument_problems_for(chain, api_map, closure_pin, locals, location) end result @@ -311,13 +371,12 @@ def call_problems # @param chain [Solargraph::Source::Chain] # @param api_map [Solargraph::ApiMap] # @param closure_pin [Solargraph::Pin::Closure] - # @param locals [Array] + # @param locals [Array] # @param location [Solargraph::Location] # @return [Array] def argument_problems_for chain, api_map, closure_pin, locals, location result = [] base = chain - # @type last_base_link [Solargraph::Source::Chain::Call] last_base_link = base.links.last return [] unless last_base_link.is_a?(Solargraph::Source::Chain::Call) @@ -340,6 +399,8 @@ def argument_problems_for chain, api_map, closure_pin, locals, location base.base.infer(api_map, closure_pin, locals).namespace end init = api_map.get_method_stack(fqns, 'initialize').first + + # @type [::Array] init ? arity_problems_for(init, arguments, location) : [] else arity_problems_for(pin, arguments, location) @@ -368,7 +429,7 @@ def argument_problems_for chain, api_map, closure_pin, locals, location # @param location [Location] # @param locals [Array] # @param closure_pin [Pin::Closure] - # @param params [Hash{String => Hash{Symbol => String, Solargraph::ComplexType}}] + # @param params [Hash{String => undefined}] # @param arguments [Array] # @param sig [Pin::Signature] # @param pin [Pin::Method] @@ -430,7 +491,7 @@ def signature_argument_problems_for location, locals, closure_pin, params, argum else argtype = argchain.infer(api_map, closure_pin, locals) argtype = argtype.self_to_type(closure_pin.context) - if argtype.defined? && ptype.defined? && !any_types_match?(api_map, ptype, argtype) + if argtype.defined? && ptype.defined? && !arg_conforms_to?(argtype, ptype) errors.push Problem.new(location, "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{argtype}") return errors end @@ -445,13 +506,13 @@ def signature_argument_problems_for location, locals, closure_pin, params, argum end # @param sig [Pin::Signature] - # @param argchain [Source::Chain] + # @param argchain [Solargraph::Source::Chain] # @param api_map [ApiMap] # @param closure_pin [Pin::Closure] # @param locals [Array] # @param location [Location] # @param pin [Pin::Method] - # @param params [Hash{String => Hash{Symbol => String, Solargraph::ComplexType}}] + # @param params [Hash{String => Hash{Symbol => undefined}}] # @param idx [Integer] # # @return [Array] @@ -459,6 +520,7 @@ def kwarg_problems_for sig, argchain, api_map, closure_pin, locals, location, pi result = [] kwargs = convert_hash(argchain.node) par = sig.parameters[idx] + # @type [Solargraph::Source::Chain] argchain = kwargs[par.name.to_sym] 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) @@ -468,13 +530,14 @@ def kwarg_problems_for sig, argchain, api_map, closure_pin, locals, location, pi if data.nil? # @todo Some level (strong, I guess) should require the param here else + # @type [ComplexType, ComplexType::UniqueType] ptype = data[:qualified] ptype = ptype.self_to_type(pin.context) unless ptype.undefined? - # @sg-ignore https://github.com/castwide/solargraph/pull/1127 + # @type [ComplexType] argtype = argchain.infer(api_map, closure_pin, locals).self_to_type(closure_pin.context) - # @sg-ignore Unresolved call to defined? - if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) + # @todo Unresolved call to defined? + if argtype.defined? && ptype && !arg_conforms_to?(argtype, ptype) result.push Problem.new(location, "Wrong argument type for #{pin.path}: #{par.name} expected #{ptype}, received #{argtype}") end end @@ -498,11 +561,12 @@ def kwrestarg_problems_for(api_map, closure_pin, locals, location, pin, params, result = [] kwargs.each_pair do |pname, argchain| next unless params.key?(pname.to_s) + # @type [ComplexType] ptype = params[pname.to_s][:qualified] ptype = ptype.self_to_type(pin.context) argtype = argchain.infer(api_map, closure_pin, locals) argtype = argtype.self_to_type(closure_pin.context) - if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) + if argtype.defined? && ptype && !arg_conforms_to?(argtype, ptype) result.push Problem.new(location, "Wrong argument type for #{pin.path}: #{pname} expected #{ptype}, received #{argtype}") end end @@ -553,6 +617,7 @@ def signature_param_details(pin) next if tag.types.nil? result[tag.name.to_s] = { tagged: tag.types.join(', '), + # @sg-ignore need to add a nil check here qualified: Solargraph::ComplexType.try_parse(*tag.types).qualify(api_map, *pin.closure.gates) } end @@ -602,6 +667,7 @@ 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 @@ -622,23 +688,31 @@ 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) + # @sg-ignore flow sensitive typing needs to handle "if foo.nil?" location = Location.new(filename, Range.from_node(pin.assignment)) locals = source_map.locals_at(location) type = chain.infer(api_map, closure_pin, locals) if type.undefined? && !rules.ignore_all_undefined? base = chain missing = chain + # @type [Solargraph::Pin::Base, nil] found = nil + # @type [Array] + all_found = [] closest = ComplexType::UNDEFINED until base.links.first.undefined? - found = base.define(api_map, closure_pin, locals).first + all_found = base.define(api_map, closure_pin, locals) + found = all_found.first break if found missing = base base = base.base end - closest = found.typify(api_map) if found + all_closest = all_found.map { |pin| pin.typify(api_map) } + closest = ComplexType.new(all_closest.flat_map(&:items).uniq) if !found || closest.defined? || internal?(found) return false end @@ -739,8 +813,10 @@ def optional_param_count(parameters) end # @param pin [Pin::Method] + # @sg-ignore need boolish support for ? methods def abstract? pin pin.docstring.has_tag?('abstract') || + # @sg-ignore of low sensitive typing needs to handle ivars (pin.closure && pin.closure.docstring.has_tag?('abstract')) end @@ -750,19 +826,25 @@ def fake_args_for(pin) args = [] with_opts = false with_block = false + # @param pin [Pin::Parameter] pin.parameters.each do |pin| + # @sg-ignore Should handle redefinition of types in simple contexts if [:kwarg, :kwoptarg, :kwrestarg].include?(pin.decl) with_opts = true + # @sg-ignore Should handle redefinition of types in simple contexts elsif pin.decl == :block with_block = true + # @sg-ignore Should handle redefinition of types in simple contexts elsif pin.decl == :restarg args.push Solargraph::Source::Chain.new([Solargraph::Source::Chain::Variable.new(pin.name)], nil, true) else args.push Solargraph::Source::Chain.new([Solargraph::Source::Chain::Variable.new(pin.name)]) end end - args.push Solargraph::Parser.chain_string('{}') if with_opts - args.push Solargraph::Parser.chain_string('&') if with_block + pin_location = pin.location + starting_line = pin_location ? pin_location.range.start.line : 0 + args.push Solargraph::Parser.chain_string('{}', filename, starting_line) if with_opts + args.push Solargraph::Parser.chain_string('&', filename, starting_line) if with_block args end @@ -774,6 +856,7 @@ 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.include?('@sg-ignore') end.keys.to_set end diff --git a/lib/solargraph/type_checker/checks.rb b/lib/solargraph/type_checker/checks.rb deleted file mode 100644 index de402978b..000000000 --- a/lib/solargraph/type_checker/checks.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -module Solargraph - class TypeChecker - # Helper methods for performing type checks - # - module Checks - module_function - - # Compare an expected type with an inferred type. Common usage is to - # check if the type declared in a method's @return tag matches the type - # inferred from static analysis of the code. - # - # @param api_map [ApiMap] - # @param expected [ComplexType] - # @param inferred [ComplexType] - # @return [Boolean] - def types_match? api_map, expected, inferred - return true if expected.to_s == inferred.to_s - matches = [] - expected.each do |exp| - found = false - inferred.each do |inf| - # if api_map.super_and_sub?(fuzz(inf), fuzz(exp)) - if either_way?(api_map, inf, exp) - found = true - matches.push inf - break - end - end - return false unless found - end - inferred.each do |inf| - next if matches.include?(inf) - found = false - expected.each do |exp| - # if api_map.super_and_sub?(fuzz(inf), fuzz(exp)) - if either_way?(api_map, inf, exp) - found = true - break - end - end - return false unless found - end - true - end - - # @param api_map [ApiMap] - # @param expected [ComplexType] - # @param inferred [ComplexType] - # @return [Boolean] - def any_types_match? api_map, expected, inferred - expected = expected.downcast_to_literal_if_possible - inferred = inferred.downcast_to_literal_if_possible - return duck_types_match?(api_map, expected, inferred) if expected.duck_type? - # walk through the union expected type and see if any members - # of the union match the inferred type - expected.each do |exp| - next if exp.duck_type? - # @todo: there should be a level of typechecking where all - # unique types in the inferred must match one of the - # expected unique types - inferred.each do |inf| - # return true if exp == inf || api_map.super_and_sub?(fuzz(inf), fuzz(exp)) - return true if exp == inf || either_way?(api_map, inf, exp) - end - end - false - end - - # @param api_map [ApiMap] - # @param inferred [ComplexType] - # @param expected [ComplexType] - # @return [Boolean] - def all_types_match? api_map, inferred, expected - expected = expected.downcast_to_literal_if_possible - inferred = inferred.downcast_to_literal_if_possible - return duck_types_match?(api_map, expected, inferred) if expected.duck_type? - inferred.each do |inf| - next if inf.duck_type? - return false unless expected.any? { |exp| exp == inf || either_way?(api_map, inf, exp) } - end - true - end - - # @param api_map [ApiMap] - # @param expected [ComplexType] - # @param inferred [ComplexType] - # @return [Boolean] - def duck_types_match? api_map, expected, inferred - raise ArgumentError, 'Expected type must be duck type' unless expected.duck_type? - expected.each do |exp| - next unless exp.duck_type? - quack = exp.to_s[1..-1] - return false if api_map.get_method_stack(inferred.namespace, quack, scope: inferred.scope).empty? - end - true - end - - # @param type [ComplexType::UniqueType] - # @return [String] - def fuzz type - if type.parameters? - type.name - else - type.tag - end - end - - # @param api_map [ApiMap] - # @param cls1 [ComplexType::UniqueType] - # @param cls2 [ComplexType::UniqueType] - # @return [Boolean] - def either_way?(api_map, cls1, cls2) - # @todo there should be a level of typechecking which uses the - # full tag with parameters to determine compatibility - f1 = cls1.name - f2 = cls2.name - api_map.type_include?(f1, f2) || api_map.super_and_sub?(f1, f2) || api_map.super_and_sub?(f2, f1) - # api_map.type_include?(f1, f2) || api_map.super_and_sub?(f1, f2) || api_map.super_and_sub?(f2, f1) - end - end - end -end diff --git a/lib/solargraph/type_checker/param_def.rb b/lib/solargraph/type_checker/param_def.rb deleted file mode 100644 index dcab5f4a8..000000000 --- a/lib/solargraph/type_checker/param_def.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Solargraph - class TypeChecker - # Data about a method parameter definition. This is the information from - # the args list in the def call, not the `@param` tags. - # - class ParamDef - # @return [String] - attr_reader :name - - # @return [Symbol] - attr_reader :type - - # @param name [String] - # @param type [Symbol] The type of parameter, such as :req, :opt, :rest, etc. - def initialize name, type - @name = name - @type = type - end - - class << self - # Get an array of ParamDefs from a method pin. - # - # @param pin [Solargraph::Pin::Method] - # @return [Array] - def from pin - result = [] - pin.parameters.each do |par| - result.push ParamDef.new(par.name, par.decl) - end - result - end - end - end - end -end diff --git a/lib/solargraph/type_checker/problem.rb b/lib/solargraph/type_checker/problem.rb index 1ae65571a..8375a58ff 100644 --- a/lib/solargraph/type_checker/problem.rb +++ b/lib/solargraph/type_checker/problem.rb @@ -5,19 +5,21 @@ class TypeChecker # A problem reported by TypeChecker. # class Problem + # @todo Missed nil violation # @return [Solargraph::Location] attr_reader :location # @return [String] attr_reader :message + # @todo Missed nil violation # @return [Pin::Base] attr_reader :pin # @return [String, nil] attr_reader :suggestion - # @param location [Solargraph::Location] + # @param location [Solargraph::Location, nil] # @param message [String] # @param pin [Solargraph::Pin::Base, nil] # @param suggestion [String, nil] diff --git a/lib/solargraph/type_checker/rules.rb b/lib/solargraph/type_checker/rules.rb index 81a2d4aa3..4622a275f 100644 --- a/lib/solargraph/type_checker/rules.rb +++ b/lib/solargraph/type_checker/rules.rb @@ -60,8 +60,69 @@ def validate_tags? report?(:validate_tags, :typed) end - def require_all_return_types_match_inferred? - report?(:require_all_return_types_match_inferred, :alpha) + def require_inferred_type_params? + report?(:require_inferred_type_params, :alpha) + end + + # + # False negatives: + # + # @todo 4: Missed nil violation + # + # pending code fixes (277): + # + # @todo 268: Need to add nil check here + # @todo 22: Translate to something flow sensitive typing understands + # @todo 9: Need to validate config + # @todo 2: Need a downcast here + # + # flow-sensitive typing could handle (96): + # + # @todo 35: flow sensitive typing needs to handle attrs + # @todo 19: flow sensitive typing needs to narrow down type with an if is_a? check + # @todo 14: flow sensitive typing needs to handle ivars + # @todo 13: Should handle redefinition of types in simple contexts + # @todo 6: need boolish support for ? methods + # @todo 5: literal arrays in this module turn into ::Solargraph::Source::Chain::Array + # @todo 4: flow sensitive typing needs better handling of ||= on lvars + # @todo 4: flow sensitive typing needs to eliminate literal from union with [:bar].include?(foo) + # @todo 3: downcast output of Enumerable#select + # @todo 3: flow sensitive typing needs to handle 'raise if' + # @todo 2: flow sensitive typing should handle return nil if location&.name.nil? + # @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: Need to handle duck-typed method calls on union types + # @todo 2: Need typed hashes + # @todo 2: Need better handling of #compact + # @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 handle while + # @todo 1: flow sensitive typing needs to eliminate literal from union with return if foo == :bar + def require_all_unique_types_match_expected? + report?(:require_all_unique_types_match_expected, :strong) + end + + def require_all_unique_types_match_expected_on_lhs? + report?(:require_all_unique_types_match_expected_on_lhs, :strong) + end + + def require_no_undefined_args? + report?(:require_no_undefined_args, :alpha) + end + + def require_generics_resolved? + report?(:require_generics_resolved, :alpha) + end + + def require_interfaces_resolved? + report?(:require_interfaces_resolved, :alpha) + end + + def require_downcasts? + report?(:require_downcasts, :alpha) end # We keep this at strong because if you added an @ sg-ignore to diff --git a/lib/solargraph/workspace.rb b/lib/solargraph/workspace.rb index 06980e6d0..4454600f1 100644 --- a/lib/solargraph/workspace.rb +++ b/lib/solargraph/workspace.rb @@ -63,6 +63,7 @@ def rules(level) # @param sources [Array] # @return [Boolean] True if the source was added to the workspace def merge *sources + # @sg-ignore Need to add nil check here unless directory == '*' || sources.all? { |source| source_hash.key?(source.filename) } # Reload the config to determine if a new source should be included @config = Solargraph::Workspace::Config.new(directory) @@ -70,10 +71,12 @@ def merge *sources includes_any = false sources.each do |source| - if directory == "*" || config.calculated.include?(source.filename) - source_hash[source.filename] = source - includes_any = true - end + # @sg-ignore Need to add nil check here + next unless directory == "*" || config.calculated.include?(source.filename) + + # @sg-ignore Need to add nil check here + source_hash[source.filename] = source + includes_any = true end includes_any @@ -149,7 +152,9 @@ def synchronize! updater source_hash[updater.filename] = source_hash[updater.filename].synchronize(updater) end + # @sg-ignore Need to validate config # @return [String] + # @sg-ignore Need to validate config def command_path server['commandPath'] || 'solargraph' end diff --git a/lib/solargraph/workspace/config.rb b/lib/solargraph/workspace/config.rb index ac9f36739..df44476d0 100644 --- a/lib/solargraph/workspace/config.rb +++ b/lib/solargraph/workspace/config.rb @@ -14,7 +14,7 @@ class Config # @return [String] attr_reader :directory - # @todo To make JSON strongly typed we'll need a record syntax + # @todo Need typed hashes # @return [Hash{String => undefined, nil}] attr_reader :raw_data @@ -78,7 +78,9 @@ def required # An array of load paths for required paths. # + # @sg-ignore Need to validate config # @return [Array] + # @sg-ignore Need to validate config def require_paths raw_data['require_paths'] || [] end diff --git a/lib/solargraph/workspace/require_paths.rb b/lib/solargraph/workspace/require_paths.rb index c8eea161b..10dce4053 100644 --- a/lib/solargraph/workspace/require_paths.rb +++ b/lib/solargraph/workspace/require_paths.rb @@ -83,6 +83,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 Should handle redefinition of types in simple contexts Solargraph.logger.warn "Error reading #{gemspec_file_path}: [#{e.class}] #{e.message}" [] end diff --git a/lib/solargraph/yard_map/helpers.rb b/lib/solargraph/yard_map/helpers.rb index 96bc454b5..cd4be9acc 100644 --- a/lib/solargraph/yard_map/helpers.rb +++ b/lib/solargraph/yard_map/helpers.rb @@ -5,7 +5,7 @@ module Helpers # @param code_object [YARD::CodeObjects::Base] # @param spec [Gem::Specification, nil] - # @return [Solargraph::Location, nil] + # @return [Solargraph::Location] def object_location code_object, spec if spec.nil? || code_object.nil? || code_object.file.nil? || code_object.line.nil? if code_object.namespace.is_a?(YARD::CodeObjects::NamespaceObject) @@ -14,6 +14,7 @@ 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) 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/mapper.rb b/lib/solargraph/yard_map/mapper.rb index 592b3805e..f0708e9d9 100644 --- a/lib/solargraph/yard_map/mapper.rb +++ b/lib/solargraph/yard_map/mapper.rb @@ -24,6 +24,7 @@ def map end # Some yardocs contain documentation for dependencies that can be # ignored here. The YardMap will load dependencies separately. + # @sg-ignore Need to add nil check here @pins.keep_if { |pin| pin.location.nil? || File.file?(pin.location.filename) } if @spec @pins end @@ -38,13 +39,17 @@ 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 flow sensitive typing needs to narrow down type with an if is_a? check if code_object.is_a?(YARD::CodeObjects::ClassObject) and !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 flow sensitive typing needs to narrow down type with an if is_a? check superclass = if code_object.superclass.is_a?(YARD::CodeObjects::Proxy) + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check "::#{code_object.superclass}" else + # @sg-ignore flow sensitive typing needs to narrow down type with an if is_a? check code_object.superclass.to_s end result.push Solargraph::Pin::Reference::Superclass.new(name: superclass, closure: nspin, source: :yard_map) diff --git a/lib/solargraph/yard_map/mapper/to_method.rb b/lib/solargraph/yard_map/mapper/to_method.rb index 0838b9f4f..42593ed8c 100644 --- a/lib/solargraph/yard_map/mapper/to_method.rb +++ b/lib/solargraph/yard_map/mapper/to_method.rb @@ -6,6 +6,7 @@ class Mapper module ToMethod extend YardMap::Helpers + # @type [Hash{Array => Symbol}] VISIBILITY_OVERRIDE = { # YARD pays attention to 'private' statements prior to class methods but shouldn't ["Rails::Engine", :class, "find_root_with_flag"] => :public @@ -25,9 +26,12 @@ 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 final_visibility ||= :private if closure.path == 'Kernel' && Kernel.private_instance_methods(false).include?(name.to_sym) final_visibility ||= visibility final_visibility ||= :private if code_object.module_function? && final_scope == :instance @@ -49,6 +53,7 @@ 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, @@ -85,7 +90,6 @@ def get_parameters code_object, location, comments, pin # HACK: Skip `nil` and `self` parameters that are sometimes emitted # for methods defined in C # See https://github.com/castwide/solargraph/issues/345 - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 code_object.parameters.select { |a| a[0] && a[0] != 'self' }.map do |a| Solargraph::Pin::Parameter.new( location: location, diff --git a/lib/solargraph/yard_map/mapper/to_namespace.rb b/lib/solargraph/yard_map/mapper/to_namespace.rb index f7063e3d6..7d1d3ce6e 100644 --- a/lib/solargraph/yard_map/mapper/to_namespace.rb +++ b/lib/solargraph/yard_map/mapper/to_namespace.rb @@ -21,6 +21,7 @@ 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/yard_map/to_method.rb b/lib/solargraph/yard_map/to_method.rb deleted file mode 100644 index 010db89a5..000000000 --- a/lib/solargraph/yard_map/to_method.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -module Solargraph - class YardMap - class ToMethod - module InnerMethods - module_function - - # @param code_object [YARD::CodeObjects::Base] - # @param location [Solargraph::Location] - # @param comments [String] - # @return [Array] - def get_parameters code_object, location, comments - return [] unless code_object.is_a?(YARD::CodeObjects::MethodObject) - # HACK: Skip `nil` and `self` parameters that are sometimes emitted - # for methods defined in C - # See https://github.com/castwide/solargraph/issues/345 - # @sg-ignore https://github.com/castwide/solargraph/pull/1114 - code_object.parameters.select { |a| a[0] && a[0] != 'self' }.map do |a| - Solargraph::Pin::Parameter.new( - location: location, - closure: self, - comments: comments, - name: arg_name(a), - presence: nil, - decl: arg_type(a), - asgn_code: a[1], - source: :yard_map - ) - end - end - - # @param a [Array] - # @return [String] - def arg_name a - a[0].gsub(/[^a-z0-9_]/i, '') - end - - # @param a [Array] - # @return [::Symbol] - def arg_type a - if a[0].start_with?('**') - :kwrestarg - elsif a[0].start_with?('*') - :restarg - elsif a[0].start_with?('&') - :blockarg - elsif a[0].end_with?(':') - a[1] ? :kwoptarg : :kwarg - elsif a[1] - :optarg - else - :arg - end - end - end - private_constant :InnerMethods - - include Helpers - - # @param code_object [YARD::CodeObjects::MethodObject] - # @param name [String, nil] - # @param scope [Symbol, nil] - # @param visibility [Symbol, nil] - # @param closure [Solargraph::Pin::Base, nil] - # @param spec [Solargraph::Pin::Base, nil] - # @return [Solargraph::Pin::Method] - def make code_object, name = nil, scope = nil, visibility = nil, closure = nil, spec = nil - closure ||= Solargraph::Pin::Namespace.new( - name: code_object.namespace.to_s, - gates: [code_object.namespace.to_s] - ) - location = object_location(code_object, spec) - comments = code_object.docstring ? code_object.docstring.all.to_s : '' - Pin::Method.new( - location: location, - closure: closure, - name: name || code_object.name.to_s, - comments: comments, - scope: scope || code_object.scope, - visibility: visibility || code_object.visibility, - parameters: InnerMethods.get_parameters(code_object, location, comments), - explicit: code_object.is_explicit?, - source: :yard_map - ) - end - end - end -end diff --git a/lib/solargraph/yardoc.rb b/lib/solargraph/yardoc.rb index 907afb2de..56ad9d546 100644 --- a/lib/solargraph/yardoc.rb +++ b/lib/solargraph/yardoc.rb @@ -78,6 +78,7 @@ def load!(gemspec) # @return [Hash{String => String}] a hash of environment variables to override def current_bundle_env_tweaks tweaks = {} + # @sg-ignore Translate to something flow sensitive typing understands if ENV['BUNDLE_GEMFILE'] && !ENV['BUNDLE_GEMFILE'].empty? tweaks['BUNDLE_GEMFILE'] = File.expand_path(ENV['BUNDLE_GEMFILE']) end diff --git a/rbs/fills/tuple/tuple.rbs b/rbs/fills/tuple/tuple.rbs index f4e213355..c21f13e1a 100644 --- a/rbs/fills/tuple/tuple.rbs +++ b/rbs/fills/tuple/tuple.rbs @@ -144,6 +144,34 @@ module Solargraph | [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) + + # + # Returns elements from `self`, or `nil`; does not modify `self`. + # + # With no argument given, returns the first element (if available): + # + # a = [:foo, 'bar', 2] + # a.first # => :foo + # a # => [:foo, "bar", 2] + # + # If `self` is empty, returns `nil`. + # + # [].first # => nil + # + # With a non-negative integer argument `count` given, returns the first `count` + # elements (as available) in a new array: + # + # a.first(0) # => [] + # a.first(2) # => [:foo, "bar"] + # a.first(50) # => [:foo, "bar", 2] + # + # Related: see [Methods for Querying](rdoc-ref:Array@Methods+for+Querying). + # + def first: %a{implicitly-returns-nil} () -> A end end end \ No newline at end of file diff --git a/solargraph.gemspec b/solargraph.gemspec index 98f524d4d..afd0a0ffb 100755 --- a/solargraph.gemspec +++ b/solargraph.gemspec @@ -1,3 +1,4 @@ +# @sg-ignore Should better support meaning of '&' in RBS $LOAD_PATH.unshift File.dirname(__FILE__) + '/lib' require 'solargraph/version' require 'date' diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb new file mode 100644 index 000000000..f8a623bf0 --- /dev/null +++ b/spec/complex_type/conforms_to_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +describe Solargraph::ComplexType do + let(:api_map) do + Solargraph::ApiMap.new + end + + it 'validates simple core types' do + exp = described_class.parse('String') + inf = described_class.parse('String') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'invalidates simple core types' do + exp = described_class.parse('String') + inf = described_class.parse('Integer') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'allows subtype skew if told' do + exp = described_class.parse('Array') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call, [:allow_subtype_skew]) + expect(match).to be(true) + end + + it 'allows covariant behavior in parameters of Array' do + exp = described_class.parse('Array') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'does not allow contravariant behavior in parameters of Array' do + exp = described_class.parse('Array') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'allows covariant behavior in key types of Hash' do + exp = described_class.parse('Hash{Object => String}') + inf = described_class.parse('Hash{Integer => String}') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'accepts valid tuple conformance' do + exp = described_class.parse('Array(Integer, Integer)') + inf = described_class.parse('Array(Integer, Integer)') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'rejects invalid tuple conformance' do + exp = described_class.parse('Array(Integer, Integer)') + inf = described_class.parse('Array(Integer, String)') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'allows empty params when specified' do + exp = described_class.parse('Array(Integer, Integer)') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call, [:allow_empty_params]) + expect(match).to be(true) + end + + it 'validates expected superclasses' do + source = Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + )) + api_map.map source + sup = described_class.parse('Sup') + sub = described_class.parse('Sub') + match = sub.conforms_to?(api_map, sup, :method_call) + expect(match).to be(true) + end + + it 'handles singleton types compared against their literals' do + 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) + expect(match).to be(true) + end + + # it 'invalidates inferred superclasses (expected must be super)' do + # # @todo This test might be invalid. There are use cases where inheritance + # # between inferred and expected classes should be acceptable in either + # # direction. + # # source = Solargraph::Source.load_string(%( + # # class Sup; end + # # class Sub < Sup; end + # # )) + # # api_map.map source + # # sup = described_class.parse('Sup') + # # sub = described_class.parse('Sub') + # # match = Solargraph::TypeChecker::Checks.types_match?(api_map, sub, sup) + # # expect(match).to be(false) + # end + + it 'fuzzy matches arrays with parameters' do + exp = described_class.parse('Array') + inf = described_class.parse('Array') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'fuzzy matches sets with parameters' do + source = Solargraph::Source.load_string("require 'set'") + source_map = Solargraph::SourceMap.map(source) + api_map.catalog Solargraph::Bench.new(source_maps: [source_map], external_requires: ['set']) + exp = described_class.parse('Set') + inf = described_class.parse('Set') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'fuzzy matches hashes with parameters' do + exp = described_class.parse('Hash{ Symbol => String}') + inf = described_class.parse('Hash') + match = inf.conforms_to?(api_map, exp, :method_call, [:allow_empty_params]) + expect(match).to be(true) + end + + it 'matches multiple types' do + exp = described_class.parse('String, Integer') + inf = described_class.parse('String, Integer') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'matches multiple types out of order' do + exp = described_class.parse('String, Integer') + inf = described_class.parse('Integer, String') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'invalidates inferred types missing from expected' do + exp = described_class.parse('String') + inf = described_class.parse('String, Integer') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(false) + end + + it 'matches nil' do + exp = described_class.parse('nil') + inf = described_class.parse('nil') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'validates classes with expected superclasses' do + exp = described_class.parse('Class') + inf = described_class.parse('Class') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + it 'validates generic classes with expected Class' do + inf = described_class.parse('Class') + exp = described_class.parse('Class') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + end + + context 'with invariant matching' do + it 'rejects String matching an Object' do + inf = described_class.parse('String') + exp = described_class.parse('Object') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :invariant) + expect(match).to be(false) + end + + it 'rejects Object matching an String' do + inf = described_class.parse('Object') + exp = described_class.parse('String') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :invariant) + expect(match).to be(false) + end + + it 'accepts String matching a String' do + inf = described_class.parse('String') + exp = described_class.parse('String') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :invariant) + expect(match).to be(true) + end + end + + context 'with contravariant matching' do + it 'rejects String matching an Objet' do + inf = described_class.parse('String') + exp = described_class.parse('Object') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :contravariant) + expect(match).to be(false) + end + + it 'accepts Object matching an String' do + inf = described_class.parse('Object') + exp = described_class.parse('String') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :contravariant) + expect(match).to be(true) + end + + it 'accepts String matching a String' do + inf = described_class.parse('String') + exp = described_class.parse('String') + match = inf.conforms_to?(api_map, exp, :method_call, variance: :contravariant) + expect(match).to be(true) + end + end + + context 'with an inheritence relationship' do + let(:source) do + Solargraph::Source.load_string(%( + class Sup; end + class Sub < Sup; end + )) + end + let(:sup) { described_class.parse('Sup') } + let(:sub) { described_class.parse('Sub') } + + before do + api_map.map source + end + + it 'validates inheritance in one way' do + match = sub.conforms_to?(api_map, sup, :method_call, [:allow_reverse_match]) + expect(match).to be(true) + end + + it 'validates inheritance the other way' do + match = sup.conforms_to?(api_map, sub, :method_call, [:allow_reverse_match]) + expect(match).to be(true) + end + end + + context 'with inheritance relationship in allow_reverse_match mode' do + let(:api_map) { Solargraph::ApiMap.new } + let(:sup) { described_class.parse('String') } + let(:sub) { described_class.parse('Array') } + + it 'conforms one way' do + match = sub.conforms_to?(api_map, sup, :method_call, [:allow_reverse_match]) + expect(match).to be(false) + end + + it 'conforms the other way' do + match = sup.conforms_to?(api_map, sub, :method_call, [:allow_reverse_match]) + expect(match).to be(false) + end + end +end diff --git a/spec/complex_type/unique_type_spec.rb b/spec/complex_type/unique_type_spec.rb new file mode 100644 index 000000000..2d9812600 --- /dev/null +++ b/spec/complex_type/unique_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +describe Solargraph::ComplexType::UniqueType do + describe '#any?' do + let(:type) { described_class.parse('String') } + + it 'yields one and only one type, itself' do + types_encountered = [] + type.any? { |t| types_encountered << t } + expect(types_encountered).to eq([type]) + end + end +end diff --git a/spec/complex_type_spec.rb b/spec/complex_type_spec.rb index ba2a1ac7c..92d24aee8 100644 --- a/spec/complex_type_spec.rb +++ b/spec/complex_type_spec.rb @@ -733,5 +733,33 @@ def make_bar expect(type.to_rbs).to eq('[Symbol, String, [Integer, Integer]]') expect(type.to_s).to eq('Array(Symbol, String, Array(Integer, Integer))') end + + it 'recognizes String conforms with itself' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('String') + atype = Solargraph::ComplexType.parse('String') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) + end + + it 'recognizes an erased container type conforms with itself' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('Hash') + atype = Solargraph::ComplexType.parse('Hash') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) + end + + it 'recognizes an unerased container type conforms with itself' do + api_map = Solargraph::ApiMap.new + ptype = Solargraph::ComplexType.parse('Array') + atype = Solargraph::ComplexType.parse('Array') + expect(atype.conforms_to?(api_map, ptype, :method_call)).to be(true) + end + + it 'recognizes a literal conforms with its type' do + 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 end end diff --git a/spec/convention/activesupport_concern_spec.rb b/spec/convention/activesupport_concern_spec.rb index b58cd6584..a0eae979a 100644 --- a/spec/convention/activesupport_concern_spec.rb +++ b/spec/convention/activesupport_concern_spec.rb @@ -149,7 +149,7 @@ class Base RBS end - it { should_not be_empty } + it { is_expected.not_to be_empty } it "has one item" do expect(method_pins.size).to eq(1) diff --git a/spec/convention/gemfile_spec.rb b/spec/convention/gemfile_spec.rb index 827da7993..ab6a5ef1b 100644 --- a/spec/convention/gemfile_spec.rb +++ b/spec/convention/gemfile_spec.rb @@ -24,7 +24,7 @@ def type_checker code it 'finds bad arguments to DSL methods' do checker = type_checker(%( - source File + source 123 gemspec bad_name: 'solargraph' @@ -35,7 +35,7 @@ def type_checker code expect(checker.problems.map(&:message).sort) .to eq(['Unrecognized keyword argument bad_name to Bundler::Dsl#gemspec', - 'Wrong argument type for Bundler::Dsl#source: source expected String, received Class'].sort) + 'Wrong argument type for Bundler::Dsl#source: source expected String, received 123'].sort) end it 'finds bad arguments to DSL ruby method' do diff --git a/spec/language_server/host/diagnoser_spec.rb b/spec/language_server/host/diagnoser_spec.rb index 69ee0b866..697d352bd 100644 --- a/spec/language_server/host/diagnoser_spec.rb +++ b/spec/language_server/host/diagnoser_spec.rb @@ -1,9 +1,9 @@ describe Solargraph::LanguageServer::Host::Diagnoser do it "diagnoses on ticks" do host = double(Solargraph::LanguageServer::Host, options: { 'diagnostics' => true }, synchronizing?: false) + allow(host).to receive(:diagnose) diagnoser = Solargraph::LanguageServer::Host::Diagnoser.new(host) diagnoser.schedule 'file.rb' - allow(host).to receive(:diagnose) diagnoser.tick expect(host).to have_received(:diagnose).with('file.rb') end diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index bf747fc76..f35ee097d 100644 --- a/spec/parser/flow_sensitive_typing_spec.rb +++ b/spec/parser/flow_sensitive_typing_spec.rb @@ -3,7 +3,7 @@ # @todo These tests depend on `Clip`, but we're putting the tests here to # avoid overloading clip_spec.rb. describe Solargraph::Parser::FlowSensitiveTyping do - it 'uses is_a? in a simple if() to refine types on a simple class' do + it 'uses is_a? in a simple if() to refine types' do source = Solargraph::Source.load_string(%( class ReproBase; end class Repro < ReproBase; end @@ -24,6 +24,28 @@ def verify_repro(repr) expect(clip.infer.to_s).to eq('ReproBase') end + it 'uses is_a? in a simple if() with a union to refine types' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro1 < ReproBase; end + class Repro2 < ReproBase; end + # @param repr [Repro1, Repro2] + def verify_repro(repr) + if repr.is_a?(Repro1) + repr + else + 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('Repro1') + + 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 if() to refine types on a module-scoped class' do source = Solargraph::Source.load_string(%( class ReproBase; end @@ -72,7 +94,7 @@ def verify_repro(repr) expect(clip.infer.to_s).to eq('ReproBase') end - it 'uses is_a? in a simple unless statement to refine types on a simple class' do + it 'uses is_a? in a simple unless statement to refine types' do source = Solargraph::Source.load_string(%( class ReproBase; end class Repro < ReproBase; end @@ -201,6 +223,7 @@ class Repro < ReproBase; end value end ), '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') @@ -212,6 +235,65 @@ class Repro < ReproBase; end expect(clip.infer.to_s).to eq('Float') end + it 'uses varname in a simple if()' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if repr + repr + else + repr + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + end + + it 'uses varname in a "break unless" statement in a while to refine types' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro < ReproBase; end + # @type [ReproBase, nil] + value = bar + while !is_done() + break unless value + value + 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('ReproBase') + end + + it 'uses varname in a "break if" statement in a while to refine types' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro < ReproBase; end + # @type [ReproBase, nil] + value = bar + while !is_done() + break if value.nil? + value + 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('ReproBase') + end + it 'understands compatible reassignments' do source = Solargraph::Source.load_string(%( class Foo @@ -223,6 +305,7 @@ def baz; end bar = Foo.new bar ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) clip = api_map.clip_at('test.rb', [6, 6]) expect(clip.infer.to_s).to eq('Foo') @@ -253,4 +336,646 @@ def baz; end clip = api_map.clip_at('test.rb', [3, 6]) expect { clip.infer.to_s }.not_to raise_error end + + it 'uses nil? in a simple if() to refine nilness' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + def verify_repro(repr) + repr = 10 if floop + repr + if repr.nil? + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + end + + it 'uses nil? and && in a simple if() to refine nilness - nil? first' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if repr.nil? && throw_the_dice + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + end + + it 'uses nil? and && in a simple if() to refine nilness - nil? second' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if throw_the_dice && repr.nil? + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + end + + it 'uses nil? and || in a simple if() - nil? first' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if repr.nil? || throw_the_dice + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + end + + it 'uses nil? and || in a simple if() - nil? second' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if throw_the_dice || repr.nil? + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + end + + it 'uses varname and || in a simple if() - varname first' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if repr || throw_the_dice + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + end + + it 'uses varname and || in a simple if() - varname second' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if throw_the_dice || repr + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + end + + it 'uses .nil? and or in an unless' do + source = Solargraph::Source.load_string(%( + # @param repr [String, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr) + repr unless repr.nil? || repr.downcase + repr + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 33]) + expect(clip.infer.rooted_tags).to eq('::String') + + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::String') + + clip = api_map.clip_at('test.rb', [5, 8]) + expect(clip.infer.rooted_tags).to eq('::String, nil') + end + + it 'uses varname and && in a simple if() - varname first' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if repr && throw_the_dice + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + end + + it 'uses varname and && in a simple if() - varname second' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + # @param throw_the_dice [Boolean] + def verify_repro(repr, throw_the_dice) + repr + if throw_the_dice && repr + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + end + + it 'uses variable in a simple if() to refine types' do + source = Solargraph::Source.load_string(%( + # @param repr [Integer, nil] + def verify_repro(repr) + repr = 10 if floop + repr + if repr + repr + else + repr + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.rooted_tags).to eq('::Integer, nil') + + clip = api_map.clip_at('test.rb', [6, 10]) + expect(clip.infer.rooted_tags).to eq('::Integer') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.rooted_tags).to eq('nil') + end + + it 'uses variable in a simple if() to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + def verify_repro(repr = nil) + repr = 10 if floop + repr + if repr + repr + else + repr + end + end + ), '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('10, nil') + + clip = api_map.clip_at('test.rb', [5, 10]) + expect(clip.infer.rooted_tags).to eq('10') + + clip = api_map.clip_at('test.rb', [7, 10]) + 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 + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + if rand + return if baz.nil? + baz + end + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + end + + # https://cse.buffalo.edu/~regan/cse305/RubyBNF.pdf + # https://ruby-doc.org/docs/ruby-doc-bundle/Manual/man-1.4/syntax.html + it 'uses .nil? in a return 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) + return 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 return if() in a block to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @param arr [Array] + # @return [void] + def bar(arr, baz: nil) + baz + arr.each do |item| + return if baz.nil? + baz + end + 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, nil') + + clip = api_map.clip_at('test.rb', [9, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [11, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses .nil? in a return if() in an unless to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + unless rand + return if baz.nil? + baz + end + 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', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [10, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses .nil? in a return if() in a while to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + while rand do + return if baz.nil? + baz + end + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses foo in a a while to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @param other [::Boolean, nil] + # @return [void] + def bar(baz: nil, other: nil) + baz + while baz do + baz + baz = other + end + 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, nil') + + clip = api_map.clip_at('test.rb', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [11, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses .nil? in a return if() in an until to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + until rand do + return if baz.nil? + baz + end + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [7, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses .nil? in a return if() in a switch/case/else to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + case rand + when 0..0.5 + return if baz.nil? + baz + else + baz + end + baz + end + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [10, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + + clip = api_map.clip_at('test.rb', [12, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'uses .nil? in a return if() in a ternary operator to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + rand > 0.5 ? (return if baz.nil?; baz) : baz + 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', [6, 44]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [6, 51]) + 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, nil') + end + + it 'uses .nil? in a return if() in a begin/end to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + begin + return if baz.nil? + baz + end + 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', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [10, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + end + + it 'uses .nil? in a return if() in a ||= to refine types using nil checks' do + source = Solargraph::Source.load_string(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + baz ||= begin + return if baz.nil? + baz + end + 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', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [10, 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 + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz + begin + return if baz.nil? + baz + rescue StandardError + baz + ensure + baz + end + 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', [8, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + clip = api_map.clip_at('test.rb', [10, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean') + + pending('better scoping of return if in begin/rescue/ensure') + + clip = api_map.clip_at('test.rb', [12, 12]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + + clip = api_map.clip_at('test.rb', [14, 10]) + expect(clip.infer.rooted_tags).to eq('::Boolean, nil') + end + + it 'provides a useful pin after a return if .nil?' do + source = Solargraph::Source.load_string(%( + class A + # @param b [Hash{String => String}] + # @return [void] + def a b + c = b["123"] + c + return c if c.nil? + c + 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('String') + + clip = api_map.clip_at('test.rb', [7, 17]) + expect(clip.infer.to_s).to eq('nil') + + clip = api_map.clip_at('test.rb', [8, 10]) + expect(clip.infer.to_s).to eq('String') + end + + it 'uses ! to detect nilness' do + source = Solargraph::Source.load_string(%( + class A + # @param a [Integer, nil] + # @return [Integer] + def foo a + return a unless !a + 123 + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [5, 17]) + expect(clip.infer.to_s).to eq('Integer') + end end diff --git a/spec/parser/node_chainer_spec.rb b/spec/parser/node_chainer_spec.rb index e92431aae..e4722d77a 100644 --- a/spec/parser/node_chainer_spec.rb +++ b/spec/parser/node_chainer_spec.rb @@ -1,51 +1,55 @@ describe 'NodeChainer' do + def chain_string str + Solargraph::Parser.chain_string(str, 'file.rb', 0) + end + it "recognizes self keywords" do - chain = Solargraph::Parser.chain_string('self.foo') + chain = chain_string('self.foo') expect(chain.links.first.word).to eq('self') expect(chain.links.first).to be_a(Solargraph::Source::Chain::Head) end it "recognizes super keywords" do - chain = Solargraph::Parser.chain_string('super.foo') + chain = chain_string('super.foo') expect(chain.links.first.word).to eq('super') expect(chain.links.first).to be_a(Solargraph::Source::Chain::ZSuper) end it "recognizes constants" do - chain = Solargraph::Parser.chain_string('Foo::Bar') + chain = chain_string('Foo::Bar') expect(chain.links.length).to eq(1) expect(chain.links.first).to be_a(Solargraph::Source::Chain::Constant) expect(chain.links.map(&:word)).to eq(['Foo::Bar']) end it "splits method calls with arguments and blocks" do - chain = Solargraph::Parser.chain_string('var.meth1(1, 2).meth2 do; end') + chain = chain_string('var.meth1(1, 2).meth2 do; end') expect(chain.links.map(&:word)).to eq(['var', 'meth1', 'meth2']) end it "recognizes literals" do - chain = Solargraph::Parser.chain_string('"string"') + chain = chain_string('"string"') expect(chain).to be_literal - chain = Solargraph::Parser.chain_string('100') + chain = chain_string('100') expect(chain).to be_literal - chain = Solargraph::Parser.chain_string('[1, 2, 3]') + chain = chain_string('[1, 2, 3]') expect(chain).to be_literal - chain = Solargraph::Parser.chain_string('{ foo: "bar" }') + chain = chain_string('{ foo: "bar" }') expect(chain).to be_literal end it "recognizes instance variables" do - chain = Solargraph::Parser.chain_string('@foo') + chain = chain_string('@foo') expect(chain.links.first).to be_a(Solargraph::Source::Chain::InstanceVariable) end it "recognizes class variables" do - chain = Solargraph::Parser.chain_string('@@foo') + chain = chain_string('@@foo') expect(chain.links.first).to be_a(Solargraph::Source::Chain::ClassVariable) end it "recognizes global variables" do - chain = Solargraph::Parser.chain_string('$foo') + chain = chain_string('$foo') expect(chain.links.first).to be_a(Solargraph::Source::Chain::GlobalVariable) end diff --git a/spec/parser/node_methods_spec.rb b/spec/parser/node_methods_spec.rb index f9504b584..1846973db 100644 --- a/spec/parser/node_methods_spec.rb +++ b/spec/parser/node_methods_spec.rb @@ -1,65 +1,69 @@ # These tests are deliberately generic because they apply to both the Legacy # and Rubyvm node methods. describe Solargraph::Parser::NodeMethods do + def parse source + Solargraph::Parser.parse(source, 'test.rb', 0) + end + it "unpacks constant nodes into strings" do - ast = Solargraph::Parser.parse("Foo::Bar") + ast = parse("Foo::Bar") expect(Solargraph::Parser::NodeMethods.unpack_name(ast)).to eq "Foo::Bar" end it "infers literal strings" do - ast = Solargraph::Parser.parse("x = 'string'") + ast = parse("x = 'string'") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast.children[1])).to eq '::String' end it "infers literal hashes" do - ast = Solargraph::Parser.parse("x = {}") + ast = parse("x = {}") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast.children[1])).to eq '::Hash' end it "infers literal arrays" do - ast = Solargraph::Parser.parse("x = []") + ast = parse("x = []") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast.children[1])).to eq '::Array' end it "infers literal integers" do - ast = Solargraph::Parser.parse("x = 100") + ast = parse("x = 100") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast.children[1])).to eq '::Integer' end it "infers literal floats" do - ast = Solargraph::Parser.parse("x = 10.1") + ast = parse("x = 10.1") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast.children[1])).to eq '::Float' end it "infers literal symbols" do - ast = Solargraph::Parser.parse(":symbol") + ast = parse(":symbol") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast)).to eq '::Symbol' end it "infers double quoted symbols" do - ast = Solargraph::Parser.parse(':"symbol"') + ast = parse(':"symbol"') expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast)).to eq '::Symbol' end it "infers interpolated double quoted symbols" do - ast = Solargraph::Parser.parse(':"#{Object}"') + ast = parse(':"#{Object}"') expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast)).to eq '::Symbol' end it "infers single quoted symbols" do - ast = Solargraph::Parser.parse(":'symbol'") + ast = parse(":'symbol'") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(ast)).to eq '::Symbol' end it 'infers literal booleans' do - true_ast = Solargraph::Parser.parse("true") + true_ast = parse("true") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(true_ast)).to eq '::Boolean' - false_ast = Solargraph::Parser.parse("false") + false_ast = parse("false") expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(false_ast)).to eq '::Boolean' end it "handles return nodes with implicit nil values" do - node = Solargraph::Parser.parse(%( + node = parse(%( return if true )) rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) @@ -69,7 +73,7 @@ end it "handles return nodes with implicit nil values" do - node = Solargraph::Parser.parse(%( + node = parse(%( return bla if true )) rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) @@ -78,7 +82,7 @@ end it 'handles return nodes from case statements' do - node = Solargraph::Parser.parse(%( + node = parse(%( case x when 100 true @@ -90,7 +94,7 @@ end it 'handles return nodes from case statements with else' do - node = Solargraph::Parser.parse(%( + node = parse(%( case x when 100, 125 true @@ -114,7 +118,7 @@ end it 'handles return nodes from case statements with boolean conditions' do - node = Solargraph::Parser.parse(%( + node = parse(%( case true when x true @@ -128,7 +132,7 @@ it "handles return nodes in reduceable (begin) nodes" do # @todo Temporarily disabled. Result is 3 nodes instead of 2. - # node = Solargraph::Parser.parse(%( + # node = parse(%( # begin # return if true # end @@ -138,7 +142,7 @@ end it "handles return nodes after other nodes" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return x )) @@ -147,7 +151,7 @@ end it "handles return nodes with unreachable code" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return x y @@ -157,7 +161,7 @@ end it "handles conditional returns with following code" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return x if foo y @@ -167,7 +171,7 @@ end it "handles return nodes with reduceable code" do - node = Solargraph::Parser.parse(%( + node = parse(%( return begin x if foo y @@ -178,29 +182,27 @@ end it "handles top 'and' nodes" do - node = Solargraph::Parser.parse('1 && "2"') + node = parse('1 && "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.length).to eq(1) expect(rets[0].type.to_s.downcase).to eq('and') end it "handles top 'or' nodes" do - node = Solargraph::Parser.parse('1 || "2"') + node = parse('1 || "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) - expect(rets.length).to eq(2) - expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(rets[0])).to eq('::Integer') - expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(rets[1])).to eq('::String') + expect(rets.length).to eq(1) end it "handles nested 'and' nodes" do - node = Solargraph::Parser.parse('return 1 && "2"') + node = parse('return 1 && "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.length).to eq(1) expect(rets[0].type.to_s.downcase).to eq('and') end it "handles nested 'or' nodes" do - node = Solargraph::Parser.parse('return 1 || "2"') + node = parse('return 1 || "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.length).to eq(2) expect(Solargraph::Parser::NodeMethods.infer_literal_node_type(rets[0])).to eq('::Integer') @@ -208,7 +210,7 @@ end it 'finds return nodes in blocks' do - node = Solargraph::Parser.parse(%( + node = parse(%( array.each do |item| return item if foo end @@ -218,7 +220,7 @@ end it 'finds correct return node line in begin expressions' do - node = Solargraph::Parser.parse(%( + node = parse(%( begin 123 '123' @@ -229,7 +231,7 @@ end it 'returns nested return blocks' do - node = Solargraph::Parser.parse(%( + node = parse(%( if foo array.each do |item| return item if foo @@ -242,7 +244,7 @@ end it "handles return nodes with implicit nil values" do - node = Solargraph::Parser.parse(%( + node = parse(%( return if true )) rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) @@ -252,7 +254,7 @@ end it "handles return nodes with implicit nil values" do - node = Solargraph::Parser.parse(%( + node = parse(%( return bla if true )) rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) @@ -261,7 +263,7 @@ it "handles return nodes in reduceable (begin) nodes" do # @todo Temporarily disabled. Result is 3 nodes instead of 2 in legacy. - # node = Solargraph::Parser.parse(%( + # node = parse(%( # begin # return if true # end @@ -271,7 +273,7 @@ end it "handles return nodes after other nodes" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return x )) @@ -280,7 +282,7 @@ end it "handles return nodes with unreachable code" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return x y @@ -290,7 +292,7 @@ end xit "short-circuits return node finding after a raise statement in a begin expressiona" do - node = Solargraph::Parser.parse(%( + node = parse(%( raise "Error" y )) @@ -299,7 +301,7 @@ end it "does not short circuit return node finding after a raise statement in a conditional" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 raise "Error" if foo y @@ -309,7 +311,7 @@ end it "does not short circuit return node finding after a return statement in a conditional" do - node = Solargraph::Parser.parse(%( + node = parse(%( x = 1 return "Error" if foo y @@ -319,7 +321,7 @@ end it "handles return nodes with reduceable code" do - node = Solargraph::Parser.parse(%( + node = parse(%( return begin x if foo y @@ -330,31 +332,31 @@ end it "handles top 'and' nodes" do - node = Solargraph::Parser.parse('1 && "2"') + node = parse('1 && "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.map(&:type)).to eq([:and]) end it "handles top 'or' nodes" do - node = Solargraph::Parser.parse('1 || "2"') + node = parse('1 || "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) - expect(rets.map(&:type)).to eq([:int, :str]) + expect(rets.map(&:type)).to eq([:or]) end it "handles nested 'and' nodes from return" do - node = Solargraph::Parser.parse('return 1 && "2"') + node = parse('return 1 && "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.map(&:type)).to eq([:and]) end it "handles nested 'or' nodes from return" do - node = Solargraph::Parser.parse('return 1 || "2"') + node = parse('return 1 || "2"') rets = Solargraph::Parser::NodeMethods.returns_from_method_body(node) expect(rets.map(&:type)).to eq([:int, :str]) end it 'finds return nodes in blocks' do - node = Solargraph::Parser.parse(%( + node = parse(%( array.each do |item| return item if foo end @@ -365,7 +367,7 @@ end it 'returns nested return blocks' do - node = Solargraph::Parser.parse(%( + node = parse(%( if foo array.each do |item| return item if foo @@ -379,7 +381,7 @@ end it 'handles return nodes from case statements' do - node = Solargraph::Parser.parse(%( + node = parse(%( case 1 when 1 then "" else @@ -391,7 +393,7 @@ end it 'handles return nodes from case statements without else' do - node = Solargraph::Parser.parse(%( + node = parse(%( case 1 when 1 "" @@ -402,7 +404,7 @@ end it 'handles return nodes from case statements with super' do - node = Solargraph::Parser.parse(%( + node = parse(%( case other when Docstring Docstring.new([all, other.all].join("\n"), object) @@ -416,13 +418,13 @@ describe 'convert_hash' do it 'converts literal hash arguments' do - node = Solargraph::Parser.parse('{foo: :bar}') + node = parse('{foo: :bar}') hash = Solargraph::Parser::NodeMethods.convert_hash(node) expect(hash.keys).to eq([:foo]) end it 'ignores call arguments' do - node = Solargraph::Parser.parse('some_call') + node = parse('some_call') hash = Solargraph::Parser::NodeMethods.convert_hash(node) expect(hash).to eq({}) end diff --git a/spec/parser/node_processor_spec.rb b/spec/parser/node_processor_spec.rb index 5b8d7cd40..2033e21ca 100644 --- a/spec/parser/node_processor_spec.rb +++ b/spec/parser/node_processor_spec.rb @@ -1,6 +1,10 @@ describe Solargraph::Parser::NodeProcessor do + def parse source + Solargraph::Parser.parse(source, 'file.rb', 0) + end + it 'ignores bare private_constant calls' do - node = Solargraph::Parser.parse(%( + node = parse(%( class Foo private_constant end @@ -11,7 +15,7 @@ class Foo end it 'orders optional args correctly' do - node = Solargraph::Parser.parse(%( + node = parse(%( def foo(bar = nil, baz = nil); end )) pins, = Solargraph::Parser::NodeProcessor.process(node) @@ -21,7 +25,7 @@ def foo(bar = nil, baz = nil); end end it 'understands +=' do - node = Solargraph::Parser.parse(%( + node = parse(%( detail = '' detail += "foo" detail.strip! @@ -53,7 +57,7 @@ def process Solargraph::Parser::NodeProcessor.register(:def, dummy_processor1) Solargraph::Parser::NodeProcessor.register(:def, dummy_processor2) - node = Solargraph::Parser.parse(%( + node = parse(%( def some_method; end )) pins, = Solargraph::Parser::NodeProcessor.process(node) diff --git a/spec/parser_spec.rb b/spec/parser_spec.rb index 267f412f4..3c1e3cca0 100644 --- a/spec/parser_spec.rb +++ b/spec/parser_spec.rb @@ -1,11 +1,15 @@ describe Solargraph::Parser do + def parse source + Solargraph::Parser.parse(source, 'file.rb', 0) + end + it "parses nodes" do - node = Solargraph::Parser.parse('class Foo; end', 'test.rb') + node = parse('class Foo; end') expect(Solargraph::Parser.is_ast_node?(node)).to be(true) end it 'raises repairable SyntaxError for unknown encoding errors' do code = "# encoding: utf-\nx = 'y'" - expect { Solargraph::Parser.parse(code) }.to raise_error(Solargraph::Parser::SyntaxError) + expect { parse(code) }.to raise_error(Solargraph::Parser::SyntaxError) end end diff --git a/spec/pin/base_variable_spec.rb b/spec/pin/base_variable_spec.rb index 8c462bff3..03e6b1a11 100644 --- a/spec/pin/base_variable_spec.rb +++ b/spec/pin/base_variable_spec.rb @@ -44,4 +44,21 @@ def bar expect(type.to_rbs).to eq('(1 | nil)') expect(type.simplify_literals.to_rbs).to eq('(::Integer | ::NilClass)') end + + xit "understands proc kwarg parameters aren't affected by @type" do + pending "understanding restarg in block param in Block#typify_parameters" + + code = %( + # @return [Proc] + def foo + # @type [Proc] + # @param layout [Boolean] + @render_method = proc { |layout = false| + 123 if layout + } + end + ) + checker = Solargraph::TypeChecker.load_string(code, 'test.rb', :alpha) + expect(checker.problems.map(&:message)).to eq([]) + end end diff --git a/spec/pin/combine_with_spec.rb b/spec/pin/combine_with_spec.rb index 38d45a3e1..cc80d76d5 100644 --- a/spec/pin/combine_with_spec.rb +++ b/spec/pin/combine_with_spec.rb @@ -9,7 +9,6 @@ end it 'combines return types with another method without type parameters' do - pending('logic being added to handle this case') pin1 = Solargraph::Pin::Method.new(name: 'foo', parameters: [], comments: '@return [Array]') pin2 = Solargraph::Pin::Method.new(name: 'foo', parameters: [], comments: '@return [Array]') combined = pin1.combine_with(pin2) diff --git a/spec/pin/local_variable_spec.rb b/spec/pin/local_variable_spec.rb index 88075efb9..97e11db93 100644 --- a/spec/pin/local_variable_spec.rb +++ b/spec/pin/local_variable_spec.rb @@ -30,24 +30,161 @@ class Foo # should indicate which one should override in the range situation end - it "asserts on attempt to merge namespace changes" do - map1 = Solargraph::SourceMap.load_string(%( - class Foo - foo = 'foo' - end - )) - pin1 = map1.locals.first - map2 = Solargraph::SourceMap.load_string(%( - class Bar - foo = 'foo' + describe '#visible_at?' do + it 'detects scoped methods in rebound blocks' do + source = Solargraph::Source.load_string(%( + object = MyClass.new + + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + clip = api_map.clip_at('test.rb', [2, 0]) + object_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'object' } + expect(object_pin).not_to be_nil + location = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(2, 0, 2, 0)) + expect(object_pin.visible_at?(Solargraph::Pin::ROOT_PIN, location)).to be true + end + + it 'does not allow access to top-level locals from top-level methods' do + map = Solargraph::SourceMap.load_string(%( + x = 'string' + def foo + x + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map map.source + x_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'x' } + expect(x_pin).not_to be_nil + foo_pin = api_map.get_path_pins('#foo').first + expect(foo_pin).not_to be_nil + location = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(3, 9, 3, 9)) + expect(x_pin.visible_at?(foo_pin, location)).to be false + end + + it 'scopes local variables correctly in class_eval blocks' do + map = Solargraph::SourceMap.load_string(%( + class Foo; end + x = 'y' + Foo.class_eval do + foo = :bar + etc + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map map.source + block_pin = api_map.get_block_pins.find do |b| + b.location.range.start.line == 3 end - )) - pin2 = map2.locals.first - # set env variable 'FOO' to 'true' in block + expect(block_pin).not_to be_nil + x_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'x' } + expect(x_pin).not_to be_nil + location = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 10, 5, 10)) + expect(x_pin.visible_at?(block_pin, location)).to be true + end + + it "understands local lookup in root scope" do + api_map = Solargraph::ApiMap.new + source = Solargraph::Source.load_string(%( + # @type [Array] + arr = [] + + + ), "test.rb") + api_map.map source + arr_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'arr' } + expect(arr_pin).not_to be_nil + location = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(3, 0, 3, 0)) + expect(arr_pin.visible_at?(Solargraph::Pin::ROOT_PIN, location)).to be true + end + + it 'selects local variables using gated scopes' do + source = Solargraph::Source.load_string(%( + lvar1 = 'lvar1' + module MyModule + lvar2 = 'lvar2' + + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + lvar1_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'lvar1' } + expect(lvar1_pin).not_to be_nil + my_module_pin = api_map.get_namespace_pins('MyModule', 'Class<>').first + expect(my_module_pin).not_to be_nil + location = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(4, 0, 4, 0)) + expect(lvar1_pin.visible_at?(my_module_pin, location)).to be false - with_env_var('SOLARGRAPH_ASSERTS', 'on') do - expect(Solargraph.asserts_on?(:combine_with_closure_name)).to be true - expect { pin1.combine_with(pin2) }.to raise_error(RuntimeError, /Inconsistent :closure name/) + lvar2_pin = api_map.source_map('test.rb').locals.find { |p| p.name == 'lvar2' } + expect(lvar2_pin).not_to be_nil + expect(lvar2_pin.visible_at?(my_module_pin, location)).to be true + end + + it 'is visible within same method' do + source = Solargraph::Source.load_string(%( + class Foo + def bar + x = 1 + puts x + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + pin = api_map.source_map('test.rb').locals.first + bar_method = api_map.get_path_pins('Foo#bar').first + expect(bar_method).not_to be_nil + range = Solargraph::Range.from_to(4, 16, 4, 17) + location = Solargraph::Location.new('test.rb', range) + expect(pin.visible_at?(bar_method, location)).to be true + end + + it 'is visible within each block scope inside function' do + source = Solargraph::Source.load_string(%( + class Foo + def bar + x = 1 + [2,3,4].each do |i| + puts x + i + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + x = api_map.source_map('test.rb').locals.find { |p| p.name == 'x' } + bar_method = api_map.get_path_pins('Foo#bar').first + each_block_pin = api_map.get_block_pins.find do |b| + b.location.range.start.line == 4 + end + expect(each_block_pin).not_to be_nil + range = Solargraph::Range.from_to(5, 24, 5, 25) + location = Solargraph::Location.new('test.rb', range) + expect(x.visible_at?(each_block_pin, location)).to be true + end + + it 'sees block parameter inside block' do + source = Solargraph::Source.load_string(%( + class Foo + def bar + [1,2,3].each do |i| + puts i + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + i = api_map.source_map('test.rb').locals.find { |p| p.name == 'i' } + bar_method = api_map.get_path_pins('Foo#bar').first + expect(bar_method).not_to be_nil + each_block_pin = api_map.get_block_pins.find do |b| + b.location.range.start.line == 3 + end + expect(each_block_pin).not_to be_nil + range = Solargraph::Range.from_to(4, 24, 4, 25) + location = Solargraph::Location.new('test.rb', range) + expect(i.visible_at?(each_block_pin, location)).to be true end end end diff --git a/spec/pin/method_spec.rb b/spec/pin/method_spec.rb index 283ef6d51..c2ef40448 100644 --- a/spec/pin/method_spec.rb +++ b/spec/pin/method_spec.rb @@ -549,6 +549,16 @@ class Foo expect(pin.return_type).to be_undefined end + it 'combines signatures by type' do + # Integer+ in RBS is a number of signatures that dispatch based + # 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) + method = api_map.get_method_stack('Integer', '+', scope: :instance).first + expect(method.signatures.count).to be > 3 + end + it 'infers untagged types from instance variables' do source = Solargraph::Source.load_string(%( class Foo diff --git a/spec/rbs_map/conversions_spec.rb b/spec/rbs_map/conversions_spec.rb index 896a55f37..61771ac10 100644 --- a/spec/rbs_map/conversions_spec.rb +++ b/spec/rbs_map/conversions_spec.rb @@ -25,7 +25,35 @@ attr_reader :temp_dir + context 'with overlapping module hierarchies and inheritance' do + subject(:method_pin) { api_map.get_method_stack('A::B::C', 'foo').first } + + let(:rbs) do + <<~RBS + module B + class C + def foo: () -> String + end + end + module A + module B + class C < ::B::C + end + end + end + RBS + end + + before do + api_map.index conversions.pins + end + + it { is_expected.to be_a(Solargraph::Pin::Method) } + end + context 'with self alias to self method' do + subject(:alias_pin) { api_map.get_method_stack('Foo', 'bar?', scope: :class).first } + let(:rbs) do <<~RBS class Foo @@ -35,13 +63,9 @@ def self.bar: () -> String RBS end - let(:method_pin) { api_map.get_method_stack('Foo', 'bar', scope: :class).first } - - subject(:alias_pin) { api_map.get_method_stack('Foo', 'bar?', scope: :class).first } - - it { should_not be_nil } + it { is_expected.not_to be_nil } - it { should be_instance_of(Solargraph::Pin::Method) } + it { is_expected.to be_instance_of(Solargraph::Pin::Method) } it 'finds the type' do expect(alias_pin.return_type.tag).to eq('String') @@ -49,6 +73,8 @@ def self.bar: () -> String end context 'with untyped response' do + subject(:method_pin) { conversions.pins.find { |pin| pin.path == 'Foo#bar' } } + let(:rbs) do <<~RBS class Foo @@ -57,16 +83,35 @@ def bar: () -> untyped RBS end - subject(:method_pin) { conversions.pins.find { |pin| pin.path == 'Foo#bar' } } - - it { should_not be_nil } + it { is_expected.not_to be_nil } - it { should be_a(Solargraph::Pin::Method) } + it { is_expected.to be_a(Solargraph::Pin::Method) } - it 'maps untyped in RBS to undefined in Solargraph 'do + it 'maps untyped in RBS to undefined in Solargraph' do expect(method_pin.return_type.tag).to eq('undefined') 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('.') + end + + let(:api_map) { @api_map } + + context 'with superclass pin for Parser::AST::Node' do + let(:superclass_pin) do + api_map.pins.find do |pin| + pin.is_a?(Solargraph::Pin::Reference::Superclass) && pin.context.namespace == 'Parser::AST::Node' + end + end + + it 'generates a rooted pin' do + # rooted! + expect(superclass_pin&.name).to eq('::AST::Node') + end + end # https://github.com/castwide/solargraph/issues/1042 context 'with Hash superclass with untyped value and alias' do @@ -99,28 +144,6 @@ class Sub < Hash[Symbol, untyped] .uniq).to eq(['Symbol']) end end - - context 'with overlapping module hierarchies and inheritance' do - let(:rbs) do - <<~RBS - module B - class C - def foo: () -> String - end - end - module A - module B - class C < ::B::C - end - end - end - RBS - end - - subject(:method_pin) { api_map.get_method_stack('A::B::C', 'foo').first } - - it { should be_a(Solargraph::Pin::Method) } - end end if Gem::Version.new(RBS::VERSION) >= Gem::Version.new('3.9.1') diff --git a/spec/source/chain/call_spec.rb b/spec/source/chain/call_spec.rb index 3725686a7..ad06cc4ec 100644 --- a/spec/source/chain/call_spec.rb +++ b/spec/source/chain/call_spec.rb @@ -224,7 +224,8 @@ def self.bar 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)) - type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + 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) @@ -371,6 +372,21 @@ def yielder(&blk) expect(type.tag).to eq('Enumerator>') end + it 'allows calls off of nilable objects by default' do + source = Solargraph::Source.load_string(%( + # @type [String, nil] + f = foo + a = f.upcase + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(4, 6)) + type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + expect(type.tag).to eq('String') + end + it 'calculates class return type based on class generic' do source = Solargraph::Source.load_string(%( # @generic A @@ -392,6 +408,21 @@ def bar; end expect(type.tag).to eq('String') end + it 'denies calls off of nilable objects when loose union mode is off' do + source = Solargraph::Source.load_string(%( + # @type [String, nil] + f = foo + a = f.upcase + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new(loose_unions: false) + api_map.map source + + chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(4, 6)) + 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}] @@ -406,8 +437,9 @@ def foo(params) 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(4, 8)) - type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + type = chain.infer(api_map, foo_pin, api_map.source_map('test.rb').locals) expect(type.rooted_tags).to eq('::Array, ::Hash{::String => undefined}, ::String, ::Integer') end @@ -443,8 +475,9 @@ def foo 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, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + type = chain.infer(api_map, foo_pin, api_map.source_map('test.rb').locals) expect(type.rooted_tags).to eq('::Array<::String>') end @@ -464,8 +497,12 @@ def c 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, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) expect(type.tags).to eq('A::B') end @@ -485,8 +522,12 @@ def c 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, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + type = chain.infer(api_map, closure_pin, api_map.source_map('test.rb').locals) expect(type.rooted_tags).to eq('::A::B') end @@ -512,11 +553,17 @@ def d api_map.map source chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(6, 14)) - type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + 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, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + 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' diff --git a/spec/source/chain/instance_variable_spec.rb b/spec/source/chain/instance_variable_spec.rb index 8326a66d2..186c176a1 100644 --- a/spec/source/chain/instance_variable_spec.rb +++ b/spec/source/chain/instance_variable_spec.rb @@ -6,12 +6,16 @@ bar_pin = Solargraph::Pin::InstanceVariable.new(closure: closure, name: '@foo') api_map = Solargraph::ApiMap.new api_map.index [closure, methpin, foo_pin, bar_pin] - link = Solargraph::Source::Chain::InstanceVariable.new('@foo') + link = Solargraph::Source::Chain::InstanceVariable.new('@foo', nil, nil) pins = link.resolve(api_map, methpin, []) expect(pins.length).to eq(1) expect(pins.first.name).to eq('@foo') expect(pins.first.context.scope).to eq(:instance) - pins = link.resolve(api_map, closure, []) + # Lookup context is Class to find the civar + name_pin = Solargraph::Pin::ProxyType.anonymous(closure.binder, + # Closure is the class + closure: closure) + pins = link.resolve(api_map, name_pin, []) expect(pins.length).to eq(1) expect(pins.first.name).to eq('@foo') expect(pins.first.context.scope).to eq(:class) diff --git a/spec/source/chain/or_spec.rb b/spec/source/chain/or_spec.rb new file mode 100644 index 000000000..084738fe3 --- /dev/null +++ b/spec/source/chain/or_spec.rb @@ -0,0 +1,31 @@ +describe Solargraph::Source::Chain::Or do + it 'handles simple nil-removal' do + source = Solargraph::Source.load_string(%( + # @param a [Integer, nil] + def foo a + b = a || 10 + b + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [4, 8]) + expect(clip.infer.simplify_literals.rooted_tags).to eq('::Integer') + end + + it 'removes nil from more complex cases' do + source = Solargraph::Source.load_string(%( + def foo + out = ENV['BAR'] || + File.join(Dir.home, '.config', 'solargraph', 'config.yml') + out + end + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [3, 8]) + expect(clip.infer.simplify_literals.rooted_tags).to eq('::String') + end +end diff --git a/spec/source/chain/q_call_spec.rb b/spec/source/chain/q_call_spec.rb new file mode 100644 index 000000000..a63568358 --- /dev/null +++ b/spec/source/chain/q_call_spec.rb @@ -0,0 +1,23 @@ +describe Solargraph::Source::Chain::QCall do + it 'understands &. in chains' do + source = Solargraph::Source.load_string(%( + # @param a [String, nil] + # @return [String, nil] + def foo a + b = a&.upcase + b + end + + b = foo 123 + b + ), 'test.rb') + + api_map = Solargraph::ApiMap.new.map(source) + + clip = api_map.clip_at('test.rb', [5, 8]) + expect(clip.infer.to_s).to eq('String, nil') + + clip = api_map.clip_at('test.rb', [9, 6]) + expect(clip.infer.to_s).to eq('String, nil') + end +end diff --git a/spec/source/chain_spec.rb b/spec/source/chain_spec.rb index abc8c2b05..369e302e9 100644 --- a/spec/source/chain_spec.rb +++ b/spec/source/chain_spec.rb @@ -428,8 +428,9 @@ def obj(foo); end str = obj.stringify ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) + obj_fn_pin = api_map.get_path_pins('Example.obj').first chain = Solargraph::Source::SourceChainer.chain(source, Solargraph::Position.new(12, 6)) - type = chain.infer(api_map, Solargraph::Pin::ROOT_PIN, api_map.source_map('test.rb').locals) + type = chain.infer(api_map, obj_fn_pin, api_map.source_map('test.rb').locals) expect(type.to_s).to eq('String') end end diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index ee7e4bcfa..87957fb51 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -302,6 +302,23 @@ def foo expect(type.tag).to eq('String') end + it 'infers method types from return nodes' do + source = Solargraph::Source.load_string(%( + class Foo + # @return [self] + def foo + bar + end + end + Foo.new.foo + ), 'test.rb') + map = Solargraph::ApiMap.new + map.map source + clip = map.clip_at('test.rb', Solargraph::Position.new(7, 10)) + type = clip.infer + expect(type.tag).to eq('Foo') + end + it 'infers multiple method types from return nodes' do source = Solargraph::Source.load_string(%( def foo @@ -679,17 +696,13 @@ def initialize @foo._ end end - Foo.define_method(:test2) do - @foo._ - define_method(:test4) { @foo._ } # only handle Module#define_method, other pin is ignored.. - end Foo.class_eval do define_method(:test5) { @foo._ } end ), 'test.rb') api_map = Solargraph::ApiMap.new api_map.map source - [[4, 39], [7, 15], [11, 13], [12, 37], [15, 37]].each do |loc| + [[4, 39], [7, 15], [11, 37]].each do |loc| clip = api_map.clip_at('test.rb', loc) paths = clip.complete.pins.map(&:path) expect(paths).to include('String#upcase'), -> { %(expected #{paths} at #{loc} to include "String#upcase") } @@ -1230,7 +1243,7 @@ def one updated = source.synchronize(updater) api_map.map updated clip = api_map.clip_at('test.rb', [2, 8]) - expect(clip.complete.pins.first.path).to start_with('Array#') + expect(clip.complete.pins.first&.path).to start_with('Array#') end it 'selects local variables using gated scopes' do @@ -2007,7 +2020,7 @@ def foo ), 'test.rb') api_map = Solargraph::ApiMap.new.map(source) - clip = api_map.clip_at('test.rb', [8, 6]) + clip = api_map.clip_at('test.rb', [9, 6]) type = clip.infer expect(type.tags).to eq('Integer') @@ -2712,7 +2725,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(':foo, 123, nil') + expect(clip.infer.to_s).to eq('123, :foo, nil') end it 'expands type with conditional reassignments' do diff --git a/spec/source_map_spec.rb b/spec/source_map_spec.rb index 60d4b523e..5d587e27c 100644 --- a/spec/source_map_spec.rb +++ b/spec/source_map_spec.rb @@ -76,7 +76,7 @@ class Foo expect(pin).to be_a(Solargraph::Pin::Block) end - it 'scopes local variables correctly from root def blocks' do + it 'scopes local variables correctly from root def methods' do map = Solargraph::SourceMap.load_string(%( x = 'string' def foo @@ -88,6 +88,20 @@ def foo expect(locals).to be_empty end + it 'scopes local variables correctly from class methods' do + map = Solargraph::SourceMap.load_string(%( + class Foo + x = 'string' + def foo + x + end + end + ), 'test.rb') + loc = Solargraph::Location.new('test.rb', Solargraph::Range.from_to(4, 11, 3, 11)) + locals = map.locals_at(loc) + expect(locals).to be_empty + end + it 'handles op_asgn case with assertions on' do # set SOLARGRAPH_ASSERTS=onto test this old_asserts = ENV.fetch('SOLARGRAPH_ASSERTS', nil) diff --git a/spec/type_checker/checks_spec.rb b/spec/type_checker/checks_spec.rb deleted file mode 100644 index 41119cefd..000000000 --- a/spec/type_checker/checks_spec.rb +++ /dev/null @@ -1,146 +0,0 @@ -describe Solargraph::TypeChecker::Checks do - it 'validates simple core types' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('String') - inf = Solargraph::ComplexType.parse('String') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'invalidates simple core types' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('String') - inf = Solargraph::ComplexType.parse('Integer') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(false) - end - - it 'validates expected superclasses' do - source = Solargraph::Source.load_string(%( - class Sup; end - class Sub < Sup; end - )) - api_map = Solargraph::ApiMap.new - api_map.map source - sup = Solargraph::ComplexType.parse('Sup') - sub = Solargraph::ComplexType.parse('Sub') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, sup, sub) - expect(match).to be(true) - end - - it 'invalidates inferred superclasses (expected must be super)' do - # @todo This test might be invalid. There are use cases where inheritance - # between inferred and expected classes should be acceptable in either - # direction. - # source = Solargraph::Source.load_string(%( - # class Sup; end - # class Sub < Sup; end - # )) - # api_map = Solargraph::ApiMap.new - # api_map.map source - # sup = Solargraph::ComplexType.parse('Sup') - # sub = Solargraph::ComplexType.parse('Sub') - # match = Solargraph::TypeChecker::Checks.types_match?(api_map, sub, sup) - # expect(match).to be(false) - end - - it 'fuzzy matches arrays with parameters' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('Array') - inf = Solargraph::ComplexType.parse('Array') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'fuzzy matches sets with parameters' do - source = Solargraph::Source.load_string("require 'set'") - source_map = Solargraph::SourceMap.map(source) - api_map = Solargraph::ApiMap.new - api_map.catalog Solargraph::Bench.new(source_maps: [source_map], external_requires: ['set']) - exp = Solargraph::ComplexType.parse('Set') - inf = Solargraph::ComplexType.parse('Set') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'fuzzy matches hashes with parameters' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('Hash{ Symbol => String}') - inf = Solargraph::ComplexType.parse('Hash') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'matches multiple types' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('String, Integer') - inf = Solargraph::ComplexType.parse('String, Integer') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'matches multiple types out of order' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('String, Integer') - inf = Solargraph::ComplexType.parse('Integer, String') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'invalidates inferred types missing from expected' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('String') - inf = Solargraph::ComplexType.parse('String, Integer') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(false) - end - - it 'matches nil' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('nil') - inf = Solargraph::ComplexType.parse('nil') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'validates classes with expected superclasses' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('Class') - inf = Solargraph::ComplexType.parse('Class') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'validates generic classes with expected Class' do - api_map = Solargraph::ApiMap.new - exp = Solargraph::ComplexType.parse('Class') - inf = Solargraph::ComplexType.parse('Class') - match = Solargraph::TypeChecker::Checks.types_match?(api_map, exp, inf) - expect(match).to be(true) - end - - it 'validates inheritance in both directions' do - source = Solargraph::Source.load_string(%( - class Sup; end - class Sub < Sup; end - )) - api_map = Solargraph::ApiMap.new - api_map.map source - sup = Solargraph::ComplexType.parse('Sup') - sub = Solargraph::ComplexType.parse('Sub') - match = Solargraph::TypeChecker::Checks.either_way?(api_map, sup, sub) - expect(match).to be(true) - match = Solargraph::TypeChecker::Checks.either_way?(api_map, sub, sup) - expect(match).to be(true) - end - - it 'invalidates inheritance in both directions' do - api_map = Solargraph::ApiMap.new - sup = Solargraph::ComplexType.parse('String') - sub = Solargraph::ComplexType.parse('Array') - match = Solargraph::TypeChecker::Checks.either_way?(api_map, sup, sub) - expect(match).to be(false) - match = Solargraph::TypeChecker::Checks.either_way?(api_map, sub, sup) - expect(match).to be(false) - end -end diff --git a/spec/type_checker/levels/alpha_spec.rb b/spec/type_checker/levels/alpha_spec.rb new file mode 100644 index 000000000..52fcf4843 --- /dev/null +++ b/spec/type_checker/levels/alpha_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +describe Solargraph::TypeChecker do + context 'when at alpha level' do + def type_checker code + Solargraph::TypeChecker.load_string(code, 'test.rb', :alpha) + end + + it 'allows a compatible function call from two distinct types in a union' do + checker = type_checker(%( + class Foo + # @param baz [::Boolean, nil] + # @return [void] + def bar(baz: nil) + baz.nil? + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not falsely enforce nil in return types' do + checker = type_checker(%( + # @return [Integer] + def foo + # @sg-ignore + # @type [Integer, nil] + a = bar + a || 123 + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'reports nilable type issues' do + checker = type_checker(%( + # @param a [String] + # @return [void] + def foo(a); end + + # @param b [String, nil] + # @return [void] + def bar(b) + foo(b) + end + )) + expect(checker.problems.map(&:message)) + .to eq(['Wrong argument type for #foo: a expected String, received String, nil']) + end + + it 'tracks type of ivar' do + checker = type_checker(%( + class Foo + # @return [void] + def initialize + @sync_count = 0 + end + + # @return [void] + def synchronized? + @sync_count < 2 + end + + # @return [void] + def catalog + @sync_count += 1 + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'accepts ivar assignments and references with no intermediate calls as safe' do + pending 'flow-sensitive typing improvements' + + checker = type_checker(%( + class Foo + def initialize + # @type [Integer, nil] + @foo = nil + end + + # @return [void] + def twiddle + @foo = nil if rand if rand > 0.5 + end + + # @return [Integer] + def bar + @foo = 123 + out = @foo.round + twiddle + out + end + end + )) + + expect(checker.problems.map(&:message)).to be_empty + end + + it 'knows that ivar references with intermediate calls are not safe' do + checker = type_checker(%( + class Foo + def initialize + # @type [Integer, nil] + @foo = nil + end + + # @return [void] + def twiddle + @foo = nil if rand if rand > 0.5 + end + + # @return [Integer] + def bar + @foo = 123 + twiddle + @foo.round + end + end + )) + + expect(checker.problems.map(&:message)).to eq(["Foo#bar return type could not be inferred", "Unresolved call to round"]) + end + + it 'understands &. in return position' do + checker = type_checker(%( + class Baz + # @param bar [String, nil] + # @return [String] + def foo bar + bar&.upcase || 'undefined' + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'can infer types based on || and &&' do + checker = type_checker(%( + class Baz + # @param bar [String, nil] + # @return [Boolean, String] + def foo bar + !bar || bar.upcase + end + + # @param bar [String, nil] + # @return [String, nil] + def bing bar + bar && bar.upcase + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + end +end diff --git a/spec/type_checker/levels/strict_spec.rb b/spec/type_checker/levels/strict_spec.rb index 47bf45a2c..74ee3b85d 100644 --- a/spec/type_checker/levels/strict_spec.rb +++ b/spec/type_checker/levels/strict_spec.rb @@ -5,6 +5,48 @@ def type_checker(code) Solargraph::TypeChecker.load_string(code, 'test.rb', :strict) end + it 'can derive return types' do + checker = type_checker(%( + # @param a [String, nil] + # @return [void] + def foo(a); end + + # @param b [String, nil] + # @return [void] + def bar(b) + foo(b) + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'ignores nilable type issues' do + checker = type_checker(%( + # @param a [String] + # @return [void] + def foo(a); end + + # @param b [String, nil] + # @return [void] + def bar(b) + foo(b) + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'understands Class is not the same as String' do + checker = type_checker(%( + # @param str [String] + # @return [void] + def foo str; end + + foo File + )) + expect(checker.problems.map(&:message)) + .to eq(['Wrong argument type for #foo: str expected String, received Class']) + end + it 'handles compatible interfaces with self types on call' do checker = type_checker(%( # @param a [Enumerable] @@ -61,7 +103,7 @@ def bar(a); end require 'kramdown-parser-gfm' Kramdown::Parser::GFM.undefined_call ), 'test.rb') - api_map = Solargraph::ApiMap.load_with_cache('.', $stdout) + api_map = Solargraph::ApiMap.load '.' api_map.catalog Solargraph::Bench.new(source_maps: [source_map], external_requires: ['kramdown-parser-gfm']) checker = Solargraph::TypeChecker.new('test.rb', api_map: api_map, level: :strict) expect(checker.problems).to be_empty @@ -581,6 +623,46 @@ def bar expect(checker.problems).to be_empty end + it 'Can infer through simple ||= on ivar' do + checker = type_checker(%( + class Foo + def recipient + @recipient ||= true + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'Can infer through simple ||= on lvar' do + checker = type_checker(%( + def recipient + recip ||= true + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'Can infer through simple ||= on cvar' do + checker = type_checker(%( + class Foo + def recipient + @@recipient ||= true + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'Can infer through simple ||= on civar' do + checker = type_checker(%( + class Foo + @recipient ||= true + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'Can infer through ||= with a begin+end' do checker = type_checker(%( def recipient @@ -701,6 +783,19 @@ def test(foo: nil) expect(checker.problems).to be_empty end + + it 'validates parameters in function calls' do + checker = type_checker(%( + # @param bar [String] + def foo(bar); end + + def baz + foo(123) + end + )) + 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 checker = type_checker(%( # @param foo [Numeric, nil] a foo @@ -769,19 +864,6 @@ def meth(param1) expect(checker.problems.map(&:message)).to eq(['Unresolved call to upcase']) end - it 'does not falsely enforce nil in return types' do - checker = type_checker(%( - # @return [Integer] - def foo - # @sg-ignore - # @type [Integer, nil] - a = bar - a || 123 - end - )) - expect(checker.problems.map(&:message)).to be_empty - end - it 'refines types on is_a? and && to downcast and avoid false positives' do checker = type_checker(%( def foo @@ -1004,11 +1086,47 @@ def bar 123 elsif rand 456 + else + nil end end end )) expect(checker.problems.map(&:message)).to eq([]) end + + it 'does not complain on defaulted reader with un-elsed if' do + checker = type_checker(%( + class Foo + # @return [Integer, nil] + def bar + @bar ||= + if rand + 123 + elsif rand + 456 + end + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'does not complain on defaulted reader with with un-elsed unless' do + checker = type_checker(%( + class Foo + # @return [Integer, nil] + def bar + @bar ||= + unless rand + 123 + end + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end end end diff --git a/spec/type_checker/levels/strong_spec.rb b/spec/type_checker/levels/strong_spec.rb index 7eeef1e96..a2286c331 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -4,9 +4,144 @@ def type_checker(code) Solargraph::TypeChecker.load_string(code, 'test.rb', :strong) end - it 'provides nil checking on calls from parameters without assignments' do - pending('https://github.com/castwide/solargraph/pull/1127') + it 'understands self type when passed as parameter' do + checker = type_checker(%( + class Location + # @return [String] + attr_reader :filename + + # @param other [self] + # @return [-1, 0, 1, nil] + def <=>(other) + return nil unless other.is_a?(Location) + + filename <=> other.filename + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'does not misunderstand types during flow-sensitive typing' do + checker = type_checker(%( + class A + # @param b [Hash{String => String}] + # @return [void] + def a b + c = b["123"] + return if c.nil? + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'respects pin visibility in if/nil? pattern' do + checker = type_checker(%( + class Foo + # Get the namespace's type (Class or Module). + # + # @param bar [Symbol, nil] + # @return [Symbol, Integer] + def foo bar + return 123 if bar.nil? + bar + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'respects || overriding nilable types' do + checker = type_checker(%( + # @return [String] + def global_config_path + ENV['SOLARGRAPH_GLOBAL_CONFIG'] || + File.join(Dir.home, '.config', 'solargraph', 'config.yml') + 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] + def global_config_path + out = 'foo' + out + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'respects pin visibility in if/foo pattern' do + checker = type_checker(%( + class Foo + # Get the namespace's type (Class or Module). + # + # @param bar [Symbol, nil] + # @return [Symbol, Integer] + def foo bar + baz = bar + return baz if baz + 123 + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'handles a flow sensitive typing if correctly' do + checker = type_checker(%( + # @param a [String, nil] + # @return [void] + def foo a = nil + b = a + if b + b.upcase + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'handles another flow sensitive typing if correctly' do + checker = type_checker(%( + class A + # @param e [String] + # @param f [String] + # @return [void] + def d(e, f:); end + + # @return [void] + def a + c = rand ? nil : "foo" + if c + d(c, f: c) + end + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'respects pin visibility' do + checker = type_checker(%( + class Foo + # Get the namespace's type (Class or Module). + # + # @param baz [Integer, nil] + # @return [Integer, nil] + def foo baz = 123 + return nil if baz.nil? + baz + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'provides nil checking on calls from parameters without assignments' do checker = type_checker(%( # @param baz [String, nil] # @@ -21,7 +156,7 @@ def quux(baz) it 'does not complain on array dereference' do checker = type_checker(%( - # @param idx [Integer, nil] an index + # @param idx [Integer] an index # @param arr [Array] an array of integers # # @return [void] @@ -32,6 +167,23 @@ def foo(idx, arr) expect(checker.problems.map(&:message)).to be_empty end + it 'understands local evaluation with ||= removes nil from lhs type' do + checker = type_checker(%( + class Foo + def initialize + @bar = nil + end + + # @return [Integer] + def bar + @bar ||= 123 + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + it 'complains on bad @type assignment' do checker = type_checker(%( # @type [Integer] @@ -82,21 +234,6 @@ def bar; end expect(checker.problems.first.message).to include('Missing @return tag') end - it 'ignores nilable type issues' do - checker = type_checker(%( - # @param a [String] - # @return [void] - def foo(a); end - - # @param b [String, nil] - # @return [void] - def bar(b) - foo(b) - end - )) - expect(checker.problems.map(&:message)).to eq([]) - end - it 'calls out keyword issues even when required arg count matches' do checker = type_checker(%( # @param a [String] @@ -112,7 +249,7 @@ def bar expect(checker.problems.map(&:message)).to include('Call to #foo is missing keyword argument b') end - it 'understands complex use of other' do + it 'understands complex use of self' do checker = type_checker(%( class A # @param other [self] @@ -168,21 +305,6 @@ def bar expect(checker.problems.map(&:message)).to include('Call to #foo is missing keyword argument b') end - it 'calls out missing args after a defaulted param' do - checker = type_checker(%( - # @param a [String] - # @param b [String] - # @return [void] - def foo(a = 'foo', b); end - - # @return [void] - def bar - foo(123) - end - )) - expect(checker.problems.map(&:message)).to include('Not enough arguments to #foo') - end - it 'reports missing param tags' do checker = type_checker(%( class Foo @@ -294,6 +416,149 @@ def bar &block expect(checker.problems).to be_empty end + it 'does not need fully specified container types' do + checker = type_checker(%( + class Foo + # @param foo [Array] + # @return [void] + def bar foo: []; end + + # @param bing [Array] + # @return [void] + def baz(bing) + bar(foo: bing) + generic_values = [1,2,3].map(&:to_s) + bar(foo: generic_values) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'treats a parameter type of undefined as not provided' do + checker = type_checker(%( + class Foo + # @param foo [Array] + # @return [void] + def bar foo: []; end + + # @param bing [Array] + # @return [void] + def baz(bing) + bar(foo: bing) + generic_values = [1,2,3].map(&:to_s) + bar(foo: generic_values) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'ignores generic resolution failure with no generic tag' do + checker = type_checker(%( + class Foo + # @param foo [Class] + # @return [void] + def bar foo:; end + + # @param bing [Class>] + # @return [void] + def baz(bing) + bar(foo: bing) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'ignores undefined resolution failures' do + checker = type_checker(%( + class Foo + # @generic T + # @param klass [Class>] + # @return [Set>] + def pins_by_class klass; [].to_set; end + end + class Bar + # @return [Enumerable] + def block_pins + foo = Foo.new + foo.pins_by_class(Integer) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'ignores generic resolution failures from current Solargraph limitation' do + checker = type_checker(%( + class Foo + # @generic T + # @param klass [Class>] + # @return [Set>] + def pins_by_class klass; [].to_set; end + end + class Bar + # @return [Enumerable] + def block_pins + foo = Foo.new + foo.pins_by_class(Integer) + 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 + # @param path [String] + # @param klass [Class>] + # @return [void] + def code_object_at path, klass = Integer + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'does not complain on select { is_a? } pattern' do + checker = type_checker(%( + # @param arr [Enumerable} + # @return [Enumerable] + def downcast_arr(arr) + arr.select { |pin| pin.is_a?(Integer) } + 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] + # @return [Integer, nil] + def foo(bar) + bar + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'does not complain on adding nil to types via select' do + checker = type_checker(%( + # @return [Float, nil]} + def bar; rand; end + + # @param arr [Enumerable} + # @return [Integer, nil] + def downcast_arr(arr) + # @type [Object, nil] + foo = arr.select { |pin| pin.is_a?(Integer) && bar }.last + foo + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'inherits param tags from superclass methods' do checker = type_checker(%( class Foo @@ -311,6 +576,20 @@ def meth arg expect(checker.problems).to be_empty end + it 'understands Open3 methods' do + checker = type_checker(%( + require 'open3' + + # @return [void] + def run_command + # @type [Hash{String => String}] + foo = {'foo' => 'bar'} + Open3.capture2e(foo, 'ls', chdir: '/tmp') + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + context 'with class name available in more than one gate' do let(:checker) do type_checker(%( @@ -368,18 +647,136 @@ def baz expect(checker.problems.map(&:message)).to be_empty end - it 'understands Open3 methods' do + it 'handles "while foo" flow sensitive typing correctly' do checker = type_checker(%( - require 'open3' - + # @param a [String, nil] # @return [void] - def run_command - # @type [Hash{String => String}] - foo = {'foo' => 'bar'} - Open3.capture2e(foo, 'ls', chdir: '/tmp') + def foo a = nil + b = a + while b + b.upcase + b = nil if rand > 0.5 + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'does flow sensitive typing even inside a block' do + checker = type_checker(%( + class Quux + # @param foo [String, nil] + # + # @return [void] + def baz(foo) + bar = foo + [].each do + bar.upcase unless bar.nil? + end + end + end)) + + expect(checker.problems.map(&:location).map(&:range).map(&:start)).to be_empty + end + + it 'accepts ivar assignments and references with no intermediate calls as safe' do + checker = type_checker(%( + class Foo + def initialize + # @type [Integer, nil] + @foo = nil + end + + # @return [void] + def twiddle + @foo = nil if rand if rand > 0.5 + end + + # @return [Integer] + def bar + @foo = 123 + out = @foo.round + twiddle + out + end + )) + + expect(checker.problems.map(&:message)).to be_empty + end + + it 'resolves self correctly in chained method calls' do + checker = type_checker(%( + class Foo + # @param other [self] + # + # @return [Symbol, nil] + def bar(other) + # @type [Symbol, nil] + baz(other) + end + + # @param other [self] + # + # @sg-ignore Missing @return tag + # @return [undefined] + def baz(other); end end )) + expect(checker.problems.map(&:message)).to be_empty end + + it 'knows that ivar references with intermediate calls are not safe' do + checker = type_checker(%( + class Foo + def initialize + # @type [Integer, nil] + @foo = nil + end + + # @return [void] + def twiddle + @foo = nil if rand if rand > 0.5 + end + + # @return [Integer] + def bar + @foo = 123 + twiddle + @foo.round + end + end + )) + + expect(checker.problems.map(&:message)).to eq(["Foo#bar return type could not be inferred", "Unresolved call to round"]) + end + + it 'uses cast type instead of defined type' do + checker = type_checker(%( + # frozen_string_literal: true + + class Base; end + + class Subclass < Base + # @return [String] + attr_reader :bar + end + + class Foo + # @param bases [::Array] + # @return [void] + def baz(bases) + # @param sub [Subclass] + bases.each do |sub| + puts sub.bar + end + end + end + )) + + # expect 'sub' to be treated as 'Subclass' inside the block, and + # an error when trying to declare sub as Subclass + expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base') + end end end diff --git a/spec/type_checker/levels/typed_spec.rb b/spec/type_checker/levels/typed_spec.rb index b2071465e..4add0903d 100644 --- a/spec/type_checker/levels/typed_spec.rb +++ b/spec/type_checker/levels/typed_spec.rb @@ -4,6 +4,23 @@ def type_checker(code) Solargraph::TypeChecker.load_string(code, 'test.rb', :typed) end + it 'respects pin visibility' do + checker = type_checker(%( + class Foo + # Get the namespace's type (Class or Module). + # + # @param bar [Array] + # @return [Symbol, Integer] + def foo bar + baz = bar.first + return 123 if baz.nil? + baz + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'reports mismatched types for empty methods' do checker = type_checker(%( class Foo @@ -38,6 +55,19 @@ def bar expect(checker.problems.first.message).to include('does not match') end + it 'reports mismatched key and subtypes' do + checker = type_checker(%( + # @return [Hash{String => String}] + def foo + # @type h [Hash{Integer => String}] + h = {} + h + end + )) + expect(checker.problems).to be_one + expect(checker.problems.first.message).to include('does not match') + end + it 'reports mismatched inherited return tags' do checker = type_checker(%( class Sup @@ -189,6 +219,31 @@ def foo expect(checker.problems).to be_empty end + it 'validates default values of parameters' do + checker = type_checker(%( + # @param bar [String] + def foo(bar = 123); end + )) + expect(checker.problems.map(&:message)) + .to eq(['Declared type String does not match inferred type 123 for variable bar']) + end + + it 'validates string default values of parameters' do + checker = type_checker(%( + # @param bar [String] + def foo(bar = 'foo'); end + )) + expect(checker.problems.map(&:message)).to be_empty + end + + it 'validates symbol default values of parameters' do + checker = type_checker(%( + # @param bar [Symbol] + def foo(bar = :baz); end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + it 'validates subclass arguments of param types' do checker = type_checker(%( class Sup