From 31876dabab4964d53ae3b10acb96699188437446 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 15 Mar 2026 12:29:40 +0000 Subject: [PATCH] Add startup banner with Ruby logo, version info, and rotating tips Display a 3-line startup banner when IRB is launched via the CLI executable. The banner shows a small braille Ruby logo alongside the IRB/Ruby version, a randomly selected usage tip, and the current working directory. The banner is gated on ap_path matching "irb" so it only appears for CLI usage, not binding.irb or embedded IRB. Users can disable it with IRB.conf[:SHOW_BANNER] = false in their .irbrc. --- lib/irb.rb | 7 ++ lib/irb/init.rb | 1 + lib/irb/ruby_logo.aa | 4 ++ lib/irb/startup_message.rb | 83 ++++++++++++++++++++++++ test/irb/helper.rb | 2 +- test/irb/test_startup_message.rb | 71 ++++++++++++++++++++ test/irb/yamatanooroti/test_rendering.rb | 20 +++--- 7 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 lib/irb/startup_message.rb create mode 100644 test/irb/test_startup_message.rb diff --git a/lib/irb.rb b/lib/irb.rb index 450529e9c..af65c8a13 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -21,6 +21,7 @@ require_relative "irb/version" require_relative "irb/easter-egg" +require_relative "irb/startup_message" require_relative "irb/debug" require_relative "irb/pager" @@ -50,7 +51,13 @@ def start(ap_path = nil) irb = Irb.new(nil, @CONF[:SCRIPT]) else irb = Irb.new + + # Only display the banner in the irb executable + if @CONF[:SHOW_BANNER] && ap_path&.end_with?("exe/irb") + StartupMessage.display + end end + irb.run(@CONF) end diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 720c4fec4..365f445da 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -87,6 +87,7 @@ def IRB.init_config(ap_path) @CONF[:IGNORE_SIGINT] = true @CONF[:IGNORE_EOF] = false @CONF[:USE_PAGER] = true + @CONF[:SHOW_BANNER] = true @CONF[:EXTRA_DOC_DIRS] = [] @CONF[:ECHO] = nil @CONF[:ECHO_ON_ASSIGNMENT] = nil diff --git a/lib/irb/ruby_logo.aa b/lib/irb/ruby_logo.aa index d0143a448..068581bb3 100644 --- a/lib/irb/ruby_logo.aa +++ b/lib/irb/ruby_logo.aa @@ -116,3 +116,7 @@ TYPE: UNICODE ⢻⣾⡇ ⠘⣷ ⣼⠃ ⠘⣷⣠⣴⠟⠋ ⠙⢷⣄⢸⣿ ⠻⣧⡀ ⠘⣧⣰⡏ ⢀⣠⣤⠶⠛⠉⠛⠛⠛⠛⠛⠛⠻⢶⣶⣶⣶⣶⣶⣤⣤⣽⣿⣿ ⠈⠛⠷⢦⣤⣽⣿⣥⣤⣶⣶⡿⠿⠿⠶⠶⠶⠶⠾⠛⠛⠛⠛⠛⠛⠛⠋⠉⠉⠉⠉⠉⠉⠁ +TYPE: UNICODE_SMALL +⢀⡴⠊⢉⡟⢟ +⣎⣀⣴⡋⡟⣻ +⣟⣼⣱⣽⣟⣾ diff --git a/lib/irb/startup_message.rb b/lib/irb/startup_message.rb new file mode 100644 index 000000000..32372f45e --- /dev/null +++ b/lib/irb/startup_message.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "color" +require_relative "version" + +module IRB + module StartupMessage + TIPS = [ + 'Type "help" for commands, "help " for details', + '"show_doc method" to view documentation', + '"ls [object]" to see methods and properties', + '"ls [object] -g pattern" to filter methods and properties', + '"edit method" to open the method\'s source in editor', + '"cd object" to navigate into an object', + '"show_source method" to view source code', + '"copy expr" to copy the output to clipboard', + '"debug" to start integration with the "debug" gem', + '"history -g pattern" to search history', + ].freeze + + class << self + def display + logo_lines = load_logo + info_lines = build_info_lines + + output = if logo_lines + combine_logo_and_info(logo_lines, info_lines) + else + info_lines.join("\n") + end + + # Add a blank line to not immediately touch warning messages + puts + puts output + puts + end + + private + + def load_logo + encoding = STDOUT.external_encoding || Encoding.default_external + return nil unless encoding == Encoding::UTF_8 + + logo = IRB.send(:easter_egg_logo, :unicode_small) + return nil unless logo + + logo.chomp.lines.map(&:chomp) + end + + def build_info_lines + version_line = "#{Color.colorize('IRB', [:BOLD])} v#{VERSION} \u2014 Ruby #{RUBY_VERSION}" + tip_line = colorize_tip(TIPS.sample) + dir_line = Color.colorize(short_pwd, [:CYAN]) + + [version_line, tip_line, dir_line] + end + + def colorize_tip(tip) + tip.gsub(/"[^"]*"/) { |match| Color.colorize(match, [:YELLOW]) } + end + + def combine_logo_and_info(logo_lines, info_lines) + max_lines = [logo_lines.size, info_lines.size].max + lines = max_lines.times.map do |i| + logo_part = logo_lines[i] || "" + info_part = info_lines[i] || "" + colored_logo = Color.colorize(logo_part, [:RED, :BOLD]) + "#{colored_logo} #{info_part}" + end + lines.join("\n") + end + + def short_pwd + dir = Dir.pwd + home = ENV['HOME'] + if home && (dir == home || dir.start_with?("#{home}/")) + dir = "~#{dir[home.size..]}" + end + dir + end + end + end +end diff --git a/test/irb/helper.rb b/test/irb/helper.rb index 5555fc48c..a58e6609e 100644 --- a/test/irb/helper.rb +++ b/test/irb/helper.rb @@ -166,7 +166,7 @@ def run_ruby_file(timeout: TIMEOUT_SEC, via_irb: false, &block) @envs["XDG_CONFIG_HOME"] ||= tmp_dir @envs["IRBRC"] = nil unless @envs.key?("IRBRC") - envs_for_spawn = @envs.merge('TERM' => 'dumb', 'TEST_IRB_FORCE_INTERACTIVE' => 'true') + envs_for_spawn = {'TERM' => 'dumb', 'TEST_IRB_FORCE_INTERACTIVE' => 'true'}.merge(@envs) PTY.spawn(envs_for_spawn, *cmd) do |read, write, pid| Timeout.timeout(timeout) do diff --git a/test/irb/test_startup_message.rb b/test/irb/test_startup_message.rb new file mode 100644 index 000000000..6b7cbbf4b --- /dev/null +++ b/test/irb/test_startup_message.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "irb" + +require_relative "helper" + +module TestIRB + class StartupMessageTest < TestCase + def test_display_includes_version_info + output, = capture_output { IRB::StartupMessage.display } + + assert_match(/IRB/, output) + assert_match(/v#{Regexp.escape(IRB::VERSION)}/, output) + assert_match(/Ruby #{Regexp.escape(RUBY_VERSION)}/, output) + end + + def test_display_includes_a_tip + output, = capture_output { IRB::StartupMessage.display } + + # Strip ANSI codes for comparison since tips have colorized quoted parts + plain = output.gsub(/\e\[\d+m/, "") + assert( + IRB::StartupMessage::TIPS.any? { |tip| plain.include?(tip) }, + "Expected output to include one of the tips" + ) + end + + def test_display_includes_working_directory + output, = capture_output { IRB::StartupMessage.display } + + assert_match(/#{Regexp.escape(File.basename(Dir.pwd))}/, output) + end + + def test_short_pwd_replaces_home_with_tilde + Dir.mktmpdir do |tmpdir| + tmpdir = File.realpath(tmpdir) + original_home = ENV['HOME'] + original_dir = Dir.pwd + ENV['HOME'] = tmpdir + Dir.chdir(tmpdir) + + result = IRB::StartupMessage.send(:short_pwd) + assert_equal "~", result + + subdir = File.join(tmpdir, "projects") + Dir.mkdir(subdir) + Dir.chdir(subdir) + + result = IRB::StartupMessage.send(:short_pwd) + assert_equal "~/projects", result + ensure + ENV['HOME'] = original_home + Dir.chdir(original_dir) + end + end + end + + class StartupMessageIntegrationTest < IntegrationTestCase + def test_banner_does_not_appear_on_binding_irb + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "exit" + end + + assert_not_match(/v#{Regexp.escape(IRB::VERSION)}/, output) + end + end +end diff --git a/test/irb/yamatanooroti/test_rendering.rb b/test/irb/yamatanooroti/test_rendering.rb index afd46cfcf..d2760607d 100644 --- a/test/irb/yamatanooroti/test_rendering.rb +++ b/test/irb/yamatanooroti/test_rendering.rb @@ -26,6 +26,7 @@ def setup @irbrc_backup = ENV['IRBRC'] @irbrc_file = ENV['IRBRC'] = File.join(@tmpdir, 'temporaty_irbrc') File.unlink(@irbrc_file) if File.exist?(@irbrc_file) + File.write(@irbrc_file, "IRB.conf[:SHOW_BANNER] = false\n") ENV['HOME'] = File.join(@tmpdir, 'home') ENV['XDG_CONFIG_HOME'] = File.join(@tmpdir, 'xdg_config_home') end @@ -59,11 +60,9 @@ def test_configuration_file_is_skipped_with_dash_f write(<<~EOC) 'Hello, World!' EOC - assert_screen(<<~EOC) - irb(main):001> 'Hello, World!' - => "Hello, World!" - irb(main):002> - EOC + assert_screen(/irb\(main\):001> 'Hello, World!'\n=> "Hello, World!"\nirb\(main\):002>/) + screen = result.join("\n") + assert_not_include(screen, '.irbrc file should be ignored') close end @@ -77,13 +76,9 @@ def test_configuration_file_is_skipped_with_dash_f_for_nested_sessions binding.irb exit! EOC - assert_screen(<<~EOC) - irb(main):001> 'Hello, World!' - => "Hello, World!" - irb(main):002> binding.irb - irb(main):003> exit! - irb(main):001> - EOC + assert_screen(/irb\(main\):001> 'Hello, World!'\n=> "Hello, World!"\nirb\(main\):002> binding\.irb\nirb\(main\):003> exit!\nirb\(main\):001>/) + screen = result.join("\n") + assert_not_include(screen, '.irbrc file should be ignored') close end @@ -520,6 +515,7 @@ def test_debug_integration_doesnt_hint_debugger_commands_in_nomultiline_mode def write_irbrc(content) File.open(@irbrc_file, 'w') do |f| + f.write "IRB.conf[:SHOW_BANNER] = false\n" f.write content end end