Dot Completion Without a Type Checker
Resolving receiver types from transpiler metadata when you don't have one
The user types order. and pauses. The editor fires a textDocument/completion request. The LSP has about 100 milliseconds to figure out what order is, what methods and fields it has, and return a sorted list of suggestions with signatures. Most language servers delegate this to a type checker -- a standalone component that maintains a typed AST of the whole program. GALA doesn't have one. It has a transpiler that targets Go.
This post is about how we fake it.
The completion cascade
Before resolving any types, the handler needs to figure out what kind of completion the user wants. GALA's completion handler runs a context detection cascade -- four checks in priority order:
isDot := isDotCompletion(text, line, char)
if isDot && richAST != nil {
receiverType := typeAtDot(text, line, char, richAST, varTypeMap)
if strings.HasPrefix(receiverType, packagePrefix) {
pkgName := strings.TrimPrefix(receiverType, packagePrefix)
items = append(items, packageCompletions(richAST, pkgName)...)
} else if receiverType != "" {
items = append(items, typeSpecificCompletions(richAST, receiverType)...)
}
} else if isNamedArgContext(text, line, char) && richAST != nil {
// Constructor named args: Person(name = ...)
} else if isMatchCaseContext(text, line, char) && richAST != nil {
// Sealed type case suggestions
} else {
// Global: types, functions, keywords
}
Dot completion wins. If it fires, nothing else runs. This matters because a dot inside a constructor call (Person(address = city.) is both a named-arg context and a dot context. The dot takes priority because the user is actively accessing a member.
Detecting the dot
isDotCompletion does something deceptively simple: it walks backwards from the cursor position on the current line, looking for a . character. But there's a subtlety. When the user types order.ge, the cursor is after e, not after .. The partial identifier ge is what they've typed so far. We need to skip past it:
func isDotCompletion(text string, line, char int) bool {
// ...
if l[char-1] == '.' {
return true
}
// Walk backwards past any partial identifier being typed
i := char - 1
for i >= 0 && isIdentChar(l[i]) {
i--
}
if i >= 0 && l[i] == '.' {
return true
}
return false
}
This handles both order. (cursor right after dot) and order.ge (cursor mid-identifier after dot). The partial identifier is irrelevant for type resolution -- we only care about what's to the left of the dot.
Resolving the receiver type
This is where things get interesting. typeAtDot needs to figure out what type order is. It has no type checker, no symbol table built from a full program analysis. What it has:
VarTypes -- a
map[string]stringpopulated by the transpiler during its last successful transform. Keys are scoped:"main.order"maps to"Order","processItems.item"maps to"Item". The transpiler already resolved these types while generating Go code; we just reuse its work.RichAST -- the transpiler's analysis output containing type metadata (fields, methods, sealed variants), function signatures, package information, and import aliases.
The resolution cascade in resolveReceiverType tries five strategies in order:
func resolveReceiverType(name, funcScope string, richAST *transpiler.RichAST,
varTypes map[string]string) string {
// 1. Transpiler's resolved var types (function-scoped)
if typStr := lookupVarType(varTypes, funcScope, name); typStr != "" {
return stripTypeParams(typStr)
}
// 2. Function call return type: GetOrder() → Order
if fm, ok := richAST.Functions[name]; ok { ... }
// 3. Sealed case constructor → parent type: Some(42) → Option
for _, tm := range richAST.Types { ... }
// 4. Package name → package marker
for _, pkgName := range richAST.Packages { ... }
// 5. Type name for static calls: List.Empty()
if isExported(name) { if findType(richAST, name) != nil { return name } }
return ""
}
The scoped lookup is important. lookupVarType tries funcScope + "." + varName first, then falls back to an unscoped lookup. If you have a val order = ... inside func main, it's stored as "main.order". The enclosing function is found by scanning backwards from the cursor for a func declaration -- crude, but effective for GALA's single-function-per-block structure.
The chain problem
Simple variable access is easy. The hard case is chains: collection.Map(transform).Filter(predicate).. The user expects completions for whatever Filter returns, which means we need to resolve the entire chain left-to-right.
typeAtDot handles this by detecting a closing paren before the dot. When it sees ), it walks backwards through balanced parentheses to find the method name, then checks if there's another dot before that name -- indicating a chain:
// Case 1: Closing paren before dot — expr.Method().
if i >= 0 && l[i] == ')' {
depth := 1
i--
for i >= 0 && depth > 0 {
if l[i] == ')' { depth++ }
else if l[i] == '(' { depth-- }
i--
}
// Extract the method name before '('
name := l[nameStart:nameEnd]
if i >= 0 && l[i] == '.' {
// Chained: resolve receiver, then get method's return type
receiverType := resolveChainType(l[:i], enclosingFunc, richAST, varTypes)
if receiverType != "" {
return resolveMethodReturn(richAST, receiverType, name)
}
}
}
resolveChainType is recursive. It peels off the rightmost segment of the expression, resolves its type, then uses resolveMethodReturn to look up what that method returns on that type. A depth limit of 10 prevents infinite recursion on pathological inputs:
const maxChainDepth = 10
func resolveChainTypeN(text string, funcScope string, richAST *transpiler.RichAST,
varTypes map[string]string, depth int) string {
if depth > maxChainDepth { return "" }
// ...
if text[len(text)-1] == ')' {
// Walk balanced parens, extract method name
if i >= 0 && text[i] == '.' {
receiverType := resolveChainTypeN(text[:i], funcScope, richAST, varTypes, depth+1)
if receiverType != "" {
return resolveMethodReturn(richAST, receiverType, methodName)
}
}
}
// Plain identifier — resolve directly
return resolveReceiverType(name, funcScope, richAST, varTypes)
}
So for collection.Map(transform).Filter(predicate)., resolution proceeds: resolve collection from VarTypes (say, List), look up Map's return type on List (say, List), look up Filter's return type on List (say, List), then show List's methods.
There's an obvious limitation: generic type parameters are erased. stripTypeParams drops [T] from List[int] before lookup, so collection.Map(toStr). still shows List methods, not List[string] methods. The method set is the same either way for GALA's standard library types, so this works in practice. It wouldn't work for a language where different type parameters produce different method sets.
The __package__ hack
When the user types http., they're not accessing a field on a variable -- they want the exports of the http package. But the resolution cascade would happily try to find a variable called http in VarTypes, fail, and return nothing.
The fix is a sentinel value. resolveReceiverType checks the RichAST's package list and import aliases. If the name matches a known package, it returns "__package__:http" instead of a type name:
const packagePrefix = "__package__:"
// In resolveReceiverType:
for _, pkgName := range richAST.Packages {
if pkgName == name {
return packagePrefix + name
}
}
if richAST.ImportAliases != nil {
if pkgName, ok := richAST.ImportAliases[name]; ok {
return packagePrefix + pkgName
}
}
Back in the completion handler, this prefix triggers a completely different code path -- packageCompletions instead of typeSpecificCompletions. Package completions pull from three sources: types belonging to that package, functions belonging to that package, and GoExports (a map of raw export names from Go-only packages that the transpiler has seen but not fully analyzed).
The alias handling matters. If the GALA source has import io = "collection/immutable", typing io. needs to resolve the alias io to the actual package name collection/immutable and show its exports.
Generating completions
Once we have a resolved type name, typeSpecificCompletions looks it up in the RichAST via findType and builds completion items from three sources:
Methods get full signatures and smart insert text. Zero-parameter methods insert () immediately; methods with parameters insert ( and let the editor handle the rest (which sets up signature help):
if len(m.ParamNames) == 0 {
insertText = name + "()"
} else {
insertText = name + "("
}
Fields show their type as the detail string. Sealed variants generate IsXxx() predicate methods. And every type gets a match keyword suggestion, because pattern matching is idiomatic in GALA.
findType itself is more resilient than you'd expect. It tries direct key lookup, std.-prefixed lookup (GALA's standard library), qualified-name decomposition (mylib.Animal matches a type with simple name Animal), and type alias resolution. This layered lookup handles the fact that the RichAST keys aren't always consistent -- sometimes they're package-qualified, sometimes they're not, depending on how the type was declared.
Edge cases: cursor position semantics
Different editors report cursor positions differently when the user triggers completion on a dot. Some place the cursor on the dot (before it), others place it after the dot. VS Code places it after, which is what isDotCompletion checks first (l[char-1] == '.'). But when the user has typed order.ge and triggers completion mid-word, the cursor is past both the dot and the partial identifier. The backwards walk through isIdentChar handles this uniformly.
There's also the question of what happens when the dot is at column 0 (nothing before it) or when the line itself is shorter than the reported cursor position. Both are guarded: char <= 0 returns early, and char is clamped to len(l) to handle editors that report positions beyond line length.
What we're actually doing
The core insight is that we never build a type checker. We mooch off the transpiler. Every time the document changes, the LSP runs TransformForLSP, which does a partial transpilation and dumps its resolved variable types into a flat map. The LSP's job is just text surgery -- walk backwards from the cursor, figure out what syntactic construct we're in, and look up the answer in pre-computed tables.
It's fragile in ways a real type checker wouldn't be. Chains longer than what the transpiler resolved break. Generic type parameters are erased. Completions after a syntax error on the same line might fail because the backwards paren-walk gets confused. But it's fast, it handles the common cases, and it was built in a fraction of the time a real type checker would take.
Sometimes the right engineering decision is to not build the thing you think you need.