Register Rails console commands/helpers with its latest extension APIs

This will greatly increase the visibility of Rails console commands and helpers,
and stop rely on IRB's internal components.

Extension API reference: https://github.com/ruby/irb/blob/master/EXTEND_IRB.md

And because we need to create new classes to use the new APIs, I also
moved all the IRB-specific code to a new file, `irb_console.rb`.

Use IRB.conf[:BACKTRACE_FILTER] for backtrace filtering in console

This change uses the new `IRB.conf[:BACKTRACE_FILTER]` to inject the backtrace
filtering logic into IRB. This avoids the need to patch IRB's internal
WorkSpace class.

Update changelog
This commit is contained in:
Stan Lo 2024-04-23 21:21:41 +01:00 committed by Rafael Mendonça França
parent f84e1ebb5a
commit 4c1f7d8328
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
6 changed files with 215 additions and 162 deletions

@ -112,7 +112,7 @@ PATH
railties (7.2.0.alpha)
actionpack (= 7.2.0.alpha)
activesupport (= 7.2.0.alpha)
irb
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
@ -288,10 +288,10 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.1)
irb (1.11.0)
rdoc
reline (>= 0.3.8)
io-console (0.7.2)
irb (1.13.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
@ -415,7 +415,7 @@ GEM
rb-inotify (0.10.1)
ffi (~> 1.0)
rbtree (0.4.6)
rdoc (6.6.2)
rdoc (6.6.3.1)
psych (>= 4.0.0)
redcarpet (3.2.3)
redis (5.0.8)
@ -425,7 +425,7 @@ GEM
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.8.3)
reline (0.4.1)
reline (0.5.4)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)

@ -1,3 +1,14 @@
* Implement Rails console commands and helpers with IRB v1.13's extension APIs
Rails console users will now see `helper`, `controller`, `new_session`, and `app` under
IRB help message's `Helper methods` category. And `reload!` command will be displayed under
the new `Rails console` commands category.
Prior to this change, Rails console's commands and helper methods are added through IRB's
private components and don't show up in its help message, which led to poor discoverability.
*Stan Lo*
* Remove deprecated `Rails::Generators::Testing::Behaviour`.
*Rafael Mendonça França*

@ -4,66 +4,6 @@
module Rails
class Console
module BacktraceCleaner
def filter_backtrace(bt)
if result = super
Rails.backtrace_cleaner.filter([result]).first
end
end
end
class IRBConsole
def initialize(app)
@app = app
require "irb"
require "irb/completion"
IRB::WorkSpace.prepend(BacktraceCleaner)
IRB::ExtendCommandBundle.include(Rails::ConsoleMethods)
end
def name
"IRB"
end
def start
IRB.setup(nil)
if !Rails.env.local? && !ENV.key?("IRB_USE_AUTOCOMPLETE")
IRB.conf[:USE_AUTOCOMPLETE] = false
end
env = colorized_env
app_name = @app.class.module_parent_name.underscore.dasherize
prompt_prefix = "#{app_name}(#{env})"
IRB.conf[:PROMPT][:RAILS_PROMPT] = {
PROMPT_I: "#{prompt_prefix}> ",
PROMPT_S: "#{prompt_prefix}%l ",
PROMPT_C: "#{prompt_prefix}* ",
RETURN: "=> %s\n"
}
# Respect user's choice of prompt mode.
IRB.conf[:PROMPT_MODE] = :RAILS_PROMPT if IRB.conf[:PROMPT_MODE] == :DEFAULT
IRB::Irb.new.run(IRB.conf)
end
def colorized_env
case Rails.env
when "development"
IRB::Color.colorize("dev", [:BLUE])
when "test"
IRB::Color.colorize("test", [:BLUE])
when "production"
IRB::Color.colorize("prod", [:RED])
else
Rails.env
end
end
end
def self.start(*args)
new(*args).start
end
@ -83,7 +23,10 @@ def initialize(app, options = {})
app.load_console
@console = app.config.console || IRBConsole.new(app)
@console = app.config.console || begin
require "rails/commands/console/irb_console"
IRBConsole.new(app)
end
end
def sandbox?

@ -0,0 +1,123 @@
# frozen_string_literal: true
require "irb/helper_method"
require "irb/command"
module Rails
class Console
class RailsHelperBase < IRB::HelperMethod::Base
include ConsoleMethods
end
class ControllerHelper < RailsHelperBase
description "Gets the helper methods available to the controller."
# This method assumes an +ApplicationController+ exists, and that it extends ActionController::Base.
def execute
helper
end
end
class ControllerInstance < RailsHelperBase
description "Gets a new instance of a controller object."
# This method assumes an +ApplicationController+ exists, and that it extends ActionController::Base.
def execute
controller
end
end
class NewSession < RailsHelperBase
description "Create a new session. If a block is given, the new session will be yielded to the block before being returned."
def execute
new_session
end
end
class AppInstance < RailsHelperBase
description "Reference the global 'app' instance, created on demand. To recreate the instance, pass a non-false value as the parameter."
def execute(create = false)
app(create)
end
end
class Reloader < IRB::Command::Base
include ConsoleMethods
category "Rails console"
description "Reloads the environment."
def execute(*)
reload!
end
end
IRB::HelperMethod.register(:helper, ControllerHelper)
IRB::HelperMethod.register(:controller, ControllerInstance)
IRB::HelperMethod.register(:new_session, NewSession)
IRB::HelperMethod.register(:app, AppInstance)
IRB::Command.register(:reload!, Reloader)
class IRBConsole
def initialize(app)
@app = app
require "irb"
require "irb/completion"
end
def name
"IRB"
end
def start
IRB.setup(nil)
if !Rails.env.local? && !ENV.key?("IRB_USE_AUTOCOMPLETE")
IRB.conf[:USE_AUTOCOMPLETE] = false
end
env = colorized_env
app_name = @app.class.module_parent_name.underscore.dasherize
prompt_prefix = "#{app_name}(#{env})"
IRB.conf[:PROMPT][:RAILS_PROMPT] = {
PROMPT_I: "#{prompt_prefix}> ",
PROMPT_S: "#{prompt_prefix}%l ",
PROMPT_C: "#{prompt_prefix}* ",
RETURN: "=> %s\n"
}
if current_filter = IRB.conf[:BACKTRACE_FILTER]
IRB.conf[:BACKTRACE_FILTER] = -> (backtrace) do
backtrace = current_filter.call(backtrace)
Rails.backtrace_cleaner.filter(backtrace)
end
else
IRB.conf[:BACKTRACE_FILTER] = -> (backtrace) do
Rails.backtrace_cleaner.filter(backtrace)
end
end
# Respect user's choice of prompt mode.
IRB.conf[:PROMPT_MODE] = :RAILS_PROMPT if IRB.conf[:PROMPT_MODE] == :DEFAULT
IRB::Irb.new.run(IRB.conf)
end
def colorized_env
case Rails.env
when "development"
IRB::Color.colorize("dev", [:BLUE])
when "test"
IRB::Color.colorize("test", [:BLUE])
when "production"
IRB::Color.colorize("prod", [:RED])
else
Rails.env
end
end
end
end
end

@ -44,7 +44,7 @@
s.add_dependency "rake", ">= 12.2"
s.add_dependency "thor", "~> 1.0", ">= 1.2.2"
s.add_dependency "zeitwerk", "~> 2.6"
s.add_dependency "irb"
s.add_dependency "irb", "~> 1.13"
s.add_development_dependency "actionview", version
end

