# Dot Completion Without a Type Checker

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:

```go
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:

```go
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:

```go
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:

```go
// 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:

```go
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:

```go
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):

```go
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.
