Skip to content

Flow-sensitive typing - automatically downcast from is_a? calls #856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 54 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
9300883
Use 'if foo.is_a?' syntax to downcast types for initial cases
apiology Mar 30, 2025
f95915d
Add node processor for :if
apiology Mar 30, 2025
91f4515
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology Mar 31, 2025
1a60b1b
Adjust presence of then clause
apiology Apr 1, 2025
70382e2
Specify more type behavior for variable reassignment
apiology Apr 1, 2025
a890c43
Adjust local variable presence to start after assignment, not before
apiology Apr 1, 2025
1c47ddb
Merge branch 'variable_visibility' into flow-sensitive-typing
apiology Apr 1, 2025
088749c
Merge branch 'variable_reassignment_specs' into flow-sensitive-typing
apiology Apr 1, 2025
9948be2
Mark pin as overriding type
apiology Apr 1, 2025
07b9929
Fix position-related bug that broke 'unless'
apiology Apr 2, 2025
491dd23
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology Apr 2, 2025
f610d38
Extract to new file
apiology Apr 2, 2025
1736a8f
Extract to new file
apiology Apr 2, 2025
7424552
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology Apr 6, 2025
598d7b5
Fix strict type-checking issues
apiology Apr 6, 2025
c520907
Handle namespace names recursively
apiology Apr 6, 2025
5a023ec
Fix merge-related duplication
apiology Apr 6, 2025
da96606
Support elsif in flow_sensitive_typing.rb
apiology Apr 6, 2025
d379582
flow_sensitive_typing.rb cleanup
apiology Apr 6, 2025
d013337
Test simple if/then/else
apiology Apr 6, 2025
dd9f1f9
Handle flow-sensitive-typing for 'break unless'
apiology Apr 7, 2025
66ca581
Handle nil case
apiology Apr 7, 2025
ced3bec
Drop outdated @return annotation
apiology Apr 7, 2025
c82ae01
Type fixes
apiology Apr 7, 2025
5fc73af
Add support for 'break unless'
apiology Apr 7, 2025
4b5a756
Mark methods as private
apiology Apr 7, 2025
d4af989
Add unless-in-.each test
apiology Apr 7, 2025
59e6ef1
Use capture block pin in clip.rb for strong typing
apiology Apr 7, 2025
112e6c3
Add simple processing for &&
apiology Apr 7, 2025
8e19ef6
Add simple processing for &&
apiology Apr 7, 2025
f6f5896
Add simple processing for &&
apiology Apr 7, 2025
195e7b4
Add more complex processing for &&
apiology Apr 7, 2025
74224dc
Take advantage of flow-sensitive typing on internal code
apiology Apr 7, 2025
db2cd70
Add regression test around assignment in return position
apiology Apr 10, 2025
b29423c
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology Apr 10, 2025
f115b82
Merge branch 'regression_test' into flow-sensitive-typing
apiology Apr 10, 2025
77d0a63
Merge remote-tracking branch 'castwide/master' into variable_visibility
apiology Apr 10, 2025
329b42e
Merge branch 'regression_test' into variable_visibility
apiology Apr 10, 2025
9677375
Fix assignment visibility code, which relied on bad asgn semantics
apiology Apr 10, 2025
c58c16f
Merge branch 'variable_visibility' into flow-sensitive-typing
apiology Apr 10, 2025
17e84be
Calculate local variable visibility in Chain::Call
apiology Apr 11, 2025
9262959
declaration? -> presence_certain?
apiology Apr 11, 2025
18d8115
Add spec for future flow-sensitive typing scope
apiology Apr 11, 2025
5dfc7b7
Merge branch 'master' into flow-sensitive-typing
apiology Apr 15, 2025
09a1fa9
Populate location information from RBS files (#768)
apiology Apr 16, 2025
8287782
Consolidate parameter handling into Pin::Callable (#844)
apiology Apr 16, 2025
a2547f1
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology Apr 16, 2025
0b2323d
Merge branch 'master' into flow-sensitive-typing
apiology Apr 19, 2025
31bc064
Improve type annotations
apiology Apr 24, 2025
6e21a58
Merge branch 'master' into flow-sensitive-typing
apiology Apr 24, 2025
000d115
Merge branch 'master' into flow-sensitive-typing
apiology Apr 25, 2025
0bc7336
Fix types
apiology Apr 26, 2025
041ae16
Merge remote-tracking branch 'castwide/master' into flow-sensitive-ty…
apiology May 1, 2025
6bb3827
Mark test as pending upcoming PR
apiology May 1, 2025
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
5 changes: 5 additions & 0 deletions lib/solargraph/api_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@ def get_instance_variable_pins(namespace, scope = :instance)
result
end

# @see Solargraph::Parser::FlowSensitiveTyping#visible_pins
def visible_pins(*args, **kwargs, &blk)
Solargraph::Parser::FlowSensitiveTyping.visible_pins(*args, **kwargs, &blk)
end

# Get an array of class variable pins for a namespace.
#
# @param namespace [String] A fully qualified namespace
Expand Down
1 change: 1 addition & 0 deletions lib/solargraph/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Parser
autoload :ParserGem, 'solargraph/parser/parser_gem'
autoload :Region, 'solargraph/parser/region'
autoload :NodeProcessor, 'solargraph/parser/node_processor'
autoload :FlowSensitiveTyping, 'solargraph/parser/flow_sensitive_typing'
autoload :Snippet, 'solargraph/parser/snippet'

class SyntaxError < StandardError
Expand Down
204 changes: 204 additions & 0 deletions lib/solargraph/parser/flow_sensitive_typing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
module Solargraph
module Parser
class FlowSensitiveTyping
include Solargraph::Parser::NodeMethods

# @param locals [Array<Solargraph::Pin::LocalVariable, Solargraph::Pin::Parameter>]
def initialize(locals, enclosing_breakable_pin = nil)
@locals = locals
@enclosing_breakable_pin = enclosing_breakable_pin
end

# @param and_node [Parser::AST::Node]
def process_and(and_node, true_ranges = [])
lhs = and_node.children[0]
rhs = and_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))
process_isa(lhs, true_ranges + [rhs_presence])
end

