Skip to content

Commit 42bb945

Browse files
committed
Display command description in doc dialog on tab completion
1 parent ff54f32 commit 42bb945

6 files changed

Lines changed: 223 additions & 33 deletions

File tree

lib/irb/command/base.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,49 @@ def execute(irb_context, arg)
3737
puts e.message
3838
end
3939

40+
# Returns formatted lines for display in the doc dialog popup.
41+
def doc_dialog_content(name, width)
42+
lines = []
43+
lines << Color.colorize(name, [:BOLD, :BLUE]) + Color.colorize(" (command)", [:CYAN])
44+
lines << ""
45+
lines.concat(wrap_lines(description, width))
46+
if help_message
47+
lines << ""
48+
lines.concat(wrap_lines(help_message, width))
49+
end
50+
lines
51+
end
52+
4053
private
4154

4255
def highlight(text)
4356
Color.colorize(text, [:BOLD, :BLUE])
4457
end
58+
59+
def wrap_lines(text, width)
60+
text.lines.flat_map do |line|
61+
line = line.chomp
62+
next [''] if line.empty?
63+
next [line] if line.length <= width
64+
65+
indent = line[/\A\s*/]
66+
words = line.strip.split(/\s+/)
67+
result = []
68+
current = indent.dup
69+
words.each do |word|
70+
if current == indent
71+
current << word
72+
elsif current.length + 1 + word.length <= width
73+
current << ' ' << word
74+
else
75+
result << current
76+
current = indent.dup + word
77+
end
78+
end
79+
result << current unless current == indent
80+
result
81+
end
82+
end
4583
end
4684

4785
def initialize(irb_context)

lib/irb/completion.rb

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@
88
require_relative 'ruby-lex'
99

1010
module IRB
11+
class DocumentTarget # :nodoc:
12+
attr_reader :name
13+
14+
def initialize(name)
15+
@name = name
16+
end
17+
end
18+
19+
class CommandDocument < DocumentTarget # :nodoc:
20+
end
21+
22+
# Represents a method/class documentation target. May hold multiple names
23+
# when the receiver is ambiguous (e.g. `{}.any?` could be Hash#any? or Proc#any?).
24+
# The dialog popup uses only the first name; the full-screen display renders all.
25+
class MethodDocument < DocumentTarget # :nodoc:
26+
attr_reader :names
27+
28+
def initialize(*names)
29+
super(names.first)
30+
@names = names
31+
end
32+
end
33+
1134
class BaseCompletor # :nodoc:
1235

1336
# Set of reserved words used by Ruby, you should not use these for
@@ -76,6 +99,12 @@ def command_candidates(target)
7699
end
77100
end
78101

