diff --git a/lib/irb/command/base.rb b/lib/irb/command/base.rb index 2f39b75cc..720e47af8 100644 --- a/lib/irb/command/base.rb +++ b/lib/irb/command/base.rb @@ -37,11 +37,46 @@ def execute(irb_context, arg) puts e.message end + # Returns formatted lines for display in the doc dialog popup. + def doc_dialog_content(name, width) + lines = [] + lines << Color.colorize(name, [:BOLD, :BLUE]) + Color.colorize(" (command)", [:CYAN]) + lines << "" + lines.concat(wrap_lines(description, width)) + if help_message + lines << "" + lines.concat(wrap_lines(help_message, width)) + end + lines + end + private def highlight(text) Color.colorize(text, [:BOLD, :BLUE]) end + + def wrap_lines(text, width) + text.lines.flat_map do |line| + line = line.chomp + next [''] if line.empty? + next [line] if line.length <= width + + indent = line[/\A\s*/] + parts = line.strip.split(/(\s+)/) + result = [] + current = indent.dup + parts.each do |part| + if current != indent && current.length + part.length > width + result << current.rstrip + current = indent.dup + end + current << part unless current == indent && part.match?(/\A\s+\z/) + end + result << current.rstrip unless current == indent + result + end + end end def initialize(irb_context) diff --git a/lib/irb/completion.rb b/lib/irb/completion.rb index 6c2706f16..3dc2fa22a 100644 --- a/lib/irb/completion.rb +++ b/lib/irb/completion.rb @@ -8,6 +8,29 @@ require_relative 'ruby-lex' module IRB + class DocumentTarget # :nodoc: + attr_reader :name + + def initialize(name) + @name = name + end + end + + class CommandDocument < DocumentTarget # :nodoc: + end + + # Represents a method/class documentation target. May hold multiple names + # when the receiver is ambiguous (e.g. `{}.any?` could be Hash#any? or Proc#any?). + # The dialog popup uses only the first name; the full-screen display renders all. + class MethodDocument < DocumentTarget # :nodoc: + attr_reader :names + + def initialize(*names) + super(names.first) + @names = names + end + end + class BaseCompletor # :nodoc: # Set of reserved words used by Ruby, you should not use these for @@ -76,6 +99,12 @@ def command_candidates(target) end end + def command_document_target(preposing, matched) + if preposing.empty? && IRB::Command.command_names.include?(matched) + CommandDocument.new(matched) + end + end + def retrieve_files_to_require_relative_from_current_dir @files_from_current_dir ||= Dir.glob("**/*.{rb,#{RbConfig::CONFIG['DLEXT']}}", base: '.').map { |path| path.sub(/\.(rb|#{RbConfig::CONFIG['DLEXT']})\z/, '') @@ -118,8 +147,10 @@ def completion_candidates(preposing, target, _postposing, bind:) end def doc_namespace(preposing, matched, _postposing, bind:) - result = ReplTypeCompletor.analyze(preposing + matched, binding: bind, filename: @context.irb_path) - result&.doc_namespace('') + command_document_target(preposing, matched) || begin + result = ReplTypeCompletor.analyze(preposing + matched, binding: bind, filename: @context.irb_path) + result&.doc_namespace('') + end end end @@ -201,8 +232,8 @@ def completion_candidates(preposing, target, postposing, bind:) commands | completion_data end - def doc_namespace(_preposing, matched, _postposing, bind:) - retrieve_completion_data(matched, bind: bind, doc_namespace: true) + def doc_namespace(preposing, matched, _postposing, bind:) + command_document_target(preposing, matched) || retrieve_completion_data(matched, bind: bind, doc_namespace: true) end def retrieve_completion_data(input, bind:, doc_namespace:) diff --git a/lib/irb/input-method.rb b/lib/irb/input-method.rb index 17ed55a50..32bdce14f 100644 --- a/lib/irb/input-method.rb +++ b/lib/irb/input-method.rb @@ -255,6 +255,16 @@ def inspect class RelineInputMethod < StdioInputMethod HISTORY = Reline::HISTORY + ALT_KEY_NAME = RUBY_PLATFORM.match?(/darwin/) ? "Option" : "Alt" + PRESS_ALT_D_TO_READ_FULL_DOC = "Press #{ALT_KEY_NAME}+d to read the full document".freeze + PRESS_ALT_D_TO_SEE_MORE = "Press #{ALT_KEY_NAME}+d to see more".freeze + ALT_D_SEQUENCES = [ + [27, 100], # Normal Alt+d when convert-meta isn't used. + # When option/alt is not configured as a meta key in terminal emulator, + # option/alt + d will send a unicode character depend on OS keyboard setting. + [195, 164], # "ä" in somewhere (FIXME: environment information is unknown). + [226, 136, 130] # "∂" Alt+d on Mac keyboard. + ].freeze include HistorySavingAbility # Creates a new input method object using Reline def initialize(completor) @@ -305,9 +315,17 @@ def auto_indent(&block) @auto_indent_proc = block end - def retrieve_doc_namespace(matched) + def retrieve_document_target(matched) preposing, _target, postposing, bind = @completion_params - @completor.doc_namespace(preposing, matched, postposing, bind: bind) + result = @completor.doc_namespace(preposing, matched, postposing, bind: bind) + case result + when DocumentTarget, nil + result + when Array + MethodDocument.new(*result) + when String + MethodDocument.new(result) + end end def rdoc_ri_driver @@ -328,146 +346,158 @@ def show_doc_dialog_proc input_method = self # self is changed in the lambda below. ->() { dialog.trap_key = nil - alt_d = [ - [27, 100], # Normal Alt+d when convert-meta isn't used. - # When option/alt is not configured as a meta key in terminal emulator, - # option/alt + d will send a unicode character depend on OS keyboard setting. - [195, 164], # "ä" in somewhere (FIXME: environment information is unknown). - [226, 136, 130] # "∂" Alt+d on Mac keyboard. - ] - - if just_cursor_moving and completion_journey_data.nil? + + if just_cursor_moving && completion_journey_data.nil? return nil end cursor_pos_to_render, result, pointer, autocomplete_dialog = context.pop(4) - return nil if result.nil? or pointer.nil? or pointer < 0 + return nil if result.nil? || pointer.nil? || pointer < 0 - name = input_method.retrieve_doc_namespace(result[pointer]) - # Use first one because document dialog does not support multiple namespaces. - name = name.first if name.is_a?(Array) + matched_text = result[pointer] + show_easter_egg = matched_text&.match?(/\ARubyVM/) && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER'] + target = show_easter_egg ? nil : input_method.retrieve_document_target(matched_text) - show_easter_egg = name&.match?(/\ARubyVM/) && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER'] + x, width = input_method.dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width) + return nil unless x - driver = input_method.rdoc_ri_driver + dialog.trap_key = ALT_D_SEQUENCES if key.match?(dialog.name) - if show_easter_egg - IRB.__send__(:easter_egg) - else - # RDoc::RI::Driver#display_names uses pager command internally. - # Some pager command like `more` doesn't use alternate screen - # so we need to turn on and off alternate screen manually. - begin - print "\e[?1049h" - driver.display_names([name]) - rescue RDoc::RI::Driver::NotFoundError - ensure - print "\e[?1049l" - end + begin + print "\e[?1049h" + input_method.display_document(matched_text) + ensure + print "\e[?1049l" end end - begin - name = driver.expand_name(name) - rescue RDoc::RI::Driver::NotFoundError - return nil - rescue - return nil # unknown error - end - doc = nil - used_for_class = false - if not name =~ /#|\./ - found, klasses, includes, extends = driver.classes_and_includes_and_extends_for(name) - if not found.empty? - doc = driver.class_document(name, found, klasses, includes, extends) - used_for_class = true + contents = case target + when CommandDocument + input_method.command_doc_dialog_contents(target.name, width) + when MethodDocument + input_method.rdoc_dialog_contents(target.name, width) + else + if show_easter_egg + input_method.easter_egg_dialog_contents end end - unless used_for_class - doc = RDoc::Markup::Document.new - begin - driver.add_method(doc, name) - rescue RDoc::RI::Driver::NotFoundError - doc = nil - rescue - return nil # unknown error - end + return nil unless contents + + contents = contents.take(preferred_dialog_height) + y = cursor_pos_to_render.y + Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(x, y), contents: contents, width: width, bg_color: '49') + } + end + + def command_doc_dialog_contents(command_name, width) + command_class = IRB::Command.load_command(command_name) + return unless command_class + + [PRESS_ALT_D_TO_READ_FULL_DOC, ""] + command_class.doc_dialog_content(command_name, width) + end + + def easter_egg_dialog_contents + type = STDOUT.external_encoding == Encoding::UTF_8 ? :unicode : :ascii + lines = IRB.send(:easter_egg_logo, type).split("\n") + lines[0][0, PRESS_ALT_D_TO_SEE_MORE.size] = PRESS_ALT_D_TO_SEE_MORE + lines + end + + def rdoc_dialog_contents(name, width) + driver = rdoc_ri_driver + return unless driver + + name = driver.expand_name(name) + + doc = if name =~ /#|\./ + d = RDoc::Markup::Document.new + driver.add_method(d, name) + d + else + found, klasses, includes, extends = driver.classes_and_includes_and_extends_for(name) + if found.empty? + d = RDoc::Markup::Document.new + driver.add_method(d, name) + d + else + driver.class_document(name, found, klasses, includes, extends) end - return nil if doc.nil? - width = 40 - - right_x = cursor_pos_to_render.x + autocomplete_dialog.width - if right_x + width > screen_width - right_width = screen_width - (right_x + 1) - left_x = autocomplete_dialog.column - width - left_x = 0 if left_x < 0 - left_width = width > autocomplete_dialog.column ? autocomplete_dialog.column : width - if right_width.positive? and left_width.positive? - if right_width >= left_width - width = right_width - x = right_x - else - width = left_width - x = left_x - end - elsif right_width.positive? and left_width <= 0 + end + + formatter = RDoc::Markup::ToAnsi.new + formatter.width = width + [PRESS_ALT_D_TO_READ_FULL_DOC] + doc.accept(formatter).split("\n") + rescue RDoc::RI::Driver::NotFoundError + end + + def dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width) + width = 40 + right_x = cursor_pos_to_render.x + autocomplete_dialog.width + if right_x + width > screen_width + right_width = screen_width - (right_x + 1) + left_x = autocomplete_dialog.column - width + left_x = 0 if left_x < 0 + left_width = width > autocomplete_dialog.column ? autocomplete_dialog.column : width + if right_width.positive? && left_width.positive? + if right_width >= left_width width = right_width x = right_x - elsif right_width <= 0 and left_width.positive? + else width = left_width x = left_x - else # Both are negative width. - return nil end - else + elsif right_width.positive? && left_width <= 0 + width = right_width x = right_x - end - formatter = RDoc::Markup::ToAnsi.new - formatter.width = width - dialog.trap_key = alt_d - mod_key = RUBY_PLATFORM.match?(/darwin/) ? "Option" : "Alt" - if show_easter_egg - type = STDOUT.external_encoding == Encoding::UTF_8 ? :unicode : :ascii - contents = IRB.send(:easter_egg_logo, type).split("\n") - message = "Press #{mod_key}+d to see more" - contents[0][0, message.size] = message + elsif right_width <= 0 && left_width.positive? + width = left_width + x = left_x else - message = "Press #{mod_key}+d to read the full document" - contents = [message] + doc.accept(formatter).split("\n") + return nil end - contents = contents.take(preferred_dialog_height) - - y = cursor_pos_to_render.y - Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(x, y), contents: contents, width: width, bg_color: '49') - } + else + x = right_x + end + [x, width] end def display_document(matched) - driver = rdoc_ri_driver - return unless driver - - if matched =~ /\A(?:::)?RubyVM/ and not ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER'] - IRB.__send__(:easter_egg) - return - end + target = retrieve_document_target(matched) + return unless target + + case target + when CommandDocument + command_class = IRB::Command.load_command(target.name) + if command_class + content = command_class.help_message || command_class.description + Pager.page(retain_content: true) do |io| + io.puts content + end + end + when MethodDocument + driver = rdoc_ri_driver + return unless driver - namespace = retrieve_doc_namespace(matched) - return unless namespace + if matched =~ /\A(?:::)?RubyVM/ && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER'] + IRB.__send__(:easter_egg) + return + end - if namespace.is_a?(Array) - out = RDoc::Markup::Document.new - namespace.each do |m| + if target.names.length > 1 + out = RDoc::Markup::Document.new + target.names.each do |m| + begin + driver.add_method(out, m) + rescue RDoc::RI::Driver::NotFoundError + end + end + driver.display(out) + else begin - driver.add_method(out, m) + driver.display_names([target.name]) rescue RDoc::RI::Driver::NotFoundError end end - driver.display(out) - else - begin - driver.display_names([namespace]) - rescue RDoc::RI::Driver::NotFoundError - end end end diff --git a/test/irb/test_completion.rb b/test/irb/test_completion.rb index 1bb462736..8959604cb 100644 --- a/test/irb/test_completion.rb +++ b/test/irb/test_completion.rb @@ -25,6 +25,22 @@ def test_command_completion assert_include(completor.completion_candidates('', 'show_s', '', bind: binding), 'show_source') assert_not_include(completor.completion_candidates(';', 'show_s', '', bind: binding), 'show_source') end + + def test_command_document_target + completor = IRB::RegexpCompletor.new + # Command with empty preposing should return a CommandDocument + result = completor.doc_namespace('', 'help', '', bind: binding) + assert_instance_of(IRB::CommandDocument, result) + assert_equal('help', result.name) + + result = completor.doc_namespace('', 'show_source', '', bind: binding) + assert_instance_of(IRB::CommandDocument, result) + assert_equal('show_source', result.name) + + # Command with non-empty preposing should not return a CommandDocument + result = completor.doc_namespace(';', 'help', '', bind: binding) + refute_instance_of(IRB::CommandDocument, result) + end end class MethodCompletionTest < CompletionTest diff --git a/test/irb/test_input_method.rb b/test/irb/test_input_method.rb index bd107551d..2045e3321 100644 --- a/test/irb/test_input_method.rb +++ b/test/irb/test_input_method.rb @@ -186,10 +186,77 @@ def test_perfect_matching_handles_nil_namespace assert_empty(out) end + def test_command_doc_display_with_help_message + out, _err = capture_output do + display_document("show_source", binding) + end + + # When help_message is available, it is displayed + assert_include(out, "Usage: show_source") + end + + def test_command_doc_display_without_help_message + out, _err = capture_output do + display_document("history", binding) + end + + # When no help_message, description is displayed + assert_include(out, IRB::Command::History.description) + end + private def has_rdoc_content? File.exist?(RDoc::RI::Paths::BASE) end end if defined?(RDoc) + + class CommandDocDialogContentTest < TestCase + def setup + @conf_backup = IRB.conf.dup + IRB.init_config(nil) + end + + def teardown + IRB.conf.replace(@conf_backup) + end + + def test_doc_dialog_content_with_description_only + lines = IRB::Command::History.doc_dialog_content("history", 40) + assert lines[0].include?("(command)") + # Description words should all be present (may be wrapped across lines) + content = lines.join(" ") + IRB::Command::History.description.split.each do |word| + assert_include content, word + end + end + + def test_doc_dialog_content_with_help_message + lines = IRB::Command::ShowSource.doc_dialog_content("show_source", 60) + assert lines[0].include?("(command)") + assert_include lines.join("\n"), "Usage: show_source" + end + + def test_doc_dialog_content_wraps_long_lines + lines = IRB::Command::Help.doc_dialog_content("help", 30) + lines.each do |line| + stripped = line.gsub(/\e\[[0-9;]*m/, '') # strip ANSI codes + assert_operator stripped.length, :<=, 30, "Line exceeds width: #{line.inspect}" + end + end + + def test_wrap_lines_preserves_whitespace_alignment + text = <<~TEXT + -g [query] Filter the output with a query. + -a [aa] Foo bar + TEXT + lines = IRB::Command::Base.send(:wrap_lines, text, 30) + expected = <<~EXPECTED.chomp + -g [query] Filter the output + with a query. + -a [aa] Foo bar + EXPECTED + assert_equal expected, lines.join("\n") + end + end end diff --git a/test/irb/test_type_completor.rb b/test/irb/test_type_completor.rb index 446be5ec4..910c97c57 100644 --- a/test/irb/test_type_completor.rb +++ b/test/irb/test_type_completor.rb @@ -81,6 +81,21 @@ def test_command_completion assert_not_include(@completor.completion_candidates(';', 'show_s', '', bind: binding), 'show_source') end + def test_command_document_target + # Command with empty preposing should return a CommandDocument + result = @completor.doc_namespace('', 'help', '', bind: binding) + assert_instance_of(IRB::CommandDocument, result) + assert_equal('help', result.name) + + result = @completor.doc_namespace('', 'show_source', '', bind: binding) + assert_instance_of(IRB::CommandDocument, result) + assert_equal('show_source', result.name) + + # Command with non-empty preposing should not return a CommandDocument + result = @completor.doc_namespace(';', 'help', '', bind: binding) + refute_instance_of(IRB::CommandDocument, result) + end + def test_type_completor_handles_encoding_errors_gracefully invalid_method_name = "b\xff".dup.force_encoding(Encoding::ASCII_8BIT)