@ -5,99 +5,6 @@
require "isolation/abstract_unit"
require "console_helpers"
class ConsoleTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation
def setup
build_app
end
def teardown
teardown_app
end
def load_environment(sandbox = false)
require "#{rails_root}/config/environment"
Rails.application.sandbox = sandbox
Rails.application.load_console
end
def irb_context
Object.new.extend(Rails::ConsoleMethods)
end
def test_app_method_should_return_integration_session
TestHelpers::Rack.remove_method :app
load_environment
console_session = irb_context.app
assert_instance_of ActionDispatch::Integration::Session, console_session
end
def test_app_can_access_path_helper_method
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get 'foo', to: 'foo#index'
end
RUBY
load_environment
console_session = irb_context.app
assert_equal "/foo", console_session.foo_path
end
def test_new_session_should_return_integration_session
load_environment
session = irb_context.new_session
assert_instance_of ActionDispatch::Integration::Session, session
end
def test_reload_should_fire_preparation_and_cleanup_callbacks
load_environment
a = b = c = nil
# TODO: These should be defined on the initializer
ActiveSupport::Reloader.to_complete { a = b = c = 1 }
ActiveSupport::Reloader.to_complete { b = c = 2 }
ActiveSupport::Reloader.to_prepare { c = 3 }
irb_context.reload!(false)
assert_equal 1, a
assert_equal 2, b
assert_equal 3, c
end
def test_reload_should_reload_constants
app_file "app/models/user.rb", <<-MODEL
class User
attr_accessor :name
end
MODEL
load_environment
assert_respond_to User.new, :name
app_file "app/models/user.rb", <<-MODEL
class User
attr_accessor :name, :age
end
MODEL
assert_not_respond_to User.new, :age
irb_context.reload!(false)
assert_respond_to User.new, :age
end
def test_access_to_helpers
load_environment
helper = irb_context.helper
assert_not_nil helper
assert_instance_of ActionView::Base, helper
assert_equal "Once upon a time in a world...",
helper.truncate("Once upon a time in a world far far away")
end
end
class FullStackConsoleTest < ActiveSupport::TestCase
include ConsoleHelpers
@ -233,6 +140,75 @@ def test_test_console_prompt
write_prompt "123", "app-template(test)> 123"
end
def test_helper_helper_method
spawn_console("-e development")
write_prompt "helper.truncate('Once upon a time in a world far far away')", "Once upon a time in a world..."
end
def test_controller_helper_method
spawn_console("-e development")
write_prompt "controller.class.name", "ApplicationController"
end
def test_new_session_helper_method
spawn_console("-e development")
write_prompt "new_session.class.name", "ActionDispatch::Integration::Session"
end
def test_app_helper_method
app_file "config/routes.rb", <<-RUBY
Rails.application.routes.draw do
get 'foo', to: 'foo#index'
end
RUBY
spawn_console("-e development")
write_prompt "app.foo_path", "/foo"
end
def test_reload_command_fires_preparation_and_cleanup_callbacks
options = "-e development"
spawn_console(options)
write_prompt "a = b = c = nil"
write_prompt "ActiveSupport::Reloader.to_complete { a = b = c = 1 }"
write_prompt "ActiveSupport::Reloader.to_complete { b = c = 2 }"
write_prompt "ActiveSupport::Reloader.to_prepare { c = 3 }"
write_prompt "reload!", "Reloading...\r\n"
write_prompt "a", "=> 1"
write_prompt "b", "=> 2"
write_prompt "c", "=> 3"
end
def test_reload_command_reload_constants
app_file "app/models/user.rb", <<-MODEL
class User
attr_accessor :name
end
MODEL
options = "-e development"
# Now the User model has only one attribute called `name`
spawn_console(options)
write_prompt "User.new.respond_to?(:age)", "=> false"
# This will be loaded after the reload! command is executed
app_file "app/models/user.rb", <<-MODEL
class User
attr_accessor :name, :age
end
MODEL
write_prompt "reload!", "Reloading...\r\n"
write_prompt "User.new.respond_to?(:age)", "=> true"
end
def test_console_respects_user_defined_prompt_mode
irbrc = Tempfile.new("irbrc")
irbrc.write <<-RUBY