From 0f0efbcd4fdf4cb0401d42521e792f13d7c5379f Mon Sep 17 00:00:00 2001 From: Kazuhiro NISHIYAMA Date: Tue, 29 Sep 2026 23:54:16 +0900 Subject: [PATCH 1/2] Add IRB.doc_providers, a hook for external documentation providers `show_doc` and the documentation dialog of the autocompletion were hard-wired to RDoc::RI::Driver, so other documentation backends (a manual in another language, YARD, a project-local doc store) could not plug into them without monkey-patching irb internals. `IRB.doc_providers` is the ordered list of providers they consult; it defaults to `[IRB::RDocDocumentProvider.new]`, the existing RI code moved behind a small duck type: `document(name)` returns a String or nil, and the optional `dialog_contents(name, width)` returns the lines of the completion dialog. Earlier providers win, so a library can `IRB.doc_providers.unshift(MyDocProvider.new)`. `show_doc NAME` pages the first result with IRB::Pager and prints RI's "maybe you meant" suggestions when nothing is found; `show_doc` without an argument still starts RI's interactive session. The dialog is registered whenever a provider can serve it, so a non-RDoc provider also works without `rdoc`, and the error fallback of the dialog no longer needs RDoc to render. Closes #1243 Co-Authored-By: Claude Fable 5.1 --- doc/EXTEND_IRB.md | 30 +++++ lib/irb/command/show_doc.rb | 39 ++++--- lib/irb/doc_provider.rb | 138 +++++++++++++++++++++++ lib/irb/input-method.rb | 115 +++++++------------ test/irb/helper.rb | 8 ++ test/irb/test_command.rb | 57 ++++++++++ test/irb/test_doc_provider.rb | 99 +++++++++++++++++ test/irb/test_input_method.rb | 203 ++++++++++++++++++++++++++++++---- 8 files changed, 580 insertions(+), 109 deletions(-) create mode 100644 lib/irb/doc_provider.rb create mode 100644 test/irb/test_doc_provider.rb diff --git a/doc/EXTEND_IRB.md b/doc/EXTEND_IRB.md index 684b0b7b1..09f811edc 100644 --- a/doc/EXTEND_IRB.md +++ b/doc/EXTEND_IRB.md @@ -120,3 +120,33 @@ Helper methods conf Returns the current context. my_helper This is a test helper ``` + +## Documentation providers + +The `show_doc` command and the documentation dialog of the autocompletion (shown next to the completion candidates, and expanded with `Alt+d`) look up RI data by default. `IRB.doc_providers` is the ordered list of the backends they consult, so a library can serve documentation from another source, such as a project-local documentation store or a manual in another language. Providers earlier in the list take precedence; the built-in `IRB::RDocDocumentProvider` is the last resort by default. + +A provider is any object that implements `document`; `dialog_contents` is optional. + +### Example + +```rb +class MyDocProvider + # name is written the way RI accepts it: "Array", "Array#each", "Array.new", + # or "String.gsub" (the completion uses a dot even for instance methods). + # Return the documentation as a String (ANSI escape sequences are allowed; + # it is shown through IRB's pager), or nil to let the next provider answer. + def document(name) + MyManual.lookup(name)&.to_ansi + end + + # Optional. Return the preview shown in the documentation dialog as an + # Array of lines that fit in `width` columns, or nil. + def dialog_contents(name, width) + MyManual.lookup(name)&.summary_lines(width) + end +end + +IRB.doc_providers.unshift(MyDocProvider.new) +``` + +`show_doc` without an argument always starts RI's interactive session; it does not consult the providers. diff --git a/lib/irb/command/show_doc.rb b/lib/irb/command/show_doc.rb index 8a2188e4e..b9d12137c 100644 --- a/lib/irb/command/show_doc.rb +++ b/lib/irb/command/show_doc.rb @@ -25,26 +25,39 @@ class ShowDoc < Base def execute(arg) # Accept string literal for backward compatibility name = unwrap_string_literal(arg) - require 'rdoc/ri/driver' - unless ShowDoc.const_defined?(:Ri) - opts = RDoc::RI::Driver.process_args([]) - ShowDoc.const_set(:Ri, RDoc::RI::Driver.new(opts)) + if name.nil? + if rdoc_provider.available? + rdoc_provider.interactive + else + warn RDocDocumentProvider::NOT_INSTALLED_MESSAGE + end + return end - if name.nil? - Ri.interactive - else - begin - Ri.display_name(name) - rescue RDoc::RI::Error - puts $!.message + IRB.doc_providers.each do |provider| + document = provider.document(name) + if document + Pager.page_content(document) + return end end + if rdoc_provider.available? + puts rdoc_provider.not_found_message(name) + else + warn RDocDocumentProvider::NOT_INSTALLED_MESSAGE + end + nil + rescue SystemExit + # RI's interactive session exits on Ctrl-C nil - rescue LoadError, SystemExit - warn "Can't display document because `rdoc` is not installed." + end + + private + + def rdoc_provider + @rdoc_provider ||= IRB.doc_providers.find { |provider| provider.is_a?(RDocDocumentProvider) } || RDocDocumentProvider.new end end end diff --git a/lib/irb/doc_provider.rb b/lib/irb/doc_provider.rb new file mode 100644 index 000000000..0cc5f4424 --- /dev/null +++ b/lib/irb/doc_provider.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require_relative 'color' + +module IRB + # Returns the documentation providers consulted, in order, by the +show_doc+ + # command and by the documentation dialog of the autocompletion (Alt+d). + # + # By default the list only contains an RDocDocumentProvider, which looks up + # RI data. Other backends can be added, for example from +.irbrc+; providers + # earlier in the list take precedence: + # + # IRB.doc_providers.unshift(MyDocProvider.new) + # + # A provider is any object that implements the following methods: + # + # +document(name)+:: + # Returns the documentation of +name+ as a String, or +nil+ when the + # provider has nothing for +name+ so that the next provider is consulted. + # +name+ is written the way RI accepts it: "Array", + # "Array#each", "Array.new", or "String.gsub" + # (the completion uses a dot even for instance methods). The returned + # String is shown through IRB::Pager and may contain ANSI escape sequences. + # + # +dialog_contents(name, width)+:: + # Optional. Returns the preview shown in the documentation dialog as an + # Array of lines that fit in +width+ columns, or +nil+. The dialog skips + # providers that do not implement this method. + class << self + def doc_providers + @doc_providers ||= [RDocDocumentProvider.new] + end + end + + # The default documentation provider. It looks up RI data (RDoc::RI::Driver), + # including the directories in IRB.conf[:EXTRA_DOC_DIRS]. + class RDocDocumentProvider + NOT_INSTALLED_MESSAGE = "Can't display document because `rdoc` is not installed." + + # Returns true when RDoc can be loaded. + def available? + require 'rdoc' + true + rescue LoadError + false + end + + def document(name) + document = retrieve_document(name) + return unless document + + formatter = Color.colorable? ? RDoc::Markup::ToAnsi.new : RDoc::Markup::ToBs.new + document.accept(formatter) + end + + def dialog_contents(name, width) + document = retrieve_document(name) + return unless document + + formatter = RDoc::Markup::ToAnsi.new + formatter.width = width + document.accept(formatter).split("\n") + end + + # Starts the interactive session of RI, as +show_doc+ without an argument does. + def interactive + driver.interactive + end + + # Returns the message +show_doc+ prints when no provider knows +name+. + # It reports the closest names RI knows, like the +ri+ command does. + def not_found_message(name) + matches = name.match?(/::|#|\./) ? driver.list_methods_matching(name) : [] + matches = driver.classes.keys.grep(/\A#{Regexp.escape(name)}/) if matches.empty? + return "#{name} not found, maybe you meant:\n\n#{matches.sort.join("\n")}" unless matches.empty? + + driver.expand_name(name) # raises NotFoundError with "Did you mean?" for an unknown class + "Nothing known about #{name}" + rescue RDoc::RI::Driver::NotFoundError => e + e.message + end + + private + + def driver + return @driver if defined?(@driver) + + require 'rdoc' + require 'rdoc/ri/driver' + options = {} + extra_doc_dirs = IRB.conf[:EXTRA_DOC_DIRS] + options[:extra_doc_dirs] = extra_doc_dirs unless extra_doc_dirs.nil? || extra_doc_dirs.empty? + @driver = RDoc::RI::Driver.new(options) + end + + # Returns an RDoc::Markup::Document for +name+, or nil when RI does not know it. + def retrieve_document(name) + return unless available? + + return retrieve_page(name) if name.match?(/\w:(\w|$)/) + + name = driver.expand_name(name) + + if name.match?(/#|\./) + document = RDoc::Markup::Document.new + driver.add_method(document, name) + else + found, klasses, includes, extends = driver.classes_and_includes_and_extends_for(name) + if found.empty? + document = RDoc::Markup::Document.new + driver.add_method(document, name) + else + return driver.class_document(name, found, klasses, includes, extends) + end + end + driver.expand_rdoc_refs_at_the_bottom(document) if driver.respond_to?(:expand_rdoc_refs_at_the_bottom) + document + rescue RDoc::RI::Driver::NotFoundError + nil + end + + # Returns the document of an RI page such as "ruby:syntax". + def retrieve_page(name) + store_name, page_name = name.split(':', 2) + store = driver.stores.find { |s| s.source == store_name } + return unless store + + pages = store.cache[:pages] + unless pages.include?(page_name) + candidates = pages.grep(/#{Regexp.escape(page_name)}\.[^.]+$/) + return unless candidates.size == 1 + + page_name = candidates.first + end + store.load_page(page_name).comment.parse + end + end +end diff --git a/lib/irb/input-method.rb b/lib/irb/input-method.rb index 10758cc83..52f6a10f1 100644 --- a/lib/irb/input-method.rb +++ b/lib/irb/input-method.rb @@ -5,6 +5,7 @@ # require_relative 'completion' +require_relative 'doc_provider' require_relative "history" require 'io/console' require 'reline' @@ -289,12 +290,8 @@ def initialize(completor) Reline.dig_perfect_match_proc = ->(matched) { display_document(matched) } Reline.autocompletion = IRB.conf[:USE_AUTOCOMPLETE] - if IRB.conf[:USE_AUTOCOMPLETE] - begin - require 'rdoc' - Reline.add_dialog_proc(:show_doc, show_doc_dialog_proc, Reline::DEFAULT_DIALOG_CONTEXT) - rescue LoadError - end + if IRB.conf[:USE_AUTOCOMPLETE] && doc_dialog_available? + Reline.add_dialog_proc(:show_doc, show_doc_dialog_proc, Reline::DEFAULT_DIALOG_CONTEXT) end end @@ -328,17 +325,12 @@ def retrieve_document_target(matched) end end - def rdoc_ri_driver - return @rdoc_ri_driver if defined?(@rdoc_ri_driver) + # Whether some provider in IRB.doc_providers can serve the documentation dialog. + def doc_dialog_available? + IRB.doc_providers.any? do |provider| + next false unless provider.respond_to?(:dialog_contents) - begin - require 'rdoc' - rescue LoadError - @rdoc_ri_driver = nil - else - options = {} - options[:extra_doc_dirs] = IRB.conf[:EXTRA_DOC_DIRS] unless IRB.conf[:EXTRA_DOC_DIRS].empty? - @rdoc_ri_driver = RDoc::RI::Driver.new(options) + provider.is_a?(RDocDocumentProvider) ? provider.available? : true end end @@ -375,7 +367,7 @@ def show_doc_dialog_proc when CommandDocument input_method.command_doc_dialog_contents(target.name, width) when MethodDocument - input_method.rdoc_dialog_contents(target.name, width) + input_method.doc_dialog_contents(target.name, width) else if show_easter_egg input_method.easter_egg_dialog_contents @@ -403,52 +395,38 @@ def easter_egg_dialog_contents lines end - def rdoc_dialog_contents(name, width) - formatter = RDoc::Markup::ToAnsi.new - formatter.width = width + # Asks IRB.doc_providers, in order, for the dialog preview of +name+. + def doc_dialog_contents(name, width) + IRB.doc_providers.each do |provider| + next unless provider.respond_to?(:dialog_contents) - begin - document = retrieve_rdoc_document(name) - rescue RDoc::RI::Driver::NotFoundError - return - rescue => e - raise if $DEBUG - return rdoc_error_document(e).accept(formatter).split("\n") + begin + contents = provider.dialog_contents(name, width) + rescue => e + raise if $DEBUG + return doc_error_dialog_contents(e) + end + return [PRESS_ALT_D_TO_READ_FULL_DOC] + contents if contents end - return unless document - - [PRESS_ALT_D_TO_READ_FULL_DOC] + document.accept(formatter).split("\n") + nil end - def retrieve_rdoc_document(name) - driver = rdoc_ri_driver - return unless driver - - name = driver.expand_name(name) - - 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 - end + def doc_error_dialog_contents(error) + [ + "Failed to load the document:", + "#{error.class}: #{error.message}", + "", + "Restart IRB with -d to see the backtrace.", + ] end - def rdoc_error_document(error) - document = RDoc::Markup::Document.new - document << RDoc::Markup::Paragraph.new("Failed to load the document:") - document << RDoc::Markup::Paragraph.new("#{error.class}: #{error.message}") - document << RDoc::Markup::BlankLine.new - document << RDoc::Markup::Paragraph.new("Restart IRB with -d to see the backtrace.") - document + # Asks IRB.doc_providers, in order, for the documentation of +name+. + def retrieve_document(name) + IRB.doc_providers.each do |provider| + document = provider.document(name) + return document if document + end + nil end def dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width) @@ -496,28 +474,17 @@ def display_document(matched) end end when MethodDocument - driver = rdoc_ri_driver - return unless driver - if matched =~ /\A(?:::)?RubyVM/ && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER'] IRB.__send__(:easter_egg) return end - 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.display_names([target.name]) - rescue RDoc::RI::Driver::NotFoundError - end + # An ambiguous receiver has several candidate names; show all of them. + documents = target.names.filter_map { |name| retrieve_document(name) } + return if documents.empty? + + Pager.page(retain_content: true) do |io| + io.puts documents.join("\n") end end end diff --git a/test/irb/helper.rb b/test/irb/helper.rb index 81ae5892f..8a161a106 100644 --- a/test/irb/helper.rb +++ b/test/irb/helper.rb @@ -84,6 +84,14 @@ def with_default_external(encoding) EnvUtil.suppress_warning { Encoding.default_external = original } end + def with_doc_providers(*providers) + original = IRB.doc_providers.dup + IRB.doc_providers.replace(providers) + yield + ensure + IRB.doc_providers.replace(original) + end + def without_rdoc(&block) ::Kernel.send(:alias_method, :irb_original_require, :require) diff --git a/test/irb/test_command.rb b/test/irb/test_command.rb index 1a99bc7eb..b07207ed8 100644 --- a/test/irb/test_command.rb +++ b/test/irb/test_command.rb @@ -874,6 +874,63 @@ def test_show_doc_without_rdoc # this is the only way to reset the redefined method without coupling the test with its implementation EnvUtil.suppress_warning { load "irb/command/help.rb" } end + + class StubDocProvider + def initialize(documents) + @documents = documents + end + + def document(name) + @documents[name] + end + end + + def test_show_doc_with_doc_provider + provider = StubDocProvider.new("Foo#bar" => "documentation of Foo#bar") + + out, err = with_doc_providers(provider, IRB::RDocDocumentProvider.new) do + execute_lines("show_doc Foo#bar") + end + + assert_empty(err) + assert_include(out, "documentation of Foo#bar") + end + + def test_show_doc_with_doc_provider_without_rdoc + provider = StubDocProvider.new("Foo#bar" => "documentation of Foo#bar") + + out, err = without_rdoc do + with_doc_providers(provider, IRB::RDocDocumentProvider.new) do + execute_lines("show_doc Foo#bar") + end + end + + assert_empty(err) + assert_include(out, "documentation of Foo#bar") + end + + if HAS_RDOC + def test_show_doc_falls_back_to_the_next_provider + provider = StubDocProvider.new({}) + + out, err = with_doc_providers(provider, IRB::RDocDocumentProvider.new) do + execute_lines("show_doc String#gsub") + end + + assert_empty(err) + possible_rdoc_output = [/Nothing known about String#gsub/, /gsub\(pattern\)/] + assert(possible_rdoc_output.any? { |output| output.match?(out) }, "Expect the `show_doc` command to match one of the possible outputs. Got:\n#{out}") + end + + def test_show_doc_reports_unknown_name + out, err = with_doc_providers(StubDocProvider.new({})) do + execute_lines("show_doc Foo#bar") + end + + assert_empty(err) + assert_include(out, "Nothing known about Foo") + end + end end class EditTest < CommandTestCase diff --git a/test/irb/test_doc_provider.rb b/test/irb/test_doc_provider.rb new file mode 100644 index 000000000..104424b35 --- /dev/null +++ b/test/irb/test_doc_provider.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: false +require "irb" +begin + require "rdoc" +rescue LoadError +end +require_relative "helper" + +module TestIRB + class DocProviderTest < TestCase + def setup + @conf_backup = IRB.conf.dup + IRB.init_config(nil) + end + + def teardown + IRB.conf.replace(@conf_backup) + end + + def test_default_providers + assert_kind_of(Array, IRB.doc_providers) + assert(IRB.doc_providers.any? { |provider| provider.is_a?(IRB::RDocDocumentProvider) }) + end + end + + class RDocDocumentProviderTest < TestCase + def setup + @conf_backup = IRB.conf.dup + IRB.init_config(nil) + @provider = IRB::RDocDocumentProvider.new + end + + def teardown + IRB.conf.replace(@conf_backup) + end + + def test_available + assert(@provider.available?) + without_rdoc do + refute(@provider.available?) + end + end + + def test_document_returns_nil_without_rdoc + without_rdoc do + assert_nil(@provider.document("String#gsub")) + assert_nil(@provider.dialog_contents("String.gsub", 40)) + end + end + + def test_document_returns_nil_for_unknown_name + assert_nil(@provider.document("Foo#bar")) + assert_nil(@provider.dialog_contents("Foo.bar", 40)) + end + + def test_not_found_message_for_unknown_class + assert_include(@provider.not_found_message("Foo#bar"), "Nothing known about Foo") + end + + def test_document + omit "This test requires RI data" unless has_rdoc_content? + + document = @provider.document("Array.new") + assert_include(strip_formatting(document), "Array.new") + + document = @provider.document("String") + assert_include(strip_formatting(document), "String < Object") + end + + def test_dialog_contents + omit "This test requires RI data" unless has_rdoc_content? + + contents = @provider.dialog_contents("Array.new", 40) + assert_kind_of(Array, contents) + assert_include(strip_formatting(contents.join("\n")), "Array.new") + end + + def test_not_found_message_suggests_similar_names + omit "This test requires RI data" unless has_rdoc_content? + + message = @provider.not_found_message("String#gsu") + assert_include(message, "maybe you meant") + assert_include(message, "String#gsub") + + assert_include(@provider.not_found_message("String#zzz"), "Nothing known about String#zzz") + end + + private + + def has_rdoc_content? + File.exist?(RDoc::RI::Paths::BASE) + end + + # remove the bold formatting of RDoc::Markup::ToBs and RDoc::Markup::ToAnsi + def strip_formatting(text) + text.gsub(/.\x08/, "").gsub(/\e\[[0-9;]*m/, "") + end + end if defined?(RDoc) +end diff --git a/test/irb/test_input_method.rb b/test/irb/test_input_method.rb index d412e9e81..924d3a4d2 100644 --- a/test/irb/test_input_method.rb +++ b/test/irb/test_input_method.rb @@ -105,16 +105,10 @@ def test_initialization_with_use_autocomplete_but_without_rdoc end class DisplayDocumentTest < InputMethodTest - def setup - super - @driver = RDoc::RI::Driver.new(use_stdout: true) - end - - def display_document(target, bind, driver = nil) + def display_document(target, bind) use_pager = IRB.conf[:USE_PAGER] IRB.conf[:USE_PAGER] = false input_method = IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) - input_method.instance_variable_set(:@rdoc_ri_driver, driver) if driver input_method.instance_variable_set(:@completion_params, ['', target, '', bind]) input_method.display_document(target) ensure @@ -125,7 +119,7 @@ def test_perfectly_matched_namespace_triggers_document_display omit unless has_rdoc_content? out, err = capture_output do - display_document("String", binding, @driver) + display_document("String", binding) end assert_empty(err) @@ -136,7 +130,7 @@ def test_perfectly_matched_namespace_triggers_document_display def test_perfectly_matched_multiple_namespaces_triggers_document_display result = nil out, err = capture_output do - result = display_document("{}.nil?", binding, @driver) + result = display_document("{}.nil?", binding) end assert_empty(err) @@ -148,17 +142,16 @@ def test_perfectly_matched_multiple_namespaces_triggers_document_display assert_include(out, "P\bPr\bro\boc\bc.\b.n\bni\bil\bl?\b?") # Proc.nil? assert_include(out, "H\bHa\bas\bsh\bh.\b.n\bni\bil\bl?\b?") # Hash.nil? else - # this is a hacky way to verify the rdoc rendering code path because CI doesn't have rdoc content - # if there are multiple namespaces to be rendered, PerfectMatchedProc renders the result with a document - # which always returns the bytes rendered, even if it's 0 - assert_equal(0, result) + # nothing is displayed when no documentation is available + assert_empty(out) + assert_nil(result) end end def test_not_matched_namespace_triggers_nothing result = nil out, err = capture_output do - result = display_document("Stri", binding, @driver) + result = display_document("Stri", binding) end assert_empty(err) @@ -183,7 +176,7 @@ def test_perfect_matching_stops_without_rdoc def test_perfect_matching_handles_nil_namespace out, err = capture_output do # symbol literal has `nil` doc namespace so it's a good test subject - assert_nil(display_document(":aiueo", binding, @driver)) + assert_nil(display_document(":aiueo", binding)) end assert_empty(err) @@ -208,6 +201,48 @@ def test_command_doc_display_without_help_message assert_include(out, IRB::Command::History.description) end + def test_documents_of_all_candidate_names_are_displayed + provider = StubDocProvider.new("Hash.any?" => "doc of Hash#any?", "Proc.any?" => "doc of Proc#any?") + + out, err = capture_output do + with_doc_providers(provider) do + display_document("{}.any?", binding) + end + end + + assert_empty(err) + assert_include(out, "doc of Hash#any?") + assert_include(out, "doc of Proc#any?") + end + + def test_providers_are_consulted_in_order + first = StubDocProvider.new({}) + second = StubDocProvider.new("String" => "doc of String") + + out, err = capture_output do + with_doc_providers(first, second) do + display_document("String", binding) + end + end + + assert_empty(err) + assert_equal(["String"], first.requested_names) + assert_include(out, "doc of String") + end + + def test_nothing_is_displayed_when_no_provider_knows_the_name + result = nil + out, err = capture_output do + with_doc_providers(StubDocProvider.new({})) do + result = display_document("String", binding) + end + end + + assert_empty(err) + assert_empty(out) + assert_nil(result) + end + private def has_rdoc_content? @@ -215,13 +250,135 @@ def has_rdoc_content? end end if defined?(RDoc) + class StubDocProvider + attr_reader :requested_names + + def initialize(documents) + @documents = documents + @requested_names = [] + end + + def document(name) + @requested_names << name + @documents[name] + end + + def dialog_contents(name, width) + @requested_names << name + document = @documents[name] + ["#{name} (width: #{width})", document] if document + end + end + + class DocumentOnlyProvider + def initialize(documents) + @documents = documents + end + + def document(name) + @documents[name] + end + end + + class DocDialogContentsTest < InputMethodTest + def test_providers_are_consulted_in_order + first = StubDocProvider.new({}) + second = StubDocProvider.new("String.gsub" => "doc of String#gsub") + + contents = with_doc_providers(first, second) do + build_input_method.doc_dialog_contents("String.gsub", 40) + end + + assert_equal(["String.gsub"], first.requested_names) + assert_equal([IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC, "String.gsub (width: 40)", "doc of String#gsub"], contents) + end + + def test_providers_without_dialog_contents_are_skipped + provider = DocumentOnlyProvider.new("String.gsub" => "doc of String#gsub") + + contents = with_doc_providers(provider) do + build_input_method.doc_dialog_contents("String.gsub", 40) + end + + assert_nil(contents) + end + + def test_returns_nil_when_no_provider_knows_the_name + contents = with_doc_providers(StubDocProvider.new({})) do + build_input_method.doc_dialog_contents("String.gsub", 40) + end + + assert_nil(contents) + end + + def test_shows_error_content_when_provider_raises + provider = Object.new + provider.define_singleton_method(:document) { |_name| nil } + provider.define_singleton_method(:dialog_contents) { |_name, _width| raise ArgumentError, "broken provider" } + + contents = nil + assert_nothing_raised do + contents = with_doc_providers(provider) do + build_input_method.doc_dialog_contents("String.gsub", 40) + end + end + + assert_include(contents, "ArgumentError: broken provider") + assert_include(contents, "Restart IRB with -d to see the backtrace.") + assert_not_include(contents, IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC) + end + + def test_dialog_is_registered_when_a_provider_supports_it_without_rdoc + original_show_doc_proc = Reline.dialog_proc(:show_doc)&.dialog_proc + empty_proc = Proc.new {} + Reline.add_dialog_proc(:show_doc, empty_proc) + IRB.conf[:USE_AUTOCOMPLETE] = true + + without_rdoc do + with_doc_providers(StubDocProvider.new({}), IRB::RDocDocumentProvider.new) do + IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) + end + end + + assert_not_equal empty_proc, Reline.dialog_proc(:show_doc).dialog_proc + ensure + Reline.add_dialog_proc(:show_doc, original_show_doc_proc, Reline::DEFAULT_DIALOG_CONTEXT) + end + + def test_dialog_is_not_registered_when_no_provider_supports_it + original_show_doc_proc = Reline.dialog_proc(:show_doc)&.dialog_proc + empty_proc = Proc.new {} + Reline.add_dialog_proc(:show_doc, empty_proc) + IRB.conf[:USE_AUTOCOMPLETE] = true + + with_doc_providers(DocumentOnlyProvider.new({})) do + IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) + end + + assert_equal empty_proc, Reline.dialog_proc(:show_doc).dialog_proc + ensure + Reline.add_dialog_proc(:show_doc, original_show_doc_proc, Reline::DEFAULT_DIALOG_CONTEXT) + end + + private + + def build_input_method + IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) + end + end + class RdocDialogContentsTest < InputMethodTest + def teardown + IRB.doc_providers.replace(@original_providers) if @original_providers + super + end + def test_shows_error_content_when_document_retrieval_raises input_method = build_input_method(failing_driver(ArgumentError.new("undefined class/module RDoc::"))) contents = nil assert_nothing_raised do - contents = input_method.rdoc_dialog_contents("1.foo", 40) + contents = input_method.doc_dialog_contents("1.foo", 40) end assert_not_nil contents @@ -243,7 +400,7 @@ def test_raises_the_error_when_debug_is_enabled # $DEBUG makes Ruby print raised exceptions to stderr; swallow that noise. capture_output do assert_raise(ArgumentError) do - input_method.rdoc_dialog_contents("1.foo", 40) + input_method.doc_dialog_contents("1.foo", 40) end end ensure @@ -253,7 +410,7 @@ def test_raises_the_error_when_debug_is_enabled def test_includes_full_document_hint_when_document_is_available input_method = build_input_method(documented_driver) - contents = input_method.rdoc_dialog_contents("1.foo", 40) + contents = input_method.doc_dialog_contents("1.foo", 40) assert_equal IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC, contents.first end @@ -261,15 +418,17 @@ def test_includes_full_document_hint_when_document_is_available def test_returns_nil_when_document_not_found input_method = build_input_method(failing_driver(RDoc::RI::Driver::NotFoundError.new("1.foo"))) - assert_nil input_method.rdoc_dialog_contents("1.foo", 40) + assert_nil input_method.doc_dialog_contents("1.foo", 40) end private def build_input_method(driver) - input_method = IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) - input_method.instance_variable_set(:@rdoc_ri_driver, driver) - input_method + provider = IRB::RDocDocumentProvider.new + provider.instance_variable_set(:@driver, driver) + @original_providers = IRB.doc_providers.dup + IRB.doc_providers.replace([provider]) + IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) end def failing_driver(error) From 41fdb862f90822b6709e1c911a66aa34ef909d3d Mon Sep 17 00:00:00 2001 From: Kazuhiro NISHIYAMA Date: Wed, 30 Sep 2026 08:46:27 +0900 Subject: [PATCH 2/2] Report the full name when show_doc finds nothing `RDocDocumentProvider#not_found_message` expanded the class part of the name to reproduce ri's "Did you mean?", so without RI data (as on CI) `show_doc String#gsub` printed "Nothing known about String" instead of "Nothing known about String#gsub" like before. Report the name as given, as show_doc always did, and keep only the "maybe you meant" suggestions. Co-Authored-By: Claude Fable 5.1 --- lib/irb/doc_provider.rb | 7 ++----- test/irb/test_command.rb | 2 +- test/irb/test_doc_provider.rb | 13 +++++++++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/irb/doc_provider.rb b/lib/irb/doc_provider.rb index 0cc5f4424..a22756d97 100644 --- a/lib/irb/doc_provider.rb +++ b/lib/irb/doc_provider.rb @@ -72,12 +72,9 @@ def interactive def not_found_message(name) matches = name.match?(/::|#|\./) ? driver.list_methods_matching(name) : [] matches = driver.classes.keys.grep(/\A#{Regexp.escape(name)}/) if matches.empty? - return "#{name} not found, maybe you meant:\n\n#{matches.sort.join("\n")}" unless matches.empty? + return "Nothing known about #{name}" if matches.empty? - driver.expand_name(name) # raises NotFoundError with "Did you mean?" for an unknown class - "Nothing known about #{name}" - rescue RDoc::RI::Driver::NotFoundError => e - e.message + "#{name} not found, maybe you meant:\n\n#{matches.sort.join("\n")}" end private diff --git a/test/irb/test_command.rb b/test/irb/test_command.rb index b07207ed8..5758d16f4 100644 --- a/test/irb/test_command.rb +++ b/test/irb/test_command.rb @@ -928,7 +928,7 @@ def test_show_doc_reports_unknown_name end assert_empty(err) - assert_include(out, "Nothing known about Foo") + assert_include(out, "Nothing known about Foo#bar") end end end diff --git a/test/irb/test_doc_provider.rb b/test/irb/test_doc_provider.rb index 104424b35..99336edfa 100644 --- a/test/irb/test_doc_provider.rb +++ b/test/irb/test_doc_provider.rb @@ -53,8 +53,17 @@ def test_document_returns_nil_for_unknown_name assert_nil(@provider.dialog_contents("Foo.bar", 40)) end - def test_not_found_message_for_unknown_class - assert_include(@provider.not_found_message("Foo#bar"), "Nothing known about Foo") + def test_not_found_message_for_unknown_name + assert_equal("Nothing known about Foo#bar", @provider.not_found_message("Foo#bar")) + assert_equal("Nothing known about Foo", @provider.not_found_message("Foo")) + end + + def test_not_found_message_without_ri_data + provider = IRB::RDocDocumentProvider.new + provider.instance_variable_set(:@driver, RDoc::RI::Driver.new(use_system: false, use_site: false, use_home: false, use_gems: false)) + + assert_equal("Nothing known about String#gsub", provider.not_found_message("String#gsub")) + assert_equal("Nothing known about String", provider.not_found_message("String")) end def test_document