LCUI Language Service
NOTE
The following content is taken from the code structure of LOICollectionA 1.17.1 and may differ in later versions.
Purpose
src/LOICollectionA/frontend/lsp/ is the language service core for .lcui scripts. It reuses the compiler front end (Lexer → Parser → ComponentExpander → SemanticAnalyzer) to produce diagnostics, symbols, completions, hovers and definitions, and wraps them into JSON-RPC messages per the LSP specification.
It does protocol and analysis only, never I/O:
- no listening sockets, no stdio access, no threads
- input is a chunk of bytes, output is the bytes to send back
- the transport (stdio, pipes, WebSocket, an in-game command) is the caller's choice
Two reasons for this split: the plugin itself must not host a resident server just to support editors, and the language core can be driven directly by gtest (tests/common/frontend/LspTest.cpp) without any inter-process communication.
Module Layout
| File | Responsibility |
|---|---|
Protocol.h / .cpp | LSP data types (Position / Range / Diagnostic / CompletionItem / Hover / Location), JSON conversion, JSON-RPC message construction, Content-Length framing and deframing |
Analysis.h / .cpp | Runs the full front end over a script text and returns a DocumentAnalysis (diagnostics + symbol table) |
LanguageServer.h / .cpp | Document sync (full sync), request dispatch, capability declaration, plus the pure functions completionsAt / hoverAt / definitionAt |
Supported Capabilities
| LSP method | Behavior |
|---|---|
initialize | Returns textDocumentSync: 1 (full sync), completionProvider (trigger character .), hoverProvider, definitionProvider, codeActionProvider |
textDocument/didOpen | Registers the document and immediately publishes diagnostics |
textDocument/didChange | Replaces the whole text (last contentChanges entry wins) and republishes diagnostics |
textDocument/didClose | Removes the document and clears its diagnostics |
textDocument/completion | See "Completion Rules" |
textDocument/hover | Returns the declaration signature of the identifier under the cursor |
textDocument/definition | Returns the Location of the declaration |
textDocument/codeAction | Returns a quickfix for diagnostics inside the requested range |
shutdown / exit | Terminates cleanly |
Unimplemented methods return -32601 Method not found; $/... notifications are ignored silently.
Wiring It Up
LanguageServer::handle accepts an arbitrary chunk of bytes and returns the bytes to send back to the client (possibly several messages, possibly empty). The caller only has to feed in what it reads and write out what it gets:
lsp::LanguageServer server;
std::string outbound = server.handle(bytesReadFromClient);
if (!outbound.empty())
writeToClient(outbound);Incomplete messages are buffered internally: a frame split across several reads is neither lost nor mis-framed. A single frame larger than 64 MiB, or one that fails JSON parsing, is dropped so the buffer cannot grow without bound.
WARNING
Encoding uses dump(-1, ' ', false, error_handler_t::replace). Script sources may contain invalid UTF-8 (the fuzz harness covers that path), and the default strict handler throws — this project compiles with set_exceptions("none"), where a throw terminates the process. Use the same arguments for any new serialization point.
Symbol Collection Rules
analyze walks the AST after semantic analysis, so methods merged into their target class by impl blocks and nodes generated by component expansion are all included:
| Source | Symbol kind | Container |
|---|---|---|
class | Class | global |
class field / static field | Field / StaticField | class name |
class method / static method | Method / StaticMethod | class name |
trait | Interface | global |
method inside a trait | Method | trait name |
methods and associated constants in impl | Method / StaticMethod / Constant | impl target type name |
top-level func | Function | global |
top-level let / const | Variable / Constant | global |
detail holds a readable signature, e.g. size() -> int or add(x: int, y: int) -> int.
Completion Rules
After . (member completion): the identifier before the dot is the receiver.
this→ the class of the nearestclass <Name>declaration before the cursor- a known class name → that class's static members
- a variable with a type annotation (
let deck: Deck = ...) → members of the annotated type - unknown receiver → an empty list (no guessed suggestions)
Anywhere else (global completion): global symbols + keywords + primitive types + builtin functions (tr / score / entity / print / println / error) + form classes (CustomForm / MessageBox / PaginatedForm / ScriptForm).
Known Limitations
- Local variables (function parameters, block-scoped
let) are not in the symbol table, so they do not participate in completion or navigation importis not resolved; cross-file symbols require the caller to expand the import graph and feed each document separately- Every request re-runs the whole front end — there is no incremental analysis; the cost is negligible at single-file script scale
- Diagnostic ranges are one character long (the diagnostic engine records a start position, not the token length)