Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions doc/EXTEND_IRB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 26 additions & 13 deletions lib/irb/command/show_doc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions lib/irb/doc_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 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: <tt>"Array"</tt>,
# <tt>"Array#each"</tt>, <tt>"Array.new"</tt>, or <tt>"String.gsub"</tt>
# (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 <tt>IRB.conf[:EXTRA_DOC_DIRS]</tt>.
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 "Nothing known about #{name}" if matches.empty?

"#{name} not found, maybe you meant:\n\n#{matches.sort.join("\n")}"
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
115 changes: 41 additions & 74 deletions lib/irb/input-method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#

require_relative 'completion'
require_relative 'doc_provider'
require_relative "history"
require 'io/console'
require 'reline'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading