From 4f6c3b4abee8e684b735a8698d165d004a8c7743 Mon Sep 17 00:00:00 2001 From: Andrii Konchyn Date: Sat, 11 Jul 2026 02:30:48 +0300 Subject: [PATCH] Improve Ripper compatibiliy layer and emit :on_embexpr_end for unmatched closing brace --- lib/prism/lex_compat.rb | 20 ++++++++++++++++++++ test/prism/ruby/ripper_test.rb | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/prism/lex_compat.rb b/lib/prism/lex_compat.rb index 4d92842bda..1b3f967fc7 100644 --- a/lib/prism/lex_compat.rb +++ b/lib/prism/lex_compat.rb @@ -846,6 +846,8 @@ def post_process_tokens(tokens, source, data_loc, bom, eof_token) cache = Translation::Ripper::LineAndColumnCache.new(source) + brace_depth = 0 + tokens.each do |token| # Skip missing heredoc ends. next if token[1] == :on_heredoc_end && token[2] == "" @@ -854,6 +856,24 @@ def post_process_tokens(tokens, source, data_loc, bom, eof_token) line, column = token[0] start_offset = source.byte_offset(line, column) + # Replicate a Ripper quirk where exactly the first unmatched closing + # brace is emitted as :on_embexpr_end, and any subsequent ones are :on_rbrace. + # + # Track brace nesting depth to handle unmatched closing braces. + # :on_lbrace (hashes/blocks), :on_embexpr_beg (interpolation), and :on_tlambeg (lambdas) + # all start a curly brace context balanced by a closing }. + case token[1] + when :on_lbrace, :on_embexpr_beg, :on_tlambeg + brace_depth += 1 + when :on_rbrace + if brace_depth == 0 + token = [token[0], :on_embexpr_end, token[2], prev_token_state] + end + brace_depth -= 1 + when :on_embexpr_end + brace_depth -= 1 + end + # Ripper reports columns on line 1 without counting the BOM, so we # adjust to get the real offset start_offset += 3 if line == 1 && bom diff --git a/test/prism/ruby/ripper_test.rb b/test/prism/ruby/ripper_test.rb index c5aa63aab2..10dee8e380 100644 --- a/test/prism/ruby/ripper_test.rb +++ b/test/prism/ruby/ripper_test.rb @@ -219,6 +219,14 @@ def test_lex_invalid_syntax assert_equal(Ripper.lex('if;)'), Translation::Ripper.lex('if;)')) end + def test_unmatched_braces + assert_ripper_lex("} }") + assert_ripper_lex("{ b: 1 } }") + assert_ripper_lex("[].each { } }") + assert_ripper_lex('"#{1}" }') + assert_ripper_lex("-> { } }") + end + def test_tokenize source = "foo;1;BAZ" assert_equal(Ripper.tokenize(source), Translation::Ripper.tokenize(source))