require "chusaku/version" require "chusaku/parser" require "chusaku/routes" # Handles core functionality of annotating projects. module Chusaku DEFAULT_CONTROLLERS_PATTERN = "**/*_controller.rb".freeze class << self # The main method to run Chusaku. Annotate all actions in a Rails project as # follows: # # # @route GET /waterlilies/:id (waterlilies) # def show # # ... # end # # @param flags [Hash] CLI flags # @return [Integer] 0 on success, 1 on error def call(flags = {}) @flags = flags @routes = Chusaku::Routes.call @changed_files = [] controllers_pattern = @flags[:controllers_pattern] || DEFAULT_CONTROLLERS_PATTERN controllers_paths = Dir.glob(Rails.root.join(controllers_pattern)) @routes.each do |controller, actions| next unless controller controller_class = "#{controller.underscore.camelize}Controller".constantize action_method_name = actions.keys.first&.to_sym next unless !action_method_name.nil? && controller_class.method_defined?(action_method_name) source_path = controller_class.instance_method(action_method_name).source_location&.[](0) next unless controllers_paths.include?(source_path) annotate_file(path: source_path, actions: actions) end output_results end # Load Rake tasks for Chusaku. Should be called in your project's `Rakefile`. # # @return [void] def load_tasks Dir[File.join(File.dirname(__FILE__), "tasks", "**/*.rake")].each do |task| load(task) end end private # Adds annotations to the given file. # # @param path [String] Path to file # @param actions [Hash] List of valid action data for the controller # @return [void] def annotate_file(path:, actions:) parsed_file = Chusaku::Parser.call(path: path, actions: actions.keys) parsed_file[:groups].each_cons(2) do |prev, curr| clean_group(prev) next unless curr[:type] == :action route_data = actions[curr[:action]] next unless route_data.any? annotate_group(group: curr, route_data: route_data) end write_to_file(path: path, parsed_file: parsed_file) end # Given a parsed group, clean out its contents. # # @param group [Hash] { type => Symbol, body => String } # @return {void} def clean_group(group) return unless group[:type] == :comment group[:body] = group[:body].gsub(/^\s*#\s*@route.*$\n/, "") group[:body] = group[:body].gsub(%r{^\s*# (GET|POST|PATCH/PUT|DELETE) /\S+$\n}, "") end # Add an annotation to the given group given by Chusaku::Parser that looks # like: # # @route GET /waterlilies/:id (waterlilies) # # @param group [Hash] Parsed content given by Chusaku::Parser # @param route_data [Hash] Individual route data given by Chusaku::Routes # @return [void] def annotate_group(group:, route_data:) whitespace = /^(\s*).*$/.match(group[:body])[1] route_data.reverse_each do |datum| comment = "#{whitespace}# #{annotate_route(**datum)}\n" group[:body] = comment + group[:body] end end # Generate route annotation. # # @param verb [String] HTTP verb for route # @param path [String] Rails path for route # @param name [String] Name used in route helpers # @param defaults [Hash] Default parameters for route # @return [String] "@route {} ()" def annotate_route(verb:, path:, name:, defaults:) annotation = "@route #{verb} #{path}" if defaults&.any? defaults_str = defaults .map { |key, value| "#{key}: #{value.inspect}" } .join(", ") annotation += " {#{defaults_str}}" end annotation += " (#{name})" unless name.nil? annotation end # Write annotated content to a file if it differs from the original. # # @param path [String] File path to write to # @param parsed_file [Hash] Hash mutated by {#annotate_group} # @return [void] def write_to_file(path:, parsed_file:) new_content = new_content_for(parsed_file) return if parsed_file[:content] == new_content !@flags.include?(:dry) && perform_write(path: path, content: new_content) @changed_files.push(path) end # Extracts the new file content for the given parsed file. # # @param parsed_file [Hash] { groups => Array } # @return [String] New file content def new_content_for(parsed_file) parsed_file[:groups].map { |pf| pf[:body] }.join end # Wraps the write operation. Needed to clearly distinguish whether it's a # write in the test suite or a write in actual use. # # @param path [String] File path # @param content [String] File content # @return [void] def perform_write(path:, content:) File.open(path, file_mode) do |file| if file.respond_to?(:test_write) file.test_write(content, path) else file.write(content) end end end # When running the test suite, we want to make sure we're not overwriting # any files. `r` mode ensures that, and `w` is used for actual usage. # # @return [String] 'r' or 'w' def file_mode File.instance_methods.include?(:test_write) ? "r" : "w" end # Output results to user. # # @return [Integer] 0 for success, 1 for error def output_results puts(output_copy) exit_code = 0 exit_code = 1 if @changed_files.any? && @flags.include?(:error_on_annotation) exit_code end # Determines the copy to be used in the program output. # # @return [String] Copy to be outputted to user def output_copy return "Controller files unchanged." if @changed_files.empty? copy = changes_copy copy += "Chusaku has finished running." copy += "\nThis was a dry run so no files were changed." if @flags.include?(:dry) copy += "\nExited with status code 1." if @flags.include?(:error_on_annotation) copy end # Returns the copy for changed files if `--verbose` flag is passed. # # @return [String] Copy for changed files def changes_copy return "" unless @flags.include?(:verbose) @changed_files.map { |file| "Annotated #{file}" }.join("\n") + "\n" end end end