Splitting the Brain: Plugin vs LSP
Why GALA's IDE runs on two processes and the sync contract that keeps them honest
The GALA IDE experience runs on two processes. One is a Kotlin plugin inside IntelliJ. The other is a Go binary speaking LSP over stdio. They share a grammar, they share a user, and they disagree about almost everything else. This post explains why the split exists, what each side owns, and the synchronization contract that keeps them from drifting apart.
Why Not Put Everything in One Place?
The obvious question: why not put all IDE features in the LSP server? Or all of them in the plugin?
Everything in the LSP sounds right until you type a character. The LSP server runs the full GALA transpilation pipeline -- parse, analyze, transform -- to produce type information. That pipeline takes tens to hundreds of milliseconds depending on file size and import graph depth. Syntax highlighting, brace matching, and code folding need to respond in under 5ms or the editor feels broken. You cannot debounce syntax highlighting. It has to be synchronous and local.
Everything in the plugin sounds right until you need types. The IntelliJ plugin has a PSI tree built from the ANTLR grammar. It knows the structure of the code -- where functions are, where braces match, what tokens are keywords. But it has no idea what type x is in val x = people.Map((p) => p.Name). That requires running the analyzer, resolving imports across packages, and executing the transformer's type inference. Reimplementing that in Kotlin would mean maintaining two type systems, and the transpiler's type system is the only one that matters because it produces the Go output.
So the split is forced by latency on one side and semantic depth on the other.
What the Plugin Owns
The plugin handles everything that can be answered by looking at tokens and the local PSI tree, without running the transpiler.
Syntax highlighting. The ANTLR grammar generates a Java lexer (Bazel patches -> skip to -> channel(HIDDEN) so whitespace tokens survive for the IDE). GalaSyntaxHighlighter.kt maps token types to IntelliJ's text attribute keys -- keywords get one color, string literals another, comments another. This is pure token classification; no parsing required.
Brace matching. Parentheses, braces, brackets. The plugin registers GalaBraceMatcher and IntelliJ handles the rest. Zero network calls.
Code folding. GalaFoldingBuilder.kt walks the PSI tree looking for block nodes (function bodies, sealed type declarations, import groups) and registers foldable regions. This is structural, not semantic -- you don't need to know what a function returns to know where its body ends.
Structure view. GalaStructureViewElement.kt extracts top-level declarations from the PSI tree -- functions, types, sealed types with their cases, vals, vars. This powers the sidebar outline and Ctrl+F12 navigation. Again, purely structural.
Keyword and basic completion. GalaCompletionContributor.kt offers keyword completions (val, func, match, sealed) and built-in type names (int, string, bool, Option, Either). These are hardcoded lists. They respond instantly because there is nothing to compute.
Live templates. Snippet expansion for common patterns. Local, instant, no server involved.
The common trait: all of these features are fast because they operate on data the plugin already has. No IPC, no analysis, no waiting.
What the LSP Server Owns
The LSP server handles everything that requires the transpiler's semantic model. The GalaHandler in server.go wraps the full pipeline: ANTLR parser, analyzer, and transformer.
Diagnostics. On every edit, the server runs analyzeFile -- parse the source, run the analyzer to resolve imports and build the RichAST, then run the transformer via TransformForLSP to catch type errors. Parse errors, semantic errors, and match exhaustiveness warnings all flow back as LSP diagnostics. The server uses a cancel-and-restart pattern with a 300ms debounce: each keystroke cancels any in-flight analysis and starts a new timer. Before publishing, it checks the document version to discard stale results. Old diagnostics stay visible until replaced -- no flicker.
Type-aware completion. When the user types a dot, completion.go resolves the receiver's type using the cached RichAST and the transformer's VarTypes map. If you type person., the server looks up the type of person, finds its methods and fields in richAST.Types, and returns them. This is qualitatively different from the plugin's keyword completion -- it knows that person is a Person struct with a Name field and an Age field. It also handles package-qualified dot completion (collection_immutable.Array), named argument completion inside constructors (Person(Name =), and match case completion with field destructuring for sealed types.
Inlay hints. For val x = someExpression, the server resolves the type of someExpression through the transformer and displays the inferred type inline. This requires the full type inference pipeline.
Hover. Ctrl+Q shows type signatures, struct fields, method lists, and sealed type variants. All sourced from the RichAST.
Go-to-definition. Cross-file navigation using the analyzer's resolved metadata. The plugin's PSI tree only knows about the current file; the analyzer knows the entire package graph.
The Sync Problem
Here is where it gets uncomfortable. The plugin has hardcoded lists:
// GalaCompletionContributor.kt
val DECLARATION_KEYWORDS = listOf("val", "var", "func", "type", "struct", ...)
val BUILTIN_TYPES = listOf("int", "string", "bool", "float64", "Option", "Either", ...)
The LSP server has its own list:
// completion.go
keywords := []string{
"package", "import", "val", "var", "func", "type", "struct",
"interface", "sealed", "embed", "if", "else", "for", "range",
"return", "match", "case", "true", "false", "nil", "map",
}
And the actual source of truth is neither of these -- it is the ANTLR grammar (gala.g4) for keywords and types.go's IsPrimitiveType() for built-in types.
If someone adds a keyword to the grammar and forgets to update the plugin, keyword completion silently omits it. If someone adds a built-in type to the transpiler and forgets to update the syntax highlighter, that type name renders in the wrong color. These bugs are subtle, they don't cause test failures, and they erode trust in the tooling.
SYNC.md as a Contract
GALA's answer is a synchronization document (ide/intellij/SYNC.md) that explicitly lists every hardcoded data point, its authoritative source, and how to extract the current values. Nine sync points are documented:
- Keywords -- plugin completion lists and highlighter vs.
gala.g4lexer rules - Built-in types -- plugin annotator, highlighter, and completion vs.
types.go - Std auto-imported types -- plugin completion vs.
std/*.galasealed types - Std methods -- plugin postfix templates vs.
std/*.galamethod definitions - Importable package types -- explicitly marked as NOT in static lists (LSP-only)
- Highlighter token mapping -- plugin
whenblock vs. generated Java lexer constants - LSP completion keywords --
completion.govs.gala.g4 - LSP type inference --
inlay_hints.govs. transpiler type system - LSP match exhaustiveness -- reads from
RichASTat runtime (no hardcoded data)
Each entry includes the exact file paths and shell commands to extract current values. The document also specifies trigger conditions: sync after any grammar change, new std type, new collection type, or built-in type change.
This is not automated enforcement -- it is a documented contract. When you change the grammar, you know exactly which files to update because the document tells you. It trades perfection for pragmatism: a grep command you can run is more useful than a validation framework nobody maintains.
Trade-offs in Practice
Latency. The plugin responds in under 5ms for highlighting, folding, brace matching, and keyword completion. The LSP server debounces at 300ms and analysis can take longer. Users see instant syntax feedback but slightly delayed error squiggles. This is the right trade-off -- laggy highlighting is unacceptable, laggy diagnostics are tolerable.
Accuracy. The plugin's keyword completion will suggest match whether or not you are in a context where match is valid. The LSP server's type-aware completion will only suggest methods that actually exist on the receiver's type. Local is approximate; semantic is precise.
Portability. The IntelliJ plugin works only in JetBrains IDEs. The LSP server works in VS Code, Neovim, Helix -- anything that speaks LSP 3.17 over stdio. If GALA only had the plugin, two-thirds of potential users would have no IDE support. The LSP server is the portable foundation; the plugin is the premium layer for JetBrains users.
Maintenance cost. Two codebases in two languages (Kotlin and Go) with shared assumptions. Every grammar change touches both. The SYNC.md contract reduces the risk but does not eliminate it. This is the real cost of the split architecture, and it is worth paying because the alternative -- reimplementing type inference in Kotlin or accepting 300ms syntax highlighting latency -- is worse.
The Uncomfortable Truth
A two-process architecture is not elegant. It is a pragmatic response to competing constraints: the plugin must be fast, the server must be smart, and neither can do the other's job well. The synchronization contract is a maintenance burden that scales linearly with language complexity. Every new keyword, every new built-in type, every new sealed variant in std is a potential sync failure.
But the alternative architectures are worse. A pure-LSP approach makes the editor feel sluggish. A pure-plugin approach cannot provide semantic features without reimplementing the transpiler. A single-binary approach that embeds the transpiler in a JetBrains plugin locks out every other editor.
The split is the least bad option. SYNC.md is the acknowledgment that it has costs. And the 300ms debounce in DidChange is the number that makes it all work -- fast enough to feel responsive, slow enough to avoid burning CPU on every keystroke.