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
49 changes: 39 additions & 10 deletions lib/solargraph/parser/flow_sensitive_typing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ class FlowSensitiveTyping
# @param ivars [Array<Solargraph::Pin::InstanceVariable>]
# @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil]
# @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil]
def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin
# @param closure [Solargraph::Pin::Closure] used to resolve a
# bare, implicit-self call like 'steps' as a method call
def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, closure
@locals = locals
@ivars = ivars
@enclosing_breakable_pin = enclosing_breakable_pin
@enclosing_compound_statement_pin = enclosing_compound_statement_pin
@closure = closure
end

# @param and_node [Parser::AST::Node]
Expand Down Expand Up @@ -329,20 +332,29 @@ def find_var variable_name, position
end
end

# Finds (single var) or builds (chain, e.g. ['pin', 'location'])
# the pin narrowing facts get recorded on. A built pin probes its
# type lazily from `node`, so it can't see its own new facts.
# Finds (single var) or builds (chain, e.g. ['pin', 'location']) the
# pin narrowing facts get recorded on. A built pin probes its type
# lazily from `node`, so it can't see its own new facts.
#
# @param chain_words [::Array<String>]
# @param node [Parser::AST::Node] the receiver expression, e.g. the
# node for 'pin.location'
# @param position [Position]
# @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil]
def chain_pin chain_words, node, position
# @sg-ignore chain_words is never empty - callers already checked
return find_var(chain_words.first, position) if chain_words.length == 1
if chain_words.length == 1
word = chain_words.first
return unless word

# @sg-ignore chain_words is never empty - callers already checked
# A bare word is ambiguous: :lvar is a tracked local, :send a self call (e.g. 'steps').
return find_var(word, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send

return unless closure

return self_call_pin(node)
end

# @sg-ignore https://github.com/apiology/solargraph/pull/53
root_pin = find_var(chain_words.first, position)
return unless root_pin

Expand All @@ -355,6 +367,22 @@ def chain_pin chain_words, node, position
)
end

# Builds a pin for a bare self call (e.g. 'steps'), rooted at
# `closure` since there's no variable pin to inherit one from.
# Named after the bare word so ApiMap#var_at_location's lookup finds it.
#
# @param node [Parser::AST::Node] the call node, e.g. 'steps'
# @return [Solargraph::Pin::LocalVariable]
def self_call_pin node
Pin::LocalVariable.new(
location: Location.from_node(node),
closure: closure,
name: node.children[1].to_s,
assignment: node,
source: :flow_sensitive_typing
)
end

# @param isa_node [Parser::AST::Node]
# @param true_presences [Array<Range>]
# @param false_presences [Array<Range>]
Expand Down Expand Up @@ -484,7 +512,8 @@ def process_variable node, true_presences, false_presences
end

# Handles a truthy check on a call chain, e.g. 'pin.location' in
# 'return nil unless pin.location'; bare vars go to #process_variable.
# 'return nil unless pin.location', or on a bare 0-arg self call,
# e.g. 'steps'; bare vars (:lvar/:ivar) go to #process_variable.
#
# @param node [Parser::AST::Node]
# @param true_presences [Array<Range>]
Expand All @@ -498,7 +527,7 @@ def process_call_chain node, true_presences, false_presences
return if %i[nil? !].include?(node.children[1])

chain_words = parse_receiver_chain(node)
return if chain_words.nil? || chain_words.length < 2
return if chain_words.nil? || chain_words.empty?

# @sg-ignore Need to add nil check here
position = Range.from_node(node).start
Expand Down Expand Up @@ -554,7 +583,7 @@ def always_leaves_compound_statement? clause_node
%i[return raise next redo retry].include?(clause_node&.type)
end

attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin
attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin, :closure
end
end
end
3 changes: 2 additions & 1 deletion lib/solargraph/parser/parser_gem/node_processors/and_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def process
FlowSensitiveTyping.new(locals,
ivars,
enclosing_breakable_pin,
enclosing_compound_statement_pin).process_and(node)
enclosing_compound_statement_pin,
region.closure).process_and(node)
end
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/solargraph/parser/parser_gem/node_processors/if_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def process
FlowSensitiveTyping.new(locals,
ivars,
enclosing_breakable_pin,
enclosing_compound_statement_pin).process_if(node)
enclosing_compound_statement_pin,
region.closure).process_if(node)
condition_node = node.children[0]
if condition_node
pins.push Solargraph::Pin::CompoundStatement.new(
Expand Down
3 changes: 2 additions & 1 deletion lib/solargraph/parser/parser_gem/node_processors/or_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def process
FlowSensitiveTyping.new(locals,
ivars,
enclosing_breakable_pin,
enclosing_compound_statement_pin).process_or(node)
enclosing_compound_statement_pin,
region.closure).process_or(node)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def process
FlowSensitiveTyping.new(locals,
ivars,
enclosing_breakable_pin,
enclosing_compound_statement_pin).process_while(node)
enclosing_compound_statement_pin,
region.closure).process_while(node)

# Note - this should not be considered a block, as the
# while statement doesn't create a closure - e.g.,
Expand Down
53 changes: 53 additions & 0 deletions spec/parser/flow_sensitive_typing_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,59 @@ def bundled_filename(pin)
expect(clip.infer.rooted_tags).to eq('::String')
end

it 'narrows a bare, implicit-self attr_reader-style accessor after a .nil? guard' do
source = Solargraph::Source.load_string(%(
class Repro
# @return [Array<Hash>, nil]
attr_reader :steps

def identify
return nil if steps.nil?
steps.empty?
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [7, 15])
expect(clip.infer.rooted_tags).to eq('::Array<::Hash>')
end

it 'narrows a bare, implicit-self attr_reader-style accessor after a truthy guard' do
source = Solargraph::Source.load_string(%(
class Repro
# @return [Array<Hash>, nil]
attr_reader :steps

def identify
return nil unless steps
steps.empty?
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [7, 15])
expect(clip.infer.rooted_tags).to eq('::Array<::Hash>')
end

it 'narrows a bare, implicit-self attr_reader-style accessor assigned into a fresh local variable' do
source = Solargraph::Source.load_string(%(
class Repro
# @return [Array<Hash>, nil]
attr_reader :steps

def identify
return nil if steps.nil?

local = steps
local.empty?
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [9, 15])
expect(clip.infer.rooted_tags).to eq('::Array<::Hash>')
end

it 'narrows a repeated call to the same attr_reader-style accessor rooted in an ivar' do
source = Solargraph::Source.load_string(%(
class Location
Expand Down
46 changes: 46 additions & 0 deletions spec/type_checker/levels/strong_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -997,5 +997,51 @@ def baz(bases)
# an error when trying to declare sub as Subclass
expect(checker.problems.map(&:message)).not_to include('Unresolved call to bar on Base')
end

it 'passes a narrowed local assigned from a bare accessor to a non-nil param, guarded by .nil?' do
checker = type_checker(%(
class Repro
# @return [Array<Hash>, nil]
attr_reader :steps

# @param steps [Array<Hash>]
# @return [void]
def consume(steps); end

# @return [void]
def unwrap
return nil if steps.nil?

steps_list = steps
consume(steps_list)
end
end
))

expect(checker.problems.map(&:message)).to eq([])
end

it 'passes a narrowed local assigned from a bare accessor to a non-nil param, guarded by a non-nil return' do
checker = type_checker(%(
class Repro
# @return [Array<Hash>, nil]
attr_reader :substeps

# @param substeps [Array<Hash>]
# @return [void]
def consume(substeps); end

# @return [void]
def extract
return ['', nil] if substeps.nil?

steps_list = substeps
consume(steps_list)
end
end
))

expect(checker.problems.map(&:message)).to eq([])
end
end
end
Loading