Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

29. Go-to-definition is a standalone scope walker, single-file for v1

  • Status: Accepted
  • Date: 2026-09-05

Context

textDocument/definition needs to answer "which binding does the name under the cursor refer to, and where in the source is that binding?" Milestone 3's bytecode compiler already answers a version of this question for every name in a program — crates/korrin/src/compiler/scope.rs's bound_names and crates/korrin/src/compiler/emit.rs's Compiler::resolve / resolve_upvalue / enclosing_local — using exactly the lexical rule ADR 0022 defines: a name is a local of the innermost enclosing function unless it is already bound in an enclosing function or at module level, in which case it refers outward to that binding instead.

That machinery cannot be called from korrin-lsp directly. It is pub(super) or private, internal to compiler, and — more fundamentally — it answers the wrong kind of question: it maps a name to a bytecode slot (u16), and bound_names returns a bare BTreeSet<String> with no position information at all. Reusing it would mean threading source spans through code whose entire job is to forget them in favor of a slot number, which is not a small change bolted onto existing code — it is rewriting the compiler's resolution pass for a second, unrelated consumer.

Decision

A new, independent pass, crates/korrin-lsp/src/goto_definition.rs, implements the same lexical rule from scratch, recording name -> Span instead of name -> slot. It mirrors bound_names's traversal exactly (only fn and class bodies are scopes; if/while/for/try are not), and reproduces the one precedence rule that is not optional to get right: when deciding whether a name a function's body binds becomes that function's own local or refers outward, a candidate is skipped — and resolves to the enclosing binding instead — if it is already bound in the module scope or any enclosing function scope. Skipping this check would misresolve the single most common shape of Korrin closure: count = count + 1 inside a function that reads a module-level count would incorrectly point at itself rather than at the module-level assignment. A test pins exactly this case.

Single-file only for v1. A name from an imported module — utils.foo()'s foo — is not resolved into utils.kor. Doing so means the server tracking a file the editor never opened, parsing and caching it, and keeping that cache correct across edits to either file: a real, separately-scoped feature (workspace-wide file awareness), not an incremental extension of a single-file walker. A use of the imported name itself (utils in utils.foo()) still resolves, back to its own import "./x" as utils statement in the current file — binding it is ordinary same-file scoping and needs nothing special.

An unresolvable name (a builtin with no source location, a name the walker genuinely cannot find, or a file that fails to lex or parse at all) returns null over the protocol, not an error. "I don't know where this is defined" is a legitimate, expected answer for a request like this — an error would incorrectly suggest something went wrong with the server itself.

Consequences

  • One accepted, narrow gap: the walker's "already bound elsewhere" check covers module-level and enclosing-function bindings, but not the prelude's builtin names (print, str, Error, ...), which the real compiler's globals set does include. A function that reassigns a builtin (fn f(): str = "hi") is treated by this pass as introducing its own fresh local, where the compiler would treat it as writing the same global the builtin occupies. The two views land on the identical span whenever nothing else in the file binds that name — which is every realistic case — and diverge only in a contrived one with no practical effect on navigation. Threading prelude names through would cost real complexity (they have no Span to put in a Scope map, so the check becomes a second, differently shaped lookup) for a case not worth it yet.
  • A syntax error means no definitions resolve anywhere in the file until it's fixed, the same all-or-nothing acceptance ADR 0027 already made for diagnostics, for the same reason: there is no tree to search.
  • The walker is read afresh from the document's current text on every request rather than cached, the same as diagnostics and hover — Korrin files are small enough that re-lexing and re-parsing on each request costs nothing worth optimizing away yet.

Alternatives considered

  • Expose the compiler's resolution as a public API and adapt it. Rejected in ADR 0022's own reasoning restated here: the compiler's job is slot allocation for bytecode, not source navigation, and the two should not share an implementation just because they ask a similar-sounding question.
  • Resolve every name, including into imported files, from the start. Correct in principle, and the more complete feature. Deferred because it is a genuinely different scope of work — the server would need to track files the editor never opened — and shipping single-file resolution now is not a half-measure that has to be undone later; it composes cleanly with cross-file resolution whenever that lands.
  • Treat an unresolvable name as an error response. Rejected: it would train a client (and a user reading its error output) to see "go to definition on a builtin" as a malfunction rather than the expected, correct answer that there is nowhere in source to jump to.