require 'delegate' require 'optparse' require 'fileutils' require 'tempfile' require 'erb' module RubiGen module Commands # Here's a convenient way to get a handle on generator commands. # Command.instance('destroy', my_generator) instantiates a Destroy # delegate of my_generator ready to do your dirty work. def self.instance(command, generator) const_get(command.to_s.camelize).new(generator) end # Even more convenient access to commands. Include Commands in # the generator Base class to get a nice #command instance method # which returns a delegate for the requested command. def self.included(base) base.send(:define_method, :command) do |command| Commands.instance(command, self) end end # Generator commands delegate RubiGen::Base and implement # a standard set of actions. Their behavior is defined by the way # they respond to these actions: Create brings life; Destroy brings # death; List passively observes. # # Commands are invoked by replaying (or rewinding) the generator's # manifest of actions. See RubiGen::Manifest and # RubiGen::Base#manifest method that generator subclasses # are required to override. # # Commands allows generators to "plug in" invocation behavior, which # corresponds to the GoF Strategy pattern. class Base < DelegateClass(RubiGen::Base) # Replay action manifest. RewindBase subclass rewinds manifest. def invoke! manifest.replay(self) after_generate end def dependency(generator_name, args, runtime_options = {}) logger.dependency(generator_name) do self.class.new(instance(generator_name, args, full_options(runtime_options))).invoke! end end # Does nothing for all commands except Create. def class_collisions(*class_names) end # Does nothing for all commands except Create. def readme(*args) end # Does nothing for all commands except Create. def write_manifest end protected def current_migration_number Dir.glob("#{RAILS_ROOT}/#{@migration_directory}/[0-9]*_*.rb").inject(0) do |max, file_path| n = File.basename(file_path).split('_', 2).first.to_i if n > max then n else max end end end def next_migration_number current_migration_number + 1 end def migration_directory(relative_path) directory(@migration_directory = relative_path) end def existing_migrations(file_name) Dir.glob("#{@migration_directory}/[0-9]*_*.rb").grep(/[0-9]+_#{file_name}.rb$/) end def migration_exists?(file_name) not existing_migrations(file_name).empty? end def next_migration_string(padding = 3) if ActiveRecord::Base.timestamped_migrations Time.now.utc.strftime("%Y%m%d%H%M%S") else "%.#{padding}d" % next_migration_number end end def gsub_file(relative_destination, regexp, *args, &block) path = destination_path(relative_destination) content = File.read(path).gsub(regexp, *args, &block) File.open(path, 'wb') { |file| file.write(content) } end private # Ask the user interactively whether to force collision. def force_file_collision?(destination, src, dst, file_options = {}, &block) stdout.print "overwrite #{destination}? (enter \"h\" for help) [Ynaiqd] " stdout.flush case $stdin.gets.chomp when /\Ad\z/i Tempfile.open(File.basename(destination), File.dirname(dst)) do |temp| temp.write render_file(src, file_options, &block) temp.rewind stdout.puts `#{diff_cmd} #{dst} #{temp.path}` end stdout.puts "retrying" raise 'retry diff' when /\Aa\z/i stdout.puts "forcing #{spec.name}" options[:collision] = :force when /\Ai\z/i stdout.puts "ignoring #{spec.name}" options[:collision] = :skip when /\Aq\z/i stdout.puts "aborting #{spec.name}" raise SystemExit when /\An\z/i then :skip when /\Ay\z/i then :force else $stdout.puts <<-HELP.gsub(/^ /, '') Y - yes, overwrite n - no, do not overwrite a - all, overwrite this and all others i - ignore, skip any conflicts q - quit, abort d - diff, show the differences between the old and the new h - help, show this help HELP raise 'retry' end rescue retry end def diff_cmd ENV['RAILS_DIFF'] || 'diff -u' end def render_template_part(template_options) # Getting Sandbox to evaluate part template in it part_binding = template_options[:sandbox].call.sandbox_binding part_rel_path = template_options[:insert] part_path = source_path(part_rel_path) # Render inner template within Sandbox binding rendered_part = ERB.new(File.readlines(part_path).join, nil, '-').result(part_binding) begin_mark = template_part_mark(template_options[:begin_mark], template_options[:mark_id]) end_mark = template_part_mark(template_options[:end_mark], template_options[:mark_id]) begin_mark + rendered_part + end_mark end def template_part_mark(name, id) "\n" end end # Base class for commands which handle generator actions in reverse, such as Destroy. class RewindBase < Base # Rewind action manifest. def invoke! manifest.rewind(self) end end # Create is the premier generator command. It copies files, creates # directories, renders templates, and more. class Create < Base # Check whether the given class names are already taken. # In the future, expand to check other namespaces # such as the rest of the user's app. def class_collisions(*class_names) class_names.flatten.each do |class_name| # Convert to string to allow symbol arguments. class_name = class_name.to_s # Skip empty strings. next if class_name.strip.empty? # Split the class from its module nesting. nesting = class_name.split('::') name = nesting.pop # Extract the last Module in the nesting. last = nesting.inject(Object) { |last, nest| break unless last.const_defined?(nest) last.const_get(nest) } # If the last Module exists, check whether the given # class exists and raise a collision if so. if last and last.const_defined?(name.camelize) raise_class_collision(class_name) end end end # Copy a file from source to destination with collision checking. # # The file_options hash accepts :chmod and :shebang and :collision options. # :chmod sets the permissions of the destination file: # file 'config/empty.log', 'log/test.log', :chmod => 0664 # :shebang sets the #!/usr/bin/ruby line for scripts # file 'bin/generate.rb', 'script/generate', :chmod => 0755, :shebang => '/usr/bin/env ruby' # :collision sets the collision option only for the destination file: # file 'settings/server.yml', 'config/server.yml', :collision => :skip # # Collisions are handled by checking whether the destination file # exists and either skipping the file, forcing overwrite, or asking # the user what to do. def file(relative_source, relative_destination, file_options = {}, &block) # Determine full paths for source and destination files. source = source_path(relative_source) destination = destination_path(relative_destination) destination_exists = File.exist?(destination) # If source and destination are identical then we're done. if destination_exists and identical?(source, destination, &block) return logger.identical(relative_destination) end # Check for and resolve file collisions. if destination_exists # Make a choice whether to overwrite the file. :force and # :skip already have their mind made up, but give :ask a shot. choice = case (file_options[:collision] || options[:collision]).to_sym #|| :ask when :ask then force_file_collision?(relative_destination, source, destination, file_options, &block) when :force then :force when :skip then :skip else raise "Invalid collision option: #{options[:collision].inspect}" end # Take action based on our choice. Bail out if we chose to # skip the file; otherwise, log our transgression and continue. case choice when :force then logger.force(relative_destination) when :skip then return(logger.skip(relative_destination)) else raise "Invalid collision choice: #{choice}.inspect" end # File doesn't exist so log its unbesmirched creation. else logger.create relative_destination end # If we're pretending, back off now. return if options[:pretend] # Write destination file with optional shebang. Yield for content # if block given so templaters may render the source file. If a # shebang is requested, replace the existing shebang or insert a # new one. File.open(destination, 'wb') do |dest| dest.write render_file(source, file_options, &block) end # Optionally change permissions. if file_options[:chmod] FileUtils.chmod(file_options[:chmod], destination) end # Optionally add file to subversion or git system("svn add #{destination}") if options[:svn] end def file_copy_each(files, path=nil, options = {}) path = path ? "#{path}/" : "" files.each do |file_name| file "#{path}#{file_name}", "#{path}#{file_name}", options end end def folder(template_path, path=nil, options = {}) template_path = "/" if template_path.blank? source = source_path(template_path) files = Dir[source + '/*'].select { |file| File.file? file }.map { |file| file.sub(/^#{source}/,"") } files.each do |file_name| file "#{template_path}#{file_name}", "#{path}#{file_name}", options end system("git add -v #{relative_destination}") if options[:git] end # Checks if the source and the destination file are identical. If # passed a block then the source file is a template that needs to first # be evaluated before being compared to the destination. def identical?(source, destination, &block) return false if File.directory? destination source = block_given? ? File.open(source) {|sf| yield(sf)} : IO.read(source) destination = IO.read(destination) source == destination end # Generate a file using an ERuby template. # Looks up and evaluates a template by name and writes the result. # # The ERB template uses explicit trim mode to best control the # proliferation of whitespace in generated code. <%- trims leading # whitespace; -%> trims trailing whitespace including one newline. # # A hash of template options may be passed as the last argument. # The options accepted by the file are accepted as well as :assigns, # a hash of variable bindings. Example: # template 'foo', 'bar', :assigns => { :action => 'view' } # # Template is implemented in terms of file. It calls file with a # block which takes a file handle and returns its rendered contents. def template(relative_source, relative_destination, template_options = {}) file(relative_source, relative_destination, template_options) do |file| # Evaluate any assignments in a temporary, throwaway binding. vars = template_options[:assigns] || {} b = binding vars.each { |k,v| eval "#{k} = vars[:#{k}] || vars['#{k}']", b } # Render the source file with the temporary binding. ERB.new(file.read, nil, '-').result(b) end end def template_copy_each(files, path = nil, options = {}) path = path ? "#{path}/" : "" files.each do |file_name| template "#{path}#{file_name}", "#{path}#{file_name.gsub(/\.erb$/,'')}", options end end def complex_template(relative_source, relative_destination, template_options = {}) options = template_options.dup options[:assigns] ||= {} options[:assigns]['template_for_inclusion'] = render_template_part(template_options) template(relative_source, relative_destination, options) end # Create a directory including any missing parent directories. # Always skips directories which exist. def directory(relative_path) path = destination_path(relative_path) if File.exist?(path) logger.exists relative_path else logger.create relative_path unless options[:pretend] FileUtils.mkdir_p(path) # git doesn't require adding the paths, adding the files later will # automatically do a path add. # Subversion doesn't do path adds, so we need to add # each directory individually. # So stack up the directory tree and add the paths to # subversion in order without recursion. if options[:svn] stack = [relative_path] until File.dirname(stack.last) == stack.last # dirname('.') == '.' stack.push File.dirname(stack.last) end stack.reverse_each do |rel_path| svn_path = destination_path(rel_path) system("svn add -N #{svn_path}") unless File.directory?(File.join(svn_path, '.svn')) end end end end end # Display a README. def readme(*relative_sources) relative_sources.flatten.each do |relative_source| logger.readme relative_source stdout.puts File.read(source_path(relative_source)) unless options[:pretend] end end def write_manifest(relative_destination) files = ([relative_destination] + Dir["#{destination_root}/**/*"]) files.reject! { |file| File.directory?(file) } files.map! { |path| path.sub("#{destination_root}/","") } files = files.uniq.sort destination = destination_path(relative_destination) destination_exists = File.exists?(destination) # Check for and resolve file collisions. if destination_exists # Always recreate the Manifest (perhaps we need to give the option... like normal files) choice = :force logger.force(relative_destination) # File doesn't exist so log its unbesmirched creation. else logger.create relative_destination end # If we're pretending, back off now. return if options[:pretend] # Write destination file with optional shebang. Yield for content # if block given so templaters may render the source file. If a # shebang is requested, replace the existing shebang or insert a # new one. File.open(destination, 'wb') do |dest| dest.write files.join("\n") dest.write "\n" end # Optionally add file to subversion system("svn add #{destination}") if options[:svn] end # When creating a migration, it knows to find the first available file in db/migrate and use the migration.rb template. def migration_template(relative_source, relative_destination, template_options = {}) migration_directory relative_destination migration_file_name = template_options[:migration_file_name] || file_name raise "Another migration is already named #{migration_file_name}: #{existing_migrations(migration_file_name).first}" if migration_exists?(migration_file_name) template(relative_source, "#{relative_destination}/#{next_migration_string}_#{migration_file_name}.rb", template_options) end def route_resources(*resources) resource_list = resources.map { |r| r.to_sym.inspect }.join(', ') sentinel = 'ActionController::Routing::Routes.draw do |map|' logger.route "map.resources #{resource_list}" unless options[:pretend] gsub_file 'config/routes.rb', /(#{Regexp.escape(sentinel)})/mi do |match| "#{match}\n map.resources #{resource_list}\n" end end end private def render_file(path, options = {}) File.open(path, 'rb') do |file| if block_given? yield file else content = '' if shebang = options[:shebang] content << "#!#{shebang}\n" if line = file.gets content << "line\n" if line !~ /^#!/ end end content << file.read end end end # Raise a usage error with an informative WordNet suggestion. # Thanks to Florian Gross (flgr). def raise_class_collision(class_name) message = <<-end_message The name '#{class_name}' is either already used in your application or reserved. Please choose an alternative and run this generator again. end_message if suggest = find_synonyms(class_name) if suggest.any? message << "\n Suggestions: \n\n" message << suggest.join("\n") end end raise UsageError, message end SYNONYM_LOOKUP_URI = "http://wordnet.princeton.edu/perl/webwn?s=%s" # Look up synonyms on WordNet. Thanks to Florian Gross (flgr). def find_synonyms(word) require 'open-uri' require 'timeout' timeout(5) do open(SYNONYM_LOOKUP_URI % word) do |stream| # Grab words linked to dictionary entries as possible synonyms data = stream.read.gsub(" ", " ").scan(/([\w ]*?)<\/a>/s).uniq end end rescue Exception return nil end end # Undo the actions performed by a generator. Rewind the action # manifest and attempt to completely erase the results of each action. class Destroy < RewindBase # Remove a file if it exists and is a file. def file(relative_source, relative_destination, file_options = {}) destination = destination_path(relative_destination) if File.exist?(destination) logger.rm relative_destination unless options[:pretend] if options[:svn] # If the file has been marked to be added # but has not yet been checked in, revert and delete if options[:svn][relative_destination] system("svn revert #{destination}") FileUtils.rm(destination) else # If the directory is not in the status list, it # has no modifications so we can simply remove it system("svn rm #{destination}") end elsif options[:git] if options[:git][:new][relative_destination] # file has been added, but not committed system("git reset HEAD #{relative_destination}") FileUtils.rm(destination) elsif options[:git][:modified][relative_destination] # file is committed and modified system("git rm -f #{relative_destination}") else # If the directory is not in the status list, it # has no modifications so we can simply remove it system("git rm #{relative_destination}") end else FileUtils.rm(destination) end end else logger.missing relative_destination return end end # Templates are deleted just like files and the actions take the # same parameters, so simply alias the file method. alias_method :template, :file # Remove each directory in the given path from right to left. # Remove each subdirectory if it exists and is a directory. def directory(relative_path) parts = relative_path.split('/') until parts.empty? partial = File.join(parts) path = destination_path(partial) if File.exist?(path) if Dir[File.join(path, '*')].empty? logger.rmdir partial unless options[:pretend] if options[:svn] # If the directory has been marked to be added # but has not yet been checked in, revert and delete if options[:svn][relative_path] system("svn revert #{path}") FileUtils.rmdir(path) else # If the directory is not in the status list, it # has no modifications so we can simply remove it system("svn rm #{path}") end # I don't think git needs to remove directories?.. # or maybe they have special consideration... else FileUtils.rmdir(path) end end else logger.notempty partial end else logger.missing partial end parts.pop end end def complex_template(*args) # nothing should be done here end # When deleting a migration, it knows to delete every file named "[0-9]*_#{file_name}". def migration_template(relative_source, relative_destination, template_options = {}) migration_directory relative_destination migration_file_name = template_options[:migration_file_name] || file_name unless migration_exists?(migration_file_name) stdout.puts "There is no migration named #{migration_file_name}" return end existing_migrations(migration_file_name).each do |file_path| file(relative_source, file_path, template_options) end end def route_resources(*resources) resource_list = resources.map { |r| r.to_sym.inspect }.join(', ') look_for = "\n map.resources #{resource_list}\n" logger.route "map.resources #{resource_list}" gsub_file 'config/routes.rb', /(#{look_for})/mi, '' end end # List a generator's action manifest. class List < Base def dependency(generator_name, args, options = {}) logger.dependency "#{generator_name}(#{args.join(', ')}, #{options.inspect})" end def class_collisions(*class_names) logger.class_collisions class_names.join(', ') end def file(relative_source, relative_destination, options = {}) logger.file relative_destination end def template(relative_source, relative_destination, options = {}) logger.template relative_destination end def complex_template(relative_source, relative_destination, options = {}) logger.template "#{options[:insert]} inside #{relative_destination}" end def directory(relative_path) logger.directory "#{destination_path(relative_path)}/" end def readme(*args) logger.readme args.join(', ') end def migration_template(relative_source, relative_destination, options = {}) migration_directory relative_destination logger.migration_template file_name end def route_resources(*resources) resource_list = resources.map { |r| r.to_sym.inspect }.join(', ') logger.route "map.resources #{resource_list}" end end # Update generator's action manifest. class Update < Create def file(relative_source, relative_destination, options = {}) # logger.file relative_destination end def template(relative_source, relative_destination, options = {}) # logger.template relative_destination end def complex_template(relative_source, relative_destination, template_options = {}) begin dest_file = destination_path(relative_destination) source_to_update = File.readlines(dest_file).join rescue Errno::ENOENT logger.missing relative_destination return end logger.refreshing "#{template_options[:insert].gsub(/\.erb/,'')} inside #{relative_destination}" begin_mark = Regexp.quote(template_part_mark(template_options[:begin_mark], template_options[:mark_id])) end_mark = Regexp.quote(template_part_mark(template_options[:end_mark], template_options[:mark_id])) # Refreshing inner part of the template with freshly rendered part. rendered_part = render_template_part(template_options) source_to_update.gsub!(/#{begin_mark}.*?#{end_mark}/m, rendered_part) File.open(dest_file, 'w') { |file| file.write(source_to_update) } end def directory(relative_path) # logger.directory "#{destination_path(relative_path)}/" end end end end