Skip to content
1384 words 7 min read

LCUI Language Service ​

NOTE

The following content is taken from the code structure of LOICollectionA 1.18.0 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, navigation, rename edits, semantic highlighting and inlay hints, and wraps them into JSON-RPC messages per the LSP specification.

LanguageServer 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

The repository also ships an optional LspServer: disabled by default, it listens on 127.0.0.1 with TCP when "LanguageServer": { "enabled": true, "port": 9257 } is configured and carries the same JSON-RPC frames. Keeping the core separate from the transport lets the plugin expose editor support only when asked, while gtest drives the core directly (tests/common/frontend/LspTest.cpp) with no inter-process communication.

Module Layout ​

FileResponsibility
Protocol.h / .cppLSP data types (Position / Range / Diagnostic / CompletionItem / Hover / Location / SemanticTokens / InlayHint), JSON conversion, JSON-RPC message construction, Content-Length framing and deframing
Analysis.h / .cppRuns the full front end over a script text and returns a DocumentAnalysis (diagnostics + symbols + imported symbols + inlay hints)
LanguageServer.h / .cppDocument sync (incremental and full), request dispatch, capability declaration, plus pure functions for completion / hover / navigation
Server.h / .cppOptional loopback TCP transport backed by an asio event loop

Supported Capabilities ​

LSP methodBehavior
initializeDeclares textDocumentSync: 2 (incremental sync), completion trigger characters . and :, hover, definition, type definition, references, document highlights, rename, document symbols, signature help, semantic tokens (full), inlay hints, formatting and code actions
textDocument/didOpenRegisters the document and immediately publishes diagnostics
textDocument/didChangeApplies contentChanges in order: entries with a range replace that span, entries without a range replace the whole text; diagnostics are republished
textDocument/didCloseRemoves the document and clears its diagnostics
textDocument/completionSee "Completion Rules"
textDocument/hoverReturns the declaration signature of the symbol under the cursor, a native member signature, or a namespace description
textDocument/definition / declarationReturns the declaration Location; cross-file results carry the imported document URI
textDocument/typeDefinitionJumps from a variable / field to the class, enum or trait named by its annotation or inferred type
textDocument/referencesReturns every reference that resolves to the same symbol
textDocument/documentHighlightMarks read / write occurrences of the same symbol (2 read, 3 write)
textDocument/renameReturns every TextEdit whose occurrence resolves to the target; the caller can wrap them in a WorkspaceEdit
textDocument/documentSymbolReturns a flat list of non-local symbols
textDocument/signatureHelpSee "Signature Help Rules"
textDocument/semanticTokens/fullReturns semantic tokens for the whole document; full/delta currently returns the full result
textDocument/inlayHintReturns type hints inside the requested range
textDocument/formattingRe-indents by {} / [] nesting and returns a whole-document edit
textDocument/codeActionReturns a quickfix for diagnostics inside the requested range
shutdown / exitTerminates cleanly

Unimplemented methods return -32601 Method not found; $/... notifications are ignored silently.

Quick Fix Rules ​

textDocument/codeAction re-runs the analysis and keeps only diagnostics that intersect the requested range and contain is not declared: it inserts let before the identifier and titles the action Declare '<name>' with let. The insertion point comes from the diagnostic start and is checked against the document text, so a shifted diagnostic cannot rewrite the wrong location.

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:

cpp
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. Each symbol records its declaration offset, scope bounds and a local flag for completion, navigation and resolution checks:

SourceSymbol kindContainerLocal
classClassglobalno
class field / static fieldField / StaticFieldclass nameno
class method / static methodMethod / StaticMethodclass nameno
traitInterfaceglobalno
method inside a traitMethodtrait nameno
methods and associated constants in implMethod / StaticMethod / Constantimpl target type nameno
enumEnumglobalno
enum variantEnumMemberenum nameno
componentComponentglobalno
component parameterParametercomponent nameyes
importModuleglobalno
top-level funcFunctionglobalno
top-level let / constVariable / Constantglobalno
function / method / lambda parameterParameterowning function or classyes
block let, for-in variables, match bindings, if let / while let bindingsVariableglobalyes

detail holds a readable signature, e.g. size() -> int, add(x: int, y: int) -> int, Some(int) or component card(title, body).

Import Resolution ​

ImportResolver maps an import path to { uri, text }. After didOpen, LanguageServer builds one automatically: it first resolves relative to the importing document URI, then matches open documents by full path or suffix. As long as the dependency is open, cross-file completion, hover, definition, references and rename all work.

  • Resolution depth is capped at 8; cyclic imports are deduplicated by the read stack
  • Selective imports filter to the requested names; import "path"; imports the whole file
  • Unresolvable files are skipped silently, and diagnostics stay scoped to the current document
  • The pure functions accept a custom ImportResolver backed by disk or a virtual file system

Completion Rules ​

After . (member completion): the identifier before the dot is the receiver.

  • this → the class of the nearest class <Name> declaration before the cursor
  • a known class name → that class's static members
  • a variable / field / parameter → its annotated type, or a class inferred from a new expression, parameter type or return type
  • an enum name → that enum's variants (e.g. the completion form Suit.He)
  • native classes: String / Array / Map etc. expose fields, methods and signatures through ABI reflection; string and array receivers map to their native class
  • std::, math::, mc::, GUIManager::, storage::, playerdata:: and other namespaces → overloads of registered functions
  • unknown receiver → an empty list (no guessed suggestions)

Anywhere else (global completion): reachable local symbols + imported symbols + global symbols + enum variants + keywords + primitive types + every registered native function / macro / class.

Signature Help Rules ​

signatureHelpAt scans backward to the innermost unclosed (, recognizes namespace::function, receiver.method, ClassName(...), enum variants and macros, and collects signatures from:

  • method / function declarations in the current and imported documents
  • native function and method overloads from ABI reflection
  • native class constructors
  • enum variant payload signatures
  • the macro registry

activeParameter counts top-level commas; nested () / [] / {} are ignored. It returns null when no signature is found.

Semantic Tokens and Inlay Hints ​

The semantic token legend order is:

text
namespace, type, class, enum, interface, struct, typeParameter, parameter,
variable, property, enumMember, event, function, method, macro, keyword,
modifier, comment, string, number, regexp, operator

Token lengths come from real lexemes: identifiers and strings cover the full source slice, while multi-line strings and block comments are split per line; comments are scanned separately. Declared symbols in the document take precedence over heuristic classification, so locals, parameters, enums, components and imported modules are typed accurately.

Inlay hints currently emit type hints only (kind 1): unannotated function parameters and local let declarations show : Type after the name. Types come from the semantic analyzer; unknown and void produce no hint.

Known Limitations ​

  • Every request re-runs the whole front end — there is no incremental parsing; incremental sync only reduces the bytes transferred
  • Diagnostic ranges prefer the full identifier or string token; other locations may still be one character long
  • Quick fixes cover only "variable not declared" and insert let without inferring an initializer
  • Semantic tokens support full requests only; full/delta returns no incremental edits
  • Cross-file resolution needs an ImportResolver; the built-in TCP server resolves only open documents

LOICollectionA · A ready-to-use multifunctional plugin set for LeviLamina