Skip to content
Closed
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
37 changes: 36 additions & 1 deletion lib/solargraph/parser/flow_sensitive_typing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ def process_or or_node, true_ranges = [], false_ranges = []
# 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
# true, so don't provide true ranges to assert facts on -
# except when both sides are `is_a?` on the same variable.
process_or_isa_union(or_node, lhs, rhs, true_ranges)

process_expression(lhs, [], false_ranges + [rhs_presence])
process_expression(rhs, [], false_ranges)
Expand Down Expand Up @@ -334,6 +336,39 @@ def process_isa isa_node, true_presences, false_presences
process_facts(if_false, false_presences)
end

# Narrows `x.is_a?(A) || x.is_a?(B)` to the union of A and B.
#
# @param or_node [Parser::AST::Node]
# @param lhs [Parser::AST::Node]
# @param rhs [Parser::AST::Node]
# @param true_ranges [Array<Range>]
#
# @return [void]
def process_or_isa_union or_node, lhs, rhs, true_ranges
return if true_ranges.empty?

lhs_type_name, lhs_variable_name = parse_isa(lhs)
return if lhs_variable_name.nil? || lhs_variable_name.empty?

rhs_type_name, rhs_variable_name = parse_isa(rhs)
return if rhs_variable_name.nil? || rhs_variable_name.empty?

# only sound to narrow when both sides test the same variable
return unless lhs_variable_name == rhs_variable_name

# @sg-ignore Need to add nil check here
or_position = Range.from_node(or_node).start

pin = find_var(lhs_variable_name, or_position)
return unless pin

# @type Hash{Pin::BaseVariable => Array<Hash{Symbol => ComplexType}>}
if_true = {}
if_true[pin] ||= []
if_true[pin] << { type: ComplexType.parse(lhs_type_name, rhs_type_name) }
process_facts(if_true, true_ranges)
end

# @param nilp_node [Parser::AST::Node]
# @return [Array(String, String), nil]
def parse_nilp nilp_node
Expand Down
60 changes: 60 additions & 0 deletions spec/parser/flow_sensitive_typing_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1025,4 +1025,64 @@ def check
clip = api_map.clip_at('test.rb', [13, 12])
expect(clip.infer.to_s).to eq('ReproBase')
end

it 'uses is_a? in an || to narrow to the union of both checked types' do
source = Solargraph::Source.load_string(%(
class Repro
# @param e [Array<Symbol>, String]
# @return [void]
def eval_function_call(e); end

# @param e [Array<Symbol>, String, Integer]
# @return [void]
def eval(e)
if e.is_a?(Array) || e.is_a?(String)
e
end
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [10, 12])
expect(clip.infer.to_s).to eq('Array<Symbol>, String')
end

it 'does not narrow via || when only one side of the check is is_a? (negative control)' do
source = Solargraph::Source.load_string(%(
class Repro
# @param e [Array<Symbol>, String]
# @return [void]
def eval_function_call(e); end

# @param e [Array<Symbol>, String, Integer]
# @return [void]
def eval(e)
if e.is_a?(Array)
e
end
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [10, 12])
expect(clip.infer.to_s).to eq('Array<Symbol>')
end

it 'does not invent a narrowing across || when the two is_a? checks test different variables (soundness control)' do
source = Solargraph::Source.load_string(%(
class Repro
# @param x [Array<Symbol>, Integer]
# @param y [String, Integer]
# @return [void]
def eval(x, y)
if x.is_a?(Array) || y.is_a?(String)
x
end
end
end
), 'test.rb')
api_map = Solargraph::ApiMap.new.map(source)
clip = api_map.clip_at('test.rb', [7, 12])
expect(clip.infer.to_s).to eq('Array<Symbol>, Integer')
end
end
Loading