One engine, many tools — Introducing Rubydex
One engine, many tools
A few years ago, the new Ruby parser Prism was released. One of its primary goals was to unify the community since we had multiple implementations of Ruby parsers, each with their own bugs, differences in implementation and portability. By having a single parser, community investments in performance and correctness benefit every single tool built on top of it (including Ruby itself!).
However, the story of repeated implementations of highly complex foundational blocks doesn’t end at the parser level. Move one level up the stack and the pattern repeats. Today, we have multiple tools that implement code indexing and related static analysis algorithms. Consider just a few examples:
- Language servers: tools like Ruby LSP and Solargraph need code indexing to provide go to definition, hover, signature help, completion and so on
- Type checkers: tools like Sorbet and Steep need code indexing for all of the previous reasons plus having the ability to type check code
- Documentation generators: tools like RDoc and YARD need code indexing to aggregate all declarations and their respective documentation for navigating and generating the static website
- Dead code detectors: tools like Spoom and debride need code indexing to match declarations and references, so that they can identify what declarations are dead (i.e.: unused)
- Linters: tools like RuboCop and Standard don’t currently use code indexing, but could provide much more sophisticated linting capabilities given a global knowledge of the codebase
The story we have here is the same. Multiple implementations of code indexing with varying performance, implementation differences and correctness discrepancies. On top of that, none of them are packaged and portable as an API that any other project can use.
It’s another case of our community’s efforts being diluted when we could instead have compounding benefits of working together in a foundational building block. We ought to do something about it.
Introducing Rubydex
Rubydex is a new portable static analysis engine intended to provide features such as code indexing and type analysis through a convenient API.
An important thing to note is that Rubydex is a framework/engine. It is not a tool by itself, but rather the core building block to create other tools. Despite being early in its development, Rubydex can already:
- Collect all definitions in a codebase and its dependencies, including classes, modules, constants, singleton classes, instance variables, class variables, global variables and methods
- Index RBS documents (including the bundled core and stdlib files and any RBS files in the workspace)
- Resolve constant references
- Track constant and instance variable references completely and method references with limitations1
- Create declarations from the discovered definitions and constant references2
- Linearize ancestor chains
- Track descendants
- Query the resulting graph in many ways
Built for portability
One of our goals with Rubydex is portability. If someone wants to write sophisticated tooling for Ruby using a different language or maybe target the browser through WASM, then they should be able to!
For this reason, Rubydex is built with Rust, C and Ruby to ship 3 distinct components:
- The main Rust crate: this is where the entire logic is implemented. Rust allows for high performance and easy parallelism, which are incredibly valuable when implementing static analysis tooling where the performance constraints are intense. Other Rust projects can use this directly, like creating a Zed extension that can understand Ruby code or writing a linter in Rust.
- A Rust FFI crate: this crate provides C compatible bindings to use the main crate’s logic, allowing other languages to integrate with Rubydex. Developers can use this to write tooling in other languages, like a VS Code extension that can analyze Ruby codebases with no Ruby runtime dependency.
-
A Ruby gem: a native extension that provides the Ruby API, which interacts with the underlying Rust implementation
through the FFI crate. The gem ships with pre-compiled binaries for macOS (Intel and M series), Linux (x64 and ARM64)
and Windows. For any other platforms,
rubydexhas a dependency oncargo(the Rust package manager) in order to compile correctly when installed.
Impact on existing tools
As of the time of this writing, we have either completed or started migrating our existing tools to use Rubydex. The impact story for all of them is essentially the same: better performance, higher accuracy and a lot less code to maintain.
Tapioca
You may know Tapioca for all of its runtime analysis, which is what allows the tool to output static RBI information for more accurate Sorbet type checking. However, Tapioca also consumes static information. There are two main use cases for static analysis in Tapioca:
- Fetching documentation for a given declaration so that it can be included in RBIs. This is important so that Sorbet can show documentation on hover
- Bootstrapping the initial analysis for generating gem RBIs. We discover the initial set of constants defined in a gem statically and then proceed to uncover the rest of the information by using the runtime
Tapioca adopted Rubydex to handle documentation, replacing the original approach which used YARD. After the change, we saw a massive performance improvement, dropping the total execution time for generating gem RBIs in our Core monolith from ~6 minutes to ~20 seconds, while using much less memory. We also replaced the gem RBI bootstrapping.
In addition to the performance gains, the correctness of Rubydex’s constant resolution algorithm and declaration handling allows comments to be attached in a more predictable way. This ensures that the comments are associated with the declaration they were meant to document, improving the quality of hover results for the Sorbet language server.
You can enjoy the Rubydex improvements starting in Tapioca v0.19.0.
Packwerk
Packwerk relies heavily on constant resolution to identify package violations. The problem is that constant resolution in Ruby is hard. The algorithm is complex and the amount of edge cases for static analysis increases the difficulty in getting it right. Investing the time to get it right in Rubydex means we don’t have to re-invent the wheel multiple times.
In our initial exploration of replacing Packwerk’s logic with Rubydex as the engine, we see the power of being able to reuse battle tested algorithms:
- Considerable reduction in codebase size (about -3000 lines)
- Significantly more violations found thanks to correct constant resolution (about 3x more constant references resolved in our monolith)
- Varying levels of performance improvements depending on the conditions (single-threaded vs parallel mode)
Spoom
We started prototyping the migration of Spoom’s dead code candidate detection to Rubydex. The story repeats once again. Faster analysis and higher accuracy thanks to being able to account for constant resolution and ancestors correctly. However, in the middle of the work, a question struck the team. Why not bundle this into Rubydex? It feels like dead code detection can be a part of the core engine, even if it’s only detecting candidates.
For that reason, we paused the Spoom migration to explore moving it into Rubydex instead. We hope to be able to ship the ability to find dead code candidates directly through our API.
Ruby LSP
For the Ruby LSP, Rubydex replaces the entire old code indexer with a significantly more performant and correct implementation. Several cases that we could not handle correctly are now understood by the language server and performance should be better across the board. The features directly impacted by Rubydex are:
- Go to definition, hover and signature help: better handling of nesting resolution will provide more accurate results
- Completion: the new completion engine in Rubydex provides significantly better results than the previous implementation
- Workspace symbol: new fuzzy search is much faster than the previous implementation
- Type hierarchy: ancestor information is more complete and correct. Additionally, descendant tracker allows us to implement the sub-types feature
- Test discovery for the explorer: faster analysis for test discovery
- Find references and rename: fast reference finding and renaming since Rubydex automatically tracks those
- Foundation: the previous indexer was not fast enough, so we ran indexing asynchronously in a thread. This improved responsiveness, but created several possible race conditions where taking an action in the editor before indexing was complete could result in discrepancies between the features and the actual state of the codebase. Rubydex is fast enough that we can simply index and manage the analysis synchronously, allowing us to eliminate the chances for race conditions and greatly simplifying the code in the Ruby LSP
In addition to the features already migrated to Rubydex, there are other improvements we are now able to deliver that
weren’t possible before. For example, in semantic highlighting we treat all constant references with the token
namespace. The reasoning behind this was performance. Resolving every constant reference in the old indexer was slow
and semantic highlighting runs on every keypress, which would cause the editor to grind to a halt. With Rubydex, we can
now solve this issue and correctly classify constant references as class, namespace or variable.constant.
Another possibility to explore is better test discovery. The current implementation of the test explorer relies on glob
patterns to find test files in a performant way. With Rubydex, identifying all classes that inherit from
Minitest::Test as a test group and all public methods prefixed with test_ is a breeze. Maybe we can finally ditch
the glob pattern and achieve higher accuracy in discovery?
Safe to say, there are lots of opportunities for more sophisticated and correct functionality in the Ruby LSP, without compromising performance in any way.
Add-ons
The switch to Rubydex means breaking changes for most add-ons. Please refer to the Ruby LSP’s documentation and release notes for migration instructions.
MCP server
As part of Rubydex, we are also including an experimental MCP server. The purpose is to provide powerful tools for LLMs to explore the codebase more efficiently. Instead of reading files in their entirety and grepping based on text, the LLM can directly request information about declarations, definitions, references, ancestors, descendants (basically anything that you could query through the regular API).
We are still working on improvements and ironing out the best way to distribute the MCP server so that it can find dependencies correctly, integrating with version managers and Bundler (which can mutate where dependencies are installed in the system, making discovering them not so straightforward). For now, the MCP server must be built from source, but we hope to ship it as part of the Ruby gem in a future release.
In early testing, we’re seeing 15 to 80% reduction in token usage, cost and duration for certain LLM tasks thanks to the ability of quickly navigating the codebase. One example where this is easy to understand is asking the LLM to perform a refactor. Instead of scanning the entire codebase for usages of a class/module, it can directly ask Rubydex to get the information.
Current API
The Rubydex API is quite extensive to support all use cases in the Ruby LSP, Tapioca, Packwerk and other tools. Here are a few simple examples of how to use it, but please refer to the repo for more complete API documentation.
For this example, consider the following files present in the workspace:
# foo.rb
class Foo
def found_me; end
end
# bar.rb
class Bar < Foo
def found_me; end
end
# qux.rb
module Zip
class Bar::Qux
Foo
end
end
We can use Rubydex to ask several questions about the state of the code:
require "rubydex"
# The graph is Rubydex's representation of the codebase. It's where everything starts and where all data is stored. To
# populate the graph, we first index the workspace (all .rb, .rbs files + dependencies) and then run the resolution step
# to transform definition information into semantic declarations.
#
# Find all of the other methods for managing the state of the graph in the repository's documentation.
graph = Rubydex::Graph.new
graph.index_workspace
graph.resolve
# Once the graph is ready, we can query it for any information we would like. This is what powers all language server's
# features in the new v0.27 and forward for the Ruby LSP.
#
# Find all of the querying features in the repository's documentation.
declaration = graph["Foo"]
puts declaration.ancestors.map(&:name) # => ["Foo", "Object", "Kernel", "BasicObject"]
puts declaration.descendants.map(&:name) # => ["Bar"]
puts graph.search("#found_me()") # => [#<Declaration name=Foo#found_me()>, #<Declaration name=Bar#found_me()>]
# Resolve a constant reference to `Foo` inside the `Zip, Baz::Qux` nesting
puts graph.resolve_constant("Foo", ["Zip", "Baz::Qux"]) # => [#<Declaration name=Foo>]
Future plans
This announcement marks the first iteration on Rubydex. There are a lot of interesting experiments that we want to do and lots to build. Here are some of the future ideas we have in our backlog.
Shared database
One of the ideas we partially experimented with and are eager to build is allowing Rubydex to save the analysis into a database. This benefits tools in a few ways:
- Reducing boot time: since the analysis is saved in the database, Rubydex wouldn’t have to start from scratch every time it is booted. This is regardless of how it gets initiated (language server, linter, MCP server)
- Loading partial data: developers rarely interact with 100% of their codebase all at the same time. Usually, you’re really only working in a small subset of the codebase at a time. This is true of LLMs too, they don’t need to read every single file in the codebase if you’re making a targeted change. Maintaining the entire analysis data in-memory at all times is wasteful. A lot of the information is simply not relevant for the changes being executed. If Rubydex can trace all of the dependencies of a document (and we believe it’s possible), then we can load a subset of the graph into memory and leave the rest in the database. This approach reduces the amount of memory being used by offloading to disk and it makes it so that memory usage does not scale linearly with the number of tools based on Rubydex (since they can share the same database)
If we can get this right, it would mean that you can have a language server, MCP server, linter, type checker all operating at the same time, reusing the same database to avoid multiple rounds of analysis and consuming little memory since most of the data is offloaded to disk.
Semantic linting
Another hope we have for Rubydex is enabling semantic linting. Rubydex can already collect diagnostics such as undefined constants. The idea is to offer a rich set of APIs so that linting rules can be created with the multitude of data available across the different phases of analysis.
The main benefits of semantic linting are reducing false positives and writing more complex rules that simple AST inspection cannot achieve. Here are some example rules that are possible (and even easy to write) with Rubydex, but not possible without a complete code indexing engine:
- Prohibiting inheriting from a certain ancestor: Maybe you’re deprecating the use of a module or parent class and you want to detect all existing uses (direct or transitive)
- Detecting potential dead code: Dead code is basically any declaration for which no references have been found. Of course, with meta-programming and untyped code, we can’t always be 100% sure it’s dead. However, our team has seen great benefit in flagging dead code candidates that then get reviewed by a developer to remove
- Detecting invalid mocks in tests: For example, stubbing a method to return a boolean when in fact it returns a string
-
Detecting methods that are always shadowed: For example, a method in a parent class with real behaviour, but all of
the subclasses override the method without invoking
super - Detecting incorrectly decomposed modules: For example, a module that depends on two instance variables, but none of the classes that include it define both instance variables, leading to module methods that can only be invoked when included into specific classes
And many, many others.
Type aware analysis
Currently, Rubydex has no concept of types and performs no inference. This limits the framework’s ability to understand method calls. For example:
var = Foo.something
var.other_thing
To know what defines other_thing, we need to know the type of var, and to know the type of var, we need to know
the type returned by Foo.something. Only by propagating types through the analysis can we know for sure which methods
are being used (or if they exist at all). Without this, Rubydex cannot associate method references to their declarations
like it can for constants and instance variables.
The natural next step is to consume type annotations to improve the accuracy of the analysis. For that to work, we need to design the internal type system, consume annotations already used by the community (e.g.: Sorbet sigs and RBS), and experiment with different algorithms. The experimentation lies in the approaches we can take and understanding how well they can fit the Ruby language (e.g.: constraint solving vs set theoretic types).
Conclusion
Prism showed what happens when the Ruby community consolidates around a shared parser: every tool got better and every improvement compounded. We believe the same is possible one layer up. Analyzing Ruby statically is inherently hard, but we don’t have to solve the same problems separately.
Rubydex is our bet on a shared foundation. If you maintain a linter, a language server, a documentation generator, a type checker, a code-mod tool, or something we haven’t imagined yet, we’d love to build this with you.
Try it through the Ruby LSP, Tapioca or Packwerk, or directly use Rubydex’s API. File bugs, open issues, send PRs, tell us what’s missing in the repo. Every improvement made to Rubydex benefits all tools—even the ones that haven’t been built yet.
-
Tracking method references with high accuracy depends on inferring the type of the receiver, which is currently not supported. ↩
-
In Rubydex’s architecture, we use the definitions and declarations nomenclature to differentiate between two concepts. If you re-open the class
Footwice, then you have two definitions of that class. However, both definitions contribute to the same global unique concept ofFoo. That global concept is what we call the declaration ofFoo. For example:# First definition class Foo def bar; end end # Second definition class Foo def baz; end end # Both definitions contribute to the same entity `Foo`. It does not matter how many times you re-open the # namespace, all of the methods, instance variables, class variables, constants and other members end up # associated with that global unique entity instance = Foo.new instance.bar instance.bazWe won’t dive into the implementation details here, but the importance of the differentiation between definitions and declarations is allowing for fast updates in the analysis when the codebase is being modified. As an example scenario, consider two
Foodefinitions created in separate files and then one of the files gets deleted. TheFooentity still exists, but one of its definitions is now gone. Having these two concepts allows us to efficiently update the internal representation ofFoo. ↩