Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/solargraph/type_checker/rules.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,12 @@ def require_inferred_type_params?
#
# @todo 4: Missed nil violation
#
# pending code fixes (277):
# pending code fixes (278):
#
# @todo 281: Need to add nil check here
# @todo 22: Translate to something flow sensitive typing understands
# @todo 3: Need a downcast here
# @todo 1: Fixnum shim (diff-lcs) shadows Integer in RBS union, breaking resolution
#
# flow sensitive typing could handle (96):
#
Expand Down
2 changes: 2 additions & 0 deletions lib/solargraph/yard_map/mapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Mapper
autoload :ToMethod, 'solargraph/yard_map/mapper/to_method'
autoload :ToNamespace, 'solargraph/yard_map/mapper/to_namespace'
autoload :ToConstant, 'solargraph/yard_map/mapper/to_constant'
autoload :ToStructInitializer, 'solargraph/yard_map/mapper/to_struct_initializer'

# @param code_objects [Array<YARD::CodeObjects::Base>]
# @param spec [Gem::Specification, nil]
Expand Down Expand Up @@ -45,6 +46,7 @@ def generate_pins code_object
nspin = namespace_with_bug_fix(code_object)
@namespace_pins[code_object.path] = nspin
result.push nspin
result.concat ToStructInitializer.make(code_object, nspin, @spec)
if code_object.is_a?(YARD::CodeObjects::ClassObject) && !code_object.superclass.nil?
# This method of superclass detection is a bit of a hack. If
# the superclass is a Proxy, it is assumed to be undefined in its
Expand Down
128 changes: 128 additions & 0 deletions lib/solargraph/yard_map/mapper/to_struct_initializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# frozen_string_literal: true

module Solargraph
class YardMap
class Mapper
# Synthesizes the constructor pins YARD never generates for a
# `Foo = Struct.new(:bar, :baz)` (or `class Foo < Struct.new(...)`)
# definition it documents.
#
# YARD's own Ruby handlers for `Struct.new`
# (`YARD::Handlers::Ruby::ConstantHandler#process_structclass`,
# `YARD::Handlers::Ruby::ClassHandler`) register the struct as a real
# `ClassObject` with a superclass of `Struct` and generate reader/writer
# methods for each member -- via `StructHandlerMethods#create_attributes`
# -- but never an `initialize`. Without one, `Foo.new(...)` resolves
# against `Struct.new`'s own signature (the nearest ancestor method
# Solargraph can find), reporting a wrong-argument-type error for
# ordinary positional Struct construction.
#
# `Solargraph::Convention::StructDefinition` already covers the
# equivalent case for workspace source, by building its own initialize
# pin from the parsed `Struct.new(...)` node. This covers the same shape
# when it arrives via a gem's yardoc, where there is no such node --
# only the member names YARD kept.
module ToStructInitializer
# Matches Ruby's own magic encoding comment, optionally preceded by a
# shebang line -- the same two-line rule `Kernel#require` applies.
# `File.readlines` has no reason to know a file's encoding and
# defaults to `Encoding.default_external`; it does not honor this
# comment the way compiling the file would.
MAGIC_ENCODING_LINE = /\A#.*coding\s*[:=]\s*([\w-]+)/i

class << self
# @param code_object [YARD::CodeObjects::Base]
# @param closure [Pin::Namespace]
# @param spec [Gem::Specification, nil]
# @return [Array<Pin::Method>] empty when code_object isn't a
# Struct.new definition, or already documents its own initialize
def make code_object, closure, spec = nil
return [] unless code_object.is_a?(YARD::CodeObjects::ClassObject)
return [] unless code_object.superclass.to_s == 'Struct'
return [] if code_object.child(name: 'initialize', scope: :instance)

members = code_object.attributes[:instance].keys
return [] if members.empty?

initializer = synthetic_initializer(code_object, members, spec)
[
ToMethod.make(initializer, 'new', :class, :public, closure, spec),
ToMethod.make(initializer, 'initialize', :instance, :private, closure, spec)
]
end

private

# @param code_object [YARD::CodeObjects::ClassObject]
# @param members [Array<Symbol>]
# @param spec [Gem::Specification, nil]
# @return [YARD::CodeObjects::MethodObject]
def synthetic_initializer code_object, members, spec
keyword = keyword_init?(code_object, spec)
initializer = YARD::CodeObjects::MethodObject.new(code_object, 'initialize', :instance)
initializer.visibility = :private
# A truthy second element marks the parameter as having a
# default, which is what ToMethod's `arg_type` uses to choose
# `:kwoptarg` over `:kwarg` -- matching how a real keyword_init
# Struct accepts any member being omitted.
initializer.parameters = members.map { |m| keyword ? ["#{m}:", ''] : [m.to_s, nil] }
initializer
end

# Best-effort: YARD's Struct handler discards whether the original
# call passed `keyword_init: true` -- it only keeps the member
# names -- so this rereads the source line the class was defined on
# to recover it. Falls back to positional (`false`) when the source
# can't be read or the line doesn't mention it.
#
# @param code_object [YARD::CodeObjects::ClassObject]
# @param spec [Gem::Specification, nil]
# @return [Boolean]
def keyword_init? code_object, spec
file = code_object.file
return false if file.nil?

line = code_object.line
return false if line.nil?

path = spec ? File.join(spec.full_gem_path, file) : file
return false unless File.file?(path)

# @sg-ignore Fixnum shim (diff-lcs) shadows Integer in RBS union, breaking resolution
!!File.readlines(path, encoding: detect_encoding(path))[line - 1].to_s.match?(/keyword_init:\s*true/)
rescue ArgumentError => e
# The declared encoding doesn't match the file's actual bytes on
# this line -- e.g. a magic comment claims UTF-8 (or none is
# present, which defaults to UTF-8) but the byte isn't valid
# UTF-8, or names an encoding under which it still isn't valid.
Solargraph.logger.info "Could not check #{path}:#{line} for keyword_init: [#{e.class}] #{e.message}"
false
end

# @param path [String]
# @return [Encoding]
# @sg-ignore Only Encoding.find("internal") can return nil, per
# https://docs.ruby-lang.org/en/3.2/Encoding.html#method-c-find --
# never a name parsed from a magic comment. The `||` below
# covers it, but flow-sensitive typing doesn't narrow it out of
# this method's inferred return type.
def detect_encoding path
first_lines = File.open(path, 'rb') { |f| [f.gets, f.gets] }.compact
first_lines.shift if first_lines.first&.start_with?('#!')
match = MAGIC_ENCODING_LINE.match(first_lines.first.to_s)
return Encoding::UTF_8 if match.nil?

encoding_name = match[1]
return Encoding::UTF_8 if encoding_name.nil?

begin
Encoding.find(encoding_name) || Encoding::UTF_8
rescue ArgumentError
Encoding::UTF_8
end
end
end
end
end
end
end
153 changes: 153 additions & 0 deletions spec/yard_map/mapper/to_struct_initializer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# frozen_string_literal: true

require 'tmpdir'

describe Solargraph::YardMap::Mapper::ToStructInitializer do
around do |example|
YARD::Registry.clear
example.run
YARD::Registry.clear
end

# Maps a source string the way a gem's yardoc would arrive: YARD parses it,
# and the Mapper converts the resulting code objects into pins.
#
# @param code [String]
# @return [Array<Solargraph::Pin::Base>]
def map code
YARD.parse_string(code)
Solargraph::YardMap::Mapper.new(YARD::Registry.all).map
end

# @param pins [Array<Solargraph::Pin::Base>]
# @param path [String]
# @return [Array<Solargraph::Pin::Base>]
def pins_at pins, path
pins.select { |pin| pin.path == path }
end

# Maps a source string from a real file on disk, the way a gem's yardoc
# would -- needed for the `keyword_init: true` heuristic, which rereads
# the source file and has nothing to read from `YARD.parse_string`'s
# synthetic "(stdin)" object.
#
# @param code [String]
# @return [Array<Solargraph::Pin::Base>]
def map_file code
Dir.mktmpdir do |dir|
path = File.join(dir, 'struct_def.rb')
File.write(path, code)
YARD.parse(path)
Solargraph::YardMap::Mapper.new(YARD::Registry.all).map
end
end

it 'synthesizes a .new and #initialize pin for a const-assigned Struct' do
pins = map(<<~RUBY)
Foo = Struct.new(:bar, :baz) do
def combined
"\#{bar}\#{baz}"
end
end
RUBY

