Skip to content

Commit b008207

Browse files
committed
Merge #53 (self-rooted-accessor-narrowing) into integration branch 2026-08-04
2 parents 92e4f81 + bbaf7f9 commit b008207

6 files changed

Lines changed: 92 additions & 15 deletions

File tree

lib/solargraph/parser/flow_sensitive_typing.rb

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ class FlowSensitiveTyping
99
# @param ivars [Array<Solargraph::Pin::InstanceVariable>]
1010
# @param enclosing_breakable_pin [Solargraph::Pin::Breakable, nil]
1111
# @param enclosing_compound_statement_pin [Solargraph::Pin::CompoundStatement, nil]
12-
def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin
12+
# @param closure [Solargraph::Pin::Closure] The pin enclosing the
13+
# code being processed (e.g. the current method), used to
14+
# resolve a bare, implicit-self call like 'steps' as a call to
15+
# a 0-arg method rather than a local variable.
16+
def initialize locals, ivars, enclosing_breakable_pin, enclosing_compound_statement_pin, closure
1317
@locals = locals
1418
@ivars = ivars
1519
@enclosing_breakable_pin = enclosing_breakable_pin
1620
@enclosing_compound_statement_pin = enclosing_compound_statement_pin
21+
@closure = closure
1722
end
1823

1924
# @param and_node [Parser::AST::Node]
@@ -431,8 +436,10 @@ def find_var variable_name, position
431436
end
432437

433438
# Finds (for a single tracked local/instance variable) or builds
434-
# (for a chain of simple calls off of one, e.g. ['pin', 'location'])
435-
# the pin flow-sensitive-typing facts should be recorded against.
439+
# (for a chain of simple calls off of one, e.g. ['pin', 'location'],
440+
# or for a bare/explicit-self 0-arg method call, e.g. ['steps'] from
441+
# 'steps' or 'self.steps') the pin flow-sensitive-typing facts
442+
# should be recorded against.
436443
#
437444
# A synthesized pin's type is computed lazily, from `node` itself,
438445
# by Pin::BaseVariable#probe the same way a real local variable's
@@ -447,8 +454,19 @@ def find_var variable_name, position
447454
# @param position [Position]
448455
# @return [Solargraph::Pin::LocalVariable, Solargraph::Pin::InstanceVariable, nil]
449456
def chain_pin chain_words, node, position
450-
# @sg-ignore chain_words is never empty - callers already checked
451-
return find_var(chain_words.first, position) if chain_words.length == 1
457+
if chain_words.length == 1
458+
# A bare word is ambiguous from chain_words alone -- 'steps'
459+
# could be a real local variable (node.type == :lvar) or a
460+
# 0-arg method call to self (node.type == :send, since the
461+
# parser only emits :lvar for a name already assigned as a
462+
# local in this scope). Only the former is a tracked variable.
463+
# @sg-ignore chain_words is never empty - callers already checked
464+
return find_var(chain_words.first, position) unless node.is_a?(::Parser::AST::Node) && node.type == :send
465+
466+
return unless closure
467+
468+
return self_call_pin(node)
469+
end
452470

453471
# @sg-ignore chain_words is never empty - callers already checked
454472
root_pin = find_var(chain_words.first, position)
@@ -463,6 +481,26 @@ def chain_pin chain_words, node, position
463481
)
464482
end
465483

484+
# Builds the synthesized pin for a bare, implicit-self call to a
485+
# 0-arg method, e.g. 'steps'. Rooted at `closure` rather than at a
486+
# tracked variable's pin, since there is no variable to inherit a
487+
# closure from. Named after the bare method word itself (not
488+
# e.g. 'self.steps') so it lines up with how Chain::Call#resolve
489+
# looks up a head-position call: by the call's word, via
490+
# ApiMap#var_at_location.
491+
#
492+
# @param node [Parser::AST::Node] the call node, e.g. 'steps'
493+
# @return [Solargraph::Pin::LocalVariable]
494+
def self_call_pin node
495+
Pin::LocalVariable.new(
496+
location: Location.from_node(node),
497+
closure: closure,
498+
name: node.children[1].to_s,
499+
assignment: node,
500+
source: :flow_sensitive_typing
501+
)
502+
end
503+
466504
# @param isa_node [Parser::AST::Node]
467505
# @param true_presences [Array<Range>]
468506
# @param false_presences [Array<Range>]
@@ -592,10 +630,11 @@ def process_variable node, true_presences, false_presences
592630
end
593631

