A text-based Ruby tags system for Emacs
RbTagger is an Emacs library based on ctags,
ripper-tags, and
xref.el. It indexes your entire Ruby project along with gems and
provides smarter than average tag lookup. It aims to provide
context-aware, accurate tag lookup by parsing the current Ruby file
and an easy-to-use tags solution that works out of the box.
RbTagger is currently beta software extracted from my Emacs configuration.
- It indexes full projects along with gems, including the Ruby standard library;
- It indexes full Ruby modules, thanks to
ripper-tagsextra options. - It has contextual tags lookup. RbTagger is aware of Ruby code and will try to jump to the most specific occurrence for the symbol at point.
- It takes into account the full Ruby module when looking for
definitions. If point is on
ModOne::ModTwo, more specifically atModTwo, the searched tag will be the full module name:ModOne::ModTwo.
- Emacs 25 or greater.
- The
ruby,gem, andbundlercommands must be readily accessible from within Emacs. If you're on macOS, I recommend installing the exec-path-from-shell package.
RbTagger is available on the two major package.el community
maintained repos - MELPA Stable and
MELPA. If you want to use MELPA stable,
add the following repository to package-archives:
;; This code snippet should be saved to init.el
(add-to-list 'package-archives
'("melpa-stable" . "https://stable.melpa.org/packages/") t)If you want to stay on the bleeding edge:
(add-to-list 'package-archives
'("melpa" . "https://melpa.milkbox.net/packages/") t)After that, you can install RbTagger with the following command:
M-x package-install [RET] rbtagger [RET]
Use the following code in init.el to enable rbtagger-mode in all
Ruby buffers:
;; For ruby-mode
(add-hook 'ruby-mode (rbtagger-mode))
;; For enh-ruby-mode
(add-hook 'enh-ruby-mode (rbtagger-mode))You can generate tags for the current Ruby project with M-x
rbtagger-generate-tags. I strongly recommend setting up automation
either through an after-save hook or git hooks for a
better experience.
After enabling the minor mode, you can find definitions for the symbol
at point with M-., which is a shortcut for M-x
rbtagger-find-definitions. The above keybinding replaces Emacs'
keybinding for xref-find-definitions.
You can also force displaying a prompt of tags to choose from with the
universal argument: C-u M-..
To pop back to where you were before, the command is still
M-, or M-x xref-pop-marker-stack, which is a
default xref command.
Here is a list of commands:
| Keybinding | Description |
|---|---|
| M-. | rbtagger-find-definitions |
| M-, | xref-pop-marker-stack |
| C-u M-. | rbtagger-find-definitions (displays prompt) |
| C-c C-. | rbtagger-find-definitions-other-window |
| C-u C-c C-. | rbtagger-find-definitions-other-window (displays prompt) |
| C-c M-. | rbtagger-find-definitions-other-frame |
| C-u C-c M-. | rbtagger-find-definitions-other-frame (displays prompt) |
I strongly recommend reading up this guide for more details on how to best use this package.
TIP: In the tag prompt, both TAB and ? are set to trigger
autocomplete or display the available tag completions. To insert a
literal question mark in the completion prompt (which is a valid
character for Ruby methods), type C-q ?.
To generate TAGS, make sure the current buffer belongs to a Ruby
project with a Gemfile and git as VCS, then call M-x
rbtagger-generate-tags.
The above command will:
- Install the
ripper-tagsgem if not already installed, - Index the main project,
- Index the Ruby standard library,
- Index all dependencies declared in
Gemfile, - Generate a single
TAGSfile and save it to the root of the project.
The first call to the command might take a few seconds to complete depending on the size of your project, but subsequent calls will be faster because the script will skip directories whose tags have already been generated. If the gem is a local git project, it will only be reindexed if the commit hash has changed from the previous indexing operation.
Make sure to add the following patterns to your global .gitignore:
$ echo "TAGS*" >> ~/.gitignore
$ echo ".TAGS" >> ~/.gitignore
$ echo .ruby_tags_commit_hash >> ~/.gitignoreM-x rbtagger-generate-tags will create two hidden buffers
that can be accessed with the following commands:
- M-x
rbtagger-stdout-log: The message log of what's being indexed; - M-x
rbtagger-stderr-log: The error log.
You can watch the output of these buffers live for troubleshooting,
or after indexing. Note that they will only hold the output of the
last rbtagger-generate-tags.
A message will also be displayed in the minibuffer (or the
*Messages* buffer) when the command finishes, or you can configure
Custom Notifications.
rbtagger supports generating tags from within Docker containers. To
work seamlessly with both docker and docker-compose, it takes a
flexible approach that lets you specify the exact Docker command to
use.
To enable Docker integration, set the following options:
-
rbtagger-use-docker- Enable Docker mode. Set this totto activate container-based tag generation. (Default:nil) -
rbtagger-docker-command- The command used to run the tag-generation script inside your container. Since we don’t assume whether you use plaindockerordocker-compose, you can specify any command prefix. This usually looks likedocker execordocker-compose run(or evendocker run).rbtaggerwill append the shell command to execute inside the container.
Here's an example:
(setq rbtagger-use-docker t)
(setq rbtagger-docker-command "docker-compose run --rm -T --entrypoint \"\" app")To scope these settings to a specific project, define them in your
.dir-locals.el file:
((nil . ((rbtagger-use-docker . t)
(rbtagger-docker-command . "docker-compose run --rm -T --entrypoint \"\" app"))))When you run rbtagger-generate-tags (either manually or via a hook),
the resulting TAGS file is generated inside the container. By
default, this means all paths in the file will reference container
directories — unusable directly on your host machine.
To fix this, rbtagger provides two options to rewrite those paths:
-
rbtagger-docker-tramp-prefix- The TRAMP prefix to use for container files. Example:/docker:myapp. Here, "myapp" should match your container’s name. With this set,rbtaggerwill rewrite paths to include the TRAMP prefix so Emacs can transparently open files inside the container. -
rbtagger-docker-app-directory- The path inside the container where your app lives (e.g./usr/src/app). When set,rbtaggerreplaces occurrences of this directory in theTAGSfile with your local project directory, so your app files open locally.
Here's how these options interact:
-
If only
rbtagger-docker-tramp-prefixis set, all files in theTAGSfile will open through TRAMP inside the container. -
If both
rbtagger-docker-tramp-prefixandrbtagger-docker-app-directoryare set,rbtaggerrewrites app files to point to your local filesystem while keeping gems and standard library files accessible through TRAMP.
With both rbtagger-docker-tramp-prefix and
rbtagger-docker-app-directory set, you will usually get local file
app access and access to gems and standard library through TRAMP,
unless gems and stdlib live in your app directory.
Example of a complete .dir-locals.el:
((nil . ((rbtagger-use-docker . t)
(rbtagger-docker-command . "docker-compose run --rm -T --entrypoint \"\" app")
(rbtagger-docker-tramp-prefix . "/docker:myapp")
(rbtagger-docker-app-directory . "/usr/src/app"))))If your gems are not stored in a Docker volume, their TAGS files
will be regenerated on every container run. That’s because the cached
gem TAGS are ephemeral and disappear when the container stops.
To avoid this, mount your gems directory as a volume or reuse the same container instance across runs.
I recommend installing the
projectile package (also
available on MELPA) and enabling
(projectile-mode)
globally in your init.el to visit a project's TAGS file
automatically when switching buffers (on Emacs' find-file-hook,)
otherwise you'll have to manually manage the active tags table with
M-x visit-tags-table.
M-x rbtagger-find-definitions or M-. (provided
that rbtagger-mode is enabled) tries to find the
best match for the symbol at point by computing a list of candidates
ordered by specificity. It tries to follow Ruby's Constant lookup
rules as closely as possible. Given the following Ruby module:
module Tags
module Lookup
module Nested
def self.call(*args)
# Some code here...
Rule.call(*args)
end
end
end
endAssuming that point is on Rule, RbTagger will try four candidates in
order:
Tags::Lookup::Nested::RuleTags::Lookup::RuleTags::RuleRule
If one of the candidates resolve to one or more matches, it will either:
- Jump to the first occurrence when dealing with a single match;
- Display a list of tags to choose from when dealing with more than one match.
Subsequent candidates will be skipped.
I recommend the following settings for a smoother tags experience with
no prompts. Save them in init.el:
;; Make tag search case sensitive. Highly recommended for
;; maximum precision.
(setq tags-case-fold-search nil)
;; Reread TAGS without querying if it has changed
(setq tags-revert-without-query 1)
;; Always start a new tags list (do not accumulate a list of
;; tags) to keep up with the convention of one TAGS per project.
(setq tags-add-tables nil)You can automate tags generation with an after save hook. If you want to update your TAGS every time you save a Ruby file, you can setup a hook like this in your Emacs config:
(add-hook 'after-save-hook
(lambda ()
(if (eq major-mode 'enh-ruby-mode)
(call-interactively 'rbtagger-generate-tags))))If you use ruby-mode instead of enh-ruby-mode, just replace
enh-ruby-mode in the above snippet.
It is possible to automate tags generation with the help of
emacsclient when committing or running other git operations. Use the
following shell script as the body of the post-commit, post-merge,
and post-rewrite git hooks:
#!/usr/bin/env bash
emacsclient -e "(rbtagger-generate-tags \"$(pwd)/\")"TIP: You can setup these hooks as git templates that are automatically copied over whenever you
git inita project.
I recommend adding the following snippet to init.el
to start the Emacs server when you launch Emacs:
(require 'server)
(unless (server-running-p)
(server-start))RbTagger supports custom notifications via hooks. The hook's callable
takes two arguments: success (boolean t or nil) and
project-name (string). Here's an example of how it can be used on
macOS to integrate with notification center:
(add-hook
'rbtagger-after-generate-tag-hook
(lambda (success project-name)
(if success
(notify-os (concat project-name " tags 👍") "Hero")
(notify-os "Is this a Ruby project? Tags FAILED! 👎" "Basso"))))This particular example assumes you have the following function:
(defun notify-os (message sound)
"Send a notification to macOS's notification center.
Requires terminal-notifier to be installed via homebrew."
(shell-command
(combine-and-quote-strings
(list "terminal-notifier" "-message" message "-sound" sound))))Can't we use
tags-table-listto setup more than one tag file for lookup, i.e., smaller tag tables for each gem?
Certainly, but that could result in hundreds of junk buffers due to
the way tags work in Emacs. In a project with 300 gems, Emacs would
open 300 buffers while searching for a tag, which would greatly
enlarge the buffer list. For that reason, my preference is a single
TAGS file.
RbTagger's source code is simple and concise on purpose and it works very well for my needs. However, it can improve in the following areas:
As you can see, contextual tag lookup isn't as efficient as it can be
and there is room for improvement. In my experience, it will find the
tag instantaneously (with 200+ gems) most of the time, but sometimes
it will freeze for about 1 second. The eventual performance hit is
negligible for me, but any improvements on performance, better usage
of xref, or the algorithm itself would be hugely appreciated.
Building up the candidates list is currently an indentation-based and regex-based algorithm that happens inside Emacs buffers. Ideally, it should work with static analysis but that would probably make the code more complex or add more dependencies. Being regex-based means it would not work properly without properly indented module declarations. I never found this to be a problem because all my Ruby files are indented, but again, any contributions on that front will be appreciated.
You are welcome to contribute with anything. Please send PRs!
Through the command line in batch mode:
$ make testThrough Emacs:
- Open
test/rbtagger-test.el - Run M-x
eval-buffer. Side-effect warning: this will add MELPA topackage-archivesand install dependencies. - Press
C-c C-rto run all tests. - To run a single test, press M-x
eval-expressionand type(ert "name-of-the-test"). Seeertdocs for more options.
The make command with no arguments will compile rbtagger.el and
run checkdoc over it. Any warnings will make the command fail.
Copyright © 2021 Thiago Araújo Silva.
Distributed under the GNU General Public License, version 3