Skip to content

Commit

Permalink
Command implementation not by method (#824)
Browse files Browse the repository at this point in the history
* Command is not a method

* Fix command test

* Implement non-method command name completion

* Add test for ExtendCommandBundle.def_extend_command

* Add helper method install test

* Remove spaces in command input parse

* Remove command arg unquote in help command

* Simplify Statement and handle execution in IRB::Irb

* Tweak require, const name

* Always install CommandBundle module to main object

* Remove considering local variable in command or expression check

* Remove unused method, tweak

* Remove outdated comment for help command arg

Co-authored-by: Stan Lo <[email protected]>

---------

Co-authored-by: Stan Lo <[email protected]>
  • Loading branch information
tompng and st0012 authored Apr 10, 2024
1 parent 805ee00 commit 8fb776e
Show file tree
Hide file tree
Showing 36 changed files with 414 additions and 328 deletions.
58 changes: 37 additions & 21 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,7 @@ class Irb
# Creates a new irb session
def initialize(workspace = nil, input_method = nil)
@context = Context.new(self, workspace, input_method)
@context.workspace.load_commands_to_main
@context.workspace.load_helper_methods_to_main
@signal_status = :IN_IRB
@scanner = RubyLex.new
@line_no = 1
Expand All @@ -950,7 +950,7 @@ def debug_break
def debug_readline(binding)
workspace = IRB::WorkSpace.new(binding)
context.replace_workspace(workspace)
context.workspace.load_commands_to_main
context.workspace.load_helper_methods_to_main
@line_no += 1

# When users run:
Expand Down Expand Up @@ -1028,7 +1028,15 @@ def eval_input
return statement.code
end

@context.evaluate(statement.evaluable_code, line_no)
case statement
when Statement::EmptyInput
# Do nothing
when Statement::Expression
@context.evaluate(statement.code, line_no)
when Statement::Command
ret = statement.command_class.execute(@context, statement.arg)
@context.set_last_value(ret)
end

if @context.echo? && !statement.suppresses_echo?
if statement.is_assignment?
Expand Down Expand Up @@ -1084,10 +1092,7 @@ def readmultiline
end

code << line

# Accept any single-line input for symbol aliases or commands that transform
# args
return code if single_line_command?(code)
return code if command?(code)

tokens, opens, terminated = @scanner.check_code_state(code, local_variables: @context.local_variables)
return code if terminated
Expand All @@ -1114,23 +1119,36 @@ def build_statement(code)
end

code.force_encoding(@context.io.encoding)
command_or_alias, arg = code.split(/\s/, 2)
# Transform a non-identifier alias (@, $) or keywords (next, break)
command_name = @context.command_aliases[command_or_alias.to_sym]
command = command_name || command_or_alias
command_class = ExtendCommandBundle.load_command(command)

if command_class
Statement::Command.new(code, command, arg, command_class)
if (command, arg = parse_command(code))
command_class = ExtendCommandBundle.load_command(command)
Statement::Command.new(code, command_class, arg)
else
is_assignment_expression = @scanner.assignment_expression?(code, local_variables: @context.local_variables)
Statement::Expression.new(code, is_assignment_expression)
end
end

def single_line_command?(code)
command = code.split(/\s/, 2).first
@context.symbol_alias?(command) || @context.transform_args?(command)
def parse_command(code)
command_name, arg = code.strip.split(/\s+/, 2)
return unless code.lines.size == 1 && command_name

arg ||= ''
command = command_name.to_sym
# Command aliases are always command. example: $, @
if (alias_name = @context.command_aliases[command])
return [alias_name, arg]
end

# Check visibility
public_method = !!Kernel.instance_method(:public_method).bind_call(@context.main, command) rescue false
private_method = !public_method && !!Kernel.instance_method(:method).bind_call(@context.main, command) rescue false
if ExtendCommandBundle.execute_as_command?(command, public_method: public_method, private_method: private_method)
[command, arg]
end
end

def command?(code)
!!parse_command(code)
end

def configure_io
Expand All @@ -1148,9 +1166,7 @@ def configure_io
false
end
else
# Accept any single-line input for symbol aliases or commands that transform
# args
next true if single_line_command?(code)
next true if command?(code)

_tokens, _opens, terminated = @scanner.check_code_state(code, local_variables: @context.local_variables)
terminated
Expand Down
118 changes: 31 additions & 87 deletions lib/irb/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,17 @@ module Command; end

# Installs the default irb extensions command bundle.
module ExtendCommandBundle
EXCB = ExtendCommandBundle # :nodoc:

# See #install_alias_method.
# See ExtendCommandBundle.execute_as_command?.
NO_OVERRIDE = 0
# See #install_alias_method.
OVERRIDE_PRIVATE_ONLY = 0x01
# See #install_alias_method.
OVERRIDE_ALL = 0x02

# Displays current configuration.
#
# Modifying the configuration is achieved by sending a message to IRB.conf.
def irb_context
IRB.CurrentContext
end

@ALIASES = [
[:context, :irb_context, NO_OVERRIDE],
[:conf, :irb_context, NO_OVERRIDE],
]


@EXTEND_COMMANDS = [
[
:irb_context, :Context, "command/context",
[:context, NO_OVERRIDE],
[:conf, NO_OVERRIDE],
],
[
:irb_exit, :Exit, "command/exit",
[:exit, OVERRIDE_PRIVATE_ONLY],
Expand Down Expand Up @@ -204,6 +192,26 @@ def irb_context
],
]

def self.command_override_policies
@@command_override_policies ||= @EXTEND_COMMANDS.flat_map do |cmd_name, cmd_class, load_file, *aliases|
[[cmd_name, OVERRIDE_ALL]] + aliases
end.to_h
end

def self.execute_as_command?(name, public_method:, private_method:)
case command_override_policies[name]
when OVERRIDE_ALL
true
when OVERRIDE_PRIVATE_ONLY
!public_method
when NO_OVERRIDE
!public_method && !private_method
end
end

def self.command_names
command_override_policies.keys.map(&:to_s)
end

@@commands = []

Expand Down Expand Up @@ -247,77 +255,13 @@ def self.load_command(command)
nil
end

# Installs the default irb commands.
def self.install_extend_commands
for args in @EXTEND_COMMANDS
def_extend_command(*args)
end
end

# Evaluate the given +cmd_name+ on the given +cmd_class+ Class.
#
# Will also define any given +aliases+ for the method.
#
# The optional +load_file+ parameter will be required within the method
# definition.
def self.def_extend_command(cmd_name, cmd_class, load_file, *aliases)
case cmd_class
when Symbol
cmd_class = cmd_class.id2name
when String
when Class
cmd_class = cmd_class.name
end

line = __LINE__; eval %[
def #{cmd_name}(*opts, **kwargs, &b)
Kernel.require_relative "#{load_file}"
::IRB::Command::#{cmd_class}.execute(irb_context, *opts, **kwargs, &b)
end
], nil, __FILE__, line

for ali, flag in aliases
@ALIASES.push [ali, cmd_name, flag]
end
end

# Installs alias methods for the default irb commands, see
# ::install_extend_commands.
def install_alias_method(to, from, override = NO_OVERRIDE)
to = to.id2name unless to.kind_of?(String)
from = from.id2name unless from.kind_of?(String)
@EXTEND_COMMANDS.delete_if { |name,| name == cmd_name }
@EXTEND_COMMANDS << [cmd_name, cmd_class, load_file, *aliases]

if override == OVERRIDE_ALL or
(override == OVERRIDE_PRIVATE_ONLY) && !respond_to?(to) or
(override == NO_OVERRIDE) && !respond_to?(to, true)
target = self
(class << self; self; end).instance_eval{
if target.respond_to?(to, true) &&
!target.respond_to?(EXCB.irb_original_method_name(to), true)
alias_method(EXCB.irb_original_method_name(to), to)
end
alias_method to, from
}
else
Kernel.warn "irb: warn: can't alias #{to} from #{from}.\n"
end
end

def self.irb_original_method_name(method_name) # :nodoc:
"irb_" + method_name + "_org"
end

# Installs alias methods for the default irb commands on the given object
# using #install_alias_method.
def self.extend_object(obj)
unless (class << obj; ancestors; end).include?(EXCB)
super
for ali, com, flg in @ALIASES
obj.install_alias_method(ali, com, flg)
end
end
# Just clear memoized values
@@commands = []
@@command_override_policies = nil
end

install_extend_commands
end
end
8 changes: 2 additions & 6 deletions lib/irb/command/backtrace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ module IRB

module Command
class Backtrace < DebugCommand
def self.transform_args(args)
args&.dump
end

def execute(*args)
super(pre_cmds: ["backtrace", *args].join(" "))
def execute(arg)
execute_debug_command(pre_cmds: "backtrace #{arg}".rstrip)
end
end
end
Expand Down
35 changes: 26 additions & 9 deletions lib/irb/command/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ module IRB
module Command
class CommandArgumentError < StandardError; end

def self.extract_ruby_args(*args, **kwargs)
throw :EXTRACT_RUBY_ARGS, [args, kwargs]
end

class Base
class << self
def category(category = nil)
Expand All @@ -29,19 +33,13 @@ def help_message(help_message = nil)

private

def string_literal?(args)
sexp = Ripper.sexp(args)
sexp && sexp.size == 2 && sexp.last&.first&.first == :string_literal
end

def highlight(text)
Color.colorize(text, [:BOLD, :BLUE])
end
end

def self.execute(irb_context, *opts, **kwargs, &block)
command = new(irb_context)
command.execute(*opts, **kwargs, &block)
def self.execute(irb_context, arg)
new(irb_context).execute(arg)
rescue CommandArgumentError => e
puts e.message
end
Expand All @@ -52,7 +50,26 @@ def initialize(irb_context)

attr_reader :irb_context

def execute(*opts)
def unwrap_string_literal(str)
return if str.empty?

sexp = Ripper.sexp(str)
if sexp && sexp.size == 2 && sexp.last&.first&.first == :string_literal
@irb_context.workspace.binding.eval(str).to_s
else
str
end
end

def ruby_args(arg)
# Use throw and catch to handle arg that includes `;`
# For example: "1, kw: (2; 3); 4" will be parsed to [[1], { kw: 3 }]
catch(:EXTRACT_RUBY_ARGS) do
@irb_context.workspace.binding.eval "IRB::Command.extract_ruby_args #{arg}"
end || [[], {}]
end

def execute(arg)
#nop
end
end
Expand Down
8 changes: 2 additions & 6 deletions lib/irb/command/break.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ module IRB

module Command
class Break < DebugCommand
def self.transform_args(args)
args&.dump
end

def execute(args = nil)
super(pre_cmds: "break #{args}")
def execute(arg)
execute_debug_command(pre_cmds: "break #{arg}".rstrip)
end
end
end
Expand Down
8 changes: 2 additions & 6 deletions lib/irb/command/catch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ module IRB

module Command
class Catch < DebugCommand
def self.transform_args(args)
args&.dump
end

def execute(*args)
super(pre_cmds: ["catch", *args].join(" "))
def execute(arg)
execute_debug_command(pre_cmds: "catch #{arg}".rstrip)
end
end
end
Expand Down
11 changes: 8 additions & 3 deletions lib/irb/command/chws.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class CurrentWorkingWorkspace < Base
category "Workspace"
description "Show the current workspace."

def execute(*obj)
def execute(_arg)
irb_context.main
end
end
Expand All @@ -23,8 +23,13 @@ class ChangeWorkspace < Base
category "Workspace"
description "Change the current workspace to an object."

def execute(*obj)
irb_context.change_workspace(*obj)
def execute(arg)
if arg.empty?
irb_context.change_workspace
else
obj = eval(arg, irb_context.workspace.binding)
irb_context.change_workspace(obj)
end
irb_context.main
end
end
Expand Down
16 changes: 16 additions & 0 deletions lib/irb/command/context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module IRB
module Command
class Context < Base
category "IRB"
description "Displays current configuration."

def execute(_arg)
# This command just displays the configuration.
# Modifying the configuration is achieved by sending a message to IRB.conf.
Pager.page_content(IRB.CurrentContext.inspect)
end
end
end
end
Loading

0 comments on commit 8fb776e

Please sign in to comment.