102+
def command_document_target(preposing, matched)
103+
if preposing.empty? && IRB::Command.command_names.include?(matched)
104+
CommandDocument.new(matched)
105+
end
106+
end
107+
79108
def retrieve_files_to_require_relative_from_current_dir
80109
@files_from_current_dir ||= Dir.glob("**/*.{rb,#{RbConfig::CONFIG['DLEXT']}}", base: '.').map { |path|
81110
path.sub(/\.(rb|#{RbConfig::CONFIG['DLEXT']})\z/, '')
@@ -118,8 +147,10 @@ def completion_candidates(preposing, target, _postposing, bind:)
118147
end
119148

120149
def doc_namespace(preposing, matched, _postposing, bind:)
121-
result = ReplTypeCompletor.analyze(preposing + matched, binding: bind, filename: @context.irb_path)
122-
result&.doc_namespace('')
150+
command_document_target(preposing, matched) || begin
151+
result = ReplTypeCompletor.analyze(preposing + matched, binding: bind, filename: @context.irb_path)
152+
result&.doc_namespace('')
153+
end
123154
end
124155
end
125156

@@ -201,8 +232,8 @@ def completion_candidates(preposing, target, postposing, bind:)
201232
commands | completion_data
202233
end
203234

204-
def doc_namespace(_preposing, matched, _postposing, bind:)
205-
retrieve_completion_data(matched, bind: bind, doc_namespace: true)
235+
def doc_namespace(preposing, matched, _postposing, bind:)
236+
command_document_target(preposing, matched) || retrieve_completion_data(matched, bind: bind, doc_namespace: true)
206237
end
207238

208239
def retrieve_completion_data(input, bind:, doc_namespace:)

lib/irb/input-method.rb

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,17 @@ def auto_indent(&block)
315315
@auto_indent_proc = block
316316
end
317317

318-
def retrieve_doc_namespace(matched)
318+
def retrieve_document_target(matched)
319319
preposing, _target, postposing, bind = @completion_params
320-
@completor.doc_namespace(preposing, matched, postposing, bind: bind)
320+
result = @completor.doc_namespace(preposing, matched, postposing, bind: bind)
321+
case result
322+
when DocumentTarget, nil
323+
result
324+
when Array
325+
MethodDocument.new(*result)
326+
when String
327+
MethodDocument.new(result)
328+
end
321329
end
322330

323331
def rdoc_ri_driver
@@ -345,22 +353,25 @@ def show_doc_dialog_proc
345353
cursor_pos_to_render, result, pointer, autocomplete_dialog = context.pop(4)
346354
return nil if result.nil? || pointer.nil? || pointer < 0
347355

348-
name = input_method.retrieve_doc_namespace(result[pointer])
349-
# Use first one because document dialog does not support multiple namespaces.
350-
name = name.first if name.is_a?(Array)
351-
352-
show_easter_egg = name&.match?(/\ARubyVM/) && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER']
356+
matched_text = result[pointer]
357+
show_easter_egg = matched_text&.match?(/\ARubyVM/) && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER']
358+
target = show_easter_egg ? nil : input_method.retrieve_document_target(matched_text)
353359

354360
x, width = input_method.dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width)
355361
return nil unless x
356362

357363
dialog.trap_key = ALT_D_SEQUENCES
358364
open_doc = key.match?(dialog.name)
359365

360-
contents = if show_easter_egg
361-
input_method.easter_egg_dialog_contents(open_doc: open_doc)
366+
contents = case target
367+
when CommandDocument
368+
input_method.command_doc_dialog_contents(target.name, width, open_doc: open_doc)
369+
when MethodDocument
370+
input_method.rdoc_dialog_contents(target.name, width, open_doc: open_doc)
362371
else
363-
input_method.rdoc_dialog_contents(name, width, open_doc: open_doc)
372+
if show_easter_egg
373+
input_method.easter_egg_dialog_contents(open_doc: open_doc)
374+
end
364375
end
365376
return nil unless contents
366377

@@ -370,6 +381,23 @@ def show_doc_dialog_proc
370381
}
371382
end
372383

384+
def command_doc_dialog_contents(command_name, width, open_doc: false)
385+
command_class = IRB::Command.load_command(command_name)
386+
return unless command_class
387+
388+
if open_doc
389+
content = command_class.help_message || command_class.description
390+
begin
391+
print "\e[?1049h"
392+
Pager.page_content(content)
393+
ensure
394+
print "\e[?1049l"
395+
end
396+
end
397+
398+
[PRESS_ALT_D_TO_READ_FULL_DOC, ""] + command_class.doc_dialog_content(command_name, width)
399+
end
400+
373401
def easter_egg_dialog_contents(open_doc: false)
374402
IRB.__send__(:easter_egg) if open_doc
375403
type = STDOUT.external_encoding == Encoding::UTF_8 ? :unicode : :ascii
@@ -447,31 +475,40 @@ def dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width)
447475
end
448476

449477
def display_document(matched)
450-
driver = rdoc_ri_driver
451-
return unless driver
452-
453-
if matched =~ /\A(?:::)?RubyVM/ && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER']
454-
IRB.__send__(:easter_egg)
455-
return
456-
end
478+
target = retrieve_document_target(matched)
479+
return unless target
480+
481+
case target
482+
when CommandDocument
483+
command_class = IRB::Command.load_command(target.name)
484+
if command_class
485+
content = command_class.help_message || command_class.description
486+
Pager.page_content(content)
487+
end
488+
when MethodDocument
489+
driver = rdoc_ri_driver
490+
return unless driver
457491

458-
namespace = retrieve_doc_namespace(matched)
459-
return unless namespace
492+
if matched =~ /\A(?:::)?RubyVM/ && !ENV['RUBY_YES_I_AM_NOT_A_NORMAL_USER']
493+
IRB.__send__(:easter_egg)
494+
return
495+
end
460496

461-
if namespace.is_a?(Array)
462-
out = RDoc::Markup::Document.new
463-
namespace.each do |m|
497+
if target.names.length > 1
498+
out = RDoc::Markup::Document.new
499+
target.names.each do |m|
500+
begin
501+
driver.add_method(out, m)
502+
rescue RDoc::RI::Driver::NotFoundError
503+
end
504+
end
505+
driver.display(out)
506+
else
464507
begin
465-
driver.add_method(out, m)
508+
driver.display_names([target.name])
466509
rescue RDoc::RI::Driver::NotFoundError
467510
end
468511
end
469-
driver.display(out)
470-
else
471-
begin
472-
driver.display_names([namespace])
473-
rescue RDoc::RI::Driver::NotFoundError
474-
end
475512
end
476513
end
477514

