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

Load Rake tasks only once for command suggestions #47718

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions railties/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
* `bin/rails --help` will now list only framework and plugin commands. Rake
tasks defined in `lib/tasks/*.rake` files will no longer be included. For a
list of those tasks, use `rake -T`.

*Jonathan Hefner*

* Allow calling `bin/rails restart` outside of app directory.

The following would previously fail with a "No Rakefile found" error.
Expand Down
45 changes: 22 additions & 23 deletions railties/lib/rails/commands/rake/rake_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,45 @@ class RakeCommand < Base # :nodoc:

class << self
def printing_commands
formatted_rake_tasks
rake_tasks.filter_map do |task|
if task.comment && task.locations.any?(non_app_file_pattern)
[task.name_with_args, task.comment]
end
end
end

def perform(task, args, config)
require_rake

Rake.with_application do |rake|
rake.init("bin/rails", [task, *args])
rake.load_rakefile
with_rake(task, *args) do |rake|
if unrecognized_task = rake.top_level_tasks.find { |task| !rake.lookup(task[/[^\[]+/]) }
@rake_tasks = rake.tasks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two different paths to set a memoized value makes me nervous, especially given the sequence of steps involved.

Any way we can unify them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary difference between the two paths is the way the Rake tasks are loaded. This difference dates back to the original implementation:

def perform(task, *)
require_rake
ARGV.unshift(task) # Prepend the task, so Rake knows how to run it.
Rake.application.standard_exception_handling do
Rake.application.init("rails")
Rake.application.load_rakefile
Rake.application.top_level

def rake_tasks
require_rake
return @rake_tasks if defined?(@rake_tasks)
ActiveSupport::Deprecation.silence do
Rails::Command.require_application_and_environment!
end
Rake::TaskManager.record_task_metadata = true
Rake.application.instance_variable_set(:@name, "rails")
Rails.application.load_tasks
@rake_tasks = Rake.application.tasks.select(&:comment)

I'm not 100% sure why it was written that way. My guess is the purpose was to exclude tasks defined in the application's Rakefile from being listed in the help output. But that would not exclude tasks defined in lib/tasks/*.rake.

Other than that, the two paths end up being equivalent for the default generated Rakefile.

That being said, we can exclude tasks from the help by using Rake::Task#locations instead. Doing so also allows us to filter out tasks defined by the application in lib/tasks/*.rake.

I also think it makes sense to load tasks in the same way in both ::perform and ::rake_tasks. That way ::rake_tasks only lists tasks which can actually be run by ::perform (in case those lists were to diverge somehow).

So I've added a couple of commits to make those changes. However, I've kept @rake_tasks in ::perform because I think it fits conceptually. Basically, ::perform is preserving its tasks before the Rake::Application instance is thrown away. Then, if any tasks have been preserved, ::rake_tasks returns them; otherwise, ::rake_tasks loads tasks in the same way that ::perform would and returns those.

raise UnrecognizedCommandError.new(unrecognized_task)
end

if Rails.respond_to?(:root)
rake.options.suppress_backtrace_pattern = /\A(?!#{Regexp.quote(Rails.root.to_s)})/
end
rake.options.suppress_backtrace_pattern = non_app_file_pattern
rake.standard_exception_handling { rake.top_level }
end
end

private
def rake_tasks
require_rake

return @rake_tasks if defined?(@rake_tasks)

require_application!
def non_app_file_pattern
/\A(?!#{Regexp.quote Rails::Command.root.to_s})/
end

def with_rake(*args, &block)
require "rake"
Rake::TaskManager.record_task_metadata = true
Rake.application.instance_variable_set(:@name, "rails")
load_tasks
@rake_tasks = Rake.application.tasks.select(&:comment)
end

def formatted_rake_tasks
rake_tasks.map { |t| [ t.name_with_args, t.comment ] }
result = nil
Rake.with_application do |rake|
rake.init(bin, args) unless args.empty?
rake.load_rakefile
result = block.call(rake)
end
result
end

def require_rake
require "rake" # Defer booting Rake until we know it's needed.
def rake_tasks
@rake_tasks ||= with_rake(&:tasks)
end
end
end
Expand Down
42 changes: 0 additions & 42 deletions railties/test/command/application_test.rb

This file was deleted.

49 changes: 49 additions & 0 deletions railties/test/command/help_integration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

require "isolation/abstract_unit"
require "rails/command"

class Rails::Command::HelpIntegrationTest < ActiveSupport::TestCase
setup :build_app
teardown :teardown_app

test "prints helpful error on unrecognized command" do
output = rails "vershen", allow_failure: true

assert_match %(Unrecognized command "vershen"), output
assert_match "Did you mean? version", output
end

test "loads Rake tasks only once on unrecognized command" do
app_file "lib/tasks/my_task.rake", <<~RUBY
puts "MY_TASK already defined? => \#{!!defined?(MY_TASK)}"
MY_TASK = true
RUBY

output = rails "vershen", allow_failure: true

assert_match "MY_TASK already defined? => false", output
assert_no_match "MY_TASK already defined? => true", output
end

test "prints help via `X:help` command when running `X` and `X:X` command is not defined" do
help = rails "dev:help"
output = rails "dev", allow_failure: true

assert_equal help, output
end

test "excludes application Rake tasks from command listing" do
app_file "Rakefile", <<~RUBY, "a"
desc "my_task"
task :my_task_1
RUBY

app_file "lib/tasks/my_task.rake", <<~RUBY
desc "my_task"
task :my_task_2
RUBY

assert_no_match "my_task", rails("--help")
end
end
22 changes: 22 additions & 0 deletions railties/test/commands/application_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

require "abstract_unit"
require "rails/command"

class Rails::Command::ApplicationTest < ActiveSupport::TestCase
test "rails new without path prints help" do
output = run_application_command "new"

# Doesn't include the default thor error message:
assert_not output.start_with?("No value provided for required arguments")

# Includes contents of ~/railties/lib/rails/generators/rails/app/USAGE:
assert output.include?("The `rails new` command creates a new Rails application with a default
directory structure and configuration at the path you specify.")
end

private
def run_application_command(*args)
capture(:stdout) { Rails::Command.invoke(:application, args) }
end
end