Skip to content

Commit eb3d4c0

Browse files
committed
Helper methods without object polluting
Instead of defining helper method to current workspace object, modify the input code to directly call helper method defined in a container object. `p conf.ap_name` will be modified to `p ::IRB::HelperMethod::Container.conf.ap_name`. Also implements highlighting, completion, and document dialog for helper methods.
1 parent 50a7961 commit eb3d4c0

13 files changed

Lines changed: 236 additions & 49 deletions

lib/irb.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ def initialize(workspace = nil, input_method = nil, from_binding: false)
105105
@from_binding = from_binding
106106
@prompt_part_cache = nil
107107
@context = Context.new(self, workspace, input_method)
108-
@context.workspace.load_helper_methods_to_main
109108
@signal_status = :IN_IRB
110109
@scanner = RubyLex.new
111110
@line_no = 1
@@ -126,7 +125,6 @@ def debug_break
126125
def debug_readline(binding)
127126
workspace = IRB::WorkSpace.new(binding)
128127
context.replace_workspace(workspace)
129-
context.workspace.load_helper_methods_to_main
130128
@line_no += 1
131129

132130
# When users run:

lib/irb/color.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ module Color
1818
CYAN = 36
1919
WHITE = 37
2020

21+
PRIORITY_ERROR = 0
22+
PRIORITY_HELPER_METHOD = 1
23+
PRIORITY_PRIOR_TOKEN = 2
24+
PRIORITY_NORMAL_TOKEN = 3
25+
2126
# Following pry's colors where possible
2227
TOKEN_SEQS = {
2328
KEYWORD_NIL: [CYAN, BOLD],
@@ -106,6 +111,8 @@ module Color
106111
method_name: [CYAN, BOLD],
107112
message_name: [CYAN],
108113
symbol: [YELLOW],
114+
# helper method
115+
helper_method: [BOLD],
109116
# special colorization
110117
error: [RED, REVERSE],
111118
}.transform_values do |styles|
@@ -162,7 +169,7 @@ def colorize(text, seq, colorable: colorable?)
162169
# If `complete` is false (code is incomplete), this does not warn compile_error.
163170
# This option is needed to avoid warning a user when the compile_error is happening
164171
# because the input is not wrong but just incomplete.
165-
def colorize_code(code, complete: true, ignore_error: false, colorable: colorable?, local_variables: [])
172+
def colorize_code(code, complete: true, ignore_error: false, colorable: colorable?, local_variables: [], helper_methods: false)
166173
return code unless colorable
167174