test/irb/test_completion.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ def test_command_completion
2525
assert_include(completor.completion_candidates('', 'show_s', '', bind: binding), 'show_source')
2626
assert_not_include(completor.completion_candidates(';', 'show_s', '', bind: binding), 'show_source')
2727
end
28+
29+
def test_command_document_target
30+
completor = IRB::RegexpCompletor.new
31+
# Command with empty preposing should return a CommandDocument
32+
result = completor.doc_namespace('', 'help', '', bind: binding)
33+
assert_instance_of(IRB::CommandDocument, result)
34+
assert_equal('help', result.name)
35+
36+
result = completor.doc_namespace('', 'show_source', '', bind: binding)
37+
assert_instance_of(IRB::CommandDocument, result)
38+
assert_equal('show_source', result.name)
39+
40+
# Command with non-empty preposing should not return a CommandDocument
41+
result = completor.doc_namespace(';', 'help', '', bind: binding)
42+
refute_instance_of(IRB::CommandDocument, result)
43+
end
2844
end
2945

3046
class MethodCompletionTest < CompletionTest

test/irb/test_input_method.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,63 @@ def test_perfect_matching_handles_nil_namespace
186186
assert_empty(out)
187187
end
188188

189+
def test_command_doc_display_with_help_message
190+
out, _err = capture_output do
191+
display_document("show_source", binding)
192+
end
193+
194+
# When help_message is available, it is displayed
195+
assert_include(out, "Usage: show_source")
196+
end
197+
198+
def test_command_doc_display_without_help_message
199+
out, _err = capture_output do
200+
display_document("history", binding)
201+
end
202+
203+
# When no help_message, description is displayed
204+
assert_include(out, IRB::Command::History.description)
205+
end
206+
189207
private
190208

191209
def has_rdoc_content?
192210
File.exist?(RDoc::RI::Paths::BASE)
193211
end
194212
end if defined?(RDoc)
213+
214+
class CommandDocDialogContentTest < TestCase
215+
def setup
216+
@conf_backup = IRB.conf.dup
217+
IRB.init_config(nil)
218+
end
219+
220+
def teardown
221+
IRB.conf.replace(@conf_backup)
222+
end
223+
224+
def test_doc_dialog_content_with_description_only
225+
lines = IRB::Command::History.doc_dialog_content("history", 40)
226+
assert lines[0].include?("(command)")
227+
# Description words should all be present (may be wrapped across lines)
228+
content = lines.join(" ")
229+
IRB::Command::History.description.split.each do |word|
230+
assert_include content, word
231+
end
232+
end
233+
234+
def test_doc_dialog_content_with_help_message
235+
lines = IRB::Command::ShowSource.doc_dialog_content("show_source", 60)
236+
assert lines[0].include?("(command)")
237+
assert_include lines.join("\n"), "Usage: show_source"
238+
end
239+
240+
def test_doc_dialog_content_wraps_long_lines
241+
lines = IRB::Command::Help.doc_dialog_content("help", 30)
242+
lines.each do |line|
243+
stripped = line.gsub(/\e\[[0-9;]*m/, '') # strip ANSI codes
244+
assert_operator stripped.length, :<=, 30, "Line exceeds width: #{line.inspect}"
245+
end
246+
end
247+
end
195248
end

test/irb/test_type_completor.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ def test_command_completion
8181
assert_not_include(@completor.completion_candidates(';', 'show_s', '', bind: binding), 'show_source')
8282
end
8383

84+
def test_command_document_target
85+
# Command with empty preposing should return a CommandDocument
86+
result = @completor.doc_namespace('', 'help', '', bind: binding)
87+
assert_instance_of(IRB::CommandDocument, result)
88+
assert_equal('help', result.name)
89+
90+
result = @completor.doc_namespace('', 'show_source', '', bind: binding)
91+
assert_instance_of(IRB::CommandDocument, result)
92+
assert_equal('show_source', result.name)
93+
94+
# Command with non-empty preposing should not return a CommandDocument
95+
result = @completor.doc_namespace(';', 'help', '', bind: binding)
96+
refute_instance_of(IRB::CommandDocument, result)
97+
end
98+
8499
def test_type_completor_handles_encoding_errors_gracefully
85100
invalid_method_name = "b\xff".dup.force_encoding(Encoding::ASCII_8BIT)
86101

0 commit comments

Comments
 (0)