Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add edit command #453

Merged
merged 5 commits into from
Nov 20, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ The following commands are available on IRB.
* Change the current workspace to an object.
* `bindings`, `workspaces`
* Show workspaces.
* `edit`
* Open a file with the editor command defined with `ENV["EDITOR"]`
* `edit` - opens the file the current context belongs to (if applicable)
* `edit foo.rb` - opens `foo.rb`
* `edit Foo` - opens the location of `Foo`
* `edit Foo.bar` - opens the location of `Foo.bar`
* `edit Foo#bar` - opens the location of `Foo#bar`
* `pushb`, `pushws`
* Push an object to the workspace stack.
* `popb`, `popws`
Expand Down
65 changes: 65 additions & 0 deletions lib/irb/cmd/edit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require 'shellwords'
require_relative "nop"

module IRB
# :stopdoc:

module ExtendCommand
class Edit < Nop
class << self
def transform_args(args)
# Return a string literal as is for backward compatibility
if args.nil? || args.empty? || string_literal?(args)
args
else # Otherwise, consider the input as a String for convenience
args.strip.dump
end
end

private

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

def execute(*args)
path = args.first

if path.nil? && (irb_path = @irb_context.irb_path)
path = irb_path
end

if !File.exist?(path)
require_relative "show_source"

source =
begin
ShowSource.find_source(path, @irb_context)
rescue NameError
# if user enters a path that doesn't exist, it'll cause NameError when passed here because find_source would try to evaluate it as well
# in this case, we should just ignore the error
end

if source && File.exist?(source.file)
path = source.file
else
puts "Can not find file: #{path}"
return
end
end

if editor = ENV['EDITOR']
puts "command: '#{editor}'"
puts " path: #{path}"
system(*Shellwords.split(editor), path)
st0012 marked this conversation as resolved.
Show resolved Hide resolved
else
puts "Can not find editor setting: ENV['EDITOR']"
end
end
end
end

# :startdoc:
end
87 changes: 44 additions & 43 deletions lib/irb/cmd/show_source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,48 @@ def transform_args(args)
end
end

def find_source(str, irb_context)
case str
when /\A[A-Z]\w*(::[A-Z]\w*)*\z/ # Const::Name
eval(str, irb_context.workspace.binding) # trigger autoload
base = irb_context.workspace.binding.receiver.yield_self { |r| r.is_a?(Module) ? r : Object }
file, line = base.const_source_location(str) if base.respond_to?(:const_source_location) # Ruby 2.7+
when /\A(?<owner>[A-Z]\w*(::[A-Z]\w*)*)#(?<method>[^ :.]+)\z/ # Class#method
owner = eval(Regexp.last_match[:owner], irb_context.workspace.binding)
method = Regexp.last_match[:method]
if owner.respond_to?(:instance_method) && owner.instance_methods.include?(method.to_sym)
file, line = owner.instance_method(method).source_location
end
when /\A((?<receiver>.+)(\.|::))?(?<method>[^ :.]+)\z/ # method, receiver.method, receiver::method
receiver = eval(Regexp.last_match[:receiver] || 'self', irb_context.workspace.binding)
method = Regexp.last_match[:method]
file, line = receiver.method(method).source_location if receiver.respond_to?(method)
end
if file && line
Source.new(file: file, first_line: line, last_line: find_end(file, line))
end
end

k0kubun marked this conversation as resolved.
Show resolved Hide resolved
def find_end(file, first_line)
return first_line unless File.exist?(file)
lex = RubyLex.new
lines = File.read(file).lines[(first_line - 1)..-1]
tokens = RubyLex.ripper_lex_without_warning(lines.join)
prev_tokens = []

# chunk with line number
tokens.chunk { |tok| tok.pos[0] }.each do |lnum, chunk|
code = lines[0..lnum].join
prev_tokens.concat chunk
continue = lex.process_continue(prev_tokens)
code_block_open = lex.check_code_block(code, prev_tokens)
if !continue && !code_block_open
return first_line + lnum
end
end
first_line
end

private
k0kubun marked this conversation as resolved.
Show resolved Hide resolved

def string_literal?(args)
Expand All @@ -32,7 +74,8 @@ def execute(str = nil)
puts "Error: Expected a string but got #{str.inspect}"
return
end
source = find_source(str)

source = self.class.find_source(str, @irb_context)
if source && File.exist?(source.file)
show_source(source)
else
Expand All @@ -53,48 +96,6 @@ def show_source(source)
puts
end

