diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..92f925b17 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +# current git branch +SOLARGRAPH_FORCE_VERSION=0.0.1.dev-$(git rev-parse --abbrev-ref HEAD | tr -d '\n' | tr -d '/' | tr -d '-'| tr -d '_') +export SOLARGRAPH_FORCE_VERSION diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index b4ef26bfe..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 b5984f3cb..f32c042a0 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.0' + 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,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.0' + ruby-version: 3.4 # keep same as typecheck.yml bundler-cache: false - uses: awalsh128/cache-apt-pkgs-action@latest with: @@ -72,7 +72,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 +83,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.0' + ruby-version: 3.4 # keep same as typecheck.yml bundler-cache: false - uses: awalsh128/cache-apt-pkgs-action@latest with: @@ -101,34 +101,64 @@ 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 - # run_solargraph_rspec_specs: - # # check out solargraph-rspec as well as this project, and point the former to use the latter as a local gem - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v3 - # - name: clone https://github.com/lekemula/solargraph-rspec/ - # run: | - # cd .. - # git clone https://github.com/lekemula/solargraph-rspec.git - # cd solargraph-rspec - # - name: Set up Ruby - # uses: ruby/setup-ruby@v1 - # with: - # ruby-version: '3.0' - # bundler-cache: false - # - name: Install gems - # run: | - # cd ../solargraph-rspec - # echo "gem 'solargraph', path: '../solargraph'" >> Gemfile - # bundle install - # - name: Run specs - # run: | - # cd ../solargraph-rspec - # bundle exec rake spec + run_solargraph_rspec_specs: + # check out solargraph-rspec as well as this project, and point the former to use the latter as a local gem + runs-on: ubuntu-latest + env: + SOLARGRAPH_CACHE: ${{ github.workspace }}/../solargraph-rspec/vendor/solargraph/cache + BUNDLE_PATH: ${{ github.workspace }}/../solargraph-rspec/vendor/bundle + steps: + - uses: actions/checkout@v3 + - name: clone https://github.com/lekemula/solargraph-rspec/ + run: | + cd .. + git clone https://github.com/lekemula/solargraph-rspec.git + cd solargraph-rspec + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + rubygems: latest + bundler-cache: false + - name: Install gems + run: | + 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 + - name: Configure .solargraph.yml + run: | + cd ../solargraph-rspec + cp .solargraph.yml.example .solargraph.yml + - name: Solargraph generate RSpec gems YARD and RBS pins + run: | + cd ../solargraph-rspec + bundle exec appraisal rbs collection update + rspec_gems=$(bundle exec appraisal ruby -r './lib/solargraph-rspec' -e 'puts Solargraph::Rspec::Gems.gem_names.join(" ")' 2>/dev/null | tail -n1) + bundle exec appraisal solargraph gems $rspec_gems + - name: Run specs + run: | + cd ../solargraph-rspec + bundle exec appraisal rspec --format progress run_solargraph_rails_specs: # check out solargraph-rails as well as this project, and point the former to use the latter as a local gem @@ -146,6 +176,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 ecc3d9771..de3a5be57 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -11,7 +11,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + branches: ['*'] permissions: contents: read @@ -23,18 +23,47 @@ jobs: matrix: ruby-version: ['3.0', '3.1', '3.2', '3.3', '3.4', 'head'] rbs-version: ['3.6.1', '3.9.4', '4.0.0.dev.4'] - # Ruby 3.0 doesn't work with RBS 3.9.4 or 4.0.0.dev.4 exclude: + # Ruby 3.0 doesn't work with RBS 3.9.4 or 4.0.0.dev.4 - ruby-version: '3.0' rbs-version: '3.9.4' - ruby-version: '3.0' rbs-version: '4.0.0.dev.4' + # 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' + # Missing require in 'rbs collection update' - hopefully + # fixed in next RBS release + - ruby-version: 'head' + rbs-version: '4.0.0.dev.4' + - ruby-version: 'head' + rbs-version: '3.9.4' + - ruby-version: 'head' + rbs-version: '3.6.1' + include: + - ruby-version: '3.1' + rbs-version: '3.6.1' + - ruby-version: '3.2' + rbs-version: '3.9.4' + - ruby-version: '3.3' + rbs-version: '4.0.0.dev.4' + - ruby-version: '3.4' + rbs-version: '4.0.0.dev.4' steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} + # see https://github.com/castwide/solargraph/actions/runs/19391419903/job/55485410493?pr=1119 + # + # match version in Gemfile.lock and use same version below + bundler: 2.5.23 bundler-cache: false - name: Set rbs version run: echo "gem 'rbs', '${{ matrix.rbs-version }}'" >> .Gemfile @@ -46,8 +75,13 @@ jobs: run: echo "gem 'tsort'" >> .Gemfile - name: Install gems run: | - bundle install + bundle _2.5.23_ install bundle update rbs # use latest available for this Ruby version + bundle list + bundle exec solargraph pin 'Bundler::Dsl#source' + - name: Update types + run: | + bundle exec rbs collection update - name: Run tests run: bundle exec rake spec undercover: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 0ae8a3d8a..e1bf05d7c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -11,14 +11,14 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + branches: ['*'] permissions: contents: read jobs: solargraph_typed: - name: Solargraph / typed + name: Solargraph / strong runs-on: ubuntu-latest @@ -36,4 +36,4 @@ jobs: - name: Install gem types run: bundle exec rbs collection install - name: Typecheck self - run: SOLARGRAPH_ASSERTS=on bundle exec solargraph typecheck --level typed + run: SOLARGRAPH_ASSERTS=on bundle exec solargraph typecheck --level strong diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 83339e756..3c059d90d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -34,7 +34,6 @@ Gemspec/OrderedDependencies: # Configuration parameters: Severity. Gemspec/RequireMFA: Exclude: - - 'solargraph.gemspec' - 'spec/fixtures/rdoc-lib/rdoc-lib.gemspec' - 'spec/fixtures/rubocop-custom-version/specifications/rubocop-0.0.0.gemspec' @@ -81,7 +80,6 @@ Layout/ElseAlignment: # Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, DefLikeMacros, AllowAdjacentOneLineDefs, NumberOfEmptyLines. Layout/EmptyLineBetweenDefs: Exclude: - - 'lib/solargraph/doc_map.rb' - 'lib/solargraph/language_server/message/initialize.rb' - 'lib/solargraph/pin/delegated_method.rb' @@ -216,11 +214,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 @@ -363,7 +356,6 @@ Lint/NonAtomicFileOperation: # This cop supports safe autocorrection (--autocorrect). Lint/ParenthesesAsGroupedExpression: Exclude: - - 'lib/solargraph.rb' - 'lib/solargraph/parser/parser_gem/node_chainer.rb' - 'spec/language_server/host_spec.rb' - 'spec/source_map/clip_spec.rb' @@ -458,7 +450,7 @@ Metrics/AbcSize: # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. # AllowedMethods: refine Metrics/BlockLength: - Max: 54 + Max: 57 # Configuration parameters: CountBlocks, CountModifierForms. Metrics/BlockNesting: @@ -469,6 +461,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' @@ -488,10 +481,10 @@ Metrics/ModuleLength: Metrics/ParameterLists: Exclude: - 'lib/solargraph/api_map.rb' + - 'lib/solargraph/parser/node_processor.rb' - '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: @@ -523,7 +516,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 @@ -624,7 +622,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). @@ -640,21 +637,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' @@ -664,14 +650,6 @@ RSpec/LetBeforeExamples: Exclude: - 'spec/complex_type_spec.rb' -# Configuration parameters: EnforcedStyle. -# SupportedStyles: have_received, receive -RSpec/MessageSpies: - Exclude: - - 'spec/doc_map_spec.rb' - - 'spec/language_server/host/diagnoser_spec.rb' - - 'spec/language_server/host/message_worker_spec.rb' - RSpec/MissingExampleGroupArgument: Exclude: - 'spec/diagnostics/rubocop_helpers_spec.rb' @@ -732,10 +710,6 @@ RSpec/ScatteredLet: Exclude: - 'spec/complex_type_spec.rb' -RSpec/StubbedMock: - Exclude: - - 'spec/language_server/host/message_worker_spec.rb' - # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. RSpec/VerifiedDoubles: Enabled: false @@ -773,7 +747,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). @@ -851,7 +824,6 @@ Style/EmptyLambdaParameter: Style/EmptyMethod: Exclude: - 'lib/solargraph/language_server/message/client/register_capability.rb' - - 'lib/solargraph/pin/base.rb' - 'spec/fixtures/formattable.rb' - 'spec/fixtures/rdoc-lib/lib/example.rb' - 'spec/fixtures/workspace-with-gemfile/lib/thing.rb' @@ -955,7 +927,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: @@ -1029,7 +1000,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. @@ -1157,7 +1127,7 @@ Style/SafeNavigation: # Configuration parameters: Max. Style/SafeNavigationChainLength: Exclude: - - 'lib/solargraph/doc_map.rb' + - 'lib/solargraph/workspace/gemspecs.rb' # This cop supports unsafe autocorrection (--autocorrect-all). Style/SlicingWithRange: @@ -1166,7 +1136,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: @@ -1188,7 +1163,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' @@ -1230,7 +1204,10 @@ 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/rbs_map/conversions.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, IgnoreClassMethods, AllowedMethods. @@ -1238,7 +1215,6 @@ Style/TrailingCommaInHashLiteral: Style/TrivialAccessors: Exclude: - 'lib/solargraph/language_server/message/extended/check_gem_version.rb' - - 'lib/solargraph/pin/keyword.rb' # This cop supports safe autocorrection (--autocorrect). Style/WhileUntilModifier: @@ -1278,12 +1254,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 d731fc786..f27abfeb6 100755 --- a/Rakefile +++ b/Rakefile @@ -9,7 +9,7 @@ task :console do end desc "Run the type checker" -task typecheck: [:typecheck_typed] +task typecheck: [:typecheck_strong] desc "Run the type checker at typed level - return code issues provable without annotations being correct" task :typecheck_typed do @@ -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/bin/solargraph b/bin/solargraph index d85561700..248dc42fd 100755 --- a/bin/solargraph +++ b/bin/solargraph @@ -1,5 +1,8 @@ #!/usr/bin/env ruby +# turn off warning diagnostics from Ruby +$VERBOSE=nil + require 'solargraph' Solargraph::Shell.start(ARGV) diff --git a/lib/solargraph.rb b/lib/solargraph.rb index 038e7bccf..27a79e4ad 100755 --- a/lib/solargraph.rb +++ b/lib/solargraph.rb @@ -55,8 +55,8 @@ class InvalidRubocopVersionError < RuntimeError; end CHDIR_MUTEX = Mutex.new - # @param type [Symbol] Type of assert. - def self.asserts_on?(type) + def self.asserts_on? + # @sg-ignore Translate to something flow sensitive typing understands if ENV['SOLARGRAPH_ASSERTS'].nil? || ENV['SOLARGRAPH_ASSERTS'].empty? false elsif ENV['SOLARGRAPH_ASSERTS'] == 'on' @@ -72,7 +72,28 @@ def self.asserts_on?(type) # @param block [Proc] A block that returns a message to log # @return [void] def self.assert_or_log(type, msg = nil, &block) - raise (msg || block.call) if asserts_on?(type) && ![:combine_with_visibility].include?(type) + if asserts_on? + # @type [String, nil] + msg ||= block.call + + raise "No message given for #{type.inspect}" if msg.nil? + + # conditional aliases to handle compatibility corner cases + # @sg-ignore flow sensitive typing needs to handle 'raise if' + return if type == :alias_target_missing && msg.include?('highline/compatibility.rb') + # @sg-ignore flow sensitive typing needs to handle 'raise if' + return if type == :alias_target_missing && msg.include?('lib/json/add/date.rb') + # @todo :combine_with_visibility is not ready for prime time - + # lots of disagreements found in practice that heuristics need + # to be created for and/or debugging needs to resolve in pin + # generation. + # @todo :api_map_namespace_pin_stack triggers in a badly handled + # self type case - 'keeps track of self type in method + # parameters in subclass' in call_spec.rb + return if %i[api_map_namespace_pin_stack combine_with_visibility].include?(type) + + raise msg + end logger.info msg, &block end diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 71855d04a..05a647c0e 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -23,10 +23,21 @@ class ApiMap # @return [Array] attr_reader :missing_docs + # @return [Solargraph::Workspace::Gemspecs] + attr_reader :gemspecs + # @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 @@ -39,6 +50,7 @@ def initialize pins: [] # @param other [Object] def eql?(other) self.class == other.class && + # @sg-ignore Flow sensitive typing needs to handle self.class == other.class equality_fields == other.equality_fields end @@ -51,6 +63,8 @@ def hash equality_fields.hash end + attr_reader :loose_unions + def to_s self.class.to_s end @@ -101,7 +115,7 @@ def catalog bench @doc_map&.uncached_rbs_collection_gemspecs&.any? || @doc_map&.rbs_collection_path != bench.workspace.rbs_collection_path if recreate_docmap - @doc_map = DocMap.new(unresolved_requires, [], bench.workspace) # @todo Implement gem preferences + @doc_map = DocMap.new(unresolved_requires, bench.workspace) # @todo Implement gem preferences @unresolved_requires = @doc_map.unresolved_requires end @cache.clear if store.update(@@core_map.pins, @doc_map.pins, conventions_environ.pins, iced_pins, live_pins) @@ -113,17 +127,17 @@ 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] def doc_map - @doc_map ||= DocMap.new([], []) + @doc_map ||= DocMap.new([], Workspace.new('.')) end # @return [::Array] def uncached_gemspecs - @doc_map&.uncached_gemspecs || [] + doc_map.uncached_gemspecs || [] end # @return [::Array] @@ -141,9 +155,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 @@ -179,10 +194,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) @@ -191,18 +207,19 @@ def self.load directory api_map end - # @param out [IO, nil] + # @param out [StringIO, IO, nil] + # @param rebuild [Boolean] whether to rebuild the pins even if they are cached # @return [void] - def cache_all!(out) - @doc_map.cache_all!(out) + def cache_all!(out, rebuild: false) + doc_map.cache_all!(out, rebuild: rebuild) end # @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) + doc_map.cache(gemspec, rebuild: rebuild, out: out) end class << self @@ -214,18 +231,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] @@ -240,13 +258,6 @@ def keyword_pins store.pins_by_class(Pin::Keyword) end - # An array of namespace names defined in the ApiMap. - # - # @return [Set] - def namespaces - store.namespaces - end - # True if the namespace exists. # # @param name [String] The namespace to match @@ -340,16 +351,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. @@ -537,7 +566,7 @@ def get_method_stack rooted_tag, name, scope: :instance, visibility: [:private, # @deprecated Use #get_path_pins instead. # # @param path [String] The path to find - # @return [Enumerable] + # @return [Array] def get_path_suggestions path return [] if path.nil? resolve_method_aliases store.get_path_pins(path) @@ -546,7 +575,7 @@ def get_path_suggestions path # Get an array of pins that match the specified path. # # @param path [String] - # @return [Enumerable] + # @return [Array] def get_path_pins path get_path_suggestions(path) end @@ -642,12 +671,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 @@ -665,7 +698,7 @@ def super_and_sub?(sup, sub) # # @return [Boolean] def type_include?(host_ns, module_ns) - store.get_includes(host_ns).map { |inc_tag| inc_tag.parametrized_tag.name }.include?(module_ns) + store.get_includes(host_ns).map { |inc_tag| inc_tag.type.name }.include?(module_ns) end # @param pins [Enumerable] @@ -675,6 +708,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 @@ -682,6 +716,11 @@ def resolve_method_aliases pins, visibility = [:public, :private, :protected] GemPins.combine_method_pins_by_path(with_resolved_aliases) end + # @return [Workspace] + def workspace + doc_map.workspace + end + # @param fq_reference_tag [String] A fully qualified whose method should be pulled in # @param namespace_pin [Pin::Base] Namespace pin for the rooted_type # parameter - used to pull generics information @@ -774,17 +813,9 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false if scope == :instance store.get_includes(fqns).reverse.each do |ref| - const = get_constants('', *ref.closure.gates).find { |pin| pin.path.end_with? ref.name } - if const.is_a?(Pin::Namespace) - result.concat inner_get_methods(const.path, scope, visibility, deep, skip, true) - elsif const.is_a?(Pin::Constant) - type = const.infer(self) - result.concat inner_get_methods(type.namespace, scope, visibility, deep, skip, true) if type.defined? - else - referenced_tag = ref.parametrized_tag - next unless referenced_tag.defined? - result.concat inner_get_methods_from_reference(referenced_tag.to_s, namespace_pin, rooted_type, scope, visibility, deep, skip, true) - end + 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) unless rooted_sc_tag.nil? @@ -793,7 +824,7 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false else logger.info { "ApiMap#inner_get_methods(#{fqns}, #{scope}, #{visibility}, #{deep}, #{skip}) - looking for get_extends() from #{fqns}" } store.get_extends(fqns).reverse.each do |em| - fqem = store.constants.dereference(em) + fqem = dereference(em) result.concat inner_get_methods(fqem, :instance, visibility, deep, skip, true) unless fqem.nil? end rooted_sc_tag = qualify_superclass(rooted_tag) @@ -862,12 +893,17 @@ 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 ancestors.each do |ancestor_fqns| next if ancestor_fqns.nil? - ancestor_method_path = "#{ancestor_fqns}#{alias_pin.scope == :instance ? '#' : '.'}#{alias_pin.original}" + ancestor_method_path = if alias_pin.original == 'new' && alias_pin.scope == :class + "#{ancestor_fqns}#initialize" + else + "#{ancestor_fqns}#{alias_pin.scope == :instance ? '#' : '.'}#{alias_pin.original}" + end # Search for the original method in the ancestor original = store.get_path_pins(ancestor_method_path).find do |candidate_pin| @@ -879,14 +915,20 @@ def resolve_method_alias(alias_pin) break resolved if resolved end - candidate_pin.is_a?(Pin::Method) && candidate_pin.scope == alias_pin.scope + candidate_pin.is_a?(Pin::Method) end break if original end + if original.nil? + # :nocov: + Solargraph.assert_or_log(:alias_target_missing) { "Rejecting alias - target is missing while looking for #{alias_pin.full_context.tag} #{alias_pin.original} in #{alias_pin.scope} scope = #{alias_pin.inspect}" } + return nil + # :nocov: + end - # @sg-ignore ignore `received nil` for original - create_resolved_alias_pin(alias_pin, original) if original + # @sg-ignore flow sensitive typing needs to handle 'raise if' + create_resolved_alias_pin(alias_pin, original) end # Fast path for creating resolved alias pins without individual method stack lookups @@ -942,7 +984,7 @@ def erase_generics(namespace_pin, rooted_type, pins) # @param namespace_pin [Pin::Namespace] # @param rooted_type [ComplexType] - def should_erase_generics_when_done?(namespace_pin, rooted_type) + def should_erase_generics_when_done? namespace_pin, rooted_type has_generics?(namespace_pin) && !can_resolve_generics?(namespace_pin, rooted_type) end @@ -953,7 +995,7 @@ def has_generics?(namespace_pin) # @param namespace_pin [Pin::Namespace] # @param rooted_type [ComplexType] - def can_resolve_generics?(namespace_pin, rooted_type) + def can_resolve_generics? namespace_pin, rooted_type has_generics?(namespace_pin) && !rooted_type.all_params.empty? end end diff --git a/lib/solargraph/api_map/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 0df8d83ce..e6512f5d3 100644 --- a/lib/solargraph/api_map/constants.rb +++ b/lib/solargraph/api_map/constants.rb @@ -12,15 +12,26 @@ def initialize store # Resolve a name to a fully qualified namespace or constant. # - # `Constants#resolve` is similar to `Constants#qualify`` in that its - # purpose is to find fully qualified (absolute) namespaces, except - # `#resolve`` is only concerned with real namespaces. It disregards - # parametrized types and special types like literals, self, and Boolean. + # `Constants#resolve` finds fully qualified (absolute) + # namespaces based on relative names and the open gates + # (namespaces) provided. Names must be runtime-visible (erased) + # non-literal types, non-duck, non-signature types - e.g., + # TrueClass, NilClass, Integer and Hash instead of true, nil, + # 96, or Hash{String => Symbol} # - # @param name [String] - # @param gates [Array, String>] - # @return [String, nil] + # Note: You may want to be using #qualify. Notably, #resolve: + # - does not handle anything with type parameters + # - will not gracefully handle nil, self and Boolean + # - will return a constant name instead of following its assignment + # + # @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 @@ -39,39 +50,50 @@ def resolve(name, *gates) # @param pin [Pin::Reference] # @return [String, nil] def dereference pin - resolve(pin.name, pin.reference_gates) + qualify_type(pin.type, *pin.reference_gates)&.tag end # Collect a list of all constants defined in the specified gates. # # @param gates [Array, String>] - # @return [Array] + # @return [Array] def collect(*gates) flat = gates.flatten cached_collect[flat] || collect_and_cache(flat) end - # Determine a fully qualified namespace for a given name referenced - # from the specified open gates. This method will search in each gate - # until it finds a match for the name. + # Determine a fully qualified namespace for a given tag + # referenced from the specified open gates. This method will + # search in each gate until it finds a match for the name. # - # @param name [String, nil] The namespace to match + # @param tag [String, nil] The type to match # @param gates [Array] # @return [String, nil] fully qualified tag - def qualify name, *gates - return name if ['Boolean', 'self', nil].include?(name) + def qualify tag, *gates + type = ComplexType.try_parse(tag) + qualify_type(type, *gates)&.tag + end + + # @param type [ComplexType, nil] The type to match + # @param gates [Array] + # + # @return [ComplexType, nil] A new rooted ComplexType + def qualify_type type, *gates + return nil if type.nil? + return type if type.selfy? || type.literal? || type.tag == 'nil' || type.interface? || + type.tag == 'Boolean' gates.push '' unless gates.include?('') - fqns = resolve(name, gates) + fqns = resolve(type.rooted_namespace, *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 - resolve(const, pin.gates) - else - fqns + fqns = resolve(const, *pin.gates) end + type.recreate(new_name: fqns, make_rooted: true) end # @return [void] @@ -86,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 @@ -106,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 @@ -119,14 +143,14 @@ 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| resolved = simple_resolve(name, gate, internal) return [resolved, gates[(idx + 1)..]] if resolved store.get_ancestor_references(gate).each do |ref| - return ref.name.sub(/^::/, '') if ref.name.end_with?("::#{name}") + return ref.name.sub(/^::/, '') if ref.name.end_with?("::#{name}") && ref.name.start_with?('::') mixin = resolve(ref.name, ref.reference_gates) next unless mixin @@ -146,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) @@ -155,7 +180,7 @@ def simple_resolve name, gate, internal end # @param gates [Array] - # @return [Array] + # @return [Array] def collect_and_cache gates skip = Set.new cached_collect[gates] = gates.flat_map do |gate| @@ -168,7 +193,7 @@ def cached_resolve @cached_resolve ||= {} end - # @return [Hash{Array => Array}] + # @return [Hash{Array => Array}] def cached_collect @cached_collect ||= {} end @@ -178,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) @@ -213,7 +239,7 @@ def inner_qualify name, root, skip return fqns if store.namespace_exists?(fqns) incs = store.get_includes(roots.join('::')) incs.each do |inc| - foundinc = inner_qualify(name, inc.parametrized_tag.to_s, skip) + foundinc = inner_qualify(name, inc.type.to_s, skip) possibles.push foundinc unless foundinc.nil? end roots.pop @@ -221,7 +247,7 @@ def inner_qualify name, root, skip if possibles.empty? incs = store.get_includes('') incs.each do |inc| - foundinc = inner_qualify(name, inc.parametrized_tag.to_s, skip) + foundinc = inner_qualify(name, inc.type.to_s, skip) possibles.push foundinc unless foundinc.nil? end end @@ -230,27 +256,30 @@ def inner_qualify name, root, skip end end - # @param fqns [String] + # @param fqns [String, nil] # @param visibility [Array] # @param skip [Set] - # @return [Array] + # @return [Array] def inner_get_constants fqns, visibility, skip return [] if fqns.nil? || skip.include?(fqns) skip.add fqns 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 35d86446a..48cf05706 100644 --- a/lib/solargraph/api_map/index.rb +++ b/lib/solargraph/api_map/index.rb @@ -17,16 +17,22 @@ def pins # @return [Hash{String => Array}] def namespace_hash + # @param h [String] + # @param k [Array] @namespace_hash ||= Hash.new { |h, k| h[k] = [] } end # @return [Hash{String => Array}] def pin_class_hash + # @param h [String] + # @param k [Array] @pin_class_hash ||= Hash.new { |h, k| h[k] = [] } end # @return [Hash{String => Array}] def path_pin_hash + # @param h [String] + # @param k [Array] @path_pin_hash ||= Hash.new { |h, k| h[k] = [] } end @@ -34,34 +40,44 @@ 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] @include_references ||= Hash.new { |h, k| h[k] = [] } end # @return [Hash{String => Array}] def include_reference_pins + # @param h [String] + # @param k [Array] @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] @superclass_references ||= Hash.new { |h, k| h[k] = [] } end @@ -99,12 +115,18 @@ def catalog new_pins @pin_select_cache = {} pins.concat new_pins set = new_pins.to_set + # @param k [String] + # @param v [Set] set.classify(&:class) - .map { |k, v| pin_class_hash[k].concat v.to_a } + .map { |k, v| pin_class_hash[k].concat v.to_a } + # @param k [String] + # @param v [Set] set.classify(&:namespace) - .map { |k, v| namespace_hash[k].concat v.to_a } + .map { |k, v| namespace_hash[k].concat v.to_a } + # @param k [String] + # @param v [Set] set.classify(&:path) - .map { |k, v| path_pin_hash[k].concat v.to_a } + .map { |k, v| path_pin_hash[k].concat v.to_a } @namespaces = path_pin_hash.keys.compact.to_set map_references Pin::Reference::Include, include_references map_references Pin::Reference::Prepend, prepend_references @@ -114,10 +136,13 @@ def catalog new_pins self end - # @param klass [Class] - # @param hash [Hash{String => Array}] + # @generic T + # @param klass [Class>] + # @param hash [Hash{String => Array>}] + # # @return [void] def map_references klass, hash + # @param pin [generic] pins_by_class(klass).each do |pin| hash[pin.namespace].push pin end @@ -125,6 +150,8 @@ 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}" } pins = path_pin_hash[ovr.name] @@ -134,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 @@ -160,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 ccbed3eb6..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>] @@ -32,13 +35,19 @@ def rake_yard store next end 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 @@ -46,16 +55,15 @@ def rake_yard store store.get_includes(pin.path).each do |ref| include_object = code_object_at(pin.path, YARD::CodeObjects::ClassObject) unless include_object.nil? || include_object.nil? - include_object.instance_mixins.push code_object_map[ref.parametrized_tag.to_s] + include_object.instance_mixins.push code_object_map[ref.type.to_s] end end store.get_extends(pin.path).each do |ref| extend_object = code_object_at(pin.path, YARD::CodeObjects::ClassObject) next unless extend_object - code_object = code_object_map[ref.parametrized_tag.to_s] + 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 @@ -65,13 +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 c41e19c09..6df0400e3 100644 --- a/lib/solargraph/api_map/store.rb +++ b/lib/solargraph/api_map/store.rb @@ -17,7 +17,7 @@ def pins index.pins end - # @param pinsets [Array>] + # @param pinsets [Array>] # - pinsets[0] = core Ruby pins # - pinsets[1] = documentation/gem pins # - pinsets[2] = convention pins @@ -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? @@ -60,6 +61,7 @@ def inspect # @return [Enumerable] def get_constants fqns, visibility = [:public] namespace_children(fqns).select { |pin| + # @sg-ignore flow-sensitive typing not smart enough to handle this case !pin.name.empty? && (pin.is_a?(Pin::Namespace) || pin.is_a?(Pin::Constant)) && visibility.include?(pin.visibility) } end @@ -78,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) @@ -97,7 +99,7 @@ def qualify_superclass fq_sub_tag return unless ref res = constants.dereference(ref) return unless res - res + type.substring + res end # @param fqns [String] @@ -124,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) @@ -151,11 +153,6 @@ def namespace_exists?(fqns) fqns_pins(fqns).any? end - # @return [Set] - def namespaces - index.namespaces - end - # @return [Enumerable] def namespace_pins pins_by_class(Solargraph::Pin::Namespace) @@ -202,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? @@ -245,9 +242,13 @@ def get_ancestors(fqns) # Add includes, prepends, and extends [get_includes(current), get_prepends(current), get_extends(current)].each do |refs| next if refs.nil? - refs.map(&:parametrized_tag).map(&:to_s).each do |ref| + # @param ref [String] + refs.map(&:type).map(&:to_s).each do |ref| + # @sg-ignore We should understand reassignment of variable to new type next if ref.nil? || ref.empty? || visited.include?(ref) + # @sg-ignore We should understand reassignment of variable to new type ancestors << ref + # @sg-ignore We should understand reassignment of variable to new type queue << ref end end @@ -258,7 +259,7 @@ def get_ancestors(fqns) # @param fqns [String] # - # @return [Array] + # @return [Array] def get_ancestor_references(fqns) (get_prepends(fqns) + get_includes(fqns) + [get_superclass(fqns)]).compact end @@ -275,9 +276,9 @@ def index @indexes.last end - # @param pinsets [Array>] + # @param pinsets [Array>] # - # @return [void] + # @return [true] def catalog pinsets @pinsets = pinsets # @type [Array] @@ -296,6 +297,9 @@ def catalog pinsets # @return [Hash{::Array(String, String) => ::Array}] def fqns_pins_map + # @param h [Hash{::Array(String, String) => ::Array}] + # @param base [String] + # @param name [String] @fqns_pins_map ||= Hash.new do |h, (base, name)| value = namespace_children(base).select { |pin| pin.name == name && pin.is_a?(Pin::Namespace) } h[[base, name]] = value @@ -307,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 669a66900..71f0bddd5 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,14 @@ 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 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 +46,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 +67,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 +78,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] @@ -105,6 +111,21 @@ def can_assign?(api_map, atype) any? { |ut| ut.can_assign?(api_map, atype) } end + # @param new_name [String, nil] + # @param make_rooted [Boolean, nil] + # @param new_key_types [Array, nil] + # @param rooted [Boolean, nil] + # @param new_subtypes [Array, nil] + # @return [self] + def recreate(new_name: nil, make_rooted: nil, new_key_types: nil, new_subtypes: nil) + ComplexType.new(map do |ut| + ut.recreate(new_name: new_name, + make_rooted: make_rooted, + new_key_types: new_key_types, + new_subtypes: new_subtypes) + end) + end + # @return [Integer] def length @items.length @@ -179,6 +200,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(', ') @@ -237,6 +312,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 || [] @@ -259,6 +341,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? @@ -271,6 +360,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] @@ -305,17 +428,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| @@ -324,6 +448,7 @@ def parse *strings, partial: false paren_stack = 0 base = String.new subtype_string = String.new + # @param char [String] type_string&.each_char do |char| if char == '=' #raise ComplexTypeError, "Invalid = in type #{type_string}" unless curly_stack > 0 @@ -335,6 +460,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 d8d4fc7d7..687d6b705 100644 --- a/lib/solargraph/complex_type/type_methods.rb +++ b/lib/solargraph/complex_type/type_methods.rb @@ -43,6 +43,10 @@ def rooted_tag @rooted_tag ||= rooted_name + rooted_substring end + def interface? + name.start_with?('_') + end + # @return [Boolean] def duck_type? @duck_type ||= name.start_with?('#') @@ -69,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) @@ -190,6 +206,7 @@ def scope # @param other [Object] def == other return false unless self.class == other.class + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 tag == other.tag end @@ -213,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 05a585dcf..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 @@ -138,11 +185,17 @@ def determine_non_literal_name def eql?(other) self.class == other.class && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @name == other.name && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @key_types == other.key_types && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @subtypes == other.subtypes && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @rooted == other.rooted? && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @all_params == other.all_params && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 @parameters_type == other.parameters_type end @@ -150,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] @@ -175,6 +304,7 @@ def desc rooted_tags end + # @sg-ignore Need better if/elseanalysis # @return [String] def to_rbs if duck_type? @@ -184,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? @@ -236,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 @@ -248,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 @@ -270,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 @@ -280,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] @@ -331,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 @@ -346,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] @@ -364,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 @@ -437,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/active_support_concern.rb b/lib/solargraph/convention/active_support_concern.rb index 74c9ce765..ed1fba175 100644 --- a/lib/solargraph/convention/active_support_concern.rb +++ b/lib/solargraph/convention/active_support_concern.rb @@ -80,7 +80,7 @@ def process_include include_tag "ActiveSupportConcern#object(#{fqns}, #{scope}, #{visibility}, #{deep}) - " \ "Handling class include include_tag=#{include_tag}" end - module_extends = api_map.get_extends(rooted_include_tag).map(&:parametrized_tag).map(&:to_s) + module_extends = api_map.get_extends(rooted_include_tag).map(&:type).map(&:to_s) logger.debug do "ActiveSupportConcern#object(#{fqns}, #{scope}, #{visibility}, #{deep}) - " \ "found module extends of #{rooted_include_tag}: #{module_extends}" 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/rubocop_helpers.rb b/lib/solargraph/diagnostics/rubocop_helpers.rb index f6f4c82c8..b306f638a 100644 --- a/lib/solargraph/diagnostics/rubocop_helpers.rb +++ b/lib/solargraph/diagnostics/rubocop_helpers.rb @@ -18,11 +18,14 @@ 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] + specs = e.specs raise InvalidRubocopVersionError, "could not find '#{e.name}' (#{e.requirement}) - "\ - "did find: [#{e.specs.map { |s| s.version.version }.join(', ')}]" + "did find: [#{specs.map { |s| s.version.version }.join(', ')}]" end require 'rubocop' end @@ -36,6 +39,7 @@ def generate_options filename, code args = ['-f', 'j', '--force-exclusion', filename] base_options = RuboCop::Options.new options, paths = base_options.parse(args) + # @sg-ignore options[:stdin] = code [options, paths] end @@ -47,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 5966717f4..bd2407902 100644 --- a/lib/solargraph/doc_map.rb +++ b/lib/solargraph/doc_map.rb @@ -5,7 +5,10 @@ require 'open3' module Solargraph - # A collection of pins generated from required gems. + # A collection of pins generated from specific 'require' statements + # in code. Multiple can be created per workspace, to represent the + # pins available in different files based on their particular + # 'require' lines. # class DocMap include Logging @@ -14,9 +17,6 @@ class DocMap attr_reader :requires alias required requires - # @return [Array] - attr_reader :preferences - # @return [Array] attr_reader :pins @@ -39,18 +39,18 @@ def uncached_gemspecs # @return [String, nil] attr_reader :rbs_collection_config_path - # @return [Workspace, nil] + # @todo Need to add nil check here + # @return [Workspace] attr_reader :workspace # @return [Environ] attr_reader :environ # @param requires [Array] - # @param preferences [Array] # @param workspace [Workspace, nil] - def initialize(requires, preferences, workspace = nil) + # @param out [IO, nil] output stream for logging + def initialize requires, workspace, out: $stderr @requires = requires.compact - @preferences = preferences.compact @workspace = workspace @rbs_collection_path = workspace&.rbs_collection_path @rbs_collection_config_path = workspace&.rbs_collection_config_path @@ -60,27 +60,35 @@ def initialize(requires, preferences, workspace = nil) pins.concat @environ.pins end - # @param out [IO] + # @param out [StringIO, IO, nil] + # @param rebuild [Boolean] whether to rebuild the pins even if they are cached + # # @return [void] - def cache_all!(out) - # if we log at debug level: - if logger.info? - gem_desc = uncached_gemspecs.map { |gemspec| "#{gemspec.name}:#{gemspec.version}" }.join(', ') - logger.info "Caching pins for gems: #{gem_desc}" unless uncached_gemspecs.empty? - end - logger.debug { "Caching for YARD: #{uncached_yard_gemspecs.map(&:name)}" } - logger.debug { "Caching for RBS collection: #{uncached_rbs_collection_gemspecs.map(&:name)}" } + def cache_all!(out, rebuild: false) load_serialized_gem_pins - uncached_gemspecs.each do |gemspec| + PinCache.cache_core(out: out) unless PinCache.core? + gem_specs = uncached_gemspecs + # try any possible standard libraries, but be quiet about it + stdlib_specs = PinCache.possible_stdlibs.map { |stdlib| workspace.find_gem(stdlib, out: nil) }.compact + specs = (gem_specs + stdlib_specs) + specs.each do |gemspec| cache(gemspec, out: out) end + out&.puts "Documentation cached for all #{specs.length} gems." + + # do this after so that we prefer stdlib requires from gems, + # which are likely to be newer and have more pins + PinCache.cache_all_stdlibs(out: out) + + out&.puts "Documentation cached for core, standard library and gems." + load_serialized_gem_pins @uncached_rbs_collection_gemspecs = [] @uncached_yard_gemspecs = [] 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 +97,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 +111,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 @@ -128,7 +136,7 @@ def unresolved_requires @unresolved_requires ||= required_gems_map.select { |_, gemspecs| gemspecs.nil? }.keys end - # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version + # @return [Hash{Array(String, String) => Array}] Indexed by gemspec name and version def self.all_yard_gems_in_memory @yard_gems_in_memory ||= {} end @@ -145,6 +153,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 @@ -166,7 +175,7 @@ def yard_plugins # @return [Set] def dependencies - @dependencies ||= (gemspecs.flat_map { |spec| fetch_dependencies(spec) } - gemspecs).to_set + @dependencies ||= (gemspecs.flat_map { |spec| workspace.fetch_dependencies(spec) } - gemspecs).to_set end private @@ -177,10 +186,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 @@ -203,12 +211,7 @@ def load_serialized_gem_pins # @return [Hash{String => Array}] def required_gems_map - @required_gems_map ||= requires.to_h { |path| [path, resolve_path_to_gemspecs(path)] } - end - - # @return [Hash{String => Gem::Specification}] - def preference_map - @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } + @required_gems_map ||= requires.to_h { |path| [path, workspace.resolve_require(path)] } end # @param gemspec [Gem::Specification] @@ -307,130 +310,8 @@ def deserialize_rbs_collection_cache gemspec, rbs_version_cache_key end end - # @param path [String] - # @return [::Array, nil] - def resolve_path_to_gemspecs path - return nil if path.empty? - return gemspecs_required_from_bundler if path == 'bundler/require' - - # @type [Gem::Specification, nil] - gemspec = Gem::Specification.find_by_path(path) - if gemspec.nil? - gem_name_guess = path.split('/').first - begin - # this can happen when the gem is included via a local path in - # a Gemfile; Gem doesn't try to index the paths in that case. - # - # See if we can make a good guess: - potential_gemspec = Gem::Specification.find_by_name(gem_name_guess) - file = "lib/#{path}.rb" - gemspec = potential_gemspec if potential_gemspec.files.any? { |gemspec_file| file == gemspec_file } - rescue Gem::MissingSpecError - logger.debug { "Require path #{path} could not be resolved to a gem via find_by_path or guess of #{gem_name_guess}" } - [] - end - end - return nil if gemspec.nil? - [gemspec_or_preference(gemspec)] - end - - # @param gemspec [Gem::Specification] - # @return [Gem::Specification] - def gemspec_or_preference gemspec - # :nocov: dormant feature - return gemspec unless preference_map.key?(gemspec.name) - return gemspec if gemspec.version == preference_map[gemspec.name].version - - change_gemspec_version gemspec, preference_map[gemspec.name].version - # :nocov: - end - - # @param gemspec [Gem::Specification] - # @param version [Gem::Version] - # @return [Gem::Specification] - def change_gemspec_version gemspec, version - Gem::Specification.find_by_name(gemspec.name, "= #{version}") - rescue Gem::MissingSpecError - Solargraph.logger.info "Gem #{gemspec.name} version #{version} not found. Using #{gemspec.version} instead" - gemspec - end - - # @param gemspec [Gem::Specification] - # @return [Array] - def fetch_dependencies gemspec - # @param spec [Gem::Dependency] - only_runtime_dependencies(gemspec).each_with_object(Set.new) do |spec, deps| - Solargraph.logger.info "Adding #{spec.name} dependency for #{gemspec.name}" - dep = Gem.loaded_specs[spec.name] - # @todo is next line necessary? - dep ||= Gem::Specification.find_by_name(spec.name, spec.requirement) - deps.merge fetch_dependencies(dep) if deps.add?(dep) - rescue Gem::MissingSpecError - Solargraph.logger.warn "Gem dependency #{spec.name} #{spec.requirement} for #{gemspec.name} not found in RubyGems." - end.to_a - end - - # @param gemspec [Gem::Specification] - # @return [Array] - def only_runtime_dependencies gemspec - gemspec.dependencies - gemspec.development_dependencies - end - - def inspect self.class.inspect end - - # @return [Array, nil] - def gemspecs_required_from_bundler - # @todo Handle projects with custom Bundler/Gemfile setups - return unless workspace.gemfile? - - if workspace.gemfile? && Bundler.definition&.lockfile&.to_s&.start_with?(workspace.directory) - # Find only the gems bundler is now using - Bundler.definition.locked_gems.specs.flat_map do |lazy_spec| - logger.info "Handling #{lazy_spec.name}:#{lazy_spec.version}" - [Gem::Specification.find_by_name(lazy_spec.name, lazy_spec.version)] - rescue Gem::MissingSpecError => e - logger.info("Could not find #{lazy_spec.name}:#{lazy_spec.version} with find_by_name, falling back to guess") - # can happen in local filesystem references - specs = resolve_path_to_gemspecs lazy_spec.name - logger.warn "Gem #{lazy_spec.name} #{lazy_spec.version} from bundle not found: #{e}" if specs.nil? - next specs - end.compact - else - logger.info 'Fetching gemspecs required from Bundler (bundler/require)' - gemspecs_required_from_external_bundle - end - end - - # @return [Array, nil] - def gemspecs_required_from_external_bundle - logger.info 'Fetching gemspecs required from external bundle' - return [] unless workspace&.directory - - Solargraph.with_clean_env do - cmd = [ - 'ruby', '-e', - "require 'bundler'; require 'json'; Dir.chdir('#{workspace&.directory}') { puts Bundler.definition.locked_gems.specs.map { |spec| [spec.name, spec.version] }.to_h.to_json }" - ] - o, e, s = Open3.capture3(*cmd) - if s.success? - Solargraph.logger.debug "External bundle: #{o}" - hash = o && !o.empty? ? JSON.parse(o.split("\n").last) : {} - hash.flat_map do |name, version| - Gem::Specification.find_by_name(name, version) - rescue Gem::MissingSpecError => e - logger.info("Could not find #{name}:#{version} with find_by_name, falling back to guess") - # can happen in local filesystem references - specs = resolve_path_to_gemspecs name - logger.warn "Gem #{name} #{version} from bundle not found: #{e}" if specs.nil? - next specs - end.compact - else - Solargraph.logger.warn "Failed to load gems from bundle at #{workspace&.directory}: #{e}" - end - end - end end end diff --git a/lib/solargraph/equality.rb b/lib/solargraph/equality.rb index 0667efacd..f8c50ff31 100644 --- a/lib/solargraph/equality.rb +++ b/lib/solargraph/equality.rb @@ -12,6 +12,7 @@ module Equality # @return [Boolean] def eql?(other) self.class.eql?(other.class) && + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 equality_fields.eql?(other.equality_fields) end diff --git a/lib/solargraph/gem_pins.rb b/lib/solargraph/gem_pins.rb index a193a8a39..04ee4da8b 100644 --- a/lib/solargraph/gem_pins.rb +++ b/lib/solargraph/gem_pins.rb @@ -27,6 +27,8 @@ def self.combine_method_pins_by_path(pins) def self.combine_method_pins(*pins) # @type [Pin::Method, nil] combined_pin = nil + # @param memo [Pin::Method, nil] + # @param pin [Pin::Method] out = pins.reduce(combined_pin) do |memo, pin| next pin if memo.nil? if memo == pin && memo.source != :combined @@ -46,6 +48,7 @@ def self.combine_method_pins(*pins) # @return [Array] def self.build_yard_pins(yard_plugins, gemspec) Yardoc.cache(yard_plugins, gemspec) unless Yardoc.cached?(gemspec) + return [] unless Yardoc.cached?(gemspec) yardoc = Yardoc.load!(gemspec) YardMap::Mapper.new(yardoc, gemspec).map end @@ -56,17 +59,25 @@ def self.build_yard_pins(yard_plugins, gemspec) # @return [Array] def self.combine(yard_pins, rbs_pins) in_yard = Set.new - rbs_api_map = Solargraph::ApiMap.new(pins: rbs_pins) + rbs_store = Solargraph::ApiMap::Store.new(rbs_pins) combined = yard_pins.map do |yard_pin| in_yard.add yard_pin.path - rbs_pin = rbs_api_map.get_path_pins(yard_pin.path).filter { |pin| pin.is_a? Pin::Method }.first - next yard_pin unless rbs_pin && yard_pin.class == Pin::Method + rbs_pin = rbs_store.get_path_pins(yard_pin.path).filter { |pin| pin.is_a? Pin::Method }.first + + next yard_pin unless rbs_pin && yard_pin.is_a?(Pin::Method) unless rbs_pin logger.debug { "GemPins.combine: No rbs pin for #{yard_pin.path} - using YARD's '#{yard_pin.inspect} (return_type=#{yard_pin.return_type}; signatures=#{yard_pin.signatures})" } next yard_pin end + # at this point both yard_pins and rbs_pins are methods or + # method aliases. if not plain methods, prefer the YARD one + next yard_pin if rbs_pin.class != Pin::Method + + next rbs_pin if yard_pin.class != Pin::Method + + # both are method pins out = combine_method_pins(rbs_pin, yard_pin) logger.debug { "GemPins.combine: Combining yard.path=#{yard_pin.path} - rbs=#{rbs_pin.inspect} with yard=#{yard_pin.inspect} into #{out}" } out diff --git a/lib/solargraph/language_server/host.rb b/lib/solargraph/language_server/host.rb index 53da20175..76daf6550 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,10 @@ def prepare directory, name = nil end end + # @sg-ignore Need to validate config # @return [String] def command_path + # @type [String] options['commandPath'] || 'solargraph' end @@ -504,6 +507,7 @@ def locate_pins params name: 'new', scope: :class, location: pin.location, + # @sg-ignore Unresolved call to parameters on Solargraph::Pin::Base parameters: pin.parameters, return_type: ComplexType.try_parse(params['data']['path']), comments: pin.comments, @@ -728,9 +732,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/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..01aa47ad4 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 Need a better type for '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 821de7ffc..7010de741 100644 --- a/lib/solargraph/language_server/message/text_document/formatting.rb +++ b/lib/solargraph/language_server/message/text_document/formatting.rb @@ -18,6 +18,7 @@ def process require_rubocop(config['version']) options, paths = ::RuboCop::Options.new.parse(args) + # @sg-ignore Unresolved call to []= options[:stdin] = original # Ensure only one instance of RuboCop::Runner is running at @@ -28,6 +29,7 @@ def process ::RuboCop::Runner.new(options, ::RuboCop::ConfigStore.new).run(paths) end end + # @sg-ignore Unresolved call to []= result = options[:stdin] log_corrections(corrections) @@ -96,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/language_server/progress.rb b/lib/solargraph/language_server/progress.rb index 10900a37e..98b155714 100644 --- a/lib/solargraph/language_server/progress.rb +++ b/lib/solargraph/language_server/progress.rb @@ -134,7 +134,7 @@ def keep_alive host end end - # @return [Mutex] + # @return [Thread::Mutex] def mutex @mutex ||= Mutex.new end diff --git a/lib/solargraph/library.rb b/lib/solargraph/library.rb index bdd579976..19ad32c12 100644 --- a/lib/solargraph/library.rb +++ b/lib/solargraph/library.rb @@ -33,6 +33,7 @@ def initialize workspace = Solargraph::Workspace.new, name = nil # @type [Source, nil] @current = nil @sync_count = 0 + @cache_progress = nil end def inspect @@ -182,9 +183,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,6 +261,8 @@ 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 @@ -273,6 +281,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 +308,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 +413,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 +421,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 @@ -522,7 +535,7 @@ def find_external_requires source_map @external_requires = nil end - # @return [Mutex] + # @return [Thread::Mutex] def mutex @mutex ||= Mutex.new end @@ -632,11 +645,15 @@ 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 better handling of ||= on lvars finished = @total - pending + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars pct = if @total.zero? 0 else + # @sg-ignore flow sensitive typing needs better handling of ||= on lvars ((finished.to_f / @total.to_f) * 100).to_i end message = "#{gem_name}#{pending > 0 ? " (+#{pending})" : ''}" @@ -647,9 +664,11 @@ def report_cache_progress gem_name, pending @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 713b4fef1..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,8 +66,12 @@ 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] diff --git a/lib/solargraph/logging.rb b/lib/solargraph/logging.rb index 8f3edaba2..707a13936 100644 --- a/lib/solargraph/logging.rb +++ b/lib/solargraph/logging.rb @@ -29,9 +29,26 @@ 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) + 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..e73a46cb2 100644 --- a/lib/solargraph/parser/flow_sensitive_typing.rb +++ b/lib/solargraph/parser/flow_sensitive_typing.rb @@ -3,18 +3,25 @@ module Parser class FlowSensitiveTyping include Solargraph::Parser::NodeMethods - # @param locals [Array] + # @param locals [Array] + # @param ivars [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, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin) @locals = locals + @ivars = ivars @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 +32,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 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 +102,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,160 +148,296 @@ 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 private - # @param pin [Pin::LocalVariable] - # @param downcast_type_name [String] + # @param pin [Pin::BaseVariable] # @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 - ) - locals.push(new_pin) - end - - # @param facts_by_pin [Hash{Pin::LocalVariable => Array String}>}] + def add_downcast_var(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) + if pin.is_a?(Pin::LocalVariable) + locals.push(new_pin) + elsif pin.is_a?(Pin::InstanceVariable) + ivars.push(new_pin) + else + raise "Tried to add invalid pin type #{pin.class} in FlowSensitiveTyping" + end + end + + # @param facts_by_pin [Hash{Pin::BaseVariable => Array ComplexType}>}] # @param presences [Array] # # @return [void] def process_facts(facts_by_pin, presences) # - # Add specialized locals for the rest of the block + # Add specialized vars for the rest of the block # 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_var(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 + # @sg-ignore Need to look at Tuple#include? handling + variable_name = call_receiver.children[0].to_s if [:lvar, :ivar].include?(call_receiver&.type) 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] # @param position [Position] # - # @return [Solargraph::Pin::LocalVariable, nil] - def find_local(variable_name, position) - pins = locals.select { |pin| pin.name == variable_name && pin.presence.include?(position) } - return unless pins.length == 1 - pins.first + # @sg-ignore Solargraph::Parser::FlowSensitiveTyping#find_var + # return type could not be inferred + # @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil] + def find_var(variable_name, position) + if variable_name.start_with?('@') + # @sg-ignore flow sensitive typing needs to handle attrs + ivars.find { |ivar| ivar.name == variable_name && (!ivar.presence || ivar.presence.include?(position)) } + else + # @sg-ignore flow sensitive typing needs to handle attrs + locals.find { |pin| pin.name == variable_name && (!pin.presence || pin.presence.include?(position)) } + end 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) + pin = find_var(variable_name, isa_position) + return unless pin + + # @type Hash{Pin::BaseVariable => 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::BaseVariable => 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_var(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_var(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 +447,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 +460,24 @@ 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 :ivars + + attr_reader :enclosing_breakable_pin, :enclosing_compound_statement_pin end end end diff --git a/lib/solargraph/parser/node_methods.rb b/lib/solargraph/parser/node_methods.rb deleted file mode 100644 index f33a924c1..000000000 --- a/lib/solargraph/parser/node_methods.rb +++ /dev/null @@ -1,97 +0,0 @@ -module Solargraph - module Parser - module NodeMethods - module_function - - # @abstract - # @param node [Parser::AST::Node] - # @return [String] - def unpack_name node - raise NotImplementedError - end - - # @abstract - # @todo Temporarily here for testing. Move to Solargraph::Parser. - # @param node [Parser::AST::Node] - # @return [Array] - def call_nodes_from node - raise NotImplementedError - end - - # Find all the nodes within the provided node that potentially return a - # value. - # - # The node parameter typically represents a method's logic, e.g., the - # second child (after the :args node) of a :def node. A simple one-line - # method would typically return itself, while a node with conditions - # would return the resulting node from each conditional branch. Nodes - # that follow a :return node are assumed to be unreachable. Nil values - # are converted to nil node types. - # - # @abstract - # @param node [Parser::AST::Node] - # @return [Array] - def returns_from_method_body node - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # - # @return [Array] - def const_nodes_from node - raise NotImplementedError - end - - # @abstract - # @param cursor [Solargraph::Source::Cursor] - # @return [Parser::AST::Node, nil] - def find_recipient_node cursor - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # @return [Array] low-level value nodes in - # value position. Does not include explicit return - # statements - def value_position_nodes_only(node) - raise NotImplementedError - end - - # @abstract - # @param nodes [Enumerable] - def any_splatted_call?(nodes) - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # @return [void] - def process node - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # @return [Hash{Symbol => Source::Chain}] - def convert_hash node - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # @return [Position] - def get_node_start_position(node) - raise NotImplementedError - end - - # @abstract - # @param node [Parser::AST::Node] - # @return [Position] - def get_node_end_position(node) - raise NotImplementedError - end - end - end -end diff --git a/lib/solargraph/parser/node_processor.rb b/lib/solargraph/parser/node_processor.rb index dbe0b7cd5..a43fb326c 100644 --- a/lib/solargraph/parser/node_processor.rb +++ b/lib/solargraph/parser/node_processor.rb @@ -35,9 +35,10 @@ def deregister type, cls # @param node [Parser::AST::Node] # @param region [Region] # @param pins [Array] - # @param locals [Array] - # @return [Array(Array, Array)] - def self.process node, region = Region.new, pins = [], locals = [] + # @param locals [Array] + # @param ivars [Array] + # @return [Array(Array, Array, Array)] + def self.process node, region = Region.new, pins = [], locals = [], ivars = [] if pins.empty? pins.push Pin::Namespace.new( location: region.source.location, @@ -45,17 +46,17 @@ def self.process node, region = Region.new, pins = [], locals = [] source: :parser, ) end - return [pins, locals] unless Parser.is_ast_node?(node) + return [pins, locals, ivars] unless Parser.is_ast_node?(node) node_processor_classes = @@processors[node.type] || [NodeProcessor::Base] node_processor_classes.each do |klass| - processor = klass.new(node, region, pins, locals) + processor = klass.new(node, region, pins, locals, ivars) process_next = processor.process break unless process_next end - [pins, locals] + [pins, locals, ivars] end end end diff --git a/lib/solargraph/parser/node_processor/base.rb b/lib/solargraph/parser/node_processor/base.rb index fad31e95b..e11078e07 100644 --- a/lib/solargraph/parser/node_processor/base.rb +++ b/lib/solargraph/parser/node_processor/base.rb @@ -16,15 +16,20 @@ class Base # @return [Array] attr_reader :locals + # @return [Array] + attr_reader :ivars + # @param node [Parser::AST::Node] # @param region [Region] # @param pins [Array] # @param locals [Array] - def initialize node, region, pins, locals + # @param ivars [Array] + def initialize node, region, pins, locals, ivars @node = node @region = region @pins = pins @locals = locals + @ivars = ivars @processed_children = false end @@ -40,6 +45,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 @@ -47,7 +74,7 @@ def process_children subregion = region @processed_children = true node.children.each do |child| next unless Parser.is_ast_node?(child) - NodeProcessor.process(child, subregion, pins, locals) + NodeProcessor.process(child, subregion, pins, locals, ivars) end end @@ -68,6 +95,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 +105,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 +113,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..db41802f5 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 @@ -36,9 +40,12 @@ def parser end # @param source [Source] - # @return [Array(Array, Array)] + # @return [Array(Array, Array)] def map source - NodeProcessor.process(source.node, Region.new(source: source)) + # @sg-ignore Need to add nil check here + pins, locals, ivars = NodeProcessor.process(source.node, Region.new(source: source)) + pins.concat(ivars) + [pins, locals] end # @param source [Source] @@ -50,15 +57,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 +109,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 +120,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 +134,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 02f790c00..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,14 +105,18 @@ 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 @@ -160,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 @@ -210,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) @@ -224,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? @@ -302,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] @@ -333,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 @@ -347,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) @@ -363,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 @@ -459,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 d3485af7c..83f14a415 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/and_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/and_node.rb @@ -10,9 +10,10 @@ class AndNode < Parser::NodeProcessor::Base def process process_children - position = get_node_start_position(node) - 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, + ivars, + 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..bf8f95635 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,43 @@ class IfNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - process_children + FlowSensitiveTyping.new(locals, + ivars, + enclosing_breakable_pin, + enclosing_compound_statement_pin).process_if(node) + condition_node = node.children[0] + if condition_node + pins.push Solargraph::Pin::CompoundStatement.new( + location: get_node_location(condition_node), + closure: region.closure, + node: condition_node, + source: :parser, + ) + NodeProcessor.process(condition_node, region, pins, locals, ivars) + 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, ivars) + 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, ivars) + 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..0da611e22 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/ivasgn_node.rb @@ -9,7 +9,7 @@ class IvasgnNode < Parser::NodeProcessor::Base def process loc = get_node_location(node) - pins.push Solargraph::Pin::InstanceVariable.new( + ivars.push Solargraph::Pin::InstanceVariable.new( location: loc, closure: region.closure, name: node.children[0].to_s, @@ -19,9 +19,10 @@ 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( + ivars.push Solargraph::Pin::InstanceVariable.new( location: loc, closure: Pin::Namespace.new(type: :module, closure: region.closure.closure, name: region.closure.name), name: node.children[0].to_s, 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/masgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/masgn_node.rb index dbef1e2d7..b5a7805d9 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/masgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/masgn_node.rb @@ -37,8 +37,10 @@ def process pin = if lhs.type == :lvasgn # lvasgn is a local variable locals.find { |l| l.location == location } + elsif lhs.type == :ivasgn + # ivasgn is an instance variable assignment + ivars.find { |iv| iv.location == location } else - # e.g., ivasgn is an instance variable, etc pins.find { |iv| iv.location == location && iv.is_a?(Pin::BaseVariable) } end # @todo in line below, nothing in typechecking alerts diff --git a/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb index 0e4d7b26a..ab23ffa0e 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/opasgn_node.rb @@ -13,8 +13,10 @@ def process operator = node.children[1] argument = node.children[2] if target.type == :send + # @sg-ignore Need a downcast here process_send_target(target, operator, argument) elsif target.type.to_s.end_with?('vasgn') + # @sg-ignore Need a downcast here process_vasgn_target(target, operator, argument) else Solargraph.assert_or_log(:opasgn_unknown_target, @@ -55,7 +57,7 @@ def process_send_target call, operator, argument [callee, asgn_method, node.updated(:send, [call, operator, argument])]) - NodeProcessor.process(new_send, region, pins, locals) + NodeProcessor.process(new_send, region, pins, locals, ivars) end # @param asgn [Parser::AST::Node] the target of the assignment @@ -87,7 +89,7 @@ def process_vasgn_target asgn, operator, argument ] send_node = node.updated(:send, send_children) new_asgn = node.updated(asgn.type, [variable_name, send_node]) - NodeProcessor.process(new_asgn, region, pins, locals) + NodeProcessor.process(new_asgn, region, pins, locals, ivars) end end end 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..6c54f1c8c --- /dev/null +++ b/lib/solargraph/parser/parser_gem/node_processors/or_node.rb @@ -0,0 +1,22 @@ +# 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, + ivars, + 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/orasgn_node.rb b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb index 105b78828..17480adfb 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/orasgn_node.rb @@ -8,7 +8,7 @@ class OrasgnNode < Parser::NodeProcessor::Base # @return [void] def process new_node = node.updated(node.children[0].type, node.children[0].children + [node.children[1]]) - NodeProcessor.process(new_node, region, pins, locals) + NodeProcessor.process(new_node, region, pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb b/lib/solargraph/parser/parser_gem/node_processors/resbody_node.rb index 21e32bd22..24846748f 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? @@ -29,7 +30,7 @@ def process source: :parser ) end - NodeProcessor.process(node.children[2], region, pins, locals) + NodeProcessor.process(node.children[2], region, pins, locals, ivars) end end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb b/lib/solargraph/parser/parser_gem/node_processors/sclass_node.rb index 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 645baf00f..86c762c98 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] @@ -36,15 +37,12 @@ def process process_autoload elsif method_name == :private_constant process_private_constant - # @sg-ignore elsif method_name == :alias_method && node.children[2] && node.children[2] && node.children[2].type == :sym && node.children[3] && node.children[3].type == :sym process_alias_method - # @sg-ignore elsif method_name == :private_class_method && node.children[2].is_a?(AST::Node) # Processing a private class can potentially handle children on its own return if process_private_class_method end - # @sg-ignore elsif method_name == :require && node.children[0].to_s == '(const nil :Bundler)' pins.push Pin::Reference::Require.new(Solargraph::Location.new(region.filename, Solargraph::Range.from_to(0, 0, 0, 0)), 'bundler/require', source: :parser) end @@ -56,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] @@ -85,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 @@ -125,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( @@ -141,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), @@ -154,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 @@ -196,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] @@ -224,9 +228,9 @@ def process_module_function node: ref.node, source: :parser) pins.push mm, cm - pins.select{|pin| pin.is_a?(Pin::InstanceVariable) && pin.closure.path == ref.path}.each do |ivar| - pins.delete ivar - pins.push Solargraph::Pin::InstanceVariable.new( + ivars.select{|pin| pin.is_a?(Pin::InstanceVariable) && pin.closure.path == ref.path}.each do |ivar| + ivars.delete ivar + ivars.push Solargraph::Pin::InstanceVariable.new( location: ivar.location, closure: cm, name: ivar.name, @@ -234,7 +238,7 @@ def process_module_function assignment: ivar.assignment, source: :parser ) - pins.push Solargraph::Pin::InstanceVariable.new( + ivars.push Solargraph::Pin::InstanceVariable.new( location: ivar.location, closure: mm, name: ivar.name, @@ -246,7 +250,7 @@ def process_module_function end end elsif node.children[2].type == :def - NodeProcessor.process node.children[2], region.update(visibility: :module_function), pins, locals + NodeProcessor.process node.children[2], region.update(visibility: :module_function), pins, locals, ivars end end diff --git a/lib/solargraph/parser/parser_gem/node_processors/when_node.rb b/lib/solargraph/parser/parser_gem/node_processors/when_node.rb 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..38ccc53b5 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,11 @@ class WhileNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods def process - location = get_node_location(node) + FlowSensitiveTyping.new(locals, + ivars, + 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..ff88bada2 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,7 +29,6 @@ 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 @@ -42,6 +40,14 @@ def filename source.filename end + # @return [Pin::Namespace, nil] + def namespace_pin + ns = closure + # @sg-ignore flow sensitive typing needs to handle while + ns = ns.closure while ns && !ns.is_a?(Pin::Namespace) + ns + end + # Generate a new Region with the provided attribute changes. # # @param closure [Pin::Closure, nil] 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 511c7deb7..231ee37a6 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,6 +219,7 @@ 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/') @@ -213,7 +230,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 +268,7 @@ def prefer_rbs_location(other, attr) end end + # @sg-ignore need boolish support for ? methods def rbs_location? type_location&.rbs? end @@ -259,6 +278,7 @@ def rbs_location? def assert_same_macros(other) return unless self.source == :yardoc && other.source == :yardoc assert_same_count(other, :macros) + # @param [YARD::Tags::MacroDirective] assert_same_array_content(other, :macros) { |macro| macro.tag.name } end @@ -308,7 +328,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 @@ -357,8 +381,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 @@ -375,7 +402,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] @@ -417,6 +444,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 @@ -452,11 +480,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 @@ -466,6 +499,7 @@ def nearly? other # @param other [Object] def == other return false unless nearly? other + # @sg-ignore Should add more explicit type check on other comments == other.comments && location == other.location end @@ -482,6 +516,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 @@ -518,7 +553,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 @@ -526,16 +561,16 @@ 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." + Solargraph.assert_or_log(:pin_infer, 'WARNING: Pin #infer methods are deprecated. Use #typify or #probe instead.') type = typify(api_map) return type unless type.undefined? probe api_map @@ -566,7 +601,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 @@ -616,7 +651,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 @@ -644,10 +679,6 @@ def all_location_text end end - # @return [void] - def reset_generated! - end - protected # @return [Boolean] @@ -656,7 +687,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 764c1fb39..661e5a16c 100644 --- a/lib/solargraph/pin/base_variable.rb +++ b/lib/solargraph/pin/base_variable.rb @@ -6,28 +6,113 @@ 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] - def initialize assignment: nil, return_type: nil, **splat + # @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 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 mass_assignment [Array(Parser::AST::Node, Integer), nil] + # @param presence [Range, nil] + def initialize assignment: nil, assignments: [], mass_assignment: nil, return_type: nil, + intersection_return_type: nil, exclude_return_type: nil, + presence: nil, **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 + end + + def reset_generated! + @assignment = nil + super + 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 combine_with(other, attrs={}) - attrs.merge({ - assignment: assert_same(other, :assignment), - mass_assignment: assert_same(other, :mass_assignment), + new_assignments = combine_assignments(other) + new_attrs = attrs.merge({ + 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) }) - super(other, attrs) + super(other, new_attrs) + 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 + + # @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 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 def completion_item_kind @@ -39,10 +124,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? @@ -66,10 +147,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 @@ -78,13 +161,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) @@ -95,7 +180,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 @@ -104,6 +192,7 @@ def probe api_map # @param other [Object] def == other return false unless super + # @sg-ignore Should add type check on other assignment == other.assignment end @@ -111,13 +200,190 @@ 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 + + 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 227bc0873..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,13 +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 @@ -66,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 @@ -85,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 207c2619b..4eed1bdd6 100644 --- a/lib/solargraph/pin/callable.rb +++ b/lib/solargraph/pin/callable.rb @@ -21,8 +21,15 @@ def initialize block: nil, return_type: nil, parameters: [], **splat @parameters = parameters end + def reset_generated! + parameters.each(&:reset_generated!) + super + 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 @@ -67,6 +74,8 @@ def generics # @return [Array] def choose_parameters(other) raise "Trying to combine two pins with different arities - \nself =#{inspect}, \nother=#{other.inspect}, \n\n self.arity=#{self.arity}, \nother.arity=#{other.arity}" if other.arity != arity + # @param param [Pin::Parameter] + # @param other_param [Pin::Parameter] parameters.zip(other.parameters).map do |param, other_param| if param.nil? && other_param.block? other_param @@ -78,6 +87,7 @@ def choose_parameters(other) end end + # @sg-ignore Need to add nil check here # @return [Array] def blockless_parameters if parameters.last&.block? @@ -135,9 +145,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 @@ -182,7 +194,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] @@ -214,6 +225,7 @@ def mandatory_positional_param_count end def to_rbs + # @sg-ignore Need to add nil check here rbs_generics + '(' + parameters.map { |param| param.to_rbs }.join(', ') + ') ' + (block.nil? ? '' : '{ ' + block.to_rbs + ' } ') + '-> ' + return_type.to_rbs end diff --git a/lib/solargraph/pin/closure.rb b/lib/solargraph/pin/closure.rb index a7b37e01b..44265dcab 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,10 +44,6 @@ def context end end - def binder - @binder || context - end - # @param api_map [Solargraph::ApiMap] # @return [void] def rebind api_map; end diff --git a/lib/solargraph/pin/common.rb b/lib/solargraph/pin/common.rb index 062099ee4..fc607e413 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] + # @sg-ignore Need to be able to do @type with a variable name + # @return [ComplexType, ComplexType::UniqueType] 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 9483fb058..917e3a4e6 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 @@ -51,7 +52,6 @@ def type_location %i[typify realize infer probe].each do |method| # @param api_map [ApiMap] define_method(method) do |api_map| - # @sg-ignore Unresolved call to resolve_method resolve_method(api_map) # @sg-ignore Need to set context correctly in define_method blocks @resolved_method ? @resolved_method.send(method, api_map) : super(api_map) @@ -70,30 +70,40 @@ def resolvable?(api_map) # # @param api_map [ApiMap] # @return [Pin::Method, nil] + # @sg-ignore Declared return type ::Solargraph::Pin::Method, nil + # does not match inferred type nil, false for + # Solargraph::Pin::DelegatedMethod#resolve_method 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/keyword.rb b/lib/solargraph/pin/keyword.rb index 089d0a417..08ea1c6e0 100644 --- a/lib/solargraph/pin/keyword.rb +++ b/lib/solargraph/pin/keyword.rb @@ -11,10 +11,6 @@ def initialize(name, **kwargs) def closure @closure ||= Pin::ROOT_PIN end - - def name - @name - end end end end diff --git a/lib/solargraph/pin/local_variable.rb b/lib/solargraph/pin/local_variable.rb index 9eae6cc6f..f0547703a 100644 --- a/lib/solargraph/pin/local_variable.rb +++ b/lib/solargraph/pin/local_variable.rb @@ -3,76 +3,16 @@ module Solargraph module Pin class LocalVariable < BaseVariable - # @return [Range] - attr_reader :presence - - def presence_certain? - @presence_certain - 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 - end - def combine_with(other, attrs={}) - new_attrs = { - assignment: assert_same(other, :assignment), - presence_certain: assert_same(other, :presence_certain?), - }.merge(attrs) - # @sg-ignore Wrong argument type for - # Solargraph::Pin::Base#assert_same: other expected - # Solargraph::Pin::Base, received self - new_attrs[:presence] = assert_same(other, :presence) unless attrs.key?(:presence) - - super(other, new_attrs) - end + # keep this as a parameter + return other.combine_with(self, attrs) if other.is_a?(Parameter) && !self.is_a?(Parameter) - # @param other_closure [Pin::Closure] - # @param other_loc [Location] - def visible_at?(other_closure, other_loc) - location.filename == other_loc.filename && - presence.include?(other_loc.range.start) && - match_named_closure(other_closure, closure) + super end def to_rbs (name || '(anon)') + ' ' + (return_type&.to_rbs || 'untyped') end - - 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 - end - false - end end end end diff --git a/lib/solargraph/pin/method.rb b/lib/solargraph/pin/method.rb index 011f096f6..fda4cf541 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,16 +33,21 @@ def initialize visibility: :public, explicit: true, block: :undefined, node: nil @attribute = attribute @signatures = signatures @anon_splat = anon_splat + @context = context if context 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) @@ -65,7 +71,9 @@ def combine_visibility(other) # @param other [Pin::Method] # @return [Array] def combine_signatures(other) + # @sg-ignore Need to add nil check here all_undefined = signatures.all? { |sig| sig.return_type.undefined? } + # @sg-ignore Need to add nil check here other_all_undefined = other.signatures.all? { |sig| sig.return_type.undefined? } if all_undefined && !other_all_undefined other.signatures @@ -155,6 +163,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 @@ -174,9 +184,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) @@ -197,6 +208,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 @@ -242,6 +254,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? @@ -271,6 +284,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)) @@ -289,6 +303,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? @@ -298,6 +313,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 @@ -376,11 +392,14 @@ def attribute? @attribute end - # @parm other [Method] + # @parm other [self] def nearly? other super && + # @sg-ignore https://github.com/castwide/solargraph/pull/1050 parameters == other.parameters && + # @sg-ignore https://github.com/castwide/solargraph/pull/1050 scope == other.scope && + # @sg-ignore https://github.com/castwide/solargraph/pull/1050 visibility == other.visibility end @@ -388,13 +407,16 @@ 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. + + # @param tag [YARD::Tags::OverloadTag] @overloads ||= docstring.tags(:overload).select(&:parameters).map do |tag| Pin::Signature.new( generics: generics, + # @param src [Array(String, String)] parameters: tag.parameters.map do |src| name, decl = parse_overload_param(src.first) Pin::Parameter.new( @@ -403,6 +425,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 @@ -457,10 +480,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::') || @@ -507,6 +532,7 @@ def clean_param name # # @return [ComplexType] def param_type_from_name(tag, name) + # @param t [YARD::Tags::Tag] param = tag.tags(:param).select { |t| t.name == name }.first return ComplexType::UNDEFINED unless param ComplexType.try_parse(*param.types) @@ -520,15 +546,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, 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 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 @@ -543,6 +574,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 @@ -550,7 +582,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? @@ -558,6 +590,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) @@ -593,9 +626,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/method_alias.rb b/lib/solargraph/pin/method_alias.rb index 28be6d8b1..feb6baccc 100644 --- a/lib/solargraph/pin/method_alias.rb +++ b/lib/solargraph/pin/method_alias.rb @@ -26,6 +26,14 @@ def visibility :public end + def to_rbs + if scope == :class + "alias self.#{name} self.#{original}" + else + "alias #{name} #{original}" + end + end + def path @path ||= namespace + (scope == :instance ? '#' : '.') + name end 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 947513689..97465da34 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 @@ -80,6 +98,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 +161,19 @@ def full end end + def reset_generated! + @return_type = nil if param_tag + super + 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 Unresolved call to undefined? on nil 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,10 +286,15 @@ 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 + # @param ref [YARD::Tags::Tag, Solargraph::Yard::Tags::RefTag] heredoc.ref_tags.each do |ref| + # @sg-ignore ref should actually be an intersection type next unless ref.tag_name == 'param' && ref.owner + # @todo ref should actually be an intersection type result = resolve_reference(ref.owner.to_s, api_map, skip) return result unless result.nil? end @@ -253,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 2323489a7..f3a9ae878 100644 --- a/lib/solargraph/pin/proxy_type.rb +++ b/lib/solargraph/pin/proxy_type.rb @@ -3,10 +3,12 @@ module Solargraph module Pin class ProxyType < Base - # @param return_type [ComplexType] + # @param return_type [ComplexType, ComplexType::UniqueType] # @param binder [ComplexType, ComplexType::UniqueType, nil] - def initialize return_type: ComplexType::UNDEFINED, binder: nil, **splat + # @param gates [Array, nil] Namespaces to try while resolving non-rooted types + def initialize return_type: ComplexType::UNDEFINED, binder: nil, gates: nil, **splat super(**splat) + @gates = gates @return_type = return_type @binder = binder if binder end @@ -22,6 +24,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 d678ab7b7..13e603d6e 100644 --- a/lib/solargraph/pin/reference.rb +++ b/lib/solargraph/pin/reference.rb @@ -18,18 +18,9 @@ def initialize generic_values: [], **splat @generic_values = generic_values end - # @return [String] - def parameter_tag - @parameter_tag ||= if generic_values&.any? - "<#{generic_values.join(', ')}>" - else - '' - end - end - # @return [ComplexType] - def parametrized_tag - @parametrized_tag ||= ComplexType.try_parse( + def type + @type ||= ComplexType.try_parse( name + if generic_values&.length&.> 0 "<#{generic_values.join(', ')}>" @@ -39,8 +30,10 @@ def parametrized_tag ) 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 f92978a35..67bd74d24 100644 --- a/lib/solargraph/pin/search.rb +++ b/lib/solargraph/pin/search.rb @@ -42,12 +42,16 @@ def do_query Result.new(match, pin) if match > 0.7 end .compact + # @param a [self] + # @param b [self] + # @sg-ignore https://github.com/castwide/solargraph/pull/1050 .sort { |a, b| b.match <=> a.match } .map(&:pin) end # @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..67aee1af4 100644 --- a/lib/solargraph/pin_cache.rb +++ b/lib/solargraph/pin_cache.rb @@ -164,18 +164,20 @@ def has_rbs_collection?(gemspec, hash) exist?(rbs_collection_path(gemspec, hash)) end + # @param out [StringIO, IO, nil] # @return [void] - def uncache_core - uncache(core_path) + def uncache_core out: nil + uncache(core_path, out: out) end + # @param out [StringIO, IO, nil] # @return [void] - def uncache_stdlib - uncache(stdlib_path) + def uncache_stdlib out: nil + uncache(stdlib_path, out: out) 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) @@ -189,9 +191,45 @@ def clear FileUtils.rm_rf base_dir, secure: true end + def core? + File.file?(core_path) + end + + # @param out [StringIO, IO, nil] + # @return [Enumerable] + def cache_core out: nil + RbsMap::CoreMap.new.cache_core(out: out) + end + + # @param out [StringIO, IO, nil] output stream for logging + # + # @return [void] + def cache_all_stdlibs out: $stderr + possible_stdlibs.each do |stdlib| + RbsMap::StdlibMap.new(stdlib, out: out) + end + end + + # @return [Array] a list of possible standard library names + def possible_stdlibs + # all dirs and .rb files in Gem::RUBYGEMS_DIR + Dir.glob(File.join(Gem::RUBYGEMS_DIR, '*')).map do |file_or_dir| + basename = File.basename(file_or_dir) + # remove .rb + # @sg-ignore Need better post-if scoping in flow sensitive typing + basename = basename[0..-4] if basename.end_with?('.rb') + basename + end.sort.uniq + rescue StandardError => e + logger.info { "Failed to get possible stdlibs: #{e.message}" } + logger.debug { e.backtrace&.join("\n") } + [] + end + private # @param 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 +257,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 +268,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 2faa0a99b..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 diff --git a/lib/solargraph/range.rb b/lib/solargraph/range.rb index 7a9bc0e30..fed97c7e7 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 @@ -51,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 @@ -60,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 diff --git a/lib/solargraph/rbs_map.rb b/lib/solargraph/rbs_map.rb index 803e3677a..ebd9f6226 100644 --- a/lib/solargraph/rbs_map.rb +++ b/lib/solargraph/rbs_map.rb @@ -26,7 +26,8 @@ class RbsMap # @param version [String, nil] # @param rbs_collection_config_path [String, Pathname, nil] # @param rbs_collection_paths [Array] - def initialize library, version = nil, rbs_collection_config_path: nil, rbs_collection_paths: [] + # @param out [IO, nil] where to log messages + def initialize library, version = nil, rbs_collection_config_path: nil, rbs_collection_paths: [], out: $stderr if rbs_collection_config_path.nil? && !rbs_collection_paths.empty? raise 'Please provide rbs_collection_config_path if you provide rbs_collection_paths' end @@ -37,6 +38,11 @@ def initialize library, version = nil, rbs_collection_config_path: nil, rbs_coll add_library loader, library, version end + CACHE_KEY_GEM_EXPORT = 'gem-export' + CACHE_KEY_UNRESOLVED = 'unresolved' + CACHE_KEY_STDLIB = 'stdlib' + CACHE_KEY_LOCAL = 'local' + # @return [RBS::EnvironmentLoader] def loader @loader ||= RBS::EnvironmentLoader.new(core_root: nil, repository: repository) @@ -47,10 +53,15 @@ def loader # updated upstream for the same library and version. May change # if the config for where information comes form changes. def cache_key + return CACHE_KEY_UNRESOLVED unless resolved? + @hextdigest ||= begin # @type [String, nil] data = nil + # @type gem_config [nil, Hash{String => Hash{String => String}}] + gem_config = nil if rbs_collection_config_path + # @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 @@ -58,16 +69,22 @@ def cache_key data = gem_config&.to_s end end - if data.nil? || data.empty? - if resolved? - # definitely came from the gem itself and not elsewhere - - # only one version per gem - 'gem-export' + if gem_config.nil? + CACHE_KEY_STDLIB + else + # @type [String] + source = gem_config.dig('source', 'type') + case source + when 'rubygems' + CACHE_KEY_GEM_EXPORT + when 'local' + CACHE_KEY_LOCAL + when 'stdlib' + CACHE_KEY_STDLIB else - 'unresolved' + # @sg-ignore Need to add nil check here + Digest::SHA1.hexdigest(data) end - else - Digest::SHA1.hexdigest(data) end end end @@ -88,14 +105,23 @@ def self.from_gemspec gemspec, rbs_collection_path, rbs_collection_config_path rbs_collection_config_path: rbs_collection_config_path) end + # @param out [IO, nil] where to log messages # @return [Array] - def pins - @pins ||= resolved? ? conversions.pins : [] + def pins out: $stderr + @pins ||= if resolved? + loader.libs.each { |lib| log_caching(lib, out: out) } + conversions.pins + else + [] + end end # @generic T # @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 } @@ -140,11 +166,17 @@ def conversions @conversions ||= Conversions.new(loader: loader) end + # @param lib [RBS::EnvironmentLoader::Library] + # @param out [IO, nil] where to log messages + # @return [void] + def log_caching lib, out:; end + # @param loader [RBS::EnvironmentLoader] # @param library [String] - # @param version [String, nil] + # @param version [String, nil] the version of the library to load, or nil for any + # @param out [IO, nil] where to log messages # @return [Boolean] true if adding the library succeeded - def add_library loader, library, version + def add_library loader, library, version, out: $stderr @resolved = if loader.has_library?(library: library, version: version) loader.add library: library, version: version logger.debug { "#{short_name} successfully loaded library #{library}:#{version}" } diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index 3e777f726..e86a6236d 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -65,6 +65,7 @@ def convert_decl_to_pin decl, closure # STDERR.puts "Skipping interface #{decl.name.relative!}" interface_decl_to_pin decl, closure when RBS::AST::Declarations::TypeAlias + # @sg-ignore https://github.com/castwide/solargraph/pull/1114 type_aliases[decl.name.to_s] = decl when RBS::AST::Declarations::Module module_decl_to_pin decl @@ -95,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, @@ -119,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}" @@ -231,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 # @@ -245,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 @@ -346,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] @@ -426,12 +441,14 @@ def method_def_to_pin decl, closure, context # @param pin [Pin::Method] # @return [void] def method_def_to_sigs decl, pin + # @param overload [RBS::AST::Members::MethodDefinition::Overload] decl.overloads.map do |overload| 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 @@ -445,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 @@ -466,12 +486,16 @@ def parts_of_function type, pin parameters = [] arg_num = -1 type.type.required_positionals.each do |param| + # @sg-ignore RBS generic type understanding issue name = param.name ? param.name.to_s : "arg_#{arg_num += 1}" + # @sg-ignore RBS generic type understanding issue parameters.push Solargraph::Pin::Parameter.new(decl: :arg, name: name, closure: pin, return_type: ComplexType.try_parse(other_type_to_tag(param.type)).force_rooted, source: :rbs, type_location: type_location) end type.type.optional_positionals.each do |param| + # @sg-ignore RBS generic type understanding issue name = param.name ? param.name.to_s : "arg_#{arg_num += 1}" parameters.push Solargraph::Pin::Parameter.new(decl: :optarg, name: name, closure: pin, + # @sg-ignore RBS generic type understanding issue return_type: ComplexType.try_parse(other_type_to_tag(param.type)).force_rooted, type_location: type_location, source: :rbs) @@ -489,18 +513,23 @@ def parts_of_function type, pin return_type: rest_positional_type,) end type.type.trailing_positionals.each do |param| + # @sg-ignore RBS generic type understanding issue name = param.name ? param.name.to_s : "arg_#{arg_num += 1}" parameters.push Solargraph::Pin::Parameter.new(decl: :arg, name: name, closure: pin, source: :rbs, type_location: type_location) end type.type.required_keywords.each do |orig, param| + # @sg-ignore RBS generic type understanding issue name = orig ? orig.to_s : "arg_#{arg_num += 1}" parameters.push Solargraph::Pin::Parameter.new(decl: :kwarg, name: name, closure: pin, + # @sg-ignore RBS generic type understanding issue return_type: ComplexType.try_parse(other_type_to_tag(param.type)).force_rooted, source: :rbs, type_location: type_location) end type.type.optional_keywords.each do |orig, param| + # @sg-ignore RBS generic type understanding issue name = orig ? orig.to_s : "arg_#{arg_num += 1}" parameters.push Solargraph::Pin::Parameter.new(decl: :kwoptarg, name: name, closure: pin, + # @sg-ignore RBS generic type understanding issue return_type: ComplexType.try_parse(other_type_to_tag(param.type)).force_rooted, type_location: type_location, source: :rbs) @@ -694,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) @@ -726,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) @@ -783,16 +814,19 @@ def other_type_to_tag type # e.g., singleton(String) type_tag(type.name) else + # @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] decl.each_mixin do |mixin| + # @todo are we handling prepend correctly? klass = mixin.is_a?(RBS::AST::Members::Include) ? Pin::Reference::Include : Pin::Reference::Extend type = build_type(mixin.name, mixin.args) generic_values = type.all_params.map(&:to_s) diff --git a/lib/solargraph/rbs_map/core_map.rb b/lib/solargraph/rbs_map/core_map.rb index d2836ffe3..884092963 100644 --- a/lib/solargraph/rbs_map/core_map.rb +++ b/lib/solargraph/rbs_map/core_map.rb @@ -15,10 +15,10 @@ def resolved? def initialize; end + # @param out [StringIO, IO, nil] output stream for logging # @return [Enumerable] - def pins + def pins out: $stderr return @pins if @pins - @pins = [] cache = PinCache.deserialize_core if cache @@ -29,12 +29,14 @@ def pins # Avoid RBS::DuplicatedDeclarationError by loading in a different EnvironmentLoader fill_loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) fill_loader.add(path: Pathname(FILLS_DIRECTORY)) + out&.puts 'Caching RBS pins for Ruby core' fill_conversions = Conversions.new(loader: fill_loader) @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 @@ -42,6 +44,12 @@ def pins @pins end + # @param out [StringIO, IO, nil] output stream for logging + # @return [Enumerable] + def cache_core out: $stderr + pins out: out + end + private # @return [RBS::EnvironmentLoader] diff --git a/lib/solargraph/rbs_map/stdlib_map.rb b/lib/solargraph/rbs_map/stdlib_map.rb index b6804157f..a90dd5158 100644 --- a/lib/solargraph/rbs_map/stdlib_map.rb +++ b/lib/solargraph/rbs_map/stdlib_map.rb @@ -12,8 +12,13 @@ class StdlibMap < RbsMap # @type [Hash{String => RbsMap}] @stdlib_maps_hash = {} + def log_caching lib, out: $stderr + out&.puts("Caching RBS pins for standard library #{lib.name}") + end + # @param library [String] - def initialize library + # @param out [StringIO, IO, nil] where to log messages + def initialize library, out: $stderr cached_pins = PinCache.deserialize_stdlib_require library if cached_pins @pins = cached_pins @@ -24,7 +29,7 @@ def initialize library super unless resolved? @pins = [] - logger.info { "Could not resolve #{library.inspect}" } + logger.debug { "StdlibMap could not resolve #{library.inspect}" } return end generated_pins = pins @@ -33,6 +38,25 @@ def initialize library end end + # @return [RBS::Collection::Sources::Stdlib] + def self.source + @source ||= RBS::Collection::Sources::Stdlib.instance + end + + # @param name [String] + # @param version [String, nil] + # @return [Array String}>, nil] + def self.stdlib_dependencies name, version = nil + if source.has?(name, version) + # @sg-ignore Wrong argument type for + # RBS::Collection::Sources::Base#dependencies_of: version + # expected String, received String, nil + source.dependencies_of(name, version) + else + [] + end + end + # @param library [String] # @return [StdlibMap] def self.load library diff --git a/lib/solargraph/shell.rb b/lib/solargraph/shell.rb index a005f600b..5a01d429f 100755 --- a/lib/solargraph/shell.rb +++ b/lib/solargraph/shell.rb @@ -15,7 +15,7 @@ def self.exit_on_failure? map %w[--version -v] => :version - desc "--version, -v", "Print the version" + desc '--version, -v', 'Print the version' # @return [void] def version puts Solargraph::VERSION @@ -79,6 +79,7 @@ def config(directory = '.') conf['extensions'].push m end end + # @param file [File] File.open(File.join(directory, '.solargraph.yml'), 'w') do |file| file.puts conf.to_yaml end @@ -103,12 +104,64 @@ def clear # @param gem [String] # @param version [String, nil] def cache gem, version = nil + gems(gem + (version ? "=#{version}" : '')) + # ' + end + + desc 'gems [GEM[=VERSION]...] [STDLIB...] [core]', 'Cache documentation for + installed libraries' + long_desc %( This command will cache the + generated type documentation for the specified libraries. While + Solargraph will generate this on the fly when needed, it takes + time. This command will generate it in advance, which can be + useful for CI scenarios. + + With no arguments, it will cache all libraries in the current + workspace. If a gem or standard library name is specified, it + will cache that library's type documentation. + + An equals sign after a gem will allow a specific gem version + to be cached. + + The 'core' argument can be used to cache the type + documentation for the core Ruby libraries. + + If the library is already cached, it will be rebuilt if the + --rebuild option is set. + + Cached documentation is stored in #{PinCache.base_dir}, which + can be stored between CI runs. + ) + option :rebuild, type: :boolean, desc: 'Rebuild existing documentation', default: false + # @param names [Array] + # @return [void] + def gems *names + # print time with ms + workspace = Solargraph::Workspace.new('.') + api_map = Solargraph::ApiMap.load(Dir.pwd) - spec = Gem::Specification.find_by_name(gem, version) - api_map.cache_gem(spec, rebuild: options[:rebuild], out: $stdout) + if names.empty? + api_map.cache_all!($stdout, rebuild: options[:rebuild]) + else + $stderr.puts("Caching these gems: #{names}") + names.each do |name| + if name == 'core' + PinCache.cache_core(out: $stdout) if !PinCache.core? || options[:rebuild] + next + end + + gemspec = workspace.find_gem(*name.split('=')) + if gemspec.nil? + warn "Gem '#{name}' not found" + else + api_map.cache_gem(gemspec, rebuild: options[:rebuild], out: $stdout) + end + end + $stderr.puts "Documentation cached for #{names.count} gems." + end end - desc 'uncache GEM [...GEM]', "Delete specific cached gem documentation" + desc 'uncache GEM [...GEM]', 'Delete specific cached gem documentation' long_desc %( Specify one or more gem names to clear. 'core' or 'stdlib' may also be specified to clear cached system documentation. @@ -120,12 +173,12 @@ def uncache *gems raise ArgumentError, 'No gems specified.' if gems.empty? gems.each do |gem| if gem == 'core' - PinCache.uncache_core + PinCache.uncache_core(out: $stdout) next end if gem == 'stdlib' - PinCache.uncache_stdlib + PinCache.uncache_stdlib(out: $stdout) next end @@ -134,26 +187,6 @@ def uncache *gems end end - desc 'gems [GEM[=VERSION]]', 'Cache documentation for installed gems' - option :rebuild, type: :boolean, desc: 'Rebuild existing documentation', default: false - # @param names [Array] - # @return [void] - def gems *names - api_map = ApiMap.load('.') - if names.empty? - Gem::Specification.to_a.each { |spec| do_cache spec, api_map } - STDERR.puts "Documentation cached for all #{Gem::Specification.count} gems." - else - names.each do |name| - spec = Gem::Specification.find_by_name(*name.split('=')) - do_cache spec, api_map - rescue Gem::MissingSpecError - warn "Gem '#{name}' not found" - end - STDERR.puts "Documentation cached for #{names.count} gems." - end - end - desc 'reporters', 'Get a list of diagnostics reporters' # @return [void] def reporters @@ -172,7 +205,12 @@ def reporters # @return [void] def typecheck *files directory = File.realpath(options[:directory]) - api_map = Solargraph::ApiMap.load_with_cache(directory, $stdout) + workspace = Solargraph::Workspace.new(directory) + level = options[:level].to_sym + rules = workspace.rules(level) + 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) @@ -180,10 +218,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) + checker = TypeChecker.new(file, api_map: api_map, 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 } @@ -191,7 +228,6 @@ def typecheck *files filecount += 1 probcount += problems.length end - # " } puts "Typecheck finished in #{time.real} seconds." puts "#{probcount} problem#{probcount != 1 ? 's' : ''} found#{files.length != 1 ? " in #{filecount} of #{files.length} files" : ''}." @@ -215,19 +251,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 @@ -241,13 +283,92 @@ def list puts "#{workspace.filenames.length} files total." end + desc 'pin [PATH]', 'Describe a pin', hide: true + option :rbs, type: :boolean, desc: 'Output the pin as RBS', default: false + option :typify, type: :boolean, desc: 'Output the calculated return type of the pin from annotations', default: false + option :references, type: :boolean, desc: 'Show references', default: false + option :probe, type: :boolean, desc: 'Output the calculated return type of the pin from annotations and inference', default: false + option :stack, type: :boolean, desc: 'Show entire stack of a method pin by including definitions in superclasses', default: false + # @param path [String] The path to the method pin, e.g. 'Class#method' or 'Class.method' + # @return [void] + def pin path + api_map = Solargraph::ApiMap.load_with_cache('.', $stderr) + is_method = path.include?('#') || path.include?('.') + if is_method && options[:stack] + scope, ns, meth = if path.include? '#' + [:instance, *path.split('#', 2)] + else + [:class, *path.split('.', 2)] + end + + # @sg-ignore Wrong argument type for + # Solargraph::ApiMap#get_method_stack: rooted_tag + # expected String, received Array + pins = api_map.get_method_stack(ns, meth, scope: scope) + else + pins = api_map.get_path_pins path + end + # @type [Hash{String, Symbol => Pin::Base}] + references = {} + pin = pins.first + case pin + when nil + $stderr.puts "Pin not found for path '#{path}'" + exit 1 + when Pin::Namespace + if options[:references] + superclass_tag = api_map.qualify_superclass(pin.return_type.tag) + superclass_pin = api_map.get_path_pins(superclass_tag).first if superclass_tag + references[:superclass] = superclass_pin if superclass_pin + end + when Pin::Method + if options[:references] + # @param sig [Solargraph::Pin::Callable] + ([pin] + pin.signatures).compact.each do |sig| + sig.parameters.each do |param| + references[param.name] = param + end + if sig.block + references[:block] = sig.block + end + end + end + end + + pins.each do |pin| + present_pin(pin, api_map) + end + + # @param key [String, Symbol] + # @param refpin [Pin::Base] + references.each do |key, refpin| + puts "\n# #{key.to_s.capitalize}:\n\n" + present_pin(refpin, api_map) + end + end + private + # @param pin [Solargraph::Pin::Base] + # @param api_map [Solargraph::ApiMap] + # @return [void] + def present_pin pin, api_map + if options[:typify] || options[:probe] + type = ComplexType::UNDEFINED + type = pin.typify(api_map) if options[:typify] + type = pin.probe(api_map) if options[:probe] && type.undefined? + print_type(type) + else + print_pin(pin) + end + end + # @param pin [Solargraph::Pin::Base] # @return [String] 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}" @@ -255,6 +376,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 @@ -267,5 +389,25 @@ def do_cache gemspec, api_map # typecheck doesn't complain on the below line api_map.cache_gem(gemspec, rebuild: options.rebuild, out: $stdout) end + + # @param type [ComplexType, ComplexType::UniqueType] + # @return [void] + def print_type(type) + if options[:rbs] + puts type.to_rbs + else + puts type.rooted_tag + end + end + + # @param pin [Solargraph::Pin::Base] + # @return [void] + def print_pin(pin) + if options[:rbs] + puts pin.to_rbs + else + puts pin.inspect + end + end end end diff --git a/lib/solargraph/source.rb b/lib/solargraph/source.rb index ae5b08d3b..f3520db79 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 c08d04878..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 @@ -264,21 +268,22 @@ def infer_from_definitions pins, context, api_map, locals ComplexType::UNDEFINED elsif types.length > 1 # Move nil to the end by convention + + # @param a [ComplexType::UniqueType] sorted = types.flat_map(&:items).sort { |a, _| a.tag == 'nil' ? 1 : 0 } ComplexType.new(sorted.uniq) 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 24d10656d..28104bbe4 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] @@ -98,7 +110,11 @@ def inferred_pins pins, api_map, name_pin, locals match = ol.parameters.any?(&:restarg?) break end - atype = atypes[idx] ||= arg.infer(api_map, Pin::ProxyType.anonymous(name_pin.context, source: :chain), locals) + arg_name_pin = Pin::ProxyType.anonymous(name_pin.context, + closure: name_pin.closure, + gates: name_pin.gates, + source: :chain) + atype = atypes[idx] ||= arg.infer(api_map, arg_name_pin, locals) unless param.compatible_arg?(atype, api_map) || param.restarg? match = false break @@ -107,6 +123,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) + # @sg-ignore 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) @@ -116,6 +133,7 @@ def inferred_pins pins, api_map, name_pin, locals end # @type new_signature_pin [Pin::Signature] new_signature_pin = ol.resolve_generics_from_context_until_complete(ol.generics, atypes, nil, nil, blocktype) + # @sg-ignore Should handle redefinition of types in simple contexts new_return_type = new_signature_pin.return_type if head? # If we're at the head of the chain, we called a @@ -137,6 +155,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. + # @sg-ignore 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 @@ -146,9 +165,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 @@ -159,8 +180,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 @@ -168,7 +192,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 @@ -187,7 +211,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 @@ -203,21 +227,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? @@ -243,6 +270,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 @@ -266,14 +294,16 @@ def yield_pins api_map, name_pin method_pin = find_method_pin(name_pin) return [] unless method_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?('$') @@ -287,13 +317,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 @@ -301,6 +332,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 @@ -308,9 +340,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 @@ -322,10 +356,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 c14d00ddf..db0b2481c 100644 --- a/lib/solargraph/source/chain/if.rb +++ b/lib/solargraph/source/chain/if.rb @@ -8,13 +8,14 @@ def word '' end - # @param links [::Array] + # @param links [::Array] def initialize links @links = links 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 + [@links] end diff --git a/lib/solargraph/source/chain/instance_variable.rb b/lib/solargraph/source/chain/instance_variable.rb index ea09f5578..2cb1a0ef8 100644 --- a/lib/solargraph/source/chain/instance_variable.rb +++ b/lib/solargraph/source/chain/instance_variable.rb @@ -4,9 +4,30 @@ 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 + + # @sg-ignore Declared return type + # ::Array<::Solargraph::Pin::Base> does not match inferred + # type ::Array<::Solargraph::Pin::BaseVariable, ::NilClass> + # for Solargraph::Source::Chain::InstanceVariable#resolve 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} + ivars = api_map.get_instance_variable_pins(name_pin.context.namespace, name_pin.context.scope).select{|p| p.name == word} + out = api_map.var_at_location(ivars, word, name_pin, location) + [out].compact end + + private + + # @todo: Missed nil violation + # @return [Location] + attr_reader :location end end end diff --git a/lib/solargraph/source/chain/link.rb b/lib/solargraph/source/chain/link.rb index bcd9eb196..344f7affd 100644 --- a/lib/solargraph/source/chain/link.rb +++ b/lib/solargraph/source/chain/link.rb @@ -38,7 +38,7 @@ def constant? # @param api_map [ApiMap] # @param name_pin [Pin::Base] - # @param locals [::Enumerable] + # @param locals [::Array] # @return [::Array] def resolve api_map, name_pin, locals [] diff --git a/lib/solargraph/source/chain/literal.rb b/lib/solargraph/source/chain/literal.rb index 2e0d65c9e..03c6149c1 100644 --- a/lib/solargraph/source/chain/literal.rb +++ b/lib/solargraph/source/chain/literal.rb @@ -16,11 +16,15 @@ 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 @@ -31,6 +35,7 @@ def initialize type, node # @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 1e3a70f40..f7ff10347 100644 --- a/lib/solargraph/source/chain/or.rb +++ b/lib/solargraph/source/chain/or.rb @@ -8,14 +8,22 @@ def word '' end - # @param links [::Array] + attr_reader :links + + # @param links [::Array] def initialize links @links = links end 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..db6772595 100644 --- a/lib/solargraph/source/cursor.rb +++ b/lib/solargraph/source/cursor.rb @@ -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 d7b6fb4fc..241cc680f 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 @@ -41,11 +43,13 @@ def initialize source # solargraph-rails is known to use this method to get the document symbols. It should probably be removed. @document_symbols = nil self.convention_pins = conventions_environ.pins + # @type [Hash{Class => Array}] @pin_select_cache = {} end # @generic T # @param klass [Class>] + # # @return [Array>] def pins_by_class klass @pin_select_cache[klass] ||= pin_class_hash.select { |key, _| key <= klass }.values.flatten @@ -82,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| @@ -95,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) @@ -123,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 @@ -141,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 @@ -171,11 +176,12 @@ def map source private - # @return [Hash{Class => Array}] # @return [Array] attr_writer :convention_pins + # @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 @@ -189,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 @@ -200,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..c3caeafda 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 @@ -150,16 +152,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 +185,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 +209,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..7d2afa0c8 100644 --- a/lib/solargraph/source_map/data.rb +++ b/lib/solargraph/source_map/data.rb @@ -8,13 +8,15 @@ def initialize source @source = source end + # @sg-ignore Translate to something flow sensitive typing understands # @return [Array] 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 18fdf1f88..fcab36c15 100644 --- a/lib/solargraph/source_map/mapper.rb +++ b/lib/solargraph/source_map/mapper.rb @@ -24,6 +24,7 @@ def map source @code = source.code @comments = source.comments @pins, @locals = Parser.map(source) + # @param p [Solargraph::Pin::Base] @pins.each { |p| p.source = :code } @locals.each { |l| l.source = :code } process_comment_directives @@ -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 @@ -70,7 +72,6 @@ def closure_at(position) # @param comment [String] # @return [void] def process_comment source_position, comment_position, comment - # @sg-ignore Wrong argument type for String#=~: object expected String::_MatchAgainst, received Regexp return unless comment.encode('UTF-8', invalid: :replace, replace: '?') =~ DIRECTIVE_REGEXP cmnt = remove_inline_comment_hashes(comment) parse = Solargraph::Source.parse_docstring(cmnt) @@ -91,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 @@ -104,11 +107,14 @@ 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 + # @todo Missed nil violation + # @todo Need to add nil check here if namespace.location.range.start.line < comment_position.line namespace = closure_at(comment_position) end @@ -166,10 +172,13 @@ 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 + # @todo Missed nil violation + # @todo Need to add nil check here if closure.location.range.start.line < comment_position.line closure = closure_at(comment_position) end @@ -187,6 +196,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,7 +206,11 @@ def process_directive source_position, comment_position, directive else comment_position.line end - Parser.process_node(src.node, region, @pins) + locals = [] + ivars = [] + Parser.process_node(src.node, region, @pins, locals, ivars) + @pins.concat ivars + # @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) @@ -209,6 +223,7 @@ def process_directive source_position, comment_position, directive namespace = closure_at(source_position) || Pin::ROOT_PIN 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' @@ -218,7 +233,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 @@ -236,6 +253,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 @@ -245,11 +263,11 @@ def remove_inline_comment_hashes comment # @return [void] def process_comment_directives - # @sg-ignore Wrong argument type for String#=~: object expected String::_MatchAgainst, received Regexp return unless @code.encode('UTF-8', invalid: :replace, replace: '?') =~ DIRECTIVE_REGEXP 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 4600767b5..29f64fa06 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] @@ -21,14 +20,24 @@ class TypeChecker # @return [ApiMap] attr_reader :api_map - # @param filename [String] + # @param filename [String, nil] # @param api_map [ApiMap, nil] - # @param level [Symbol] - def initialize filename, api_map: nil, level: :normal + # @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, + workspace: filename ? Workspace.new(File.dirname(filename)) : nil, + rules: workspace ? workspace.rules(level) : Rules.new(level, {}) @filename = filename # @todo Smarter directory resolution - @api_map = api_map || Solargraph::ApiMap.load(File.dirname(filename)) - @rules = Rules.new(level) + @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 = [] end @@ -40,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] @@ -58,23 +100,31 @@ def problems class << self # @param filename [String] # @param level [Symbol] + # @param workspace [Workspace, nil] # @return [self] - def load filename, level = :normal + def load filename, level = :normal, workspace: nil source = Solargraph::Source.load(filename) - api_map = Solargraph::ApiMap.new + rules = workspace ? workspace.rules(level) : 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] + # @param workspace [Workspace, nil] # @return [self] - def load_string code, filename = nil, level = :normal + def load_string code, filename = nil, level = :normal, api_map: nil, workspace: nil source = Solargraph::Source.load_string(code, filename) - api_map = Solargraph::ApiMap.new + rules = workspace ? workspace.rules(level) : 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 @@ -98,6 +148,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) @@ -105,6 +156,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? @@ -118,7 +170,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 @@ -145,6 +197,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 @@ -192,10 +245,11 @@ 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? - if declared.defined? + if declared.defined? && pin.assignment if rules.validate_tags? inferred = pin.probe(api_map) if inferred.undefined? @@ -206,7 +260,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 @@ -216,7 +270,7 @@ def variable_type_tag_problems elsif !pin.is_a?(Pin::Parameter) && !resolved_constant?(pin) result.push Problem.new(pin.location, "Unresolved type #{pin.return_type} for variable #{pin.name}", pin: pin) end - else + elsif pin.assignment inferred = pin.probe(api_map) if inferred.undefined? && declared_externally?(pin) ignored_pins.push pin @@ -238,8 +292,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) @@ -256,34 +312,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}") @@ -294,6 +364,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 @@ -302,13 +373,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) @@ -331,6 +401,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) @@ -359,7 +431,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] @@ -420,7 +492,8 @@ def signature_argument_problems_for location, locals, closure_pin, params, argum # @todo Some level (strong, I guess) should require the param here else argtype = argchain.infer(api_map, closure_pin, locals) - if argtype.defined? && ptype.defined? && !any_types_match?(api_map, ptype, argtype) + argtype = argtype.self_to_type(closure_pin.context) + 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 @@ -435,13 +508,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] @@ -449,6 +522,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) @@ -458,10 +532,13 @@ 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? - argtype = argchain.infer(api_map, closure_pin, locals) - if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) + # @type [ComplexType] + argtype = argchain.infer(api_map, closure_pin, locals).self_to_type(closure_pin.context) + 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 @@ -485,9 +562,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) - if argtype.defined? && ptype && !any_types_match?(api_map, ptype, argtype) + argtype = argtype.self_to_type(closure_pin.context) + 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 @@ -538,6 +618,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 @@ -587,6 +668,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 @@ -604,25 +686,34 @@ def external? pin # @param pin [Pin::BaseVariable] def declared_externally? pin - return true if pin.assignment.nil? + 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 @@ -723,8 +814,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 @@ -734,19 +827,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 @@ -758,6 +857,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 a27fcbefa..1d749a53b 100644 --- a/lib/solargraph/type_checker/rules.rb +++ b/lib/solargraph/type_checker/rules.rb @@ -20,7 +20,8 @@ class Rules attr_reader :rank # @param level [Symbol] - def initialize level + # @param overrides [Hash{Symbol => Symbol}] + def initialize level, overrides @rank = if LEVELS.key?(level) LEVELS[level] else @@ -28,34 +29,100 @@ def initialize level 0 end @level = LEVELS[LEVELS.values.index(@rank)] + @overrides = overrides end def ignore_all_undefined? - rank < LEVELS[:strict] + !report_undefined? + end + + def report_undefined? + report?(:report_undefined, :strict) end def validate_consts? - rank >= LEVELS[:strict] + report?(:validate_consts, :strict) end def validate_calls? - rank >= LEVELS[:strict] + report?(:validate_calls, :strict) end def require_type_tags? - rank >= LEVELS[:strong] + report?(:validate_type_tags, :strong) end def must_tag_or_infer? - rank > LEVELS[:typed] + report?(:must_tag_or_infer, :strict) end def validate_tags? - rank > LEVELS[:normal] + report?(:validate_tags, :typed) + end + + 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 12: Should handle redefinition of types in simple contexts + # @todo 6: need boolish support for ? methods + # @todo 6: flow sensitive typing needs to handle 'raise if' + # @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 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 + # Need better post-if scoping in flow sensitive typing + # @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_all_return_types_match_inferred? - rank >= LEVELS[:alpha] + def require_downcasts? + report?(:require_downcasts, :alpha) end # We keep this at strong because if you added an @ sg-ignore to @@ -63,7 +130,15 @@ def require_all_return_types_match_inferred? # get a false positive - we don't run stronger level checks than # requested for performance reasons def validate_sg_ignores? - rank >= LEVELS[:strong] + report?(:validate_sg_ignores, :strong) + end + + private + + # @param type [Symbol] + # @param level [Symbol] + def report?(type, level) + rank >= LEVELS[@overrides.fetch(type, level)] end end end diff --git a/lib/solargraph/version.rb b/lib/solargraph/version.rb index 2c2219ecb..183008cbe 100755 --- a/lib/solargraph/version.rb +++ b/lib/solargraph/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Solargraph - VERSION = '0.57.0' + VERSION = ENV.fetch('SOLARGRAPH_FORCE_VERSION', '0.57.0') end diff --git a/lib/solargraph/workspace.rb b/lib/solargraph/workspace.rb index 07cf26f09..e55b4e291 100644 --- a/lib/solargraph/workspace.rb +++ b/lib/solargraph/workspace.rb @@ -9,7 +9,10 @@ module Solargraph # in an associated Library or ApiMap. # class Workspace + include Logging + autoload :Config, 'solargraph/workspace/config' + autoload :Gemspecs, 'solargraph/workspace/gemspecs' autoload :RequirePaths, 'solargraph/workspace/require_paths' # @return [String] @@ -19,11 +22,17 @@ class Workspace attr_reader :gemnames alias source_gems gemnames - # @param directory [String] + # @param directory [String] TODO: Remove '' and '*' special cases # @param config [Config, nil] # @param server [Hash] def initialize directory = '', config = nil, server = {} - @directory = directory + raise ArgumentError, 'directory must be a String' unless directory.is_a?(String) + + @directory = if ['*', ''].include?(directory) + directory + else + File.absolute_path(directory) + end @config = config @server = server load_sources @@ -44,6 +53,25 @@ def config @config ||= Solargraph::Workspace::Config.new(directory) end + # @param level [Symbol] + # @return [TypeChecker::Rules] + def rules(level) + @rules ||= TypeChecker::Rules.new(level, config.type_checker_rules) + end + + # @param out [IO, nil] output stream for logging + # @param gemspec [Gem::Specification] + # @return [Array] + def fetch_dependencies gemspec, out: $stderr + gemspecs.fetch_dependencies(gemspec, out: out) + end + + # @param require [String] The string sent to 'require' in the code to resolve, e.g. 'rails', 'bundler/require' + # @return [Array, nil] + def resolve_require require + gemspecs.resolve_require(require) + end + # Merge the source. A merge will update the existing source for the file # or add it to the sources if the workspace is configured to include it. # The source is ignored if the configuration excludes it. @@ -114,6 +142,23 @@ def would_require? path false end + # True if the workspace contains at least one gemspec file. + # + # @return [Boolean] + def gemspec? + !gemspec_files.empty? + end + + # Get an array of all gemspec files in the workspace. + # + # @return [Array] + def gemspec_files + return [] if directory.empty? || directory == '*' + @gemspec_files ||= Dir[File.join(directory, '**/*.gemspec')].select do |gs| + config.allow? gs + end + end + # @return [String, nil] def rbs_collection_path @gem_rbs_collection ||= read_rbs_collection_path @@ -129,6 +174,16 @@ def rbs_collection_config_path end end + # @param name [String] + # @param version [String, nil] + # + # @return [Gem::Specification, nil] + def find_gem name, version = nil + Gem::Specification.find_by_name(name, version) + rescue Gem::MissingSpecError + nil + end + # Synchronize the workspace from the provided updater. # # @param updater [Source::Updater] @@ -137,6 +192,7 @@ def synchronize! updater source_hash[updater.filename] = source_hash[updater.filename].synchronize(updater) end + # @sg-ignore Need to validate config # @return [String] def command_path server['commandPath'] || 'solargraph' @@ -148,12 +204,9 @@ def directory_or_nil directory end - # True if the workspace has a root Gemfile. - # - # @todo Handle projects with custom Bundler/Gemfile setups (see DocMap#gemspecs_required_from_bundler) - # - def gemfile? - directory && File.file?(File.join(directory, 'Gemfile')) + # @return [Solargraph::Workspace::Gemspecs] + def gemspecs + @gemspecs ||= Solargraph::Workspace::Gemspecs.new(directory_or_nil) end private diff --git a/lib/solargraph/workspace/config.rb b/lib/solargraph/workspace/config.rb index d1e6c27b5..da18ee64d 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 @@ -63,6 +63,7 @@ def calculated # namespace. It's typically used to identify available DSLs. # # @return [Array] + # @sg-ignore Need to validate config def domains raw_data['domains'] end @@ -70,12 +71,14 @@ def domains # An array of required paths to add to the workspace. # # @return [Array] + # @sg-ignore Need to validate config def required raw_data['require'] end # An array of load paths for required paths. # + # @sg-ignore Need to validate config # @return [Array] def require_paths raw_data['require_paths'] || [] @@ -83,6 +86,7 @@ def require_paths # An array of reporters to use for diagnostics. # + # @sg-ignore Need to validate config # @return [Array] def reporters raw_data['reporters'] @@ -90,6 +94,7 @@ def reporters # A hash of options supported by the formatter # + # @sg-ignore Need to validate config # @return [Hash] def formatter raw_data['formatter'] @@ -97,6 +102,7 @@ def formatter # An array of plugins to require. # + # @sg-ignore Need to validate config # @return [Array] def plugins raw_data['plugins'] @@ -104,11 +110,21 @@ def plugins # The maximum number of files to parse from the workspace. # + # @sg-ignore Need to validate config # @return [Integer] def max_files raw_data['max_files'] end + # @return [Hash{Symbol => Symbol}] + def type_checker_rules + # @type [Hash{String => String}] + raw_rules = raw_data.fetch('type_checker', {}).fetch('rules', {}) + raw_rules.to_h do |k, v| + [k.to_sym, v.to_sym] + end + end + private # @return [String] @@ -162,6 +178,9 @@ def default_config 'extra_args' =>[] } }, + 'type_checker' => { + 'rules' => { } + }, 'require_paths' => [], 'plugins' => [], 'max_files' => MAX_FILES diff --git a/lib/solargraph/workspace/gemspecs.rb b/lib/solargraph/workspace/gemspecs.rb new file mode 100644 index 000000000..8d53104bf --- /dev/null +++ b/lib/solargraph/workspace/gemspecs.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'rubygems' +require 'bundler' + +module Solargraph + class Workspace + # Manages determining which gemspecs are available in a workspace + class Gemspecs + include Logging + + attr_reader :directory, :preferences + + # @param directory [String, nil] If nil, assume no bundle is present + # @param preferences [Array] + def initialize directory, preferences: [] + # @todo an issue with both external bundles and the potential + # preferences feature is that bundler gives you a 'clean' + # rubygems environment with only the specified versions + # installed. Possible alternatives: + # + # *) prompt the user to run solargraph outside of bundler + # and treat all bundles as external + # *) reinstall the needed gems dynamically each time + # *) manipulate the rubygems/bundler environment + @directory = directory && File.absolute_path(directory) + # @todo implement preferences as a config-exposed feature + @preferences = preferences + end + + # Take the path given to a 'require' statement in a source file + # and return the Gem::Specifications which will be brought into + # scope with it, so we can load pins for them. + # + # @param require [String] The string sent to 'require' in the code to resolve, e.g. 'rails', 'bundler/require' + # @return [::Array, nil] + def resolve_require require + return nil if require.empty? + return gemspecs_required_from_bundler if require == 'bundler/require' + + # @type [Gem::Specification, nil] + gemspec = Gem::Specification.find_by_path(require) + if gemspec.nil? + gem_name_guess = require.split('/').first + begin + # this can happen when the gem is included via a local path in + # a Gemfile; Gem doesn't try to index the paths in that case. + # + # See if we can make a good guess: + potential_gemspec = Gem::Specification.find_by_name(gem_name_guess) + file = "lib/#{require}.rb" + gemspec = potential_gemspec if potential_gemspec.files.any? { |gemspec_file| file == gemspec_file } + rescue Gem::MissingSpecError + logger.debug do + "Require path #{require} could not be resolved to a gem via find_by_path or guess of #{gem_name_guess}" + end + [] + end + end + return nil if gemspec.nil? + [gemspec_or_preference(gemspec)] + end + + # @param gemspec [Gem::Specification] + # @param out[IO, nil] output stream for logging + # + # @return [Array] + def fetch_dependencies gemspec, out: $stderr + # @param spec [Gem::Dependency] + # @param deps [Set] + only_runtime_dependencies(gemspec).each_with_object(Set.new) do |spec, deps| + Solargraph.logger.info "Adding #{spec.name} dependency for #{gemspec.name}" + dep = Gem.loaded_specs[spec.name] + # @todo is next line necessary? + # @sg-ignore Unresolved call to requirement on Gem::Dependency + dep ||= Gem::Specification.find_by_name(spec.name, spec.requirement) + deps.merge fetch_dependencies(dep) if deps.add?(dep) + rescue Gem::MissingSpecError + # @sg-ignore Unresolved call to requirement on Gem::Dependency + Solargraph.logger.warn "Gem dependency #{spec.name} #{spec.requirement} for " \ + "#{gemspec.name} not found in RubyGems." + end.to_a + end + + private + + # True if the workspace has a root Gemfile. + # + # @todo Handle projects with custom Bundler/Gemfile setups (see DocMap#gemspecs_required_from_bundler) + # + def gemfile? + directory && File.file?(File.join(directory, 'Gemfile')) + end + + # @return [Hash{String => Gem::Specification}] + def preference_map + @preference_map ||= preferences.to_h { |gemspec| [gemspec.name, gemspec] } + end + + # @param gemspec [Gem::Specification] + # @return [Gem::Specification] + def gemspec_or_preference gemspec + return gemspec unless preference_map.key?(gemspec.name) + return gemspec if gemspec.version == preference_map[gemspec.name].version + + # @todo this code is unused but broken + # @sg-ignore Unresolved call to by_path + change_gemspec_version gemspec, preference_map[by_path.name].version + end + + # @param gemspec [Gem::Specification] + # @param version [Gem::Version] + # @return [Gem::Specification] + def change_gemspec_version gemspec, version + Gem::Specification.find_by_name(gemspec.name, "= #{version}") + rescue Gem::MissingSpecError + Solargraph.logger.info "Gem #{gemspec.name} version #{version} not found. Using #{gemspec.version} instead" + gemspec + end + + # @param gemspec [Gem::Specification] + # @return [Array] + def only_runtime_dependencies gemspec + gemspec.dependencies - gemspec.development_dependencies + end + + # @return [Array, nil] + def gemspecs_required_from_bundler + # @todo Handle projects with custom Bundler/Gemfile setups + return unless gemfile? + + if gemfile? && Bundler.definition&.lockfile&.to_s&.start_with?(directory) + # Find only the gems bundler is now using + Bundler.definition.locked_gems.specs.flat_map do |lazy_spec| + logger.info "Handling #{lazy_spec.name}:#{lazy_spec.version}" + [Gem::Specification.find_by_name(lazy_spec.name, lazy_spec.version)] + rescue Gem::MissingSpecError => e + logger.info("Could not find #{lazy_spec.name}:#{lazy_spec.version} with " \ + 'find_by_name, falling back to guess') + # can happen in local filesystem references + specs = resolve_require lazy_spec.name + logger.warn "Gem #{lazy_spec.name} #{lazy_spec.version} from bundle not found: #{e}" if specs.nil? + next specs + end.compact + else + logger.info 'Fetching gemspecs required from Bundler (bundler/require)' + gemspecs_required_from_external_bundle + end + end + + # @return [Array, nil] + def gemspecs_required_from_external_bundle + logger.info 'Fetching gemspecs required from external bundle' + return [] unless directory + + Solargraph.with_clean_env do + cmd = [ + 'ruby', '-e', + "require 'bundler'; " \ + "require 'json'; " \ + "Dir.chdir('#{directory}') { " \ + 'puts Bundler.definition.locked_gems.specs.map { |spec| [spec.name, spec.version] }' \ + '.to_h.to_json }' + ] + o, e, s = Open3.capture3(*cmd) + if s.success? + Solargraph.logger.debug "External bundle: #{o}" + hash = o && !o.empty? ? JSON.parse(o.split("\n").last) : {} + hash.flat_map do |name, version| + Gem::Specification.find_by_name(name, version) + rescue Gem::MissingSpecError => e + logger.info("Could not find #{name}:#{version} with find_by_name, falling back to guess") + # can happen in local filesystem references + specs = resolve_require name + logger.warn "Gem #{name} #{version} from bundle not found: #{e}" if specs.nil? + next specs + end.compact + else + Solargraph.logger.warn "Failed to load gems from bundle at #{directory}: #{e}" + nil + end + end + end + end + end +end diff --git a/lib/solargraph/workspace/require_paths.rb b/lib/solargraph/workspace/require_paths.rb index 67adae9e6..10dce4053 100644 --- a/lib/solargraph/workspace/require_paths.rb +++ b/lib/solargraph/workspace/require_paths.rb @@ -76,7 +76,6 @@ def require_path_from_gemspec_file gemspec_file_path "spec = eval(File.read('#{gemspec_file_path}'), TOPLEVEL_BINDING, '#{gemspec_file_path}'); " \ 'return unless Gem::Specification === spec; ' \ 'puts({name: spec.name, paths: spec.require_paths}.to_json)'] - # @sg-ignore Unresolved call to capture3 on Module o, e, s = Open3.capture3(*cmd) if s.success? begin @@ -84,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 d8e3b8b43..42593ed8c 100644 --- a/lib/solargraph/yard_map/mapper/to_method.rb +++ b/lib/solargraph/yard_map/mapper/to_method.rb @@ -6,12 +6,13 @@ 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 } - # @param code_object [YARD::CodeObjects::Base] + # @param code_object [YARD::CodeObjects::MethodObject] # @param name [String, nil] # @param scope [Symbol, nil] # @param visibility [Symbol, nil] @@ -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, 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 3ecb7ac26..000000000 --- a/lib/solargraph/yard_map/to_method.rb +++ /dev/null @@ -1,88 +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 - 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::Base] - # @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 2d709f778..00a6d0419 100644 --- a/lib/solargraph/yardoc.rb +++ b/lib/solargraph/yardoc.rb @@ -18,12 +18,22 @@ def cache(yard_plugins, gemspec) path = PinCache.yardoc_path gemspec return path if cached?(gemspec) + unless Dir.exist? gemspec.gem_dir + # Can happen in at least some (old?) RubyGems versions when we + # have a gemspec describing a standard library like bundler. + # + # https://github.com/apiology/solargraph/actions/runs/17650140201/job/50158676842?pr=10 + Solargraph.logger.info { "Bad info from gemspec - #{gemspec.gem_dir} does not exist" } + return path + end + Solargraph.logger.info "Caching yardoc for #{gemspec.name} #{gemspec.version}" cmd = "yardoc --db #{path} --no-output --plugin solargraph" yard_plugins.each { |plugin| cmd << " --plugin #{plugin}" } Solargraph.logger.debug { "Running: #{cmd}" } # @todo set these up to run in parallel - stdout_and_stderr_str, status = Open3.capture2e(cmd, chdir: gemspec.gem_dir) + # @sg-ignore Unrecognized keyword argument chdir to Open3.capture2e + stdout_and_stderr_str, status = Open3.capture2e(current_bundle_env_tweaks, cmd, chdir: gemspec.gem_dir) unless status.success? Solargraph.logger.warn { "YARD failed running #{cmd.inspect} in #{gemspec.gem_dir}" } Solargraph.logger.info stdout_and_stderr_str @@ -57,5 +67,23 @@ def load!(gemspec) YARD::Registry.load! PinCache.yardoc_path gemspec YARD::Registry.all end + + # If the BUNDLE_GEMFILE environment variable is set, we need to + # make sure it's an absolute path, as we'll be changing + # directories. + # + # 'bundle exec' sets an absolute path here, but at least the + # overcommit gem does not, breaking on-the-fly documention with a + # spawned yardoc command from our current bundle + # + # @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 + tweaks + end end 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 49265f9c6..c3abcc218 100755 --- a/solargraph.gemspec +++ b/solargraph.gemspec @@ -1,7 +1,9 @@ +# @sg-ignore Should better support meaning of '&' in RBS $LOAD_PATH.unshift File.dirname(__FILE__) + '/lib' require 'solargraph/version' require 'date' +# @param s [Gem::Specification] Gem::Specification.new do |s| s.name = 'solargraph' s.version = Solargraph::VERSION diff --git a/spec/api_map/constants_spec.rb b/spec/api_map/constants_spec.rb index c0460e79a..833a928cf 100644 --- a/spec/api_map/constants_spec.rb +++ b/spec/api_map/constants_spec.rb @@ -20,6 +20,31 @@ module Quuz expect(resolved).to eq('Foo::Bar') end + it 'resolves constants in includes' do + code = %( + module A + module Parser + module C + # @return [String] + def baz; "abc"; end + end + + B = C + end + + class Foo + include Parser::B + + # @return [String] + def bar + baz + end + end + end) + checker = Solargraph::TypeChecker.load_string(code, 'test.rb', :strong) + expect(checker.problems.map(&:message)).to be_empty + end + it 'resolves straightforward mixins' do source_map = Solargraph::SourceMap.load_string(%( module Bar diff --git a/spec/api_map/index_spec.rb b/spec/api_map/index_spec.rb new file mode 100644 index 000000000..8afb74759 --- /dev/null +++ b/spec/api_map/index_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +describe Solargraph::ApiMap::Index do + subject(:output_pins) { described_class.new(input_pins).pins } + + describe '#map_overrides' do + let(:foo_class) do + Solargraph::Pin::Namespace.new(name: 'Foo') + end + + let(:foo_initialize) do + init = Solargraph::Pin::Method.new(name: 'initialize', + scope: :instance, + parameters: [], + closure: foo_class) + # no return type specified + param = Solargraph::Pin::Parameter.new(name: 'bar', + closure: init) + init.parameters << param + init + end + + let(:foo_new) do + init = Solargraph::Pin::Method.new(name: 'new', + scope: :class, + parameters: [], + closure: foo_class) + # no return type specified + param = Solargraph::Pin::Parameter.new(name: 'bar', + closure: init) + init.parameters << param + init + end + + let(:foo_override) do + Solargraph::Pin::Reference::Override.from_comment('Foo#initialize', + '@param [String] bar') + end + + let(:input_pins) do + [ + foo_initialize, + foo_new, + foo_override + ] + end + + it 'has a docstring to process on override' do + expect(foo_override.docstring.tags).to be_empty + end + + it 'overrides .new method' do + method_pin = output_pins.find { |pin| pin.path == 'Foo.new' } + first_parameter = method_pin.parameters.first + expect(first_parameter.return_type.tag).to eq('String') + end + + it 'overrides #initialize method in signature' do + method_pin = output_pins.find { |pin| pin.path == 'Foo#initialize' } + first_parameter = method_pin.parameters.first + expect(first_parameter.return_type.tag).to eq('String') + end + end +end diff --git a/spec/api_map_method_spec.rb b/spec/api_map_method_spec.rb index 9d4e4f553..13b577b31 100644 --- a/spec/api_map_method_spec.rb +++ b/spec/api_map_method_spec.rb @@ -11,6 +11,13 @@ api_map.catalog bench end + describe '#resolve_method_alias' do + it 'resolves the IO.for_fd alias to IO.new' do + stack = api_map.get_method_stack('IO', 'for_fd', scope: :class) + expect(stack.map(&:class).uniq).to eq([Solargraph::Pin::Method]) + end + end + describe '#qualify' do let(:external_requires) { ['yaml'] } @@ -133,6 +140,37 @@ class B end end + describe '#cache_all!' do + it 'can cache gems without a bench' do + api_map = Solargraph::ApiMap.new + doc_map = instance_double(Solargraph::DocMap, cache_all!: true) + allow(Solargraph::DocMap).to receive(:new).and_return(doc_map) + api_map.cache_all!($stderr) + expect(doc_map).to have_received(:cache_all!).with($stderr, rebuild: false) + end + end + + describe '#cache_gem' do + it 'can cache gem without a bench' do + api_map = Solargraph::ApiMap.new + expect { api_map.cache_gem('rake', out: StringIO.new) }.not_to raise_error + end + end + + describe '#workspace' do + it 'can get a default workspace without a bench' do + api_map = Solargraph::ApiMap.new + expect(api_map.workspace).not_to be_nil + end + end + + describe '#uncached_gemspecs' do + it 'can get uncached gemspecs workspace without a bench' do + api_map = Solargraph::ApiMap.new + expect(api_map.uncached_gemspecs).not_to be_nil + end + end + describe '#get_methods' do it 'recognizes mixin references from context' do source = Solargraph::Source.load_string(%( diff --git a/spec/api_map_spec.rb b/spec/api_map_spec.rb index 1e0130c14..f76adb078 100755 --- a/spec/api_map_spec.rb +++ b/spec/api_map_spec.rb @@ -432,8 +432,9 @@ class Sup expect(pins.map(&:path)).to include('Mixin#bar') end - # pending https://github.com/apiology/solargraph/pull/4 - xit 'understands tuples inherit from regular arrays' do + it 'understands tuples inherit from regular arrays' do + pending('Fix to remove trailing generic<> after resolution') + method_pins = @api_map.get_method_stack("Array(1, 2, 'a')", 'include?') method_pin = method_pins.first expect(method_pin).to_not be_nil @@ -771,7 +772,7 @@ def bar; end it 'knows that false is a "subtype" of Boolean' do api_map = Solargraph::ApiMap.new - expect(api_map.super_and_sub?('Boolean', 'true')).to be(true) + expect(api_map.super_and_sub?('Boolean', 'false')).to be(true) end it 'resolves aliases for YARD methods' do 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..8a5b273dd 100644 --- a/spec/complex_type_spec.rb +++ b/spec/complex_type_spec.rb @@ -13,7 +13,8 @@ expect(types.length).to eq(0) end - xit 'parses zero types as a string' do + it 'parses zero types as a string' do + pending('special case being added') types = Solargraph::ComplexType.parse '' expect(types.length).to eq(0) end @@ -265,14 +266,18 @@ # See literal details at # https://github.com/ruby/rbs/blob/master/docs/syntax.md and # https://yardoc.org/types.html - xit 'understands literal strings with double quotes' do + it 'understands literal strings with double quotes' do + pending('string escaping support being added') + type = Solargraph::ComplexType.parse('"foo"') expect(type.tag).to eq('"foo"') expect(type.to_rbs).to eq('"foo"') expect(type.to_s).to eq('String') end - xit 'understands literal strings with single quotes' do + it 'understands literal strings with single quotes' do + pending('string escaping support being added') + type = Solargraph::ComplexType.parse("'foo'") expect(type.tag).to eq("'foo'") expect(type.to_rbs).to eq("'foo'") @@ -725,7 +730,9 @@ def make_bar expect(result.to_rbs).to eq('::Array[::String]') end - xit 'stops parsing when the first character indicates a string literal' do + it 'stops parsing when the first character indicates a string literal' do + pending('string escaping support being added') + api_map = Solargraph::ApiMap.new type = Solargraph::ComplexType.parse('"Array(Symbol, String, Array(Integer, Integer)"') type = type.qualify(api_map) @@ -733,5 +740,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 e75e8749c..a0eae979a 100644 --- a/spec/convention/activesupport_concern_spec.rb +++ b/spec/convention/activesupport_concern_spec.rb @@ -96,4 +96,100 @@ def self.my_method; end expect(pins.first.typify(api_map).map(&:tag)).to include('Numeric') end end + + context 'with RBS to digest' do + # create a temporary directory with the scope of the spec + around do |example| + require 'tmpdir' + Dir.mktmpdir("rspec-solargraph-") do |dir| + @temp_dir = dir + example.run + end + end + + let(:conversions) do + loader = RBS::EnvironmentLoader.new(core_root: nil, repository: RBS::Repository.new(no_stdlib: false)) + loader.add(path: Pathname(temp_dir)) + Solargraph::RbsMap::Conversions.new(loader: loader) + end + + let(:api_map) { Solargraph::ApiMap.new } + + before do + rbs_file = File.join(temp_dir, 'foo.rbs') + File.write(rbs_file, rbs) + api_map.index conversions.pins + end + + attr_reader :temp_dir + + context 'with Inheritance module in ActiveRecord' do + # See + # https://github.com/ruby/gem_rbs_collection/blob/main/gems/activerecord/6.0/activerecord-generated.rbs + # for full RBS + subject(:method_pins) { api_map.get_method_stack('MyActiveRecord::Base', 'abstract_class', scope: :class) } + + let(:rbs) do + <<~RBS + module MyActiveRecord + module Inheritance + extend ActiveSupport::Concern + + module ClassMethods + attr_accessor abstract_class: untyped + end + end + end + + module MyActiveRecord + class Base + include Inheritance + end + end + RBS + end + + it { is_expected.not_to be_empty } + + it "has one item" do + expect(method_pins.size).to eq(1) + end + + it "is a Pin::Method" do + expect(method_pins.first).to be_a(Solargraph::Pin::Method) + end + end + + # https://github.com/castwide/solargraph/issues/1042 + context 'with Hash superclass with untyped value and alias' do + let(:rbs) do + <<~RBS + class Sub < Hash[Symbol, untyped] + alias meth_alias [] + end + RBS + end + + let(:sup_method_stack) { api_map.get_method_stack('Hash{Symbol => undefined}', '[]', scope: :instance) } + + let(:sub_alias_stack) { api_map.get_method_stack('Sub', 'meth_alias', scope: :instance) } + + it 'does not crash looking at superclass method' do + expect { sup_method_stack }.not_to raise_error + end + + it 'does not crash looking at alias' do + expect { sub_alias_stack }.not_to raise_error + end + + it 'finds superclass method pin return type' do + expect(sup_method_stack.map(&:return_type).map(&:rooted_tags).uniq).to eq(['undefined']) + end + + it 'finds superclass method pin parameter type' do + expect(sup_method_stack.flat_map(&:signatures).flat_map(&:parameters).map(&:return_type).map(&:rooted_tags) + .uniq).to eq(['Symbol']) + end + end + end end 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/doc_map_spec.rb b/spec/doc_map_spec.rb index 1315f6c90..b0f181d9d 100644 --- a/spec/doc_map_spec.rb +++ b/spec/doc_map_spec.rb @@ -1,80 +1,170 @@ # frozen_string_literal: true +require 'bundler' +require 'benchmark' + describe Solargraph::DocMap do - before :all do - # We use ast here because it's a known dependency. - gemspec = Gem::Specification.find_by_name('ast') - yard_pins = Solargraph::GemPins.build_yard_pins([], gemspec) - Solargraph::PinCache.serialize_yard_gem(gemspec, yard_pins) + subject(:doc_map) do + described_class.new(requires, workspace) end - it 'generates pins from gems' do - doc_map = Solargraph::DocMap.new(['ast'], []) - doc_map.cache_all!($stderr) - node_pin = doc_map.pins.find { |pin| pin.path == 'AST::Node' } - expect(node_pin).to be_a(Solargraph::Pin::Namespace) - end + let(:out) { StringIO.new } + let(:pre_cache) { true } + let(:requires) { [] } + + let(:workspace) { Solargraph::Workspace.new(Dir.pwd) } - it 'tracks unresolved requires' do - doc_map = Solargraph::DocMap.new(['not_a_gem'], []) - expect(doc_map.unresolved_requires).to include('not_a_gem') + let(:plain_doc_map) { described_class.new([], workspace) } + + before do + doc_map.cache_all!(nil) if pre_cache end - it 'tracks uncached_gemspecs' do - gemspec = Gem::Specification.new do |spec| - spec.name = 'not_a_gem' - spec.version = '1.0.0' + context 'with a require in solargraph test bundle' do + let(:requires) do + ['ast'] + end + + it 'generates pins from gems' do + node_pin = doc_map.pins.find { |pin| pin.path == 'AST::Node' } + expect(node_pin).to be_a(Solargraph::Pin::Namespace) end - allow(Gem::Specification).to receive(:find_by_path).and_return(gemspec) - doc_map = Solargraph::DocMap.new(['not_a_gem'], [gemspec]) - expect(doc_map.uncached_yard_gemspecs).to eq([gemspec]) - expect(doc_map.uncached_rbs_collection_gemspecs).to eq([gemspec]) end - it 'imports all gems when bundler/require used' do - workspace = Solargraph::Workspace.new(Dir.pwd) - plain_doc_map = Solargraph::DocMap.new([], [], workspace) - doc_map_with_bundler_require = Solargraph::DocMap.new(['bundler/require'], [], workspace) + context 'understands rspec + rspec-mocks require pattern' do + let(:requires) do + ['rspec-mocks'] + end + + # This is a gem name vs require name issue - works under + # solargraph-rspec, but not without + xit 'generates pins from gems' do + pending('handling dependencies from conventions as gem names, not requires') - expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + ns_pin = doc_map.pins.find { |pin| pin.path == 'RSpec::Mocks' } + expect(ns_pin).to be_a(Solargraph::Pin::Namespace) + end end it 'does not warn for redundant requires' do # Requiring 'set' is unnecessary because it's already included in core. It # might make sense to log redundant requires, but a warning is overkill. - expect(Solargraph.logger).not_to receive(:warn).with(/path set/) - Solargraph::DocMap.new(['set'], []) + allow(Solargraph.logger).to receive(:warn).and_call_original + Solargraph::DocMap.new(['set'], workspace) + expect(Solargraph.logger).not_to have_received(:warn).with(/path set/) + end + + context 'with an invalid require' do + let(:requires) do + ['not_a_gem'] + end + + # expected: ["not_a_gem"] + # got: ["not_a_gem", "rspec-mocks"] + # + # This is a gem name vs require name issue coming from conventions + # - will pass once the above context passes + xit 'tracks unresolved requires' do + # These are auto-required by solargraph-rspec in case the bundle + # includes these gems. In our case, it doesn't! + unprovided_solargraph_rspec_requires = [ + 'rspec-rails', + 'actionmailer', + 'activerecord', + 'shoulda-matchers', + 'rspec-sidekiq', + 'airborne', + 'activesupport' + ] + expect(doc_map.unresolved_requires - unprovided_solargraph_rspec_requires) + .to eq(['not_a_gem']) + end + end + + context 'when deserialization takes a while' do + let(:pre_cache) { false } + let(:requires) { ['backport'] } + + before do + # proxy this method to simulate a long-running deserialization + allow(Benchmark).to receive(:measure) do |&block| + block.call + 5.0 + end + end + + it 'logs timing' do + pending('logging being implemented') + # force lazy evaluation + _pins = doc_map.pins + expect(out.string).to include('Deserialized ').and include(' gem pins ').and include(' ms') + end end - it 'ignores nil requires' do - expect { Solargraph::DocMap.new([nil], []) }.not_to raise_error + context 'with require as bundle/require' do + it 'imports all gems when bundler/require used' do + doc_map_with_bundler_require = described_class.new(['bundler/require'], workspace) + doc_map_with_bundler_require.cache_all!(nil) + expect(doc_map_with_bundler_require.pins.length - plain_doc_map.pins.length).to be_positive + end end - it 'ignores empty requires' do - expect { Solargraph::DocMap.new([''], []) }.not_to raise_error + context 'with a require not needed by Ruby core' do + let(:requires) { ['set'] } + + it 'does not warn' do + # Requiring 'set' is unnecessary because it's already included in core. It + # might make sense to log redundant requires, but a warning is overkill. + allow(Solargraph.logger).to receive(:warn) + doc_map + expect(Solargraph.logger).not_to have_received(:warn).with(/path set/) + end end - it 'collects dependencies' do - doc_map = Solargraph::DocMap.new(['rspec'], []) - expect(doc_map.dependencies.map(&:name)).to include('rspec-core') + context 'with a nil require' do + let(:requires) { [nil] } + + it 'does not raise error' do + expect { doc_map }.not_to raise_error + end end - it 'includes convention requires from environ' do - dummy_convention = Class.new(Solargraph::Convention::Base) do - def global(doc_map) - Solargraph::Environ.new( - requires: ['convention_gem1', 'convention_gem2'] - ) - end + context 'with an empty require' do + let(:requires) { [''] } + + it 'does not raise error' do + expect { doc_map }.not_to raise_error end + end - Solargraph::Convention.register dummy_convention + context 'with a require that has dependencies' do + let(:requires) { ['rspec'] } - doc_map = Solargraph::DocMap.new(['original_gem'], []) + it 'collects dependencies' do + expect(doc_map.dependencies.map(&:name)).to include('rspec-core') + end + end - expect(doc_map.requires).to include('original_gem', 'convention_gem1', 'convention_gem2') + context 'with convention' do + let(:pre_cache) { false } - # Clean up the registered convention - Solargraph::Convention.unregister dummy_convention + it 'includes convention requires from environ' do + dummy_convention = Class.new(Solargraph::Convention::Base) do + def global(doc_map) + Solargraph::Environ.new( + requires: ['convention_gem1', 'convention_gem2'] + ) + end + end + + Solargraph::Convention.register dummy_convention + + doc_map = Solargraph::DocMap.new(['original_gem'], workspace) + + expect(doc_map.requires).to include('original_gem', 'convention_gem1', 'convention_gem2') + ensure + # Clean up the registered convention + Solargraph::Convention.unregister dummy_convention + end end end diff --git a/spec/gem_pins_spec.rb b/spec/gem_pins_spec.rb index d630784cf..8e3962341 100644 --- a/spec/gem_pins_spec.rb +++ b/spec/gem_pins_spec.rb @@ -11,4 +11,9 @@ expect(core_root.return_type.to_s).to eq('Pathname, nil') expect(core_root.location.filename).to end_with('environment_loader.rb') end + + it 'does not error out when handed incorrect gemspec' do + gemspec = instance_double(Gem::Specification, name: 'foo', version: '1.0', gem_dir: '/not-there') + expect { Solargraph::GemPins.build_yard_pins([], gemspec) }.not_to raise_error + end end diff --git a/spec/language_server/host/diagnoser_spec.rb b/spec/language_server/host/diagnoser_spec.rb index d59a843f1..697d352bd 100644 --- a/spec/language_server/host/diagnoser_spec.rb +++ b/spec/language_server/host/diagnoser_spec.rb @@ -1,9 +1,10 @@ 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' - expect(host).to receive(:diagnose).with('file.rb') diagnoser.tick + expect(host).to have_received(:diagnose).with('file.rb') end end diff --git a/spec/language_server/host/message_worker_spec.rb b/spec/language_server/host/message_worker_spec.rb index b9ce2a41f..5e5bef481 100644 --- a/spec/language_server/host/message_worker_spec.rb +++ b/spec/language_server/host/message_worker_spec.rb @@ -2,11 +2,12 @@ it "handle requests on queue" do host = double(Solargraph::LanguageServer::Host) message = {'method' => '$/example'} - expect(host).to receive(:receive).with(message).and_return(nil) + allow(host).to receive(:receive).with(message).and_return(nil) worker = Solargraph::LanguageServer::Host::MessageWorker.new(host) worker.queue(message) expect(worker.messages).to eq [message] worker.tick + expect(host).to have_received(:receive).with(message) end end diff --git a/spec/language_server/message/text_document/definition_spec.rb b/spec/language_server/message/text_document/definition_spec.rb index 72ff77f1e..b6c98b99b 100644 --- a/spec/language_server/message/text_document/definition_spec.rb +++ b/spec/language_server/message/text_document/definition_spec.rb @@ -1,4 +1,33 @@ describe Solargraph::LanguageServer::Message::TextDocument::Definition do + it 'prepares empty directory' do + Dir.mktmpdir do |dir| + host = Solargraph::LanguageServer::Host.new + test_rb_path = File.join(dir, 'test.rb') + thing_rb_path = File.join(dir, 'thing.rb') + FileUtils.cp('spec/fixtures/workspace/lib/other.rb', test_rb_path) + FileUtils.cp('spec/fixtures/workspace/lib/thing.rb', thing_rb_path) + host.prepare(dir) + sleep 0.1 until host.libraries.all?(&:mapped?) + host.catalog + file_uri = Solargraph::LanguageServer::UriHelpers.file_to_uri(test_rb_path) + other_uri = Solargraph::LanguageServer::UriHelpers.file_to_uri(thing_rb_path) + message = Solargraph::LanguageServer::Message::TextDocument::Definition + .new(host, { + 'params' => { + 'textDocument' => { + 'uri' => file_uri + }, + 'position' => { + 'line' => 4, + 'character' => 10 + } + } + }) + message.process + expect(message.result.first[:uri]).to eq(other_uri) + end + end + it 'finds definitions of methods' do host = Solargraph::LanguageServer::Host.new host.prepare('spec/fixtures/workspace') @@ -21,7 +50,7 @@ expect(message.result.first[:uri]).to eq(other_uri) end - it 'finds definitions of require paths' do + it 'finds definitions of require paths', time_limit_seconds: 120 do path = File.absolute_path('spec/fixtures/workspace') host = Solargraph::LanguageServer::Host.new host.prepare(path) diff --git a/spec/parser/flow_sensitive_typing_spec.rb b/spec/parser/flow_sensitive_typing_spec.rb index bf747fc76..258680983 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,693 @@ 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 + + + it 'supports !@x.nil && @x.y' do + source = Solargraph::Source.load_string(%( + class Bar + # @param foo [String, nil] + def initialize(foo) + @foo = foo + end + + def foo? + out = !@foo.nil? && @foo.upcase == 'FOO' + out + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [9, 10]) + expect(clip.infer.to_s).to eq('Boolean') + end + + it 'uses is_a? with instance variables to refine types' do + source = Solargraph::Source.load_string(%( + class ReproBase; end + class Repro < ReproBase; end + class Example + # @param value [ReproBase] + def initialize(value) + @value = value + end + + def check + if @value.is_a?(Repro) + @value + else + @value + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [11, 12]) + expect(clip.infer.to_s).to eq('Repro') + + clip = api_map.clip_at('test.rb', [13, 12]) + expect(clip.infer.to_s).to eq('ReproBase') + end end diff --git a/spec/parser/node_chainer_spec.rb b/spec/parser/node_chainer_spec.rb index e92431aae..5f3865b13 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 @@ -141,7 +145,9 @@ class Foo expect(chain.links.first).to be_with_block end - xit 'tracks complex multiple assignment' do + it 'tracks complex multiple assignment' do + pending('complex multiple assignment support') + source = Solargraph::Source.load_string(%( foo.baz, bar = [1, 2] )) diff --git a/spec/parser/node_methods_spec.rb b/spec/parser/node_methods_spec.rb index f9504b584..3cecac09d 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 @@ -289,7 +291,9 @@ expect(rets.length).to eq(1) end - xit "short-circuits return node finding after a raise statement in a begin expressiona" do + it "short-circuits return node finding after a raise statement in a begin expression" do + pending('case being handled') + node = Solargraph::Parser.parse(%( raise "Error" y @@ -299,7 +303,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 +313,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 +323,7 @@ end it "handles return nodes with reduceable code" do - node = Solargraph::Parser.parse(%( + node = parse(%( return begin x if foo y @@ -330,31 +334,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 +369,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 +383,7 @@ end it 'handles return nodes from case statements' do - node = Solargraph::Parser.parse(%( + node = parse(%( case 1 when 1 then "" else @@ -391,7 +395,7 @@ end it 'handles return nodes from case statements without else' do - node = Solargraph::Parser.parse(%( + node = parse(%( case 1 when 1 "" @@ -402,7 +406,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 +420,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_alias_spec.rb b/spec/pin/method_alias_spec.rb new file mode 100644 index 000000000..d3b408273 --- /dev/null +++ b/spec/pin/method_alias_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +describe Solargraph::Pin::MethodAlias do + describe '#to_rbs' do + it 'generates RBS from simple alias' do + method_alias = described_class.new(name: 'name', original: 'original_name') + + expect(method_alias.to_rbs).to eq('alias name original_name') + end + + it 'generates RBS from static alias' do + method_alias = described_class.new(name: 'name', original: 'original_name', scope: :class) + + expect(method_alias.to_rbs).to eq('alias self.name self.original_name') + end + end +end diff --git a/spec/pin/parameter_spec.rb b/spec/pin/parameter_spec.rb index 082ec54c6..14c39f3fe 100644 --- a/spec/pin/parameter_spec.rb +++ b/spec/pin/parameter_spec.rb @@ -473,5 +473,33 @@ def self.foo bar: 'bar' type = pin.probe(api_map) expect(type.simple_tags).to eq('String') end + + it 'handles a relative type name case' do + source = Solargraph::Source.load_string(%( + module A + module B + class Method + end + end + end + + module A + module B + class C < B::Method + # @param alt [Method] + # @return [B::Method, nil] + def resolve_method alt + alt + end + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map(source) + + clip = api_map.clip_at('test.rb', [14, 16]) + expect(clip.infer.rooted_tags).to eq('::A::B::Method') + end end end 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/rbs_map/core_map_spec.rb b/spec/rbs_map/core_map_spec.rb index 7b5007529..cada2754c 100644 --- a/spec/rbs_map/core_map_spec.rb +++ b/spec/rbs_map/core_map_spec.rb @@ -59,7 +59,9 @@ expect(signature.block.parameters.map(&:return_type).map(&:to_s)).to eq(['String']) end - xit 'understands defaulted type parameters' do + it 'understands defaulted type parameters' do + pending('defaulted type parameter support') + # @todo Enumerable#each's' return type not yet supported as _Each<> # takes two type parameters, the second has a default value, # Enumerable specifies it, but Solargraph doesn't support type diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index 91f84b4c7..2d5c3970e 100644 --- a/spec/shell_spec.rb +++ b/spec/shell_spec.rb @@ -1,18 +1,24 @@ +# frozen_string_literal: true + require 'tmpdir' require 'open3' describe Solargraph::Shell do + let(:shell) { described_class.new } + let(:temp_dir) { Dir.mktmpdir } before do File.open(File.join(temp_dir, 'Gemfile'), 'w') do |file| file.puts "source 'https://rubygems.org'" - file.puts "gem 'solargraph', path: #{File.expand_path('..', __dir__)}" + file.puts "gem 'solargraph', path: '#{File.expand_path('..', __dir__)}'" end output, status = Open3.capture2e("bundle install", chdir: temp_dir) raise "Failure installing bundle: #{output}" unless status.success? end + # @type cmd [Array] + # @return [String] def bundle_exec(*cmd) # run the command in the temporary directory with bundle exec output, status = Open3.capture2e("bundle exec #{cmd.join(' ')}", chdir: temp_dir) @@ -25,20 +31,270 @@ def bundle_exec(*cmd) FileUtils.rm_rf(temp_dir) end - describe "--version" do - it "returns a version when run" do - output = bundle_exec("solargraph", "--version") + describe '--version' do + let(:output) { bundle_exec('solargraph', '--version') } + it 'returns output' do expect(output).not_to be_empty + end + + it 'returns a version when run' do expect(output).to eq("#{Solargraph::VERSION}\n") end end - describe "uncache" do - it "uncaches without erroring out" do - output = bundle_exec("solargraph", "uncache", "solargraph") + describe 'uncache' do + it 'uncaches without erroring out' do + output = capture_stdout do + shell.uncache('backport') + end expect(output).to include('Clearing pin cache in') end + + it 'uncaches stdlib without erroring out' do + expect { shell.uncache('stdlib') }.not_to raise_error + end + + it 'uncaches core without erroring out' do + expect { shell.uncache('core') }.not_to raise_error + end + end + + describe 'scan' do + context 'with mocked dependencies' do + let(:api_map) { instance_double(Solargraph::ApiMap) } + + before do + allow(Solargraph::ApiMap).to receive(:load_with_cache).and_return(api_map) + end + + it 'scans without erroring out' do + allow(api_map).to receive(:pins).and_return([]) + output = capture_stdout do + shell.options = { directory: 'spec/fixtures/workspace' } + shell.scan + end + + expect(output).to include('Scanned ').and include(' seconds.') + end + end + end + + describe 'typecheck' do + context 'with mocked dependencies' do + let(:type_checker) { instance_double(Solargraph::TypeChecker) } + let(:api_map) { instance_double(Solargraph::ApiMap) } + + before do + allow(Solargraph::ApiMap).to receive(:load_with_cache).and_return(api_map) + allow(Solargraph::TypeChecker).to receive(:new).and_return(type_checker) + allow(type_checker).to receive(:problems).and_return([]) + end + + it 'typechecks without erroring out' do + output = capture_stdout do + shell.options = { level: 'normal', directory: '.' } + shell.typecheck('Gemfile') + end + + expect(output).to include('Typecheck finished in') + end + end + end + + describe 'gems' do + context 'without mocked ApiMap' do + it 'complains when gem does not exist' do + output = capture_both do + shell.gems('nonexistentgem') + end + + expect(output).to include("Gem 'nonexistentgem' not found") + end + + it 'caches core without erroring out' do + capture_both do + shell.uncache('core') + end + + expect { shell.cache('core') }.not_to raise_error + end + + it 'gives sensible error for gem that does not exist' do + output = capture_both do + shell.gems('solargraph123') + end + + expect(output).to include("Gem 'solargraph123' not found") + end + end + + context 'with mocked Workspace' do + let(:api_map) { instance_double(Solargraph::ApiMap) } + let(:workspace) { instance_double(Solargraph::Workspace) } + let(:gemspec) { instance_double(Gem::Specification, name: 'backport') } + + before do + allow(Solargraph::Workspace).to receive(:new).and_return(workspace) + allow(Solargraph::ApiMap).to receive(:load).with(Dir.pwd).and_return(api_map) + allow(api_map).to receive(:cache_gem) + end + + it 'caches all without erroring out' do + allow(api_map).to receive(:cache_all!) + + _output = capture_both { shell.gems } + + expect(api_map).to have_received(:cache_all!) + end + + it 'caches single gem without erroring out' do + allow(workspace).to receive(:find_gem).with('backport').and_return(gemspec) + + capture_both do + shell.options = { rebuild: false } + shell.gems('backport') + end + + expect(api_map).to have_received(:cache_gem).with(gemspec, out: an_instance_of(StringIO), rebuild: false) + end + end + end + + describe 'cache' do + it 'caches a stdlib gem without erroring out' do + expect { shell.cache('stringio') }.not_to raise_error + end + + context 'when gem does not exist' do + subject(:call) { shell.cache('nonexistentgem8675309') } + + it 'gives a good error message' do + # capture stderr output + expect { call }.to output(/not found/).to_stderr + end + end + end + + # @type cmd [Array] + # @return [String] + def bundle_exec(*cmd) + # run the command in the temporary directory with bundle exec + Bundler.with_unbundled_env do + output, status = Open3.capture2e("bundle exec #{cmd.join(' ')}") + expect(status.success?).to be(true), "Command failed: #{output}" + output + end + end + + describe 'pin' do + let(:api_map) { instance_double(Solargraph::ApiMap) } + let(:to_s_pin) { instance_double(Solargraph::Pin::Method, return_type: Solargraph::ComplexType.parse('String')) } + + before do + allow(Solargraph::Pin::Method).to receive(:===).with(to_s_pin).and_return(true) + allow(Solargraph::ApiMap).to receive(:load_with_cache).and_return(api_map) + allow(api_map).to receive(:get_path_pins).with('String#to_s').and_return([to_s_pin]) + end + + context 'with no options' do + it 'prints a pin' do + allow(to_s_pin).to receive(:inspect).and_return('pin inspect result') + + out = capture_both { shell.pin('String#to_s') } + + expect(out).to eq("pin inspect result\n") + end + end + + context 'with --rbs option' do + it 'prints a pin with RBS type' do + allow(to_s_pin).to receive(:to_rbs).and_return('pin RBS result') + + out = capture_both do + shell.options = { rbs: true } + shell.pin('String#to_s') + end + expect(out).to eq("pin RBS result\n") + end + end + + context 'with --stack option' do + it 'prints a pin using stack results' do + allow(to_s_pin).to receive(:to_rbs).and_return('pin RBS result') + + allow(api_map).to receive(:get_method_stack).and_return([to_s_pin]) + capture_both do + shell.options = { stack: true } + shell.pin('String#to_s') + end + expect(api_map).to have_received(:get_method_stack).with('String', 'to_s', scope: :instance) + end + + it 'prints a static pin using stack results' do + # allow(to_s_pin).to receive(:to_rbs).and_return('pin RBS result') + string_new_pin = instance_double(Solargraph::Pin::Method, return_type: Solargraph::ComplexType.parse('String')) + + allow(api_map).to receive(:get_method_stack).with('String', 'new', scope: :class).and_return([string_new_pin]) + allow(Solargraph::Pin::Method).to receive(:===).with(string_new_pin).and_return(true) + allow(api_map).to receive(:get_path_pins).with('String.new').and_return([string_new_pin]) + capture_both do + shell.options = { stack: true } + shell.pin('String.new') + end + expect(api_map).to have_received(:get_method_stack).with('String', 'new', scope: :class) + end + end + + context 'with --typify option' do + it 'prints a pin with typify type' do + allow(to_s_pin).to receive(:typify).and_return(Solargraph::ComplexType.parse('::String')) + + out = capture_both do + shell.options = { typify: true } + shell.pin('String#to_s') + end + expect(out).to eq("::String\n") + end + end + + context 'with --typify --rbs options' do + it 'prints a pin with typify type' do + allow(to_s_pin).to receive(:typify).and_return(Solargraph::ComplexType.parse('::String')) + + out = capture_both do + shell.options = { typify: true, rbs: true } + shell.pin('String#to_s') + end + expect(out).to eq("::String\n") + end + end + + context 'with no pin' do + it 'prints error' do + allow(api_map).to receive(:get_path_pins).with('Not#found').and_return([]) + allow(Solargraph::Pin::Method).to receive(:===).with(nil).and_return(false) + + out = capture_both do + shell.options = {} + shell.pin('Not#found') + rescue SystemExit + # Ignore the SystemExit raised by the shell when no pin is found + end + expect(out).to include("Pin not found for path 'Not#found'") + end + end + end + + # @type cmd [Array] + # @return [String] + def bundle_exec(*cmd) + # run the command in the temporary directory with bundle exec + Bundler.with_unbundled_env do + output, status = Open3.capture2e("bundle exec #{cmd.join(' ')}") + expect(status.success?).to be(true), "Command failed: #{output}" + output + end end end diff --git a/spec/source/chain/call_spec.rb b/spec/source/chain/call_spec.rb index 8b67a3c66..eaedfa998 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) @@ -250,7 +251,9 @@ def baz expect(type.simple_tags).to eq('Integer') end - xit 'infers method return types based on method generic' do + it 'infers method return types based on method generic' do + pending('deeper inference support') + source = Solargraph::Source.load_string(%( class Foo # @Generic A @@ -315,7 +318,9 @@ def baz expect(type.tag).to eq('String') end - xit 'infers generic return types from block from yield being a return node' do + it 'infers generic return types from block from yield being a return node' do + pending('deeper inference support') + source = Solargraph::Source.load_string(%( def yielder(&blk) yield @@ -371,6 +376,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 +412,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 +441,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 +479,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 +501,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 +526,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 +557,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' @@ -595,7 +646,7 @@ def k expect(clip.infer.rooted_tags).to eq('::Array<::A::D::E>') end - xit 'correctly looks up civars' do + it 'correctly looks up civars' do source = Solargraph::Source.load_string(%( class Foo BAZ = /aaa/ @@ -627,4 +678,41 @@ def bl clip = api_map.clip_at('test.rb', [3, 8]) expect(clip.infer.rooted_tags).to eq('::String') end + + it 'sends proper gates in ProxyType' do + source = Solargraph::Source.load_string(%( + module Foo + module Bar + class Symbol + end + end + end + + module Foo + module Baz + class Quux + # @return [void] + def foo + s = objects_by_class(Bar::Symbol) + s + end + + # @generic T + # @param klass [Class>] + # @return [Set>] + def objects_by_class klass + # @type [Set>] + s = Set.new + s + end + end + end + end + ), 'test.rb') + api_map = Solargraph::ApiMap.new + api_map.map source + + clip = api_map.clip_at('test.rb', [14, 14]) + expect(clip.infer.rooted_tags).to eq('::Set<::Foo::Bar::Symbol>') + end end diff --git a/spec/source/chain/instance_variable_spec.rb b/spec/source/chain/instance_variable_spec.rb index 8326a66d2..ee4604f91 100644 --- a/spec/source/chain/instance_variable_spec.rb +++ b/spec/source/chain/instance_variable_spec.rb @@ -1,17 +1,34 @@ describe Solargraph::Source::Chain::InstanceVariable do it "resolves instance variable pins" do - closure = Solargraph::Pin::Namespace.new(name: 'Foo') - methpin = Solargraph::Pin::Method.new(closure: closure, name: 'imeth', scope: :instance) - foo_pin = Solargraph::Pin::InstanceVariable.new(closure: methpin, name: '@foo') - bar_pin = Solargraph::Pin::InstanceVariable.new(closure: closure, name: '@foo') + closure = Solargraph::Pin::Namespace.new(name: 'Foo', + location: Solargraph::Location.new('test.rb', Solargraph::Range.from_to(1, 1, 9, 0)), + source: :closure) + methpin = Solargraph::Pin::Method.new(closure: closure, name: 'imeth', scope: :instance, + location: Solargraph::Location.new('test.rb', Solargraph::Range.from_to(1, 1, 3, 0)), + source: :methpin) + foo_pin = Solargraph::Pin::InstanceVariable.new(closure: methpin, name: '@foo', + location: Solargraph::Location.new('test.rb', Solargraph::Range.from_to(2, 0, 2, 0)), + presence: Solargraph::Range.from_to(2, 0, 2, 4), + source: :foo_pin) + bar_pin = Solargraph::Pin::InstanceVariable.new(closure: closure, name: '@foo', + location: Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 0, 5, 0)), + presence: Solargraph::Range.from_to(5, 1, 5, 4), + source: :bar_pin) 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, Solargraph::Location.new('test.rb', Solargraph::Range.from_to(2, 2, 2, 3))) 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) + link = Solargraph::Source::Chain::InstanceVariable.new('@foo', nil, Solargraph::Location.new('test.rb', Solargraph::Range.from_to(5, 1, 5, 2))) + 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..6db1686d4 100644 --- a/spec/source/chain_spec.rb +++ b/spec/source/chain_spec.rb @@ -362,7 +362,9 @@ class Bar; end expect(chain.links[1]).to be_with_block end - xit 'infers instance variables from multiple assignments' do + it 'infers instance variables from sequential assignments' do + pending('sequential assignment support') + source = Solargraph::Source.load_string(%( def foo @foo = nil @@ -428,8 +430,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..1884120a3 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 @@ -320,7 +337,9 @@ def foo expect(type.simple_tags).to eq('String, Integer') end - xit 'uses flow-sensitive typing to infer non-nil method return type' do + it 'uses flow-sensitive typing to infer non-nil method return type' do + pending('if x.nil? support in flow sensitive typing') + source = Solargraph::Source.load_string(%( # @return [Gem::Specification,nil] def find_by_name; end @@ -679,17 +698,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 +1245,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 +2022,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') @@ -2627,7 +2642,9 @@ def bar; end expect(clip.infer.to_s).to eq('Foo') end - xit 'replaces nil with reassignments' do + it 'replaces nil with reassignments' do + pending 'sequential assignment support' + source = Solargraph::Source.load_string(%( bar = nil bar @@ -2642,7 +2659,9 @@ def bar; end expect(clip.infer.to_s).to eq('Integer') end - xit 'replaces type with reassignments' do + it 'replaces type with reassignments' do + pending 'sequential assignment support' + source = Solargraph::Source.load_string(%( bar = 'a' bar @@ -2670,7 +2689,9 @@ def bar; end expect(clip.infer.to_s).to eq('String, nil') end - xit 'replaces nil with alternate reassignments' do + it 'replaces nil with alternate reassignments' do + pending 'conditional assignment support' + source = Solargraph::Source.load_string(%( bar = nil if baz @@ -2685,7 +2706,9 @@ def bar; end expect(clip.infer.to_s).to eq('Symbol, Integer') end - xit 'replaces type with alternate reassignments' do + it 'replaces type with alternate reassignments' do + pending 'conditional assignment support' + source = Solargraph::Source.load_string(%( bar = 'a' if baz @@ -2712,7 +2735,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 @@ -2950,7 +2973,7 @@ def foo expect(clip.infer.to_s).to eq('Array, Hash, Integer, nil') end - xit 'infers that type of argument has been overridden' do + it 'infers that type of argument has been overridden' do source = Solargraph::Source.load_string(%( def foo a a = 'foo' @@ -2963,7 +2986,9 @@ def foo a expect(clip.infer.to_s).to eq('String') end - xit 'preserves hash value when it is a union with brackets' do + it 'preserves hash value when it is a union with brackets' do + pending 'union in bracket support' + source = Solargraph::Source.load_string(%( # @type [Hash{String => [Array, Hash, Integer, nil]}] raw_data = {} @@ -2989,7 +3014,9 @@ def foo a expect(clip.infer.to_s).to eq('Array') end - xit 'preserves hash value when it is a union with brackets' do + it 'preserves hash value when it is a union with brackets' do + pending 'union in bracket support' + source = Solargraph::Source.load_string(%( # @type [Hash{String => [Array, Hash, Integer, nil]}] raw_data = {} 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/spec_helper.rb b/spec/spec_helper.rb index 00cc6c8c3..366c22cc3 100755 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -27,6 +27,9 @@ end require 'solargraph' # Suppress logger output in specs (if possible) +# execute any logging blocks to make sure they don't blow up +Solargraph::Logging.logger.sev_threshold = Logger::DEBUG +# ...but still suppress logger output in specs (if possible) if Solargraph::Logging.logger.respond_to?(:reopen) && !ENV.key?('SOLARGRAPH_LOG') Solargraph::Logging.logger.reopen(File::NULL) end @@ -43,3 +46,29 @@ def with_env_var(name, value) ENV[name] = old_value # Restore the old value end end + +def capture_stdout &block + original_stdout = $stdout + $stdout = StringIO.new + begin + block.call + $stdout.string + ensure + $stdout = original_stdout + end +end + +def capture_both &block + original_stdout = $stdout + original_stderr = $stderr + stringio = StringIO.new + $stdout = stringio + $stderr = stringio + begin + block.call + ensure + $stdout = original_stdout + $stderr = original_stderr + end + stringio.string +end 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..f6f60b221 --- /dev/null +++ b/spec/type_checker/levels/alpha_spec.rb @@ -0,0 +1,108 @@ +# 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 '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..d327199e3 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 @@ -539,7 +581,9 @@ def bar(baz:, bing:) expect(checker.problems).to be_empty end - xit 'requires strict return tags' do + it 'requires strict return tags' do + pending 'nil? support in flow sensitive typing' + checker = type_checker(%( class Foo # The tag is [String] but the inference is [String, nil] @@ -554,7 +598,9 @@ def bar expect(checker.problems.first.message).to include('does not match inferred type') end - xit 'requires strict return tags' do + it 'requires strict return tags' do + pending 'nil? support in flow sensitive typing' + checker = type_checker(%( class Foo # The tag is [String] but the inference is [String, nil] @@ -581,6 +627,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 +787,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 @@ -755,7 +854,9 @@ def meth(param1) expect(checker.problems).to be_one end - xit 'uses nil? to refine type' do + it 'uses nil? to refine type' do + pending 'nil? support in flow sensitive typing' + checker = type_checker(%( # @sg-ignore # @type [String, nil] @@ -769,19 +870,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 @@ -876,7 +964,9 @@ def bar expect(checker.problems.map(&:message)).to be_empty end - xit "Uses flow scope to specialize understanding of cvar types" do + it "Uses flow scope to specialize understanding of cvar types" do + pending 'better cvar support' + checker = type_checker(%( class Bar # @return [String] @@ -1004,11 +1094,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 970435dc3..3a1c59aaa 100644 --- a/spec/type_checker/levels/strong_spec.rb +++ b/spec/type_checker/levels/strong_spec.rb @@ -4,9 +4,159 @@ def type_checker(code) Solargraph::TypeChecker.load_string(code, 'test.rb', :strong) end + 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] + # + # @return [String] + def quux(baz) + baz.upcase # ERROR: Unresolved call to upcase on String, nil + end + )) + expect(checker.problems.map(&:message)).to eq(['#quux return type could not be inferred', + 'Unresolved call to upcase on String, nil']) + end + 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] @@ -17,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] @@ -67,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] @@ -97,6 +249,29 @@ def bar expect(checker.problems.map(&:message)).to include('Call to #foo is missing keyword argument b') end + it 'understands complex use of self' do + checker = type_checker(%( + class A + # @param other [self] + # + # @return [void] + def foo other; end + + # @param other [self] + # + # @return [void] + def bar(other); end + end + + class B < A + def bar(other) + foo(other) + end + end + )) + expect(checker.problems.map(&:message)).to be_empty + end + it 'calls out type issues even when keyword issues are there' do pending('fixes to arg vs param checking algorithm') @@ -130,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 @@ -256,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 @@ -273,6 +576,57 @@ 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(%( + module Foo + module Bar + class Symbol + end + end + end + + module Foo + module Baz + class Quux + # @return [void] + def foo + objects_by_class(Bar::Symbol) + end + + # @generic T + # @param klass [Class>] + # @return [Set>] + def objects_by_class klass + # @type [Set>] + s = Set.new + s + end + end + end + end + )) + end + + it 'resolves class name correctly in generic resolution' do + expect(checker.problems.map(&:message)).to be_empty + end + end + it 'resolves constants inside modules inside classes' do checker = type_checker(%( class Bar @@ -293,18 +647,202 @@ 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 on Integer, nil"]) + end + + it 'performs simple flow-sensitive typing on lvars' do + checker = type_checker(%( + class Foo + # @param bar [Integer, nil] + # @return [::Boolean, ::Integer] + def foo bar + !bar || bar.abs + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'performs simple flow-sensitive typing on ivars' do + checker = type_checker(%( + class Foo + # @param bar [::Integer, nil] + def initialize bar: nil + @bar = bar + end + + # @return [::Boolean, ::Integer] + def foo + !@bar || @bar.abs + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'performs complex flow-sensitive typing on ivars' do + checker = type_checker(%( + class Foo + # @param bar [::Array, nil] + def initialize bar: nil + @bar = bar + end + + def maybe_bar? + return !@bar.empty? if defined?(@bar) && @bar + false + end + end + )) + + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'supports !@x.nil && @x.y' do + checker = type_checker(%( + class Bar + # @param foo [String, nil] + def initialize(foo) + @foo = foo + end + + def foo? + !@foo.nil? && @foo.upcase == 'FOO' + end + end + )) + expect(checker.problems.map(&:message)).to eq([]) + end + + it 'uses cast type instead of defined type' do + checker = type_checker(%( + # frozen_string_literal: true + + 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 diff --git a/spec/type_checker/rules_spec.rb b/spec/type_checker/rules_spec.rb index e665e43a8..ded5302fa 100644 --- a/spec/type_checker/rules_spec.rb +++ b/spec/type_checker/rules_spec.rb @@ -1,6 +1,6 @@ describe Solargraph::TypeChecker::Rules do it 'sets normal rules' do - rules = Solargraph::TypeChecker::Rules.new(:normal) + rules = Solargraph::TypeChecker::Rules.new(:normal, {}) expect(rules.ignore_all_undefined?).to be(true) expect(rules.must_tag_or_infer?).to be(false) expect(rules.require_type_tags?).to be(false) @@ -9,7 +9,7 @@ end it 'sets typed rules' do - rules = Solargraph::TypeChecker::Rules.new(:typed) + rules = Solargraph::TypeChecker::Rules.new(:typed, {}) expect(rules.ignore_all_undefined?).to be(true) expect(rules.must_tag_or_infer?).to be(false) expect(rules.require_type_tags?).to be(false) @@ -18,7 +18,7 @@ end it 'sets strict rules' do - rules = Solargraph::TypeChecker::Rules.new(:strict) + rules = Solargraph::TypeChecker::Rules.new(:strict, {}) expect(rules.ignore_all_undefined?).to be(false) expect(rules.must_tag_or_infer?).to be(true) expect(rules.require_type_tags?).to be(false) @@ -27,7 +27,7 @@ end it 'sets strong rules' do - rules = Solargraph::TypeChecker::Rules.new(:strong) + rules = Solargraph::TypeChecker::Rules.new(:strong, {}) expect(rules.ignore_all_undefined?).to be(false) expect(rules.must_tag_or_infer?).to be(true) expect(rules.require_type_tags?).to be(true) diff --git a/spec/workspace/gemspecs_fetch_dependencies_spec.rb b/spec/workspace/gemspecs_fetch_dependencies_spec.rb new file mode 100644 index 000000000..285f8e1a0 --- /dev/null +++ b/spec/workspace/gemspecs_fetch_dependencies_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'rubygems/commands/install_command' + +describe Solargraph::Workspace::Gemspecs, '#fetch_dependencies' do + subject(:deps) { gemspecs.fetch_dependencies(gemspec) } + + let(:gemspecs) { described_class.new(dir_path) } + let(:dir_path) { Dir.pwd } + + context 'when in our bundle' do + context 'with a Bundler::LazySpecification' do + let(:gemspec) do + Bundler::LazySpecification.new('solargraph', nil, nil) + end + + it 'finds a known dependency' do + pending('https://github.com/castwide/solargraph/pull/1006') + expect(deps.map(&:name)).to include('backport') + end + end + + context 'with gem whose dependency does not exist in our bundle' do + let(:gemspec) do + instance_double(Gem::Specification, + dependencies: [Gem::Dependency.new('activerecord')], + development_dependencies: [], + name: 'my_fake_gem', + version: '123') + end + let(:gem_name) { 'my_fake_gem' } + + it 'gives a useful message' do + pending('https://github.com/castwide/solargraph/pull/1006') + + output = capture_both { deps.map(&:name) } + expect(output).to include('Please install the gem activerecord') + end + end + end + + context 'with external bundle' do + let(:dir_path) { File.realpath(Dir.mktmpdir).to_s } + + let(:gemspec) do + Gem::Specification.find_by_name(gem_name) + end + + before do + # write out Gemfile + File.write(File.join(dir_path, 'Gemfile'), <<~GEMFILE) + source 'https://rubygems.org' + gem '#{gem_name}' + GEMFILE + + # run bundle install + output, status = Solargraph.with_clean_env do + Open3.capture2e('bundle install --verbose', chdir: dir_path) + end + raise "Failure installing bundle: #{output}" unless status.success? + + # ensure Gemfile.lock exists + unless File.exist?(File.join(dir_path, 'Gemfile.lock')) + raise "Gemfile.lock not found after bundle install in #{dir_path}" + end + end + + context 'with gem that exists in our bundle' do + let(:gem_name) { 'undercover' } + + it 'finds dependencies' do + expect(deps.map(&:name)).to include('ast') + end + end + + context 'with gem does not exist in our bundle' do + let(:gemspec) do + Gem::Specification.new(fake_gem_name) + end + + let(:gem_name) { 'undercover' } + + let(:fake_gem_name) { 'faaaaaake912' } + + it 'gives a useful message' do + pending('https://github.com/castwide/solargraph/pull/1006') + dep_names = nil + output = capture_both { dep_names = deps.map(&:name) } + expect(output).to include('Please install the gem activerecord') + end + end + end +end diff --git a/spec/workspace/gemspecs_resolve_require_spec.rb b/spec/workspace/gemspecs_resolve_require_spec.rb new file mode 100644 index 000000000..4ffcd0bd1 --- /dev/null +++ b/spec/workspace/gemspecs_resolve_require_spec.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'rubygems/commands/install_command' + +describe Solargraph::Workspace::Gemspecs, '#resolve_require' do + subject(:specs) { gemspecs.resolve_require(require) } + + let(:gemspecs) { described_class.new(dir_path) } + + def find_or_install gem_name, version + Gem::Specification.find_by_name(gem_name, version) + rescue Gem::LoadError + install_gem(gem_name, version) + end + + def add_bundle + # write out Gemfile + File.write(File.join(dir_path, 'Gemfile'), <<~GEMFILE) + source 'https://rubygems.org' + gem 'backport' + GEMFILE + # run bundle install + output, status = Solargraph.with_clean_env do + Open3.capture2e('bundle install --verbose', chdir: dir_path) + end + raise "Failure installing bundle: #{output}" unless status.success? + # ensure Gemfile.lock exists + return if File.exist?(File.join(dir_path, 'Gemfile.lock')) + raise "Gemfile.lock not found after bundle install in #{dir_path}" + end + + def install_gem gem_name, version + Bundler.with_unbundled_env do + cmd = Gem::Commands::InstallCommand.new + cmd.handle_options [gem_name, '-v', version] + cmd.execute + rescue Gem::SystemExitException => e + raise unless e.exit_code == 0 + end + end + + context 'with local bundle' do + let(:dir_path) { File.realpath(Dir.pwd) } + + context 'with a known gem' do + let(:require) { 'solargraph' } + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with an unknown type from Bundler / RubyGems' do + let(:require) { 'solargraph' } + let(:specish_objects) { [double] } + + before do + lockfile = instance_double(Pathname) + locked_gems = instance_double(Bundler::LockfileParser, specs: specish_objects) + + definition = instance_double(Bundler::Definition, + locked_gems: locked_gems, + lockfile: lockfile) + allow(Bundler).to receive(:definition).and_return(definition) + allow(lockfile).to receive(:to_s).and_return(dir_path) + end + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + def configure_bundler_spec stub_value + platform = Gem::Platform::RUBY + bundler_stub_spec = Bundler::StubSpecification.new('solargraph', '123', platform, spec_fetcher) + specish_objects = [bundler_stub_spec] + lockfile = instance_double(Pathname) + locked_gems = instance_double(Bundler::LockfileParser, specs: specish_objects) + definition = instance_double(Bundler::Definition, + locked_gems: locked_gems, + lockfile: lockfile) + # specish_objects = Bundler.definition.locked_gems.specs + allow(Bundler).to receive(:definition).and_return(definition) + allow(lockfile).to receive(:to_s).and_return(dir_path) + allow(bundler_stub_spec).to receive(:respond_to?).with(:name).and_return(true) + allow(bundler_stub_spec).to receive(:respond_to?).with(:version).and_return(true) + allow(bundler_stub_spec).to receive(:respond_to?).with(:gem_dir).and_return(false) + allow(bundler_stub_spec).to receive(:respond_to?).with(:materialize_for_installation).and_return(false) + allow(bundler_stub_spec).to receive_messages(name: 'solargraph', stub: stub_value) + end + + context 'with a Bundler::StubSpecification from Bundler / RubyGems' do + # this can happen from local gems, which is hard to test + # organically + + let(:require) { 'solargraph' } + let(:spec_fetcher) { instance_double(Gem::SpecFetcher) } + + before do + platform = Gem::Platform::RUBY + real_spec = instance_double(Gem::Specification) + allow(real_spec).to receive(:name).and_return('solargraph') + gem_stub_spec = Gem::StubSpecification.new('solargraph', '123', platform, spec_fetcher) + configure_bundler_spec(gem_stub_spec) + allow(gem_stub_spec).to receive_messages(name: 'solargraph', version: '123', spec: real_spec) + end + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with a Bundler::StubSpecification that resolves straight to Gem::Specification' do + # have seen different behavior with different versions of rubygems/bundler + + let(:require) { 'solargraph' } + let(:spec_fetcher) { instance_double(Gem::SpecFetcher) } + let(:real_spec) { Gem::Specification.new('solargraph', '123') } + + before do + configure_bundler_spec(real_spec) + end + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with a less usual require mapping' do + let(:require) { 'diff/lcs' } + + it 'returns a single spec' do + expect(specs.size).to eq(1) + end + + it 'resolves to the right known gem' do + expect(specs.map(&:name)).to eq(['diff-lcs']) + end + end + + context 'with Bundler.require' do + let(:require) { 'bundler/require' } + + it 'returns the gemspec gem' do + expect(specs.map(&:name)).to include('solargraph') + end + end + end + + context 'with nil as directory' do + let(:dir_path) { nil } + + context 'with simple require' do + let(:require) { 'solargraph' } + + it 'finds solargraph' do + expect(specs.map(&:name)).to eq(['solargraph']) + end + end + + context 'with Bundler.require' do + let(:require) { 'bundler/require' } + + it 'finds nothing' do + pending('https://github.com/castwide/solargraph/pull/1006') + + expect(specs).to be_empty + end + end + end + + context 'with external bundle' do + let(:dir_path) { File.realpath(Dir.mktmpdir).to_s } + + context 'with no actual bundle' do + let(:require) { 'bundler/require' } + + it 'raises' do + pending('https://github.com/castwide/solargraph/pull/1006') + + expect { specs }.to raise_error(Solargraph::BundleNotFoundError) + end + end + + context 'with Gemfile and Bundler.require' do + before { add_bundle } + + let(:require) { 'bundler/require' } + + it 'does not raise' do + expect { specs }.not_to raise_error + end + + it 'returns gems' do + expect(specs.map(&:name)).to include('backport') + end + end + + context 'with Gemfile and deep require into a possibly-core gem' do + before { add_bundle } + + let(:require) { 'bundler/gem_tasks' } + + xit 'returns gems' do + pending('improved logic for require lookups') + + expect(specs&.map(&:name)).to include('bundler') + end + end + + context 'with Gemfile and deep require into a gem' do + before { add_bundle } + + let(:require) { 'rspec/mocks' } + + it 'returns gems' do + expect(specs&.map(&:name)).to include('rspec-mocks') + end + end + + context 'with Gemfile but an unknown gem' do + before { add_bundle } + + let(:require) { 'unknown_gemlaksdflkdf' } + + it 'returns nil' do + expect(specs).to be_nil + end + end + + context 'with a Gemfile and a gem preference' do + # find_or_install helper doesn't seem to work on older versions + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1.0') + before do + add_bundle + find_or_install('backport', '1.0.0') + Gem::Specification.find_by_name('backport', '= 1.0.0') + end + + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = '1.0.0' + end + ] + end + + it 'returns the preferred gemspec' do + pending('https://github.com/castwide/solargraph/pull/1006') + + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq('1.0.0') + end + + context 'with a gem preference that does not exist' do + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = '99.0.0' + end + ] + end + + it 'returns the gemspec we do have' do + pending('https://github.com/castwide/solargraph/pull/1006') + + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq('1.2.0') + end + end + + context 'with a gem preference already set to the version we use' do + let(:version) { Gem::Specification.find_by_name('backport').version.to_s } + + let(:preferences) do + [ + Gem::Specification.new.tap do |spec| + spec.name = 'backport' + spec.version = version + end + ] + end + + it 'returns the gemspec we do have' do + gemspecs = described_class.new(dir_path, preferences: preferences) + specs = gemspecs.resolve_require('backport') + backport = specs.find { |spec| spec.name == 'backport' } + + expect(backport.version.to_s).to eq(version) + end + end + end + end + end +end diff --git a/spec/workspace_spec.rb b/spec/workspace_spec.rb index db8141249..dbfeab63c 100644 --- a/spec/workspace_spec.rb +++ b/spec/workspace_spec.rb @@ -68,6 +68,13 @@ }.not_to raise_error end + it "detects gemspecs in workspaces" do + gemspec_file = File.join(dir_path, 'test.gemspec') + File.write(gemspec_file, '') + expect(workspace.gemspec?).to be(true) + expect(workspace.gemspec_files).to eq([gemspec_file]) + end + it "generates default require path" do expect(workspace.require_paths).to eq([File.join(dir_path, 'lib')]) end @@ -117,13 +124,15 @@ it "uses configured require paths" do workspace = Solargraph::Workspace.new('spec/fixtures/workspace') - expect(workspace.require_paths).to eq(['spec/fixtures/workspace/lib', 'spec/fixtures/workspace/ext']) + expect(workspace.require_paths).to eq([File.absolute_path('spec/fixtures/workspace/lib'), + File.absolute_path('spec/fixtures/workspace/ext')]) end it 'ignores gemspecs in excluded directories' do # vendor/**/* is excluded by default workspace = Solargraph::Workspace.new('spec/fixtures/vendored') - expect(workspace.require_paths).to eq(['spec/fixtures/vendored/lib']) + abs_path = File.absolute_path('spec/fixtures/vendored/lib') + expect(workspace.require_paths).to end_with([abs_path]) end it 'rescues errors loading files into sources' do diff --git a/spec/yard_map/mapper/to_method_spec.rb b/spec/yard_map/mapper/to_method_spec.rb index 9c5caa705..c90fe75ed 100644 --- a/spec/yard_map/mapper/to_method_spec.rb +++ b/spec/yard_map/mapper/to_method_spec.rb @@ -67,7 +67,9 @@ expect(param.full).to eq("&bar") end - xit 'parses undefined but typed blockargs' do + it 'parses undeclared but typed blockargs' do + pending('block args coming from YARD alone') + code_object.parameters = [] code_object.docstring = <