Skip to content

Commit

Permalink
Support signature generation for ActionMailer
Browse files Browse the repository at this point in the history
ActionMailer automatically generates class methods from the instance methods
user defined via method_missing.  For example, `AccountMailer.welcome_mail`
will be generated from `AccountMail#welcome_mail` that is defined by users.

This starts to support signature generation for auto-generated class methods
for mailer classes dynamically.
  • Loading branch information
tk0miya committed Sep 3, 2023
1 parent 4ba3f0f commit 89807eb
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 4 deletions.
1 change: 1 addition & 0 deletions lib/rbs_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require_relative "rbs_rails/version"
require_relative "rbs_rails/util"
require_relative 'rbs_rails/action_mailer'
require_relative 'rbs_rails/active_record'
require_relative 'rbs_rails/path_helpers'
require_relative 'rbs_rails/dependency_builder'
Expand Down
63 changes: 63 additions & 0 deletions lib/rbs_rails/action_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
module RbsRails
module ActionMailer
def self.class_to_rbs(klass)
Generator.new(klass).generate
end

class Generator
def initialize(klass)
@klass = klass
@klass_name = Util.module_name(klass, abs: false)
end

def generate
Util.format_rbs klass_decl
end

private def klass_decl
<<~RBS
#{header}
#{methods}
#{footer}
RBS
end

private def header
namespace = +''
klass_name(abs: false).split('::').map do |mod_name|
namespace += "::#{mod_name}"
mod_object = Object.const_get(namespace)
case mod_object
when Class
# @type var superclass: Class
superclass = _ = mod_object.superclass
superclass_name = Util.module_name(superclass, abs: false)

"class #{mod_name} < ::#{superclass_name}"
when Module
"module #{mod_name}"
else
raise 'unreachable'
end
end.join("\n")
end

private def methods
klass.action_methods.map do |method_name|
"def self.#{method_name}: (*untyped) -> ActionMailer::MessageDelivery"
end.join("\n")
end

private def footer
"end\n" * klass_name(abs: false).split('::').size
end

private def klass_name(abs: true)
abs ? "::#{@klass_name}" : @klass_name
end

private
attr_reader :klass
end
end
end
22 changes: 20 additions & 2 deletions lib/rbs_rails/rake_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ def initialize(name = :rbs_rails, &block)
block.call(self) if block

def_generate_rbs_for_models
def_generate_rbs_for_mailers
def_generate_rbs_for_path_helpers
def_all
end

def def_all
desc 'Run all tasks of rbs_rails'

deps = [:"#{name}:generate_rbs_for_models", :"#{name}:generate_rbs_for_path_helpers"]
deps = [:"#{name}:generate_rbs_for_models", :"#{name}:generate_rbs_for_mailers", :"#{name}:generate_rbs_for_path_helpers"]
task("#{name}:all": deps)
end

Expand All @@ -34,7 +35,7 @@ def def_generate_rbs_for_models
Rails.application.eager_load!

dep_builder = DependencyBuilder.new

::ActiveRecord::Base.descendants.each do |klass|
next unless RbsRails::ActiveRecord.generatable?(klass)
next if ignore_model_if&.call(klass)
Expand All @@ -53,6 +54,23 @@ def def_generate_rbs_for_models
end
end

def def_generate_rbs_for_mailers
desc 'Generate RBS files for Active Mailer mailers'
task("#{name}:generate_rbs_for_mailers": :environment) do
require 'rbs_rails'

Rails.application.eager_load!

::ActionMailer::Base.descendants.each do |klass|
path = signature_root_dir / "app/mailers/#{klass.name.underscore}.rbs"
path.dirname.mkpath

sig = RbsRails::ActionMailer.class_to_rbs(klass)
path.write sig
end
end
end

def def_generate_rbs_for_path_helpers
desc 'Generate RBS files for path helpers'
task("#{name}:generate_rbs_for_path_helpers": :environment) do
Expand Down
27 changes: 27 additions & 0 deletions sig/rbs_rails/action_mailer.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module RbsRails
module ActionMailer
def self.class_to_rbs: (untyped klass) -> String

class Generator
@klass_name: String

def initialize: (singleton(ActionMailer::Base) klass) -> void

def generate: () -> String

def klass_decl: () -> String

def header: () -> String

def methods: () -> String

def footer: () -> String

def klass_name: (?abs: boolish) -> String

private

attr_reader klass: singleton(ActionMailer::Base)
end
end
end
2 changes: 2 additions & 0 deletions sig/rbs_rails/rake_task.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ module RbsRails

def def_generate_rbs_for_models: () -> void

def def_generate_rbs_for_mailers: () -> void

def def_generate_rbs_for_path_helpers: () -> void

private
Expand Down
16 changes: 15 additions & 1 deletion test/app/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ GEM
builder (3.2.4)
concurrent-ruby (1.1.10)
crass (1.0.6)
date (3.3.3)
erubi (1.10.0)
globalid (1.0.1)
activesupport (>= 5.0)
Expand All @@ -82,14 +83,26 @@ GEM
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.7.1)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.1)
method_source (1.0.0)
mini_mime (1.0.3)
mini_portile2 (2.8.2)
minitest (5.17.0)
msgpack (1.4.2)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
timeout
net-smtp (0.3.3)
net-protocol
nio4r (2.5.8)
nokogiri (1.15.2)
mini_portile2 (~> 2.8.2)
Expand Down Expand Up @@ -141,6 +154,7 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
thor (1.1.0)
timeout (0.4.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
websocket-driver (0.7.3)
Expand Down
4 changes: 4 additions & 0 deletions test/app/app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: '[email protected]'
layout 'mailer'
end
9 changes: 9 additions & 0 deletions test/app/app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class UserMailer < ApplicationMailer
default from: '[email protected]'

def welcome_email
@user = params[:user]
@url = 'http://example.com/login'
mail(to: @user.email, subject: '私の素敵なサイトへようこそ')
end
end
1 change: 1 addition & 0 deletions test/app/app/views/layouts/mailer.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= yield %>
2 changes: 1 addition & 1 deletion test/app/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
# require "action_mailer/railtie"
require "action_mailer/railtie"
# require "action_mailbox/engine"
# require "action_text/engine"
require "action_view/railtie"
Expand Down
3 changes: 3 additions & 0 deletions test/expectations/user_mailer.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class UserMailer < ::ApplicationMailer
def self.welcome_email: (*untyped) -> ActionMailer::MessageDelivery
end
43 changes: 43 additions & 0 deletions test/rbs_rails/action_mailer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require 'test_helper'

class ActionMailerTest < Minitest::Test
def test_type_check
clean_test_signatures

setup!

dir = app_dir
sh!('steep', 'check', chdir: dir)
end

def test_user_mailer_rbs_snapshot
clean_test_signatures

setup!

rbs_path = app_dir.join('sig/rbs_rails/app/mailers/user_mailer.rbs')
expect_path = expectations_dir / 'user_mailer.rbs'
# Code to re-generate the expectation files
# expect_path.write rbs_path.read

assert_equal expect_path.read, rbs_path.read
end

def app_dir
Pathname(__dir__).join('../app')
end

def expectations_dir
Pathname(__dir__).join('../expectations')
end

def setup!
dir = app_dir

Bundler.with_unbundled_env do
sh!('bundle', 'install', chdir: dir)
sh!('bin/rake', 'db:create', 'db:schema:load', chdir: dir)
sh!('bin/rake', 'rbs_rails:all', '--trace', chdir: dir)
end
end
end

0 comments on commit 89807eb

Please sign in to comment.