new_pin = pins_at(pins, 'Foo.new').first
init_pin = pins_at(pins, 'Foo#initialize').first
expect(new_pin).to be_a(Solargraph::Pin::Method)
expect(init_pin).to be_a(Solargraph::Pin::Method)
expect(new_pin.parameters.map(&:name)).to eq(%w[bar baz])
expect(init_pin.parameters.map(&:name)).to eq(%w[bar baz])
expect(init_pin.visibility).to be(:private)
end

it 'gives struct members positional (arg) parameters by default' do
pins = map('Foo = Struct.new(:bar, :baz)')
init_pin = pins_at(pins, 'Foo#initialize').first
expect(init_pin.parameters.map(&:decl)).to eq(%i[arg arg])
end

it 'gives struct members keyword parameters when keyword_init: true is used' do
pins = map_file('Foo = Struct.new(:bar, :baz, keyword_init: true)')
init_pin = pins_at(pins, 'Foo#initialize').first
expect(init_pin.parameters.map(&:decl)).to eq(%i[kwoptarg kwoptarg])
end

it 'resolves .new against the real field list rather than Struct.new' do
pins = map('Foo = Struct.new(:bar, :baz)')
api_map = Solargraph::ApiMap.new(pins: pins)
stack = api_map.get_method_stack('Foo', 'new', scope: :class)
expect(stack.first.parameters.map(&:name)).to eq(%w[bar baz])
end

it 'handles the inheritance form (class Foo < Struct.new(...))' do
pins = map(<<~RUBY)
class Foo < Struct.new(:bar, :baz)
def combined
"\#{bar}\#{baz}"
end
end
RUBY

init_pin = pins_at(pins, 'Foo#initialize').first
expect(init_pin).to be_a(Solargraph::Pin::Method)
expect(init_pin.parameters.map(&:name)).to eq(%w[bar baz])
end

it 'does not synthesize a constructor for a plain constant' do
pins = map('Foo = 42')
expect(pins_at(pins, 'Foo.new')).to be_empty
expect(pins_at(pins, 'Foo#initialize')).to be_empty
end

it 'honors a magic encoding comment when rereading the definition line, since ' \
'File.readlines defaults to UTF-8 and would not honor it on its own, even though YARD does' do
Dir.mktmpdir do |dir|
path = File.join(dir, 'struct_def.rb')
content = "# encoding: ISO-8859-1\nFoo = Struct.new(:bar, :baz, keyword_init: true) # caf\xE9\n"
File.binwrite(path, content)
YARD.parse(path)
pins = Solargraph::YardMap::Mapper.new(YARD::Registry.all).map

init_pin = pins_at(pins, 'Foo#initialize').first
expect(init_pin.parameters.map(&:decl)).to eq(%i[kwoptarg kwoptarg])
end
end

it 'falls back to positional when the declared encoding does not match the actual bytes -- ' \
'checked directly against #keyword_init?, since a file this malformed would never reach YARD as a real ClassObject' do
Dir.mktmpdir do |dir|
path = File.join(dir, 'struct_def.rb')
content = "# encoding: US-ASCII\nFoo = Struct.new(:bar, :baz, keyword_init: true) # caf\xE9\n"
File.binwrite(path, content)

code_object = Struct.new(:file, :line).new(path, 2)
result = described_class.send(:keyword_init?, code_object, nil)
expect(result).to be(false)
end
end

it 'falls back to UTF-8 when the magic comment names an unknown encoding' do
Dir.mktmpdir do |dir|
path = File.join(dir, 'struct_def.rb')
File.write(path, "# encoding: totally-bogus-encoding\nFoo = Struct.new(:bar)\n")

result = described_class.send(:detect_encoding, path)
expect(result).to eq(Encoding::UTF_8)
end
end

it 'does not override an explicitly documented initialize' do
pins = map(<<~RUBY)
Foo = Struct.new(:bar) do
def initialize(bar, extra)
super(bar)
@extra = extra
end
end
RUBY

init_pins = pins_at(pins, 'Foo#initialize')
expect(init_pins.length).to eq(1)
expect(init_pins.first.parameters.map(&:name)).to eq(%w[bar extra])
end
end
Loading