168175
result = Prism.parse_lex(code, scopes: [local_variables])
@@ -180,9 +187,13 @@ def colorize_code(code, complete: true, ignore_error: false, colorable: colorabl
180187
visitor = ColorizeVisitor.new
181188
prism_node.accept(visitor)
182189

183-
error_tokens = errors.map { |e| [e.location.start_line, e.location.start_column, 0, e.location.end_line, e.location.end_column, :error, e.location.slice] }
190+
helper_method_locations = helper_methods ? IRB::HelperMethod.extract_helper_method_locations(prism_node) : []
191+
helper_method_tokens = helper_method_locations.map { |loc| [loc.start_line, loc.start_column, PRIORITY_HELPER_METHOD, loc.end_line, loc.end_column, :helper_method, loc.slice] }
192+
193+
error_tokens = errors.map { |e| [e.location.start_line, e.location.start_column, PRIORITY_ERROR, e.location.end_line, e.location.end_column, :error, e.location.slice] }
184194
error_tokens.reject! { |t| t.last.match?(/\A\s*\z/) }
185-
tokens = prism_tokens.map { |t,| [t.location.start_line, t.location.start_column, 2, t.location.end_line, t.location.end_column, t.type, t.value] }
195+
196+
tokens = prism_tokens.map { |t,| [t.location.start_line, t.location.start_column, PRIORITY_NORMAL_TOKEN, t.location.end_line, t.location.end_column, t.type, t.value] }
186197
tokens.pop if tokens.last&.[](5) == :EOF
187198

188199
colored = +''
@@ -201,7 +212,7 @@ def colorize_code(code, complete: true, ignore_error: false, colorable: colorabl
201212
end
202213
}
203214

204-
(visitor.tokens + tokens + error_tokens).sort.each do |start_line, start_column, _priority, end_line, end_column, type, value|
215+
(visitor.tokens + tokens + error_tokens + helper_method_tokens).sort.each do |start_line, start_column, _priority, end_line, end_column, type, value|
205216
next if start_line - 1 < line_index || (start_line - 1 == line_index && start_column < col)
206217

207218
flush.call(start_line - 1, start_column)
@@ -235,7 +246,7 @@ def initialize
235246

236247
def dispatch(location, type)
237248
if location
238-
@tokens << [location.start_line, location.start_column, 1, location.end_line, location.end_column, type, location.slice]
249+
@tokens << [location.start_line, location.start_column, PRIORITY_PRIOR_TOKEN, location.end_line, location.end_column, type, location.slice]
239250
end
240251
end
241252

lib/irb/completion.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def initialize(name)
1919
class CommandDocument < DocumentTarget # :nodoc:
2020
end
2121

22+
class HelperMethodDocument < DocumentTarget # :nodoc:
23+
end
24+
2225
# Represents a method/class documentation target. May hold multiple names
2326
# when the receiver is ambiguous (e.g. `{}.any?` could be Hash#any? or Proc#any?).
2427
# The dialog popup uses only the first name; the full-screen display renders all.
@@ -105,6 +108,12 @@ def command_document_target(preposing, matched)
105108
end
106109
end
107110

111+
def helper_method_document_target(preposing, matched, local_variables:)
112+
if IRB::HelperMethod.completions(preposing, matched, local_variables: local_variables).include?(matched)
113+
HelperMethodDocument.new(matched)
114+
end
115+
end
116+
108117
def retrieve_files_to_require_relative_from_current_dir
109118
@files_from_current_dir ||= Dir.glob("**/*.{rb,#{RbConfig::CONFIG['DLEXT']}}", base: '.').map { |path|
110119
path.sub(/\.(rb|#{RbConfig::CONFIG['DLEXT']})\z/, '')
@@ -143,11 +152,14 @@ def completion_candidates(preposing, target, _postposing, bind:)
143152
# If the string cannot be converted, we just ignore it
144153
nil
145154
end
146-
commands | encoded_candidates
155+
helper_methods = IRB::HelperMethod.completions(preposing, target, local_variables: bind.local_variables)
156+
commands | helper_methods | encoded_candidates
147157
end
148158

149159
def doc_namespace(preposing, matched, _postposing, bind:)
150-
command_document_target(preposing, matched) || begin
160+
command_document_target(preposing, matched) ||
161+
helper_method_document_target(preposing, matched, local_variables: bind.local_variables) ||
162+
begin
151163
result = ReplTypeCompletor.analyze(preposing + matched, binding: bind, filename: @context.irb_path)
152164
result&.doc_namespace('')
153165
end
@@ -229,11 +241,15 @@ def completion_candidates(preposing, target, postposing, bind:)
229241
# If the string cannot be converted, we just ignore it
230242
nil
231243
end
232-
commands | completion_data
244+
245+
helper_methods = IRB::HelperMethod.completions(preposing, target, local_variables: bind.local_variables)
246+
commands | helper_methods | completion_data
233247
end
234248

235249
def doc_namespace(preposing, matched, _postposing, bind:)
236-
command_document_target(preposing, matched) || retrieve_completion_data(matched, bind: bind, doc_namespace: true)
250+
command_document_target(preposing, matched) ||
251+
helper_method_document_target(preposing, matched, local_variables: bind.local_variables) ||
252+
retrieve_completion_data(matched, bind: bind, doc_namespace: true)
237253
end
238254

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

lib/irb/context.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ def colorize_input(input, complete:)
652652
arg = IRB::Color.colorize_code(arg, complete: complete, local_variables: lvars) if arg
653653
"#{IRB::Color.colorize(name, [:BOLD])}\e[m#{sep}#{arg}"
654654
else
655-
IRB::Color.colorize_code(input, complete: complete, local_variables: lvars)
655+
IRB::Color.colorize_code(input, complete: complete, local_variables: lvars, helper_methods: true)
656656
end
657657
else
658658
Reline::Unicode.escape_for_print(input)

lib/irb/ext/change-ws.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ def change_workspace(*_main)
3131

3232
workspace = WorkSpace.new(_main[0])
3333
replace_workspace(workspace)
34-
workspace.load_helper_methods_to_main
3534
end
3635
end
3736
end

lib/irb/ext/workspaces.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ def push_workspace(*_main)
2121
else
2222
new_workspace = WorkSpace.new(workspace.binding, _main[0])
2323
@workspace_stack.push new_workspace
24-
new_workspace.load_helper_methods_to_main
2524
end
2625
end
2726

lib/irb/helper_method.rb

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require_relative "helper_method/base"
2+
require "prism"
23

34
module IRB
45
module HelperMethod
@@ -9,9 +10,8 @@ class << self
910

1011
def register(name, helper_class)
1112
@helper_methods[name] = helper_class
12-
13-
if defined?(HelpersContainer)
14-
HelpersContainer.install_helper_methods
13+
Container.define_singleton_method name do |*args, **opts, &block|
14+
helper_class.instance.execute(*args, **opts, &block)
1515
end
1616
end
1717

@@ -20,8 +20,84 @@ def all_helper_methods_info
2020
{ display_name: name, description: helper_class.description }
2121
end
2222
end
23+
24+
# Injects helper method calls with the corresponding container method calls.
25+
# For example, `tap { p conf.ap_name }` will be transformed to `tap { p IRB::HelperMethod::Container.conf.ap_name }`.
26+
# Ideally, we want to use `::IRB::HelperMethod::Container`, but `{ key:conf }` can't be parsed if we prefix with `::`.
27+
def inject_helper_methods(code, local_variables: [])
28+
parse_result = Prism.parse(code, scopes: [local_variables])
29+
return code unless parse_result.success?
30+
31+
locations = extract_helper_method_locations(parse_result.value)
32+
return code if locations.empty?
33+
34+
injected = +''
35+
offset = 0
36+
locations.each do |loc|
37+
injected << code.byteslice(offset...loc.start_offset)
38+
injected << "IRB::HelperMethod::Container.#{loc.slice}"
39+
offset = loc.end_offset
40+
end
41+
injected << code.byteslice(offset..)
42+
injected
43+
end
44+
45+
def completions(preposing, target, local_variables:)
46+
helper_method_names = @helper_methods.keys.map(&:to_s)
47+
candidates = helper_method_names.select {|name| name.start_with?(target) }
48+
return [] if candidates.empty?
49+
50+
target_message = nil
51+
end_offset = preposing.bytesize + target.bytesize
52+
visitor = MethodCallVisitor.new do |call_node|
53+
target_message = call_node.message if call_node.message_loc.end_offset == end_offset
54+
end
55+
Prism.parse(preposing + target, scopes: [local_variables]).value.accept(visitor)
56+
return [] unless target_message
57+
58+
candidates
59+
end
60+
61+
def extract_helper_method_locations(node)
62+
helper_method_names = @helper_methods.keys.map(&:to_s)
63+
64+
# Legacy helper methods defined in ExtendCommandBundle should also be considered as helper methods
65+
helper_method_names += IRB::ExtendCommandBundle.instance_methods.map(&:to_s)
66+
67+
helper_method_locations = []
68+
visitor = MethodCallVisitor.new do |call_node|
69+
if helper_method_names.include?(call_node.message)
70+
helper_method_locations << call_node.message_loc
71+
end
72+
end
73+
visitor.visit(node)
74+
helper_method_locations.sort_by(&:start_offset)
75+
end
76+
end
77+
78+
# Traverse and finds CallNode without receiver which may be helper method calls.
79+
class MethodCallVisitor < Prism::Visitor # :nodoc:
80+
def initialize(&block)
81+
@callback = block
82+
end
83+
84+
def visit_call_node(node)
85+
super
86+
@callback.call node if node.receiver.nil?
87+
end
88+
89+
def visit_implicit_node(node)
90+
# We can't modify `{ conf: }` to `{ IRB::HelperMethod::Container.conf: }`
91+
# so it can't be a helper method call
92+
end
2393
end
2494

95+
Container = Object.new
96+
97+
# Enable legacy helper method registration for backward compatibility
98+
require_relative "default_commands"
99+
Container.extend IRB::ExtendCommandBundle
100+
25101
# Default helper_methods
26102
require_relative "helper_method/conf"
27103
register(:conf, HelperMethod::Conf)

lib/irb/input-method.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ def show_doc_dialog_proc
374374
contents = case target
375375
when CommandDocument
376376
input_method.command_doc_dialog_contents(target.name, width)
377+
when HelperMethodDocument
378+
input_method.helper_method_doc_dialog_contents(target.name, width)
377379
when MethodDocument
378380
input_method.rdoc_dialog_contents(target.name, width)
379381
else
@@ -396,6 +398,16 @@ def command_doc_dialog_contents(command_name, width)
396398
[PRESS_ALT_D_TO_READ_FULL_DOC, ""] + command_class.doc_dialog_content(command_name, width)
397399
end
398400

401+
def helper_method_doc_dialog_contents(helper_method_name, width)
402+
helper_method_class = IRB::HelperMethod.helper_methods[helper_method_name.to_sym]
403+
return unless helper_method_class
404+
[
405+
PRESS_ALT_D_TO_READ_FULL_DOC, "",
406+
Color.colorize(helper_method_name, [:BOLD, :BLUE]) + Color.colorize(" (helper method)", [:CYAN]), "",
407+
helper_method_class.description
408+
]
409+
end
410+
399411
def easter_egg_dialog_contents
400412
type = STDOUT.external_encoding == Encoding::UTF_8 ? :unicode : :ascii
401413
lines = IRB.send(:easter_egg_logo, type).split("\n")
@@ -474,6 +486,13 @@ def display_document(matched)
474486
io.puts content
475487
end
476488
end
489+
when HelperMethodDocument
490+
helper_method_class = IRB::HelperMethod.helper_methods[target.name.to_sym]
491+
if helper_method_class
492+
Pager.page(retain_content: true) do |io|
493+
io.puts helper_method_class.description
494+
end
495+
end
477496
when MethodDocument
478497
driver = rdoc_ri_driver
479498
return unless driver

lib/irb/workspace.rb

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,9 @@ def initialize(*main)
9696
# <code>IRB.conf[:__MAIN__]</code>
9797
attr_reader :main
9898

99-
def load_helper_methods_to_main
100-
# Do not load helper methods to frozen objects and BasicObject
101-
return unless Object === @main && !@main.frozen?
102-
103-
ancestors = class<<main;ancestors;end
104-
main.extend ExtendCommandBundle if !ancestors.include?(ExtendCommandBundle)
105-
main.extend HelpersContainer if !ancestors.include?(HelpersContainer)
106-
end
107-
10899
# Evaluate the given +statements+ within the context of this workspace.
109100
def evaluate(statements, file = __FILE__, line = __LINE__)
101+
statements = HelperMethod.inject_helper_methods(statements, local_variables: @binding.local_variables)
110102
eval(statements, @binding, file, line)
111103
end
112104

@@ -163,18 +155,4 @@ def code_around_binding
163155
"\nFrom: #{file} @ line #{pos + 1} :\n\n#{body}#{Color.clear}\n"
164156
end
165157
end
166-
167-
module HelpersContainer
168-
class << self
169-
def install_helper_methods
170-
HelperMethod.helper_methods.each do |name, helper_method_class|
171-
define_method name do |*args, **opts, &block|
172-
helper_method_class.instance.execute(*args, **opts, &block)
173-
end unless method_defined?(name)
174-
end
175-
end
176-
end
177-
178-
install_helper_methods
179-
end
180158
end

test/irb/test_command.rb

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -495,15 +495,6 @@ def test_pushws_switches_to_new_workspace_and_pushes_the_current_one_to_the_stac
495495
assert_match(/=> #{self.class}\n$/, out)
496496
end
497497

498-
def test_pushws_extends_the_new_workspace_with_command_bundle
499-
out, err = execute_lines(
500-
"pushws Object.new",
501-
"self.singleton_class.ancestors"
502-
)
503-
assert_empty err
504-
assert_include(out, "IRB::ExtendCommandBundle")
505-
end
506-
507498
def test_pushws_prints_workspace_stack_when_no_arg_is_given
508499
out, err = execute_lines(
509500
"pushws",

0 commit comments

Comments
 (0)