# @param if_node [Parser::AST::Node]
def process_if(if_node)
#
# See if we can refine a type based on the result of 'if foo.nil?'
#
# [3] pry(main)> require 'parser/current'; Parser::CurrentRuby.parse("if foo.is_a? Baz; then foo; else bar; end")
# => s(:if,
# s(:send,
# s(:send, nil, :foo), :is_a?,
# s(:const, nil, :Baz)),
# s(:send, nil, :foo),
# s(:send, nil, :bar))
# [4] pry(main)>
conditional_node = if_node.children[0]
then_clause = if_node.children[1]
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))
true_ranges << rest_of_breakable_body
end
end

unless then_clause.nil?
#
# Add specialized locals for the then clause range
#
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)
true_ranges << Range.new(before_then_clause_pos,
get_node_end_position(then_clause))
end

process_conditional(conditional_node, true_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<Pin::LocalVariable>]
# @param closure [Pin::Closure]
# @param location [Location]
def self.visible_pins(pins, name, closure, location)
pins_with_name = pins.select { |p| p.name == name }
return [] if pins_with_name.empty?
pins_with_specific_visibility = pins.select { |p| p.name == name && p.presence && p.visible_at?(closure, location) }
return pins_with_name if pins_with_specific_visibility.empty?
visible_pins_specific_to_this_closure = pins_with_specific_visibility.select { |p| p.closure == closure }
return pins_with_specific_visibility if visible_pins_specific_to_this_closure.empty?
flow_defined_pins = pins_with_specific_visibility.select { |p| p.presence_certain? }
return visible_pins_specific_to_this_closure if flow_defined_pins.empty?
flow_defined_pins
end

private

# @param pin [Pin::LocalVariable]
# @param if_node [Parser::AST::Node]
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
)
locals.push(new_pin)
end

# @param facts_by_pin [Hash{Pin::LocalVariable => Array<Hash{Symbol => String}>}]
# @param presences [Array<Range>]
# @return [void]
def process_facts(facts_by_pin, presences)
#
# Add specialized locals for the rest of the block
#
facts_by_pin.each_pair do |pin, facts|
facts.each do |fact|
downcast_type_name = fact.fetch(:type)
presences.each do |presence|
add_downcast_local(pin, downcast_type_name, presence)
end
end
end
end

# @param conditional_node [Parser::AST::Node]
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
end

# @param isa_node [Parser::AST::Node]
# @return [Array(String, String)]
def parse_isa(isa_node)
return unless isa_node.type == :send && isa_node.children[1] == :is_a?
# 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

# check if isa_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
end
# or like this:
# (lvar :repr)
variable_name = isa_receiver.children[0].to_s if isa_receiver.type == :lvar
return unless variable_name

[isa_type_name, variable_name]
end

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
end

def process_isa(isa_node, true_presences)
isa_type_name, variable_name = parse_isa(isa_node)
return if variable_name.nil? || variable_name.empty?
isa_position = Range.from_node(isa_node).start

pin = find_local(variable_name, isa_position)
return unless pin

if_true = {}
if_true[pin] ||= []
if_true[pin] << { type: isa_type_name }
process_facts(if_true, true_presences)
end

# @param node [Parser::AST::Node]
def type_name(node)
# e.g.,
# s(:const, nil, :Baz)
return unless node.type == :const
module_node = node.children[0]
class_node = node.children[1]

return class_node.to_s if module_node.nil?

module_type_name = type_name(module_node)
return unless module_type_name

"#{module_type_name}::#{class_node}"
end

# @todo "return type could not be inferred" should not trigger here
# @sg-ignore
# @param clause_node [Parser::AST::Node]
def always_breaks?(clause_node)
clause_node&.type == :break
end

attr_reader :locals

attr_reader :enclosing_breakable_pin
end
end
end
14 changes: 14 additions & 0 deletions lib/solargraph/parser/node_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ def process node
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
14 changes: 7 additions & 7 deletions lib/solargraph/parser/parser_gem/node_chainer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,22 @@ def generate_links n
elsif n.type == :send
if n.children[0].is_a?(::Parser::AST::Node)
result.concat generate_links(n.children[0])
result.push Chain::Call.new(n.children[1].to_s, node_args(n), passed_block(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 = []
n.children[2..-1].each do |c|
args.push NodeChainer.chain(c, @filename, n)
end
result.push Chain::Call.new(n.children[1].to_s, node_args(n), passed_block(n))
result.push Chain::Call.new(n.children[1].to_s, Location.from_node(n), node_args(n), passed_block(n))
else
raise "No idea what to do with #{n}"
end
elsif n.type == :csend
if n.children[0].is_a?(::Parser::AST::Node)
result.concat generate_links(n.children[0])
result.push Chain::QCall.new(n.children[1].to_s, node_args(n))
result.push Chain::QCall.new(n.children[1].to_s, Location.from_node(n), node_args(n))
elsif n.children[0].nil?
result.push Chain::QCall.new(n.children[1].to_s, node_args(n))
result.push Chain::QCall.new(n.children[1].to_s, Location.from_node(n), node_args(n))
else
raise "No idea what to do with #{n}"
end
Expand All @@ -82,17 +82,17 @@ def generate_links n
result.push Chain::ZSuper.new('super')
elsif n.type == :super
args = n.children.map { |c| NodeChainer.chain(c, @filename, n) }
result.push Chain::Call.new('super', args)
result.push Chain::Call.new('super', Location.from_node(n), args)
elsif n.type == :yield
args = n.children.map { |c| NodeChainer.chain(c, @filename, n) }
result.push Chain::Call.new('yield', args)
result.push Chain::Call.new('yield', Location.from_node(n), args)
elsif n.type == :const
const = unpack_name(n)
result.push Chain::Constant.new(const)
elsif [:lvasgn, :ivasgn, :gvasgn, :cvasgn].include?(n.type)
result.concat generate_links(n.children[1])
elsif n.type == :lvar
result.push Chain::Call.new(n.children[0].to_s)
result.push Chain::Call.new(n.children[0].to_s, Location.from_node(n))
elsif n.type == :ivar
result.push Chain::InstanceVariable.new(n.children[0].to_s)
elsif n.type == :cvar
Expand Down
8 changes: 8 additions & 0 deletions lib/solargraph/parser/parser_gem/node_processors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module NodeProcessors
autoload :NamespaceNode, 'solargraph/parser/parser_gem/node_processors/namespace_node'
autoload :SclassNode, 'solargraph/parser/parser_gem/node_processors/sclass_node'
autoload :IvasgnNode, 'solargraph/parser/parser_gem/node_processors/ivasgn_node'
autoload :IfNode, 'solargraph/parser/parser_gem/node_processors/if_node'
autoload :CvasgnNode, 'solargraph/parser/parser_gem/node_processors/cvasgn_node'
autoload :LvasgnNode, 'solargraph/parser/parser_gem/node_processors/lvasgn_node'
autoload :GvasgnNode, 'solargraph/parser/parser_gem/node_processors/gvasgn_node'
Expand All @@ -24,6 +25,9 @@ module NodeProcessors
autoload :OrasgnNode, 'solargraph/parser/parser_gem/node_processors/orasgn_node'
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 :WhileNode, 'solargraph/parser/parser_gem/node_processors/while_node'
autoload :AndNode, 'solargraph/parser/parser_gem/node_processors/and_node'
end
end

Expand All @@ -35,6 +39,7 @@ module NodeProcessor
register :resbody, ParserGem::NodeProcessors::ResbodyNode
register :def, ParserGem::NodeProcessors::DefNode
register :defs, ParserGem::NodeProcessors::DefsNode
register :if, ParserGem::NodeProcessors::IfNode
register :send, ParserGem::NodeProcessors::SendNode
register :class, ParserGem::NodeProcessors::NamespaceNode
register :module, ParserGem::NodeProcessors::NamespaceNode
Expand All @@ -51,6 +56,9 @@ module NodeProcessor
register :block, ParserGem::NodeProcessors::BlockNode
register :or_asgn, ParserGem::NodeProcessors::OrasgnNode
register :sym, ParserGem::NodeProcessors::SymNode
register :until, ParserGem::NodeProcessors::UntilNode
register :while, ParserGem::NodeProcessors::WhileNode
register :and, ParserGem::NodeProcessors::AndNode
end
end
end
21 changes: 21 additions & 0 deletions lib/solargraph/parser/parser_gem/node_processors/and_node.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Solargraph
module Parser
module ParserGem
module NodeProcessors
class AndNode < Parser::NodeProcessor::Base
include ParserGem::NodeMethods

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)
end
end
end
end
end
end
21 changes: 21 additions & 0 deletions lib/solargraph/parser/parser_gem/node_processors/if_node.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Solargraph
module Parser
module ParserGem
module NodeProcessors
class IfNode < Parser::NodeProcessor::Base
include ParserGem::NodeMethods

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_if(node)
end
end
end
end
end
end
Loading