Skip to main content

Command Palette

Search for a command to run...

Dot Completion Without a Type Checker

Resolving receiver types from transpiler metadata when you don't have one

Updated
8 min read

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:

  1. VarTypes -- a map[string]string populated 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.

  2. 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.

GALA From the Ground Up

Part 1 of 8

A ten-part technical blog series exploring GALA — a modern programming language that brings sealed types, pattern matching, monadic error handling, and immutable-by-default semantics to the Go ecosystem. Each post takes a single concept, shows the problem it solves with real code, compares it to idiomatic Go, and is honest about trade-offs. Written for Go developers who want more expressiveness and functional programmers who want Go's runtime.

Up next

The Type Information Problem

When your transpiler knows the types but your editor doesn't