A code action that refuses to guess
A language server spends most of its time reading half-finished code and deciding what the author probably meant. That is a generous job and a dangerous one. The generous side is plain enough: you saw the typo, you can offer the fix. The dangerous part is that every guess you make is a guess the author now has to check, and a tool that guesses confidently and wrongly is worse than one that says nothing.
So when I picked up an open issue on the Gleam compiler [1] — a code action to generate a
type that does not exist yet — the feature looked like a one screen task and turned out to be
a long argument with myself about how little to do. You write Wobble where no Wobble is
defined, the language server underlines it, and offers to write the missing definition for
you:
type Wibble {
Wibble(Wobble)
// ^^^^^^ this type does not exist, trigger the action
}
type Wobble
type Wibble {
Wibble(Wobble)
}
That is the whole feature. The pull request that shipped it [2] is small. Everything interesting is in what I decided to leave out.
Idea one: generate the stub, not the guess
The first temptation is to be helpful. The user wrote Wobble(Int, String), so you know it
is used like a record with two fields, so why not generate the constructor too, fields and
all? Because you do not actually know that. You know one call site. The type might have five
constructors and this is one. Generate the constructor and you have not saved the user work,
you have written code they now have to delete and rewrite, which is slower than the blank
they started with.
So the action generates exactly one line: type Wobble. An opaque, empty type. It is almost nothing at all. But it is the most that is certainly correct, and Gleam already has a separate code action for filling in constructors once the type exists. The right design was
to do the smallest true thing and let the other action compose on top of it, rather than have
one action try to be clever twice. A reviewer made this point about an earlier draft that was
too eager [3], and he was right. The restraint is the feature.
The one place the action does have to be careful is visibility. If the unknown type appears in the signature of a public function, the generated type has to be public too, because a private type in a public signature does not compile:
pub fn wibble(argument: Wobble) { todo }
needs pub type Wobble, not type Wobble. Generating the private version there produces code
that is broken the instant it appears, which is the one outcome a fix-it action must never
have. So the action looks at the definition the error sits inside and matches its publicity.
Less guessing, more reading what is already on the screen.
Idea two: the best generated name is the one already written
A type with parameters needs parameter names. Wobble(Int, String) becomes
type Wobble(a, b), two type variables named with the boring alphabet. Fine. But the user
sometimes hands you better names than you would invent:
pub fn wibble(argument: Wibble(some, generics)) { todo }
Here some and generics are already type variables with names the author chose. Inventing
a and b and throwing their words away is the kind of small rudeness that makes a tool feel
like it is not paying attention. So the action keeps the names that were written and only
generates the ones that were not. Wibble(some, generics) produces pub type Wibble(some, generics). The user’s vocabulary survives.
The bugs lurk here, and they are instructive, because every one of them is the price of inventing names instead of reading them. The moment you generate, two failure modes open up.
The first is collision. Mix a written name with a generated one and they can land on the same letter:
pub fn wibble(argument: Wibble(b, Int)) { todo }
The author wrote b for the first parameter. The naive generator, walking the alphabet by
position, hands the second parameter the letter at index one, which is also b. You have
just produced type Wibble(b, b), two parameters with one name, which does not compile. The
fix is to treat the names the user wrote as reserved and generate around them: collect b
first, then pick the next free letter, so the result is Wibble(b, a). Boring, valid, done.
The second is running out. Walk the alphabet by adding to a byte and the twenty-seventh
parameter is not a letter at all, it is whatever character sits past z in the encoding. A
type with more than twenty-six parameters is absurd, and the tool should still not emit
garbage when it meets one. So the counter rolls over the way a spreadsheet column does, z
to aa to ab, and the absurd input gets a valid answer instead of a punctuation mark. The
compiler already had this exact routine for printing inferred types; the right move was to
reuse its scheme rather than reinvent a worse one.
Both bugs were found in review, not by me [4]. That is the value of handing your work to someone whose taste you trust: they read the two inputs you did not think to try and tell you the code lies on both.
The part worth keeping
The diff that merged [2] is small and almost none of it is clever. It generates one line. It reuses an existing name when there is one and an existing routine when there is not. It reads the visibility off the surrounding code instead of deciding it. The work was not in writing those things, it was in talking myself out of the larger, more impressive, more wrong versions of each.
A fix-it action is a promise: take this, it is correct, do not check it. The only way to keep that promise is to make the action generate the smallest thing it can be sure of and to lean on what the user already told you instead of inventing a replacement. Every bug in this one came from a place where it stopped doing that and started guessing. The good version is the one that guesses as little as it can get away with, and then a little less.
References
[1] gleam-lang/gleam#5389 — the original issue: a code action to generate a missing type
[2] gleam-lang/gleam#5477 — the merged pull request
[3] Gleam language server code actions — including “generate function”, whose restraint this one borrows
[4] The Gleam compiler — written in Rust, reviewed with the kind of care that finds the inputs you did not try