LSP-driven API design
Because Ruby is such a dynamic language, its language server, Ruby LSP, is quite limited.
Ruby LSP can index your constants and the methods you define in source files, associating them with comments. It can follow constants that point to instances of objects and even guesses some types.
What it can’t do is follow meta-programming. And though one day I hope to be able to write add-ons for Ruby LSP that can follow some specific meta-programming, in the meantime there are a few techniques you can use to give hints to the LSP, or work with it as it is.
Here’s a few examples from my projects:
Constant members in Literal Enums
Ruby LSP can follow constant definitions in source files — that means it can’t follow const_set
or const_missing
but it can follow FooBar = 1
if that appears directly in the source code.
Working with this constraint, we updated Literal Enums to use real static constant definitions as part of its API. You now define a Literal Enum like this:
class Color < Literal::Enum(Integer)
Red = new(1)
Green = new(2)
Blue = new(3)
end
Let’s break down how this works. First, you’re defining a real class with the class
keyword. This inherits from Literal::Enum(Integer)
, which is actually calling the method Enum
on the Literal
constant and that method returns a new class configured with the type Integer
for your to inherit from.
The type constraint here means if you pass an invalid type such as Red = new("Hello")
, it would raise an exception. But the next bit is where it gets interesting:
Red = new(1)
This is a static definition of a constant Red
on the class Color
pointing to a new instance of the Color
class.
Literal uses const_added
on the class that Enum
method returned to detect that you added this constant, and it does some validation and setup here. It maintains some indexes for fast lookups from a given value or key but it also ensures your keys and values are unique.
Since this is a static constant definition, Ruby LSP knows that Color
has the constants Red
, Green
, Blue
so it will predict them in your editor when type Color::
.
What’s more, you could put a comment above each member of the enum and that comment will appear in the autocomplete popover, with markdown formatting and all.
In Yippee, we’re using this technique to provide just-in-time documentation of HTTP statuses via a Literal Enum.
Here’s an example:
# `404` The requested resource could not be found.
#
# [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)
NotFound = new(404)
These comments help verify you’ve picked the correct status, and even link to the MDN documentation.
Finally, we actually hook end
using a TracePoint
in order to freeze the class so you can’t define new members at runtime.
Static element methods in Phlex
Phlex defines a method for each HTML and SVG element. And since each of these methods are almost exactly the same, we use class_eval
to define them from a template.
We used to have an array of element names that we would iterate over, defining each method from the template, but Ruby LSP can’t follow that.
What we now do instead is define each HTML element as a real method and then immediately redefine it with class_eval
. Essentially:
def div(**attributes, &content) = nil
register_element :div
The static definition is picked up by Ruby LSP and the register_element
macro overrides it with the real definition. Since method definitions return their symbol, we actually write it like this:
register_element def div(**attributes, &content) = nil
Additionally, since Ruby LSP associates comments as documentation, each element is documented with relevant links in Markdown. Here’s an example:
# Outputs a `<dialog>` tag.
#
# [MDN Docs](https://developer.mozilla.org/docs/Web/HTML/Element/dialog)
# [Spec](https://html.spec.whatwg.org/#the-dialog-element)
# [Can I use?](https://caniuse.com/dialog)
register_element def dialog(**attributes, &content) = nil
Component methods shadowing constants in Phlex
When you use Kits in Phlex, you can render components by simply calling the name of the component as a method. Your component might look something like this:
class Components::Sidebar
def view_template
Card do
Title { "Hello" }
Icon("foo")
Nav do |n|
n.item("/") { "Home" }
n.item("/about") { "About" }
end
end
end
end
We use two techniques to accomplish this. First, if you call a missing method on your kit (Components
here), we will attempt to load the constant if an auto-loadable constant exists using const_get
.
Then on const_added
, we define an instance method on the kit with the same name as the component class name.
These methods are defined dynamically, but since they shadow the constant names, we get a few benefits:
- they stand out and look distinct from HTML elements.
article
is the HTML element<article>
, butArticle
is the componentArticle
; - they make it easy to find the component — you don’t need to do any mental inflection — the name of the method is the name of the component; and finally
- since Ruby LSP predicts the constant component class names, it’s already predicting the method calls even though it doesn’t know about the methods.
Since Components::Sidebar
includes Components
, and Components
has the constant Card
, typing Card
gives you predictions for that constant, which happens to be almost exactly the same as the method.
The only catch is you have to either pass a block or arguments or use empty parentheses ()
to disambiguate the method call from a constant reference.
If that’s not to taste, you can use the long form render Card.new do
, but I prefer Card do
.