Skip to content

Commit

Permalink
fix: inherited actions (#58)
Browse files Browse the repository at this point in the history
# Issue

Closes #57.

# Overview

This PR updates Chusaku to account for controller actions that are
passed down through object-oriented class inheritance. The high-level
changes include:

- Store `#source_location` data in `Chusaku::Routes` as `source_path`.
- Map source paths to corresponding actions data and loop over that to
annotate files.
- Update tests.
  • Loading branch information
nshki authored Nov 10, 2024
1 parent 6fe24ad commit dc8af58
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 41 deletions.
40 changes: 31 additions & 9 deletions lib/chusaku.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,7 @@ def call(flags = {})
.new(Rails.root.join(controllers_pattern))
.exclude(Rails.root.join(exclusion_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)
source_paths_map.each do |source_path, actions|
next unless controllers_paths.include?(source_path)

annotate_file(path: source_path, actions: actions)
Expand All @@ -56,6 +49,34 @@ def load_tasks

private

# Maps source paths to their respective routes.
#
# Example output:
#
# {
# "/path/to/users_controller.rb" => {
# "edit" => [...],
# "update" => [...]
# }
# }
#
# @return [Hash] Source paths mapped to their respective routes
def source_paths_map
map = {}

@routes.each do |controller, actions|
actions.each do |action, data|
data.each do |datum|
map[datum[:source_path]] ||= {}
map[datum[:source_path]][action] ||= []
map[datum[:source_path]][action].push(datum)
end
end
end

map
end

# Adds annotations to the given file.
#
# @param path [String] Path to file
Expand Down Expand Up @@ -110,8 +131,9 @@ def annotate_group(group:, route_data:)
# @param path [String] Rails path for route
# @param name [String] Name used in route helpers
# @param defaults [Hash] Default parameters for route
# @param source_path [String] Path to controller file
# @return [String] "@route <verb> <path> {<defaults>} (<name>)"
def annotate_route(verb:, path:, name:, defaults:)
def annotate_route(verb:, path:, name:, defaults:, source_path:)
annotation = "@route #{verb} #{path}"
if defaults&.any?
defaults_str =
Expand Down
77 changes: 63 additions & 14 deletions lib/chusaku/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,40 @@ class << self
# {
# "users" => {
# "edit" => [
# {verb: "GET", path: "/users/:id", name: "edit_user"}
# {
# verb: "GET",
# path: "/users/:id",
# name: "edit_user",
# defaults: {},
# source_path: "/path/to/users_controller.rb"
# }
# ],
# "update" => [
# {verb: "PATCH", path: "/users", name: "edit_user"},
# {verb: "PUT", path: "/users", name: "edit_user"}
# {
# verb: "PATCH",
# path: "/users",
# name: "edit_user",
# defaults: {},
# source_path: "/path/to/users_controller.rb"
# },
# {
# verb: "PUT",
# path: "/users",
# name: "edit_user",
# defaults: {},
# source_path: "/path/to/users_controller.rb"
# }
# ]
# },
# "empanadas" => {
# "create" => [
# {verb: "POST", path: "/empanadas", name: nil}
# {
# verb: "POST",
# path: "/empanadas",
# name: nil,
# defaults: {},
# source_path: "/path/to/empanadas_controller.rb"
# }
# ]
# }
# }
Expand All @@ -33,14 +57,20 @@ def call

private

# Recursively populate the routes hash with information from the given Rails
# application. Accounts for Rails engines.
#
# @param app [Rails::Application] Result of `Rails.application`
# @param routes [Hash] Collection of all route info
# @return [void]
def populate_routes(app, routes)
app.routes.routes.each do |route|
if route.app.engine?
populate_routes(route.app.app, routes)
next
end

controller, action, defaults = extract_data_from(route)
controller, action, defaults, source_path = extract_data_from(route)
routes[controller] ||= {}
routes[controller][action] ||= []

Expand All @@ -49,7 +79,8 @@ def populate_routes(app, routes)
routes: routes,
controller: controller,
action: action,
defaults: defaults
defaults: defaults,
source_path: source_path
end
end

Expand All @@ -60,11 +91,17 @@ def populate_routes(app, routes)
# @param controller [String] Controller key
# @param action [String] Action key
# @param defaults [Hash] Default parameters for route
# @param source_path [String] Path to controller file
# @return [void]
def add_info_for(route:, routes:, controller:, action:, defaults:)
def add_info_for(route:, routes:, controller:, action:, defaults:, source_path:)
verbs_for(route).each do |verb|
routes[controller][action]
.push(format(route: route, verb: verb, defaults: defaults))
routes[controller][action].push \
format(
route: route,
verb: verb,
defaults: defaults,
source_path: source_path
)
routes[controller][action].uniq!
end
end
Expand All @@ -88,13 +125,15 @@ def verbs_for(route)
# @param route [ActionDispatch::Journey::Route] Route given by Rails
# @param verb [String] HTTP verb
# @param defaults [Hash] Default parameters for route
# @param source_path [String] Path to controller file
# @return [Hash] { verb => String, path => String, name => String }
def format(route:, verb:, defaults:)
def format(route:, verb:, defaults:, source_path:)
{
verb: verb,
path: route.path.spec.to_s.gsub("(.:format)", ""),
name: route.name,
defaults: defaults
defaults: defaults,
source_path: source_path
}
end

Expand Down Expand Up @@ -143,16 +182,26 @@ def backfill_routes(routes)
routes
end

# Given a route, extract the controller and action strings.
# Given a route, extract the controller & action strings as well as defaults
# hash and source path.
#
# @param route [ActionDispatch::Journey::Route] Route instance
# @return [Array<Object>] (String, String, Hash)
# @return [Array<Object>] (String, String, Hash, String)
def extract_data_from(route)
defaults = route.defaults.dup
controller = defaults.delete(:controller)
action = defaults.delete(:action)

[controller, action, defaults]
controller_class = controller ? "#{controller.underscore.camelize}Controller".constantize : nil
action_method_name = action&.to_sym
source_path =
if !action_method_name.nil? && controller_class&.method_defined?(action_method_name)
controller_class.instance_method(action_method_name).source_location&.[](0)
else
""
end

[controller, action, defaults, source_path]
end
end
end
Expand Down
20 changes: 16 additions & 4 deletions test/chusaku_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
out, _err = capture_io { exit_code = Chusaku.call(error_on_annotation: true) }

assert_equal(1, exit_code)
assert_equal(4, File.written_files.count)
assert_equal(5, File.written_files.count)
assert_includes(out, "Exited with status code 1.")
end

Expand Down Expand Up @@ -53,13 +53,13 @@
engine_path = "#{Rails.root}/engine/app/controllers"

assert_equal(0, exit_code)
assert_equal(4, files.count)
assert_equal(5, files.count)
refute_includes(files, "#{app_path}/api/burritos_controller.rb")

expected =
<<~HEREDOC
module Api
class CakesController < ApplicationController
class CakesController < PastriesController
# This route's GET action should be named the same as its PUT action,
# even though only the PUT action is named.
# @route GET /api/cakes/inherit (inherit)
Expand Down Expand Up @@ -109,6 +109,18 @@ def tacos_params
HEREDOC
assert_equal(expected, files["#{app_path}/api/tacos_controller.rb"])

expected =
<<~HEREDOC
class PastriesController < ApplicationController
# This should be annotated for each child controller that inherits from this class.
# @route GET /api/cakes (cakes)
# @route GET /api/croissants (croissants)
def index
end
end
HEREDOC
assert_equal(expected, files["#{app_path}/pastries_controller.rb"])

expected =
<<~HEREDOC
class WaterliliesController < ApplicationController
Expand Down Expand Up @@ -165,7 +177,7 @@ def create
out, = capture_io { exit_code = Chusaku.call(args) }

assert_equal(0, exit_code)
assert_equal(3, File.written_files.count)
assert_equal(4, File.written_files.count)
assert_equal("Chusaku has finished running.\n", out)
end
end
2 changes: 1 addition & 1 deletion test/mock/app/controllers/api/cakes_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Api
class CakesController < ApplicationController
class CakesController < PastriesController
# This route's GET action should be named the same as its PUT action,
# even though only the PUT action is named.
def inherit
Expand Down
2 changes: 2 additions & 0 deletions test/mock/app/controllers/api/croissants_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class Api::CroissantsController < PastriesController
end
5 changes: 5 additions & 0 deletions test/mock/app/controllers/pastries_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class PastriesController < ApplicationController
# This should be annotated for each child controller that inherits from this class.
def index
end
end
16 changes: 16 additions & 0 deletions test/mock/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
require_relative "route_helper"

require_relative "app/controllers/application_controller"
require_relative "app/controllers/pastries_controller"
require_relative "app/controllers/waterlilies_controller"
require_relative "app/controllers/api/burritos_controller"
require_relative "app/controllers/api/cakes_controller"
require_relative "app/controllers/api/croissants_controller"
require_relative "app/controllers/api/tacos_controller"

module Rails
Expand Down Expand Up @@ -47,6 +49,20 @@ def application
verb: "PUT",
path: "/api/cakes/inherit(.:format)",
name: "inherit"
routes.push \
mock_route \
controller: "api/cakes",
action: "index",
verb: "GET",
path: "/api/cakes(.:format)",
name: "cakes"
routes.push \
mock_route \
controller: "api/croissants",
action: "index",
verb: "GET",
path: "/api/croissants(.:format)",
name: "croissants"
routes.push \
mock_route \
controller: "api/tacos",
Expand Down
Loading

0 comments on commit dc8af58

Please sign in to comment.