# frozen_string_literal: true
require "active_support/core_ext/hash/except"
require "rails/generators/rails/app/app_generator"
require "date"
module Rails
# The plugin builder allows you to override elements of the plugin
# generator without being forced to reverse the operations of the default
# generator.
#
# This allows you to override entire operations, like the creation of the
# Gemfile, \README, or JavaScript files, without needing to know exactly
# what those operations do so you can create another template action.
class PluginBuilder
def rakefile
template "Rakefile"
end
def app
if mountable?
if api?
directory "app", exclude_pattern: %r{app/(views|helpers)}
else
directory "app"
empty_directory_with_keep_file "app/assets/images/#{namespaced_name}"
end
empty_directory_with_keep_file "app/models/concerns"
empty_directory_with_keep_file "app/controllers/concerns"
remove_dir "app/mailers" if skip_action_mailer?
remove_dir "app/jobs" if options[:skip_active_job]
elsif full?
empty_directory_with_keep_file "app/models"
empty_directory_with_keep_file "app/controllers"
empty_directory_with_keep_file "app/models/concerns"
empty_directory_with_keep_file "app/controllers/concerns"
empty_directory_with_keep_file "app/mailers" unless skip_action_mailer?
empty_directory_with_keep_file "app/jobs" unless options[:skip_active_job]
unless api?
empty_directory_with_keep_file "app/assets/images/#{namespaced_name}"
empty_directory_with_keep_file "app/helpers"
empty_directory_with_keep_file "app/views"
end
end
end
def readme
template "README.md"
end
def gemfile
template "Gemfile"
end
def license
template "MIT-LICENSE" unless inside_application?
end
def gemspec
template "%name%.gemspec"
end
def gitignore
template "gitignore", ".gitignore"
end
def version_control
if !options[:skip_git] && !options[:pretend]
run "git init", capture: options[:quiet], abort_on_failure: false
if user_default_branch.strip.empty?
`git symbolic-ref HEAD refs/heads/main`
end
end
end
def lib
template "lib/%namespaced_name%.rb"
template "lib/tasks/%namespaced_name%_tasks.rake"
template "lib/%namespaced_name%/version.rb"
if engine?
template "lib/%namespaced_name%/engine.rb"
else
template "lib/%namespaced_name%/railtie.rb"
end
end
def config
template "config/routes.rb" if engine?
end
def test
template "test/test_helper.rb"
template "test/%namespaced_name%_test.rb"
if engine?
empty_directory_with_keep_file "test/fixtures/files"
empty_directory_with_keep_file "test/controllers"
empty_directory_with_keep_file "test/mailers"
empty_directory_with_keep_file "test/models"
empty_directory_with_keep_file "test/integration"
unless api?
empty_directory_with_keep_file "test/helpers"
end
template "test/integration/navigation_test.rb"
end
end
DUMMY_IGNORE_OPTIONS = %i[dev edge master template]
def generate_test_dummy(force = false)
opts = options.transform_keys(&:to_sym).except(*DUMMY_IGNORE_OPTIONS)
opts[:force] = force
opts[:skip_bundle] = true
opts[:skip_git] = true
opts[:skip_hotwire] = true
opts[:dummy_app] = true
invoke Rails::Generators::AppGenerator,
[ File.expand_path(dummy_path, destination_root) ], opts
end
def test_dummy_config
template "rails/boot.rb", "#{dummy_path}/config/boot.rb", force: true
insert_into_file "#{dummy_path}/config/application.rb", <<~RUBY, after: /^Bundler\.require.+\n/
require #{namespaced_name.inspect}
RUBY
if mountable?
template "rails/routes.rb", "#{dummy_path}/config/routes.rb", force: true
end
if engine? && !api?
insert_into_file "#{dummy_path}/config/application.rb", indent(<<~RUBY, 4), after: /^\s*config\.load_defaults.*\n/
# For compatibility with applications that use this config
config.action_controller.include_all_helpers = false
RUBY
end
end
def test_dummy_sprocket_assets
template "rails/stylesheets.css", "#{dummy_path}/app/assets/stylesheets/application.css", force: true
template "rails/dummy_manifest.js", "#{dummy_path}/app/assets/config/manifest.js", force: true
end
def test_dummy_clean
inside dummy_path do
remove_file ".ruby-version"
remove_file "db/seeds.rb"
remove_file "Gemfile"
remove_file "lib/tasks"
remove_file "public/robots.txt"
remove_file "README.md"
remove_file "test"
remove_file "vendor"
end
end
def assets_manifest
template "rails/engine_manifest.js", "app/assets/config/#{underscored_name}_manifest.js"
end
def stylesheets
if mountable?
copy_file "rails/stylesheets.css",
"app/assets/stylesheets/#{namespaced_name}/application.css"
elsif full?
empty_directory_with_keep_file "app/assets/stylesheets/#{namespaced_name}"
end
end
def bin(force = false)
bin_file = engine? ? "bin/rails.tt" : "bin/test.tt"
template bin_file, force: force do |content|
"#{shebang}\n" + content
end
chmod "bin", 0755, verbose: false
end
def gemfile_entry
return unless inside_application?
gemfile_in_app_path = File.join(rails_app_path, "Gemfile")
if File.exist? gemfile_in_app_path
entry = "\ngem '#{name}', path: '#{relative_path}'"
append_file gemfile_in_app_path, entry
end
end
private
def user_default_branch
@user_default_branch ||= `git config init.defaultbranch`
end
end
module Generators
class PluginGenerator < AppBase # :nodoc:
add_shared_options_for "plugin"
alias_method :plugin_path, :app_path
class_option :dummy_path, type: :string, default: "test/dummy",
desc: "Create dummy application at given path"
class_option :full, type: :boolean, default: false,
desc: "Generate a rails engine with bundled Rails application for testing"
class_option :mountable, type: :boolean, default: false,
desc: "Generate mountable isolated engine"
class_option :skip_gemspec, type: :boolean, default: false,
desc: "Skip gemspec file"
class_option :skip_gemfile_entry, type: :boolean, default: false,
desc: "If creating plugin in application's directory " \
"skip adding entry to Gemfile"
class_option :api, type: :boolean, default: false,
desc: "Generate a smaller stack for API application plugins"
def initialize(*args)
@dummy_path = nil
super
if !engine? || !with_dummy_app?
self.options = options.merge(skip_asset_pipeline: true).freeze
end
end
public_task :set_default_accessors!
public_task :create_root
def target_rails_prerelease
super("plugin new")
end
def create_root_files
build(:readme)
build(:rakefile)
build(:gemspec) unless options[:skip_gemspec]
build(:license)
build(:gitignore) unless options[:skip_git]
build(:gemfile)
build(:version_control)
end
def create_app_files
build(:app)
end
def create_config_files
build(:config)
end
def create_lib_files
build(:lib)
end
def create_assets_manifest_file
build(:assets_manifest) if !api? && engine?
end
def create_public_stylesheets_files
build(:stylesheets) unless api?
end
def create_bin_files
build(:bin)
end
def create_test_files
build(:test) unless options[:skip_test]
end
def create_test_dummy_files
return unless with_dummy_app?
create_dummy_app
end
def update_gemfile
build(:gemfile_entry) unless options[:skip_gemfile_entry]
end
def finish_template
build(:leftovers)
end
public_task :apply_rails_template
def name
@name ||= begin
# same as ActiveSupport::Inflector#underscore except not replacing '-'
underscored = original_name.dup
underscored.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
underscored.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
underscored.downcase!
underscored
end
end
def underscored_name
@underscored_name ||= original_name.underscore
end
def namespaced_name
@namespaced_name ||= name.tr("-", "/")
end
private
def gemfile_entries
[
rails_gemfile_entry,
simplify_gemfile_entries(
web_server_gemfile_entry,
database_gemfile_entry,
asset_pipeline_gemfile_entry,
),
].flatten.compact
end
def rails_gemfile_entry
if options[:skip_gemspec]
super
elsif rails_prerelease?
super.dup.tap do |entry|
entry.comment = <<~COMMENT
Your gem is dependent on a prerelease version of Rails. Once you can lock this
dependency down to a specific version, move it to your gemspec.
COMMENT
end
end
end
def simplify_gemfile_entries(*gemfile_entries)
gemfile_entries.flatten.compact.map { |entry| GemfileEntry.floats(entry.name) }
end
def create_dummy_app(path = nil)
dummy_path(path) if path
say_status :vendor_app, dummy_path
mute do
build(:generate_test_dummy)
build(:test_dummy_config)
build(:test_dummy_sprocket_assets) unless skip_sprockets?
build(:test_dummy_clean)
# ensure that bin/rails has proper dummy_path
build(:bin, true)
end
end
def engine?
full? || mountable? || options[:engine]
end
def full?
options[:full]
end
def mountable?
options[:mountable]
end
def skip_git?
options[:skip_git]
end
def with_dummy_app?
options[:skip_test].blank? || options[:dummy_path] != "test/dummy"
end
def api?
options[:api]
end
def self.banner
"rails plugin new #{arguments.map(&:usage).join(' ')} [options]"
end
def original_name
@original_name ||= File.basename(destination_root)
end
def modules
@modules ||= namespaced_name.camelize.split("::")
end
def wrap_in_modules(unwrapped_code)
unwrapped_code = "#{unwrapped_code}".strip.gsub(/\s$\n/, "")
modules.reverse.inject(unwrapped_code) do |content, mod|
str = +"module #{mod}\n"
str << content.lines.map { |line| " #{line}" }.join
str << (content.present? ? "\nend" : "end")
end
end
def camelized_modules
@camelized_modules ||= namespaced_name.camelize
end
def humanized
@humanized ||= original_name.underscore.humanize
end
def camelized
@camelized ||= name.gsub(/\W/, "_").squeeze("_").camelize
end
def author
default = "TODO: Write your name"
if skip_git?
@author = default
else
@author = `git config user.name`.chomp rescue default
end
end
def email
default = "TODO: Write your email address"
if skip_git?
@email = default
else
@email = `git config user.email`.chomp rescue default
end
end
def rails_version_specifier(gem_version = Rails.gem_version)
[">= #{gem_version}"]
end
def valid_const?
if /-\d/.match?(original_name)
raise Error, "Invalid plugin name #{original_name}. Please give a name which does not contain a namespace starting with numeric characters."
elsif /[^\w-]+/.match?(original_name)
raise Error, "Invalid plugin name #{original_name}. Please give a name which uses only alphabetic, numeric, \"_\" or \"-\" characters."
elsif /^\d/.match?(camelized)
raise Error, "Invalid plugin name #{original_name}. Please give a name which does not start with numbers."
elsif RESERVED_NAMES.include?(name)
raise Error, "Invalid plugin name #{original_name}. Please give a " \
"name which does not match one of the reserved rails " \
"words: #{RESERVED_NAMES.join(", ")}"
elsif Object.const_defined?(camelized)
raise Error, "Invalid plugin name #{original_name}, constant #{camelized} is already in use. Please choose another plugin name."
end
end
def get_builder_class
defined?(::PluginBuilder) ? ::PluginBuilder : Rails::PluginBuilder
end
def dummy_path(path = nil)
@dummy_path = path if path
@dummy_path || options[:dummy_path]
end
def mute(&block)
shell.mute(&block)
end
def rails_app_path
APP_PATH.sub("/config/application", "") if defined?(APP_PATH)
end
def inside_application?
rails_app_path && destination_root.start_with?(rails_app_path.to_s)
end
def relative_path
return unless inside_application?
app_path.delete_prefix("#{rails_app_path}/")
end
end
end
end