How to Build Your Own Rails Generator
Tags: ruby, rails
Published: 2023-02-09 00:04:36

*This article was originally written by [William Kennedy](https://www.honeybadger.io/blog/authors/williamkennedy/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/build-a-rails-generator/).*

Rails generators are one of the most prominent features of Rails. We can generate feature scaffolds, migrate databases, create mailers, and even create new background jobs. There is nothing more satisfying than running a generator with everything set up and ready to go.  
  
Scaffolds also serve an essential function in a lot of successful gems. Many popular gems ship with an install command that sets up everything the developer needs to succeed with that library.
  
If you’re a long-time Rails user, you probably take generators for granted until you venture outside of Rails.  
  
Generators are easy to view as "magic". How they work can be pretty obscure to a newcomer. This article will dig into exactly how generators work under the hood, and then we'll build one ourselves.  

## What is a Generator

A generator consists of the following:

- A class that inherits from `Rails::Generators::NamedBase`
- A template(optional)
- A repeatable pattern

## Kinds of Generators

Generators come in all shapes and sizes, but generally, they come in two variations—generators that take arguments and do something with them and installers that set up the plumbing.

These kinds of generators exist in the [Noticed gem](https://github.com/excid3/noticed)and within Rails itself via the various `rails scaffold commands` and even the `rails new` command, which is a Rails generator itself.  
  
In the Noticed gem, we can do the following:

```ruby
rails generate noticed:model
```
  
This generates a new model called notifications, a migration, and some tests.
  
It then proceeds to give you some instructions on what to do next.  

Now let’s dive into the code, which you can read [here](https://github.com/excid3/noticed/blob/master/lib/generators/noticed/model_generator.rb).

The critical thing here is that we inherit from `Rails::Generators::NamedBase`; note that we have a `source_root` method that points to the templates folder.  

```ruby
module Noticed
 module Generators
  class ModelGenerator < Rails::Generators::NamedBase
   include Rails::Generators::ResourceHelpers

   source_root File.expand_path("../templates", __FILE__)

   desc "Generates a Notification model for storing notifications."

   argument :name, type: :string, default: "Notification", banner: "Notification"
   argument :attributes, type: :array, default: [], banner: "field:type field:type"
 ...
```
  
This class governs all behavior that goes into the noticed generator.  
  
Note that we also have the `argument` method, which defaults to string. We can also pass arguments into this generator. For example, we might want the notifications model to be called something else instead of notification.  

```ruby
rails generate noticed:model Notice 
```

Noticed has a generator targeted for taking an argument in the form of a Ruby Class.

```ruby
rails generate noticed:notification CommentNotification 
```
  
This can be [seen here](https://github.com/excid3/noticed/blob/master/lib/generators/noticed/notification_generator.rb) and is remarkably simple. Like the previous generator, it inherits from `Rails::Generators::NamedBase` and has the `source_root` method, which points to the templates folder. However, this example uses a template that can be seen [here](https://github.com/excid3/noticed/blob/master/lib/generators/noticed/templates/notification.rb.tt).  

```plain
# To deliver this notification:
#
# <%= class_name %>.with(post: @post).deliver_later(current_user)
# <%= class_name %>.with(post: @post).deliver(current_user)

class <%= class_name %> < Noticed::Base
 # Add your delivery methods
 #
 # deliver_by :database
 # deliver_by :email, mailer: "UserMailer"
 # deliver_by :slack
 # deliver_by :custom, class: "MyDeliveryMethod"

 # Add required params
 #
 # param :post

 # Define helper methods to make rendering easier.
 #
 # def message
 #  t(".message")
 # end
 #
 # def url
 #  post_path(params[:post])
 # end
end
```
  
## Invocation Order

All public methods in the generator will be called one after the [other](https://github.com/rails/thor/wiki/Invocations). Private methods will not be called but are available in your public methods like regular Ruby classes.  
  
## Building A Generator - Getting Started

The best way to understand generators is, of course, to build one ourselves. This generator will be simple initially but easy to build upon in the future. We will keep our generator simple and create new StimulusJS controllers.

The first thing we do is build a new Rails app to get started.  
  
Assuming you’re using Rails 7, run the following command:  

```ruby
rails new rails_generator
```

Now we change into that directory:

```bash
cd rails_generator
```

Once that's ready, we will use a Rails generator to create our first generator.

```ruby
rails g generator stimulus
```

Then we will do the following:  

```ruby
   create lib/generators/stimulus
   create lib/generators/stimulus/stimulus_generator.rb
   create lib/generators/stimulus/USAGE
   create lib/generators/stimulus/templates
   invoke test_unit
   create  test/lib/generators/stimulus_generator_test.rb
```

The first file I like to edit is the `lib/generators/stimulus/USAGE` file. This allows us to do some documentation upfront and provide helpful hints to fellow developers who might want to use your generator.  
  
So, let’s change that by updating the `lib/generators/stimulus/USAGE`file:

```bash
Description:
  Generates a New Stimulus Controller

Example:
  bin/rails generate stimulus Thing

  This will create:
    app/javascripts/controllers/thing_controller.js

```

In our terminal, we can run the following, and we’ll see the output.  

```bash
rails g stimulus --help
```

```bash
Usage:
 rails generate stimulus NAME [options]

Options:
 [--skip-namespace], [--no-skip-namespace]       # Skip namespace (affects only isolated engines)
 [--skip-collision-check], [--no-skip-collision-check] # Skip collision check

Runtime options:
 -f, [--force]          # Overwrite files that already exist
 -p, [--pretend], [--no-pretend] # Run but do not make any changes
 -q, [--quiet], [--no-quiet]   # Suppress status output
 -s, [--skip], [--no-skip]    # Skip files that already exist

Description:
  Generates a New Stimulus Controller

Example:
  bin/rails generate stimulus Thing

  This will create:
    app/javascripts/controllers/thing_controller.js
```

This is a good start, but nothing happens if we run the generator. Let’s fix this. Let’s start by adding a test to our `test/lib/generators/stimulus_generator_test.rb`

```ruby
# test/lib/generators/stimulus_generator_test.rb
require "test_helper"
require "generators/stimulus/stimulus_generator"

class StimulusGeneratorTest < Rails::Generators::TestCase
 tests StimulusGenerator
 destination Rails.root.join("tmp/generators")
 setup :prepare_destination

 test 'should add thing controller file' do
  run_generator ['Thing']

  assert_file 'app/javascript/controllers/thing_controller.js'
 end
end
```

Now we have created a failing test. We can run it.  

```ruby
rails test test/lib/generators/stimulus_generator_test.rb
```

```ruby
# Running:

F

Failure:
StimulusGeneratorTest#test_should_add_thing_controller_file [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:12]:
Expected file "app/javascript/controllers/thing_controller.js" to exist, but does not


rails test test/lib/generators/stimulus_generator_test.rb:9



Finished in 0.007052s, 141.8037 runs/s, 141.8037 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
```
  
Let’s fix this.  
  
```ruby
# lib/generators/stimulus/stimulus_generator.rb
class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 def create_stimulus_controller
  create_file "app/javascript/controllers/#{file_path}_controller.js"
 end
end

```

Now when we run our tests, it should pass.

```ruby
rails test test/lib/generators/stimulus_generator_test.rb
```

```ruby
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 41043

# Running:

.

Finished in 0.007780s, 128.5347 runs/s, 128.5347 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
```

## Building A Generator - Generating Code

So far, what we’ve built is pretty cool but not practical. It only creates an empty file. However, we’ve got all the building blocks ready to take our generator further, so let’s start with a non-empty file.

Most stimulus controllers have the following code. The connect and initialize methods are optional.

```js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }
 
 connect() {
 }
}
```

Our generator does not add any of this code, and it would be helpful if it did. Let’s modify our generator to do this.  
  
In our tests, let’s add the following:  

```ruby
# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
 ...
 test 'should add stimulusjs boilerplate' do
  run_generator ['Thing']
  assert_file "app/javascript/controllers/thing_controller.js" do |content|
   assert_match /Controller/, content
   assert_match /connect()/, content
   assert_match /@hotwired/, content
  end
 end
end
```
  
In true test-driven-development fashion, we run this test, and it fails.

```bash
rails test test/lib/generators/stimulus_generator_test.rb
```

```bash
Run options: --seed 15750

# Running:

F

Failure:
StimulusGeneratorTest#test_should_add_stimulusjs_boilerplate [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:17]:
Expected /Controller/ to match "".


rails test test/lib/generators/stimulus_generator_test.rb:15

.

Finished in 0.009860s, 202.8398 runs/s, 405.6795 assertions/s.
2 runs, 4 assertions, 1 failures, 0 errors, 0 skips
```

We can take advantage of our templates folder to make this test pass. Let’s create a new file that will be a template for our stimulus controller.

```bash
touch lib/generators/stimulus/templates/stimulus_controller.js.erb
```

In that file, add our stimulus controller boilerplate.  

```js
// lib/generators/stimulus/templates stimulus_controller.js.erb
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }
 
 connect() {
 }
}
```

Let’s also update our generator class.

```ruby
# lib/generators/stimulus/stimulus_generator.rb

class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 def create_stimulus_controller
  template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
 end
end
```

Now, let’s rerun our test.

```bash
rails test test/lib/generators/stimulus_generator_test.rb
```

```bash
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 61204

# Running:

..

Finished in 0.010278s, 194.5904 runs/s, 778.3615 assertions/s.
2 runs, 8 assertions, 0 failures, 0 errors, 0 skips
```
  
We have saved ourselves quite a lot of time. The next time we need a new stimulus controller, we run our generator, and we’re ready to go. However, now that we have gone beyond the basics of creating a file, could we go further. Could we make our generator even better?

## Build A Generator - Handling Command Line Arguments

When creating a new Rails model, we can pass in options.

```bash
rails g model Item name:string description:text
```

This will create an ”items” table with name and description columns. StimulusJS controllers have several options that allow us to expand the usefulness of our controller.  

- [Actions](https://stimulus.hotwired.dev/reference/actions)
- [Targets](https://stimulus.hotwired.dev/reference/targets)
- [Values](https://stimulus.hotwired.dev/reference/values)
- [CSS Classes](https://stimulus.hotwired.dev/reference/css-classes)
  
Wouldn’t it be great if we could also generate markup from our generator? Let’s try it.

Go back to our tests and write our test case.  
  
```ruby
# test/lib/generators/stimulus_generator_test.rb
class StimulusGeneratorTest < Rails::Generators::TestCase
 ...
 test 'should generate actions' do
  run_generator ["toggle", "--actions", "toggle hide"]
  assert_file "app/javascript/controllers/toggle_controller.js" do |content|
   assert_match /toggle()/, content
   assert_match /hide()/, content
  end
 end
end
```
  
Let’s run our tests.

```bash
rails test test/lib/generators/stimulus_generator_test.rb
```

```bash
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 2776

# Running:

.F

Failure:
StimulusGeneratorTest#test_should_generate_actions [/Users/williamkennedy/projects/honeybadger/rails_generators/test/lib/generators/stimulus_generator_test.rb:26]:
Expected /toggle()/ to match "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n\n initialize() {\n }\n \n connect() {\n }\n}\n\n".


rails test test/lib/generators/stimulus_generator_test.rb:24

.

Finished in 0.013761s, 218.0074 runs/s, 799.3605 assertions/s.
3 runs, 11 assertions, 1 failures, 0 errors, 0 skips
```

When taking arguments in our generators, we can use the `class_option` method. We now need to update our generator once again.

```ruby
class StimulusGenerator < Rails::Generators::NamedBase
 source_root File.expand_path("templates", __dir__)

 class_option :actions, type: :array, default: []

 def create_stimulus_controller
  template 'stimulus_controller.js.erb', "app/javascript/controllers/#{file_path}_controller.js"
 end
end
```
  
Note that we have an empty array as the default for the argument. This means we don’t have to worry about nil errors if we don’t use the action argument.

We can access the arguments by using the `options` method. Here’s how I have done it in the `lib/generators/stimulus/templates/stimulus_controller.js.erb`:

```erb
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }
<% options[:actions].each do |action| %>

 <%= action %>(){
 }

<% end %>
}
```

Now when we can run our generator with options.

```ruby
rails g stimulus toggle --actions toggle hide
```

This will produce the following in the file `app/javascript/controllers/toggle_controller.js`:  

```ruby
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

 initialize() {
 }

 connect() {
 }

 toggle(){
 }

 hide(){
 }
}
```

And, of course, if we rerun our tests, we’ll see that they pass.  

```bash
rails test test/lib/generators/stimulus_generator_test.rb
```

```bash
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 47721

# Running:

...

Finished in 0.015036s, 199.5211 runs/s, 864.5916 assertions/s.
3 runs, 13 assertions, 0 failures, 0 errors, 0 skips
```

We could do the same process for the other stimulus features, such as targets, values, and CSS classes.

1. Create the test.
2. Add the class option.
3. Adjust our template.
4. Ensure the test passes.

## Conclusion

Generators are pretty helpful and can save hours, reduce boilerplate errors, and create a standard across your codebase as it gets larger over time.  
  
However, there is a balance to be struck on when to automate something using a generator. Luckily, XKCD has [a handy guide](https://xkcd.com/1205/).

The source code for this app can be found [here](https://github.com/williamkennedy/rails_generator).