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 Feb 10, 2024
1 parent 5c94b64 commit 135194c
Show file tree
Hide file tree
Showing 13 changed files with 193 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
3 changes: 3 additions & 0 deletions test/app/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ gem 'bcrypt'

gem 'rbs_rails', path: '../../'

# to run Rails6.1 under Ruby 3.1+
gem 'mail', '>= 2.8.0'

# to run Rails6.1 under Ruby 3.4
gem 'base64'
gem 'bigdecimal'
Expand Down
17 changes: 16 additions & 1 deletion test/app/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ GEM
builder (3.2.4)
concurrent-ruby (1.1.10)
crass (1.0.6)
date (3.3.4)
drb (2.2.0)
ruby2_keywords
erubi (1.10.0)
Expand All @@ -86,15 +87,27 @@ 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)
mutex_m (0.2.0)
net-imap (0.4.10)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.4.0.1)
net-protocol
nio4r (2.5.8)
nokogiri (1.15.2)
mini_portile2 (~> 2.8.2)
Expand Down Expand Up @@ -147,6 +160,7 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (1.4.2)
thor (1.1.0)
timeout (0.4.1)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
websocket-driver (0.7.3)
Expand All @@ -163,6 +177,7 @@ DEPENDENCIES
bigdecimal
bootsnap
drb
mail (>= 2.8.0)
mutex_m
psych (< 4)
puma
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: 'Welcome to My Awesome Site')
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 135194c

Please sign in to comment.