Skip to content

Commit 2cbea46

Browse files
committed
Fix bracket/brace map corruption from Ruby 3.0+ pattern matching deconstruction
- Rename on_aptn to on_aryptn (on_aptn is not a valid Ripper event; the real event for array patterns is on_aryptn) - Add on_hshptn to clean up @Map[:lbrace]/@Map[:rbrace] after braced hash patterns ({key: val}); bare hash patterns (key: val) are detected by the absence of an entry in @Map[:rbrace] - Override on_rbrace to push closing positions to @Map[:rbrace], mirroring on_rbracket/@Map[:aref]; visit_event drains @Map[:rbrace] when consuming a lbrace node so the maps stay in sync - Replace tests that asserted on %i[] source range (which never went through the bracket maps) with tests that assert on a subsequent aref/hash node, which is the actual observable corrupted by the bug
1 parent 77fb72f commit 2cbea46

3 files changed

Lines changed: 85 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# main
22

3+
- Fix garbled source ranges after Ruby 3.0+ array/hash pattern matching deconstruction (#1547)
4+
35
# [0.9.39] - April 8th, 2026
46

57
[0.9.39]: https://github.com/lsegal/yard/compare/v0.9.38...v0.9.39
68

79
- Add support for Ruby 4.0 (#1663)
810
- Add changelog URI to gemspec metadata (#1641)
911
- Fix issues with source ranges (#1642)
10-
- Fix an issue loading relative links from file list in HTML template (#1660)
12+
- Fix incorrect relative link resolution in class/file/method list navigation for static HTML (#1639, #1660)
1113
- Various test fixes (#1650, #1651)
1214

1315

lib/yard/parser/ruby/ruby_parser.rb

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ def visit_event(node)
244244
sstart = child_node.source_range.first
245245
else
246246
lstart, sstart = *(map ? map.pop : [lineno, @ns_charno - 1])
247+
(@map[:rbrace] ||= []).shift if map && MAPPINGS[node.type] == :lbrace
247248
end
248249

249250
raise "Cannot determine start of node #{node} around #{file}:#{lineno}" if lstart.nil? || sstart.nil?
@@ -432,16 +433,21 @@ def on_rbracket(tok)
432433
visit_ns_token(:rbracket, tok, false)
433434
end
434435

436+
def on_rbrace(tok)
437+
(@map[:rbrace] ||= []) << [lineno, charno]
438+
visit_ns_token(:rbrace, tok, false)
439+
end
440+
435441
# Ruby 3.0+ pattern matching: array patterns (SomeClass[a, b]) and find patterns
436442
# (SomeClass[*pre, val, *post]) use [...] brackets, which fire on_lbracket and
437-
# on_rbracket scanner events. The corresponding parser events are on_aptn/on_fndptn
443+
# on_rbracket scanner events. The corresponding parser events are on_aryptn/on_fndptn
438444
# (not on_aref), so we must clean up the bracket maps to prevent stale entries from
439445
# corrupting source ranges of later array indexing expressions.
440-
def on_aptn(*args)
446+
def on_aryptn(*args)
441447
@map[:lbracket].pop
442448
@map[:aref].shift
443449
# Source range is intentionally not set; YARD does not traverse pattern-match arms.
444-
AstNode.new(:aptn, args)
450+
AstNode.new(:aryptn, args)
445451
end
446452

447453
def on_fndptn(*args)
@@ -451,6 +457,20 @@ def on_fndptn(*args)
451457
AstNode.new(:fndptn, args)
452458
end
453459

460+
# Ruby 3.0+ pattern matching: braced hash patterns ({key: val} syntax) fire
461+
# on_lbrace and on_rbrace scanner events. The corresponding parser event is
462+
# on_hshptn (not on_hash), so we must clean up the brace maps to prevent stale
463+
# entries from corrupting source ranges of later hash literals and brace blocks.
464+
# Bare hash patterns (key: val without braces) fire no brace scanner events, so
465+
# we only clean up when @map[:rbrace] confirms a closing brace was scanned.
466+
def on_hshptn(*args)
467+
if (@map[:rbrace] ||= []).any?
468+
@map[:lbrace].pop
469+
@map[:rbrace].shift
470+
end
471+
AstNode.new(:hshptn, args)
472+
end
473+
454474
def on_dyna_symbol(sym)
455475
rng = if sym.source_range.to_a.size == 0 # rubocop:disable Style/ZeroLengthPredicate
456476
(sym.source_range.begin - 3)...sym.source_range.end

spec/parser/ruby/ruby_parser_spec.rb

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -679,62 +679,82 @@ def add(x) = x + 1
679679
end
680680
end if RUBY_VERSION >= '3.'
681681

682-
it "provides correct source range for %i[] constant after pattern matching deconstruction" do
683-
code = <<-RUBY
684-
class Foo
685-
def check(obj)
686-
case obj
687-
in Bar['key', v]
688-
v
689-
end
690-
end
682+
it "provides correct source range for aref after array pattern deconstruction" do
683+
code = <<~RUBY
684+
class Foo
685+
def check(obj)
686+
case obj
687+
in Bar["key", v]
688+
v
689+
end
690+
end
691691
692-
SYMBOLS = %i[foo bar baz]
693-
end
692+
def fetch(h)
693+
h["result"]
694+
end
695+
end
694696
RUBY
695697

696698
parser = YARD::Parser::Ruby::RubyParser.new(code, nil)
697699
ast = parser.parse.root
698700

699-
const_node = nil
700-
ast.traverse do |node|
701-
if node.type == :assign && node.children.first.source == 'SYMBOLS'
702-
const_node = node
703-
break
704-
end
705-
end
701+
aref_node = nil
702+
ast.traverse { |n| (aref_node = n; break) if n.type == :aref }
706703

707-
expect(const_node).not_to be_nil
708-
expect(code[const_node.source_range]).to eq('SYMBOLS = %i[foo bar baz]')
704+
expect(aref_node).not_to be_nil
705+
expect(code[aref_node.source_range]).to eq('h["result"]')
709706
end if RUBY_VERSION >= '3.'
710707

711-
it "provides correct source range for %i[] constant after find pattern deconstruction" do
712-
code = <<-RUBY
713-
class Foo
714-
def check(obj)
715-
case obj
716-
in Bar[*, v, *]
717-
v
718-
end
719-
end
708+
it "provides correct source range for aref after find pattern deconstruction" do
709+
code = <<~RUBY
710+
class Foo
711+
def check(obj)
712+
case obj
713+
in Bar[*, v, *]
714+
v
715+
end
716+
end
720717
721-
SYMBOLS = %i[foo bar baz]
722-
end
718+
def fetch(h)
719+
h["result"]
720+
end
721+
end
723722
RUBY
724723

725724
parser = YARD::Parser::Ruby::RubyParser.new(code, nil)
726725
ast = parser.parse.root
727726

728-
const_node = nil
729-
ast.traverse do |node|
730-
if node.type == :assign && node.children.first.source == 'SYMBOLS'
731-
const_node = node
732-
break
727+
aref_node = nil
728+
ast.traverse { |n| (aref_node = n; break) if n.type == :aref }
729+
730+
expect(aref_node).not_to be_nil
731+
expect(code[aref_node.source_range]).to eq('h["result"]')
732+
end if RUBY_VERSION >= '3.'
733+
734+
it "provides correct source range for hash literal after braced hash pattern deconstruction" do
735+
code = <<~RUBY
736+
class Foo
737+
def check(obj)
738+
case obj
739+
in { name: String }
740+
"matched"
741+
end
742+
end
743+
744+
def build
745+
{ name: "Alice" }
746+
end
733747
end
734-
end
748+
RUBY
749+
750+
parser = YARD::Parser::Ruby::RubyParser.new(code, nil)
751+
ast = parser.parse.root
752+
753+
hash_node = nil
754+
ast.traverse { |n| (hash_node = n; break) if n.type == :hash }
735755

736-
expect(const_node).not_to be_nil
737-
expect(code[const_node.source_range]).to eq('SYMBOLS = %i[foo bar baz]')
756+
expect(hash_node).not_to be_nil
757+
expect(code[hash_node.source_range]).to eq('{ name: "Alice" }')
738758
end if RUBY_VERSION >= '3.'
739759

740760
it "provides correct range for `next` statement following `def` _symbol_" do

0 commit comments

Comments
 (0)