def find_source(str)
case str
when /\A[A-Z]\w*(::[A-Z]\w*)*\z/ # Const::Name
eval(str, irb_context.workspace.binding) # trigger autoload
base = irb_context.workspace.binding.receiver.yield_self { |r| r.is_a?(Module) ? r : Object }
file, line = base.const_source_location(str) if base.respond_to?(:const_source_location) # Ruby 2.7+
when /\A(?<owner>[A-Z]\w*(::[A-Z]\w*)*)#(?<method>[^ :.]+)\z/ # Class#method
owner = eval(Regexp.last_match[:owner], irb_context.workspace.binding)
method = Regexp.last_match[:method]
if owner.respond_to?(:instance_method) && owner.instance_methods.include?(method.to_sym)
file, line = owner.instance_method(method).source_location
end
when /\A((?<receiver>.+)(\.|::))?(?<method>[^ :.]+)\z/ # method, receiver.method, receiver::method
receiver = eval(Regexp.last_match[:receiver] || 'self', irb_context.workspace.binding)
method = Regexp.last_match[:method]
file, line = receiver.method(method).source_location if receiver.respond_to?(method)
end
if file && line
Source.new(file: file, first_line: line, last_line: find_end(file, line))
end
end

def find_end(file, first_line)
return first_line unless File.exist?(file)
lex = RubyLex.new
lines = File.read(file).lines[(first_line - 1)..-1]
tokens = RubyLex.ripper_lex_without_warning(lines.join)
prev_tokens = []

# chunk with line number
tokens.chunk { |tok| tok.pos[0] }.each do |lnum, chunk|
code = lines[0..lnum].join
prev_tokens.concat chunk
continue = lex.process_continue(prev_tokens)
code_block_open = lex.check_code_block(code, prev_tokens)
if !continue && !code_block_open
return first_line + lnum
end
end
first_line
end

def bold(str)
Color.colorize(str, [:BOLD])
end
Expand Down
4 changes: 4 additions & 0 deletions lib/irb/extend-command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def irb_context
:irb_debug, :Debug, "cmd/debug",
[:debug, NO_OVERRIDE],
],
[
:irb_edit, :Edit, "cmd/edit",
[:edit, NO_OVERRIDE],
],
[
:irb_help, :Help, "cmd/help",
[:help, NO_OVERRIDE],
Expand Down
78 changes: 77 additions & 1 deletion test/irb/test_cmd.rb
Original file line number Diff line number Diff line change
Expand Up @@ -565,16 +565,92 @@ def test_vars_with_aliases
$bar = nil
end

class EditTest < ExtendCommandTest
def setup
@original_editor = ENV["EDITOR"]
# noop the command so nothing gets executed
ENV["EDITOR"] = ": code"
end

def teardown
ENV["EDITOR"] = @original_editor
end

def test_edit_without_arg
out, err = execute_lines(
"edit",
irb_path: __FILE__
)

assert_empty err
assert_match("path: #{__FILE__}", out)
assert_match("command: ': code'", out)
end

def test_edit_with_path
out, err = execute_lines(
"edit #{__FILE__}"
)

assert_empty err
assert_match("path: #{__FILE__}", out)
assert_match("command: ': code'", out)
end

def test_edit_with_non_existing_path
out, err = execute_lines(
"edit foo.rb"
)

assert_empty err
assert_match /Can not find file: foo\.rb/, out
end

def test_edit_with_constant
# const_source_location is supported after Ruby 2.7
omit if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.7.0') || RUBY_ENGINE == 'truffleruby'

out, err = execute_lines(
"edit IRB::Irb"
)

assert_empty err
assert_match(/path: .*\/lib\/irb\.rb/, out)
assert_match("command: ': code'", out)
end

def test_edit_with_class_method
out, err = execute_lines(
"edit IRB.start"
)

assert_empty err
assert_match(/path: .*\/lib\/irb\.rb/, out)
assert_match("command: ': code'", out)
end

def test_edit_with_instance_method
out, err = execute_lines(
"edit IRB::Irb#run"
)

assert_empty err
assert_match(/path: .*\/lib\/irb\.rb/, out)
assert_match("command: ': code'", out)
end
end

private

def execute_lines(*lines, conf: {}, main: self)
def execute_lines(*lines, conf: {}, main: self, irb_path: nil)
IRB.init_config(nil)
IRB.conf[:VERBOSE] = false
IRB.conf[:PROMPT_MODE] = :SIMPLE
IRB.conf.merge!(conf)
input = TestInputMethod.new(lines)
irb = IRB::Irb.new(IRB::WorkSpace.new(main), input)
irb.context.return_format = "=> %s\n"
irb.context.irb_path = irb_path if irb_path
IRB.conf[:MAIN_CONTEXT] = irb.context
capture_output do
irb.eval_input
Expand Down