From 35eb9e2dd7d454ffd98bb5350cf070855d29b582 Mon Sep 17 00:00:00 2001 From: tompng Date: Sat, 18 Oct 2025 05:09:16 +0900 Subject: [PATCH 1/3] Improve prompt generating performance by caching prompt parts(%m, %M) In prompt calculation, `main.to_s` was called on every keystroke and every line in multiline input. Cache prompt parts(%m, %M) so that `main.to_s` is only called once per read-eval cycle. --- lib/irb.rb | 25 +++++++++++++------- test/irb/test_context.rb | 20 ++++++++++++++++ test/irb/yamatanooroti/test_rendering.rb | 30 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/lib/irb.rb b/lib/irb.rb index 6d9c96c8f..e2696e951 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -87,6 +87,7 @@ class Irb # Creates a new irb session def initialize(workspace = nil, input_method = nil, from_binding: false) @from_binding = from_binding + @prompt_part_cache = nil @context = Context.new(self, workspace, input_method) @context.workspace.load_helper_methods_to_main @signal_status = :IN_IRB @@ -239,6 +240,7 @@ def read_input(prompt) end def readmultiline + @prompt_part_cache = {} prompt = generate_prompt([], false, 0) # multiline @@ -263,6 +265,8 @@ def readmultiline continue = @scanner.should_continue?(tokens) prompt = generate_prompt(opens, continue, line_offset) end + ensure + @prompt_part_cache = nil end def each_top_level_statement @@ -598,25 +602,28 @@ def generate_prompt(opens, continue, line_offset) end def truncate_prompt_main(str) # :nodoc: - str = str.tr(CONTROL_CHARACTERS_PATTERN, ' ') - if str.size <= PROMPT_MAIN_TRUNCATE_LENGTH - str - else - str[0, PROMPT_MAIN_TRUNCATE_LENGTH - PROMPT_MAIN_TRUNCATE_OMISSION.size] + PROMPT_MAIN_TRUNCATE_OMISSION + if str.size > PROMPT_MAIN_TRUNCATE_LENGTH + str = str[0, PROMPT_MAIN_TRUNCATE_LENGTH - PROMPT_MAIN_TRUNCATE_OMISSION.size] + PROMPT_MAIN_TRUNCATE_OMISSION end + str.tr(CONTROL_CHARACTERS_PATTERN, ' ') end def format_prompt(format, ltype, indent, line_no) # :nodoc: + part_cache = @prompt_part_cache || {} format.gsub(/%([0-9]+)?([a-zA-Z%])/) do case $2 when "N" @context.irb_name when "m" - main_str = "#{@context.safe_method_call_on_main(:to_s)}" rescue "!#{$!.class}" - truncate_prompt_main(main_str) + part_cache[:m] ||= ( + main_str = "#{@context.safe_method_call_on_main(:to_s)}" rescue "!#{$!.class}" + truncate_prompt_main(main_str) + ) when "M" - main_str = "#{@context.safe_method_call_on_main(:inspect)}" rescue "!#{$!.class}" - truncate_prompt_main(main_str) + part_cache[:M] ||= ( + main_str = "#{@context.safe_method_call_on_main(:inspect)}" rescue "!#{$!.class}" + truncate_prompt_main(main_str) + ) when "l" ltype when "i" diff --git a/test/irb/test_context.rb b/test/irb/test_context.rb index 432f56e8f..cdcd35b8d 100644 --- a/test/irb/test_context.rb +++ b/test/irb/test_context.rb @@ -644,6 +644,26 @@ def test_prompt_main_inspect_escape assert_equal("irb(main\\n main)>", irb.send(:format_prompt, 'irb(%M)>', nil, 1, 1)) end + def test_prompt_part_cached + main = Object.new + def main.to_s; "to_s#{rand}"; end + def main.inspect; "inspect#{rand}"; end + irb = IRB::Irb.new(IRB::WorkSpace.new(main), TestInputMethod.new) + format = '[%m %M %m %M]>' + pattern = /\A\[(to_s[\d.]+) (inspect[\d.]+) \1 \2\]>\z/ + + irb.instance_variable_set(:@prompt_part_cache, {}) + prompt1 = irb.send(:format_prompt, format, nil, 1, 1) + prompt2 = irb.send(:format_prompt, format, nil, 1, 1) + assert_equal(prompt1, prompt2) + assert_match(pattern, prompt1) + + irb.instance_variable_set(:@prompt_part_cache, nil) + prompt3 = irb.send(:format_prompt, format, nil, 1, 1) + assert_not_equal(prompt1, prompt3) + assert_match(pattern, prompt3) + end + def test_prompt_main_truncate main = Struct.new(:to_s).new("a" * 100) def main.inspect; to_s.inspect; end diff --git a/test/irb/yamatanooroti/test_rendering.rb b/test/irb/yamatanooroti/test_rendering.rb index c1bc81241..20c2a5e80 100644 --- a/test/irb/yamatanooroti/test_rendering.rb +++ b/test/irb/yamatanooroti/test_rendering.rb @@ -112,6 +112,36 @@ def test_nomultiline close end + def test_main_to_s_call_cached + start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@pwd}/exe/irb}, startup_message: /irb\(main\)/) + write(<<~'EOC') + @count = 0 + def self.to_s; @count += 1; "[#{@count}]"; end + if false + 123 + end + if false + 123 + end + EOC + assert_screen(<<~'EOC') + irb(main):001> @count = 0 + => 0 + irb(main):002> def self.to_s; @count += 1; "[#{@count}]"; end + => :to_s + irb([1]):003* if false + irb([1]):004* 123 + irb([1]):005> end + => nil + irb([2]):006* if false + irb([2]):007* 123 + irb([2]):008> end + => nil + irb([3]):009> + EOC + close + end + def test_multiline_paste start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@pwd}/exe/irb}, startup_message: /irb\(main\)/) write(<<~EOC) From addd117a56644e22efe7b0218d183bd8cb03db4c Mon Sep 17 00:00:00 2001 From: tompng Date: Sun, 19 Oct 2025 02:23:39 +0900 Subject: [PATCH 2/3] Introduce `with_prompt_part_cahced do end` to enable prompt caching --- lib/irb.rb | 32 ++++++++++++++++++++++---------- test/irb/test_context.rb | 9 +++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/irb.rb b/lib/irb.rb index e2696e951..35f62f5ab 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -239,14 +239,7 @@ def read_input(prompt) end end - def readmultiline - @prompt_part_cache = {} - prompt = generate_prompt([], false, 0) - - # multiline - return read_input(prompt) if @context.io.respond_to?(:check_termination) - - # nomultiline + def read_input_nomultiline(prompt) code = +'' line_offset = 0 loop do @@ -265,8 +258,20 @@ def readmultiline continue = @scanner.should_continue?(tokens) prompt = generate_prompt(opens, continue, line_offset) end - ensure - @prompt_part_cache = nil + end + + def readmultiline + with_prompt_part_cached do + prompt = generate_prompt([], false, 0) + + if @context.io.respond_to?(:check_termination) + # multiline + read_input(prompt) + else + # nomultiline + read_input_nomultiline(prompt) + end + end end def each_top_level_statement @@ -571,6 +576,13 @@ def inspect private + def with_prompt_part_cached + @prompt_part_cache = {} + yield + ensure + @prompt_part_cache = nil + end + def generate_prompt(opens, continue, line_offset) ltype = @scanner.ltype_from_open_tokens(opens) indent = @scanner.calc_indent_level(opens) diff --git a/test/irb/test_context.rb b/test/irb/test_context.rb index cdcd35b8d..0c1c527b4 100644 --- a/test/irb/test_context.rb +++ b/test/irb/test_context.rb @@ -652,13 +652,14 @@ def main.inspect; "inspect#{rand}"; end format = '[%m %M %m %M]>' pattern = /\A\[(to_s[\d.]+) (inspect[\d.]+) \1 \2\]>\z/ - irb.instance_variable_set(:@prompt_part_cache, {}) - prompt1 = irb.send(:format_prompt, format, nil, 1, 1) - prompt2 = irb.send(:format_prompt, format, nil, 1, 1) + prompt1, prompt2 = nil + irb.send(:with_prompt_part_cached) do + prompt1 = irb.send(:format_prompt, format, nil, 1, 1) + prompt2 = irb.send(:format_prompt, format, nil, 1, 1) + end assert_equal(prompt1, prompt2) assert_match(pattern, prompt1) - irb.instance_variable_set(:@prompt_part_cache, nil) prompt3 = irb.send(:format_prompt, format, nil, 1, 1) assert_not_equal(prompt1, prompt3) assert_match(pattern, prompt3) From 4349ca3da1b02c77ebb63f5dc72c9d2be5233d78 Mon Sep 17 00:00:00 2001 From: tomoya ishida Date: Sat, 25 Oct 2025 00:40:52 +0900 Subject: [PATCH 3/3] Add prompt_part_cache nil-able reason comment Co-authored-by: Stan Lo --- lib/irb.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/irb.rb b/lib/irb.rb index 35f62f5ab..c76c3f569 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -621,6 +621,7 @@ def truncate_prompt_main(str) # :nodoc: end def format_prompt(format, ltype, indent, line_no) # :nodoc: + # @prompt_part_cache could be nil in unit tests part_cache = @prompt_part_cache || {} format.gsub(/%([0-9]+)?([a-zA-Z%])/) do case $2