Tweaking Emacs for Ruby Development in 2023

Preamble

Since I started a new job in April, I've been spending the majority of my time with legacy Ruby code for the first time since 2017 (I'd been mainly working on Elixir and Typescript codebases between then and now).

Before my start-date at the new job, I spent some time setting up a basic Emacs configuration for Ruby. I'd made a good start, but with all those unknown unknowns swimming around, I was only able to make vague assumptions about what I needed. I've since done a bunch more tweaking to get a configuration that works well with the development environment in this company.

I thought I'd share where I am with that in case it's helpful for others. It's still very much a work in progress (like every Emacs configuration ever), but I'm already getting a lot of value out of it.

Tweak Zero: Rootless Podman & Podman Compose

A lot of Emacs' container integration is designed to work well with Docker. I wanted to use Rootless Podman instead, since it has better Linux integration, it more closely matches the runtime environment in production, it's safer because it doesn't need privileged access, and it would simplify the potential for moving away from docker-compose.yml files in the future. Refactoring the docker-compose.yml and Dockerfiles of all projects such that they're no-longer Docker specific is a good thing for flexibility at the end of the day. Going through the setup with a fine-toothed comb like this was also a very educational exercise for me, someone who was in the early stages of understanding the lay of the land. I also knew I didn't want to rely on Docker Compose, since this would reqiure an extra compatibility layer, and I would miss out on some caching features.

All I had to do to make tweaks to the Docker.el package's configuration to work well enough with Podman for my light uses, was to set docker-command and docker-compose-command to podman and podman-compose respectively.

Tweak One: Using the Solargraph LSP server with bundler

In the project root, I set project-scoped configuration using a .dir-locals.el file. Because I'm using Emacs 30.0.50 which comes with Treesitter and Eglot, I set the eglot-server-programs variable within the context of ruby-base-mode, which covers ruby with or without Treesitter enabled.

  ((ruby-base-mode . ((eglot-server-programs . ((ruby-base-mode "bundle" "exec" "solargraph" "stdio"))))))

Tweak Two: Rspec & Podman Compose

As mentioned above, I'm using podman-compose to orchestrate the development environment. When I want to run unit tests with RSpec, I really want to do it from inside the container. This ensures all the connectivity and configuration is setup correctly, without creating further complication.

Another .dir-locals.el incantation is in order for rspec-mode's docker configuration:

  ((ruby-base-mode . ((rspec-use-docker-when-possible . t)
                      (rspec-docker-command . "podman-compose exec")
                      (rspec-docker-cwd . "/app/")
                      (rspec-docker-file-name . "../dev/docker-compose.yml")
                      (rspec-docker-container . "ruby-app")
                      )))

In my case, I'm using podman-compose instead of docker, and the docker-compose.yml file is in another project. Luckily, I can use relative paths here. This seems to work pretty well!

Tweak Three: Encapsulate dev env tasks in a Justfile

I've had the pleasure of using Just as a convenient makefile alternative a few times over the years, so when I saw that I wanted to refactor the dev env scripts, I reached for it right away. I knew that I could refactor the myriad of shell scripts into a Justfile easily, and that using just in Emacs was straightforward thanks to Justl.el. I recommend checking out this EmacsConf talk on justl.el if you're interested in seeing why I find it so compelling.

The .dir-locals.el comes to the rescue once again, and this time we use to setup the justfile we wish to use:

((nil . ((justl-justfile . "/home/john/code/project/dev/justfile"))

Notice how I set the mode to nil, which means that this variable will be enabled for any buffer type within the project. Now I can run M-x justl to run common tasks like redeploying containers, purging caches, triggering a re-indexing for sourcegraph, and generating documentation.

I submitted a few PRs to make this possible, and I also added support for the --unstable flag, so that I can unlock the ability to split the justfile up into smaller files using include directives.

Just

justl.el

EmacsConf talk on Just and justl.el

Some PRs I submitted to justl.el

Just's documentation on "include directives".

Tweak Four: Evil Matchit + Ruby

As a chronic vim keybindings addict, I am wedded to Evil for the forseeable future. For me, the evil-matchit package is fundamental to my ability to navigate a file quickly. While evil-matchit does have a ruby configuration, it needs to be enabled for ruby-base-mode and for my usecase, the tags it matches needed to be adjusted:

(require 'evil-matchit-ruby)

;; Add ruby matchit to ruby-base-mode so that it works with Treesitter
;; modes too.
(evilmi-load-plugin-rules '(ruby-base-mode ruby-ts-mode) '(simple ruby))

;; Improve the match tags for ruby
(defvar evilmi-ruby-match-tags
  '((("unless" "if") ("elsif" "else") "end")
    ("begin" ("rescue" "ensure") "end")
    ("case" ("when" "else") "end")
    (("class" "def" "while" "do" "module" "for" "until") () "end")
    (("describe" "context" "subject" "specify" "it" "let") () "end"))) ;; RSpec

evil-matchit

Tweak Five: A rails console REPL

In Emacs parliance, a REPL-type environment would be encapsulated in an "inferior mode". So I want to be able to run a ruby inferior mode within the context of my project, which is running in a container! Luckily, the inf-ruby package supports containers by default, but I haven't yet figured out how to configure inf-ruby to run within the context of a container by default - even if the buffer I'm editing is on the host machine. To work around this, I use the docker package to browse to the container's filesystem, then run inf-ruby from there. It works great, but I will automate this some day, as it's a little annoying to do.

I setup inf-ruby such that it automatically steals focus if a breakpoint is triggered.

(use-package inf-ruby
  :straight t
  :config
  (add-hook 'after-init-hook 'inf-ruby-switch-setup)
  (add-hook 'compilation-filter-hook 'inf-ruby-auto-enter-and-focus)
  (add-hook 'ruby-base-mode 'inf-ruby-minor-mode)
  (inf-ruby-enable-auto-breakpoint))

inf-ruby package

Tweak Seven: Refactoring UI

I started to collect useful functions for modifying the ruby. Many of them came from the ruby-refactor package, which while old seems to work well for me.

I decided to coalesce them all into a Transient popup so that they can be triggered easily. Building a transient popup is rather easy:

(transient-define-prefix jjh/ruby-refactor-transient ()
  "My custom Transient menu for Ruby refactoring."
  [["Refactor"
    ("e" "Extract Region to Method" ruby-refactor-extract-to-method)
    ("v" "Extract Local Variable" ruby-refactor-extract-local-variable)
    ("l" "Extract to let" ruby-refactor-extract-to-let)
    ("c" "Extract Constant" ruby-refactor-extract-constant)
    ("r" "Rename Local Variable or Method (LSP)" eglot-rename)
    ("{" "Toggle block style" ruby-toggle-block)
    ("'" "Toggle string quotes" ruby-toggle-string-quotes)
    ]
   ["Actions"
    ("d" "Documentation Buffer" eldoc-doc-buffer)
    ("q" "Quit" transient-quit-one)
    ("C" "Run a REPL" inf-ruby-console-auto)
    ("TAB" "Switch to REPL" ruby-switch-to-inf)]])

I decided to bind the transient popup to C-c r when in ruby-base-mode buffers. I find ruby-toggle-block to be especially useful when working with RSpec specs. Whenever the LSP server has a function available, I will default to using that, since it's more context-aware. I fully expect to write some Tree Sitter based refactoring functions in the future, which I'd like to call from this popup also. The idea is that I should have a "single pain of glass" for common ruby refactoring tasks.

Ruby Refactor package

Transient package

Bonus Tweak: Clickable JIRA bug references

Did you know that Emacs can auto-detect bug references in your buffers and turn them into clickable links? Here's what I use for a cloud JIRA instance - again in my .dir-locals.el:

((nil . ((bug-reference-url-format . "https://example.atlassian.net/browse/%s")
         (bug-reference-bug-regexp . "\\(\\[\\([A-Z]+-[0-9]+\\)\\]\\)"))))