GSX Language Server
The LSP implementation for .gsx files, providing real-time editor intelligence including hover, completion, go-to-definition, find-references, diagnostics, formatting, semantic tokens, and document/workspace symbols.
Architecture Overview
The LSP is organized as a two-package system connected by adapters:
Editor (VSCode, etc.)
│
│ JSON-RPC 2.0 over stdio
▼
┌──────────────────────────────────────────────────┐
│ pkg/lsp/ (server shell) │
│ │
│ server.go ─ Server struct, Run() loop, │
│ readMessage/writeMessage │
│ router.go ─ Route() dispatch, CursorContext │
│ resolution, provider delegation │
│ handler.go ─ Lifecycle (init/shutdown) and │
│ document sync (open/change/close)│
│ document.go ─ DocumentManager, parse on edit │
│ context.go ─ ResolveCursorContext, AST walk, │
│ scope collection, text heuristics│
│ providers.go ─ Provider interfaces + Registry │
│ provider_adapters.go ─ Bridges lsp ↔ provider │
│ index.go ─ ComponentIndex (workspace-wide) │
│ │
│ gopls/ ─ gopls proxy + virtual file gen │
│ schema/ ─ Element/attribute/keyword defs │
│ log/ ─ Structured debug logging │
└──────────────┬───────────────────────────────────┘
│ adapters convert
│ CursorContext & Document
▼
┌──────────────────────────────────────────────────┐
│ pkg/lsp/provider/ (feature logic) │
│ │
│ provider.go ─ Types, interfaces, NodeKind │
│ hover.go ─ Hover documentation │
│ completion.go ─ Completion suggestions │
│ definition.go ─ Go-to-definition │
│ references.go ─ Find all references │
│ diagnostics.go ─ Error/warning diagnostics │
│ semantic.go ─ Semantic token highlighting │
│ symbols.go ─ Document & workspace symbols │
│ formatting.go ─ Document formatting │
└──────────────────────────────────────────────────┘
Request Lifecycle
Every LSP request flows through 6 phases:
Phase 1: Startup and Capability Negotiation
Editor Server
│ │
│─── initialize ───────────────>│ Store rootURI
│<── capabilities ──────────────│ Advertise: hover, completion,
│ │ definition, references, symbols,
│─── initialized ──────────────>│ formatting, semanticTokens
│ │
│ ├── go indexWorkspace()
│ │ Walk rootURI for *.gsx files,
│ │ parse each, populate ComponentIndex
│ │
│ └── go InitGopls()
│ Spawn gopls subprocess,
│ initialize over JSON-RPC
The server advertises full-document sync (TextDocumentSyncKindFull). On initialized, two background goroutines start: workspace indexing (for cross-file component/function lookups) and gopls proxy initialization (for Go expression intelligence).
Phase 2: Document Lifecycle
didOpen / didChange / didSave
│
▼
┌──────────────┐
│ DocumentManager │
│ .Open() │ Full content stored
│ .Update() │ Re-parsed on every change
│ .Close() │
└──────┬─────────┘
│
├──> Lexer → Parser → AST (tuigen.File)
│ Parse errors stored on Document.Errors
│
├──> Analyzer.Analyze(ast)
│ Semantic errors (e.g. invalid Tailwind classes)
│ appended to Document.Errors
│
├──> ComponentIndex.IndexDocument(uri, ast)
│ Components, functions, params registered
│ for workspace-wide lookup
│
├──> UpdateVirtualFile(doc)
│ GenerateVirtualGo(ast) → .go content + SourceMap
│ Notify gopls of the virtual file
│
└──> publishDiagnostics(doc)
DiagnosticsProvider.Diagnose() → editor
Every keystroke triggers a full re-parse, re-index, virtual file regeneration, and diagnostic publish. The DocumentManager holds a map[string]*Document keyed by URI. When a file is closed, its AST moves to workspaceASTs so cross-file lookups still work.
Phase 3: Request Routing
JSON-RPC message
│
▼
Server.handleMessage()
│ json.Unmarshal → Request
▼
Router.Route(req)
│
├── Lifecycle methods → Server.handle*() directly
│ (initialize, initialized, shutdown, exit)
│
├── Document sync → Server.handle*() directly
│ (didOpen, didChange, didClose, didSave)
│
└── Language features → Provider dispatch
(hover, completion, definition, references,
documentSymbol, workspaceSymbol, formatting,
semanticTokens/full)
The Router splits requests into three categories. Lifecycle and document sync are handled by the Server directly. Language feature requests go through provider dispatch, which involves resolving a CursorContext first.
Phase 4: CursorContext Resolution
This is the core of the LSP. Every position-based request (hover, completion, definition, references) resolves a CursorContext before any provider logic runs.
ResolveCursorContext(doc, position)
│
├── 1. Compute byte offset from line:character
│
├── 2. Extract line text and word under cursor
│ (includes hyphens for Tailwind, @ for keywords, # for refs)
│
├── 3. Check text-level context flags:
│ InGoExpr ─ backwards brace counting for {...}
│ InClassAttr ─ backwards search for class="..."
│ InElement ─ backwards search for < vs >
│
├── 4. If no AST → classifyFromText() and return
│
└── 5. Walk AST (resolveFromAST):
│
├── Check component declaration lines
│ (component name → NodeKindComponent,
│ param name → NodeKindParameter)
│
├── Find enclosing component by position
│ Verify cursor is inside component body
│ Set Scope.Component, Scope.Params
│
├── collectScopeFromBody():
│ Walk body recursively collecting:
│ ├── Refs (with InLoop/InConditional flags)
│ ├── StateVars (via DetectStateVars on first GoCode w/ tui.NewState)
│ ├── LetBindings
│ └── ForLoop/IfStmt nesting
│
├── resolveInNodes() → resolveInNode() → resolveInNodeInner()
│ Dispatch on AST node type:
│ ├── Element → tag, ref, attributes, event handlers
│ ├── ForLoop → loop header, body children
│ ├── IfStmt → condition, then/else branches
│ ├── LetBinding → variable name, element children
│ ├── ComponentCall → call name, children
│ ├── GoExpr → classifyGoExpr() for StateAccess
│ ├── GoCode → classifyGoCode() for StateDecl/StateAccess
│ └── TextContent → NodeKindText
│
└── Fallback: check functions, then classifyFromText()
The CursorContext struct contains everything a provider needs:
| Field | Description |
|---|---|
Document |
The open document (content, AST, errors) |
Position |
0-indexed line:character |
Offset |
Byte offset in content |
Node |
The resolved AST node (may be nil) |
NodeKind |
One of 17 classifications (see below) |
Scope |
Enclosing component, function, for loop, if stmt, named refs, state vars, let bindings, params |
ParentChain |
Path from root to current node |
Word |
Word under cursor (hyphen-aware) |
Line |
Full line text |
InGoExpr |
Inside a {...} Go expression |
InClassAttr |
Inside class="..." |
InElement |
Inside an element tag <...> |
AttrTag |
Element tag when on an attribute |
AttrName |
Attribute name when on an attribute |
NodeKind Classifications
Every cursor position is classified into one of these kinds, which drives dispatch in every provider:
| NodeKind | What it represents |
|---|---|
Component |
templ Name(...) declaration line, on the name |
Element |
HTML-like element tag (<div>, <span>, etc.) |
Attribute |
Element attribute name (class, id, etc.) |
Ref |
ref attribute on an element |
GoExpr |
Go expression inside {...} |
ForLoop |
for loop header |
IfStmt |
if conditional header |
LetBinding |
:= variable binding |
StateDecl |
tui.NewState(...) declaration |
StateAccess |
.Get(), .Set(), .Update(), .Bind(), .Batch() |
Parameter |
Component parameter on the declaration line |
Function |
func declaration line |
ComponentCall |
@Component(args) call |
EventHandler |
Event handler attribute (e.g. onClick) |
Text |
Plain text content |
Keyword |
Language keywords (templ, for, if, else) |
TailwindClass |
Class name inside class="..." |
Phase 5: Provider Decision Logic
Each provider receives the CursorContext and switches on NodeKind:
Hover Provider (hover.go):
NodeKind → Action
─────────────────────────────────────────────────────
Component → Show component signature (func Name(params) *element.Element)
Element → Show element description + available attributes from schema
Attribute → Show attribute type + description from schema
EventHandler → Show event handler signature + description
Parameter → Show parameter name, type, and owning component
Keyword → Show keyword documentation from schema
ForLoop → Show keyword documentation
IfStmt → Show keyword documentation
LetBinding → Show keyword documentation
Function → Show function signature from index
ComponentCall → Show component signature from index
Ref → Show ref type (simple, slice, map) + access pattern
StateDecl → Show state variable type, initial value, available methods
StateAccess → Show specific state method documentation
TailwindClass → Show class documentation from schema
GoExpr → Delegate to gopls via virtual file + SourceMap
Completion Provider (completion.go):
Context → Completions offered
─────────────────────────────────────────────────────
InClassAttr → Tailwind class names matching prefix
InGoExpr → gopls completions via virtual file, state var methods
InElement → Attribute names for current tag from schema
@ prefix → Component names from index + keywords
< prefix → Element tags from schema
Default → Components, functions, keywords
Definition Provider (definition.go):
NodeKind → Jump target
─────────────────────────────────────────────────────
ComponentCall → Component declaration location (from index)
Component → Self (declaration line)
Parameter → Parameter position on declaration line
Function → Function declaration location
Ref → Element with the ref attribute
LetBinding → Let declaration line
StateDecl → State variable declaration line
GoExpr → gopls definition via virtual file + SourceMap
References Provider (references.go):
NodeKind → Search scope
─────────────────────────────────────────────────────
Component/ComponentCall → All @Name calls across open docs + workspace ASTs
Parameter → Parameter declaration + usages in component body
Ref → ref declaration + usages
StateDecl/StateAccess → Declaration + .Get()/.Set()/etc. usages
LetBinding → Declaration + usages in component body
Function → Function declaration + calls across workspace
Phase 6: gopls Bridge
For Go expressions inside {...}, the LSP delegates to a real gopls instance:
.gsx file Virtual .go file gopls
───────── ──────────────── ─────
templ Counter(n int) { func Counter(n int) Hover/Complete/
<span>{n + 1}</span> ──> *element.Element { Definition on
} _ = n + 1 ──────> generated Go
return nil
}
Position translation:
.gsx line:col ──[SourceMap.TuiToGo()]──> .go line:col ──> gopls
.gsx line:col <──[SourceMap.GoToTui()]── .go line:col <── gopls result
How virtual files are generated (gopls/generate.go):
- Package declaration and imports are copied as-is
- Each
templcomponent becomes a Go function with the same signature - State declarations (
tui.NewState(...)) are emitted as Go variable declarations - Ref variables are emitted as typed variable declarations
- Go expressions (
{expr}) become_ = exprassignments - For loops, if statements, and let bindings map to their Go equivalents
- Component calls become
_ = Name(args)assignments
Every generated construct has a SourceMap entry recording the bidirectional mapping between .gsx and .go positions. The SourceMap uses Mapping structs:
type Mapping struct {
TuiLine, TuiCol int // 0-indexed position in .gsx
GoLine, GoCol int // 0-indexed position in .go
Length int // length of the mapped region
}
Package Dependency Graph
pkg/lsp/provider/
│
├── depends on ──> pkg/lsp/gopls/ (GoplsProxy, CachedVirtualFile, SourceMap)
├── depends on ──> pkg/lsp/schema/ (elements, attributes, keywords, tailwind)
├── depends on ──> pkg/tuigen/ (AST node types)
└── NO dependency on pkg/lsp/ (avoids circular imports)
pkg/lsp/
│
├── depends on ──> pkg/lsp/provider/ (provider interfaces, type aliases)
├── depends on ──> pkg/lsp/gopls/ (proxy, virtual file cache)
├── depends on ──> pkg/lsp/log/ (debug logging)
└── depends on ──> pkg/tuigen/ (lexer, parser, AST, analyzer)
The two packages share structurally identical types (CursorContext, Document, Scope, NodeKind) to avoid circular imports. The adapter layer in provider_adapters.go converts between them:
lsp.CursorContext ──[CursorContextToProvider()]──> provider.CursorContext
lsp.Document ──[convertDocument()]──────────> provider.Document
Protocol types (Position, Range, Location, Hover, etc.) are defined once in provider/provider.go and aliased in lsp/document.go, so no conversion is needed for these.
Sub-packages
gopls/
proxy.go- Manages a gopls subprocess over JSON-RPC. ProvidesHover(),Complete(),Definition()methods. Spawns gopls withcmd/gopls serve, communicates over stdin/stdout.generate.go- Transforms.gsxASTs into valid Go source files that gopls can analyze. Handles parameter mapping, state variable declarations, named ref declarations, expression mapping, control flow mapping.mapping.go-SourceMapfor bidirectional.gsx<->.goposition translation.VirtualFileCachestores generated content and maps keyed by.gsxURI.
schema/
schema.go- Element definitions (<div>,<span>,<button>, etc.), attribute definitions (per-element and global), event handler definitions.keywords.go- Language keyword definitions (templ,for,if,else) with documentation.tailwind.go- Tailwind-style class definitions, parameterized patterns (e.g.p-N,text-COLOR), class documentation for hover, class matching for completion.
log/
log.go- Structured logging with categories (Server, Generate, Mapping) that can be enabled/disabled. Writes to a configurable output file for debugging.
Key Design Decisions
-
Full-document sync: Every edit sends the complete document content. Simpler than incremental sync, and
.gsxfiles are small enough that re-parsing the entire file on each keystroke is fast. -
CursorContext as universal currency: All provider logic receives a pre-resolved context instead of raw positions. This centralizes the complex "what's under the cursor" logic and ensures consistency across all features.
-
Separate provider package: Feature logic lives in
provider/with no dependency on the server. This enables unit testing providers with mock contexts without needing a running server. -
Non-fatal gopls: The gopls proxy is optional. If it fails to start or crashes, all GSX-native features continue working. Only Go expression intelligence is lost.
-
Workspace AST caching: When a file is closed in the editor, its last-known AST moves to
workspaceASTsso cross-file lookups (find references, workspace symbols) still work.