594632
# Handles a bare truthy check on a call chain, e.g. 'pin.location'
595-
# in 'return nil unless pin.location'. Bare references to a single
596-
# local/instance variable are handled by #process_variable instead;
597-
# this only fires once there's an explicit receiver (chain_words
598-
# has more than one word).
633+
# in 'return nil unless pin.location', or on a bare, implicit-self
634+
# 0-arg method call, e.g. 'steps' in 'return nil unless steps'.
635+
# Bare references to a single local/instance *variable* are
636+
# handled by #process_variable instead (node.type would be :lvar
637+
# or :ivar there, not :send, so this never double-processes them).
599638
#
600639
# @param node [Parser::AST::Node]
601640
# @param true_presences [Array<Range>]
@@ -609,7 +648,7 @@ def process_call_chain node, true_presences, false_presences
609648
return if %i[nil? !].include?(node.children[1])
610649

611650
chain_words = parse_receiver_chain(node)
612-
return if chain_words.nil? || chain_words.length < 2
651+
return if chain_words.nil? || chain_words.empty?
613652

614653
# @sg-ignore Range.from_node is nil only for a node without
615654
# source location info, which doesn't happen for real parsed
@@ -677,7 +716,7 @@ def always_leaves_compound_statement? clause_node
677716
clause_node.children[0].nil? && %i[raise fail].include?(clause_node.children[1])
678717
end
679718

680-
attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin
719+
attr_reader :locals, :ivars, :enclosing_breakable_pin, :enclosing_compound_statement_pin, :closure
681720
end
682721
end
683722
end

lib/solargraph/parser/parser_gem/node_processors/and_node.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def process
1313
FlowSensitiveTyping.new(locals,
1414
ivars,
1515
enclosing_breakable_pin,
16-
enclosing_compound_statement_pin).process_and(node)
16+
enclosing_compound_statement_pin,
17+
region.closure).process_and(node)
1718
end
1819
end
1920
end

lib/solargraph/parser/parser_gem/node_processors/if_node.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ def process
1111
FlowSensitiveTyping.new(locals,
1212
ivars,
1313
enclosing_breakable_pin,
14-
enclosing_compound_statement_pin).process_if(node)
14+
enclosing_compound_statement_pin,
15+
region.closure).process_if(node)
1516
condition_node = node.children[0]
1617
if condition_node
1718
pins.push Solargraph::Pin::CompoundStatement.new(

lib/solargraph/parser/parser_gem/node_processors/or_node.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def process
1313
FlowSensitiveTyping.new(locals,
1414
ivars,
1515
enclosing_breakable_pin,
16-
enclosing_compound_statement_pin).process_or(node)
16+
enclosing_compound_statement_pin,
17+
region.closure).process_or(node)
1718
end
1819
end
1920
end

lib/solargraph/parser/parser_gem/node_processors/while_node.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ def process
1111
FlowSensitiveTyping.new(locals,
1212
ivars,
1313
enclosing_breakable_pin,
14-
enclosing_compound_statement_pin).process_while(node)
14+
enclosing_compound_statement_pin,
15+
region.closure).process_while(node)
1516

1617
# Note - this should not be considered a block, as the
1718
# while statement doesn't create a closure - e.g.,

spec/parser/flow_sensitive_typing_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,40 @@ def bundled_filename(pin)
13591359
expect(clip.infer.rooted_tags).to eq('::String')
13601360
end
13611361

1362+
it 'narrows a bare, implicit-self attr_reader-style accessor after a .nil? guard' do
1363+
source = Solargraph::Source.load_string(%(
1364+
class Repro
1365+
# @return [Array<Hash>, nil]
1366+
attr_reader :steps
1367+
1368+
def identify
1369+
return nil if steps.nil?
1370+
steps.empty?
1371+
end
1372+
end
1373+
), 'test.rb')
1374+
api_map = Solargraph::ApiMap.new.map(source)
1375+
clip = api_map.clip_at('test.rb', [7, 15])
1376+
expect(clip.infer.rooted_tags).to eq('::Array<::Hash>')
1377+
end
1378+
1379+
it 'narrows a bare, implicit-self attr_reader-style accessor after a truthy guard' do
1380+
source = Solargraph::Source.load_string(%(
1381+
class Repro
1382+
# @return [Array<Hash>, nil]
1383+
attr_reader :steps
1384+
1385+
def identify
1386+
return nil unless steps
1387+
steps.empty?
1388+
end
1389+
end
1390+
), 'test.rb')
1391+
api_map = Solargraph::ApiMap.new.map(source)
1392+
clip = api_map.clip_at('test.rb', [7, 15])
1393+
expect(clip.infer.rooted_tags).to eq('::Array<::Hash>')
1394+
end
1395+
13621396
it 'narrows a repeated call to the same attr_reader-style accessor rooted in an ivar' do
13631397
source = Solargraph::Source.load_string(%(
13641398
class Location

0 commit comments

Comments
 (0)