Language server internals
This page describes how hot-ls works inside, and how an editor uses it with clangd. For the setup steps see Tools.
hot-ls is a stdio LSP 3.17 server. It serves .hot files only. A .cpp script is a C++ file for clangd (see C++ scripts). The code is in src/ls/. The extension in editors/vscode/ starts it.
Editor setup: two servers
An editor uses two language servers on one project:
- The official
clangdextension serves the C++ files. It reads thecompile_commands.jsonof the project build. - The
hotextension andhot-lsserve all features for the.hotfiles. For a.cppscript that a host lists, they show only the errors ofhotcas diagnostics.hot-lsis not a transparentclangdproxy. It does not answer for C++ files.
Steps:
- Install the
clangdextension (llvm-vs-code-extensions.vscode-clangd) andclangd. Set it up for the C++ build as usual. - Build
hot-lsand install the extension ineditors/vscode/. Put thehot-lsarguments inhot.server.args. The include dirs come from the script hosts (next subsection). The flags-Iand--cxxflagstill work as overrides. - Optional, for workspace symbols and the background index of scripts: run
hotc <file.hot>... --gen N --out-dir DIR --compile-commands CDIR. Give--compile-commands-dir CDIRtohot-ls. - Add
.cache/to the ignore list of the project (for example in.gitignore).
Both servers can use the same project and the same .cache/ directory. They do not disturb each other:
- The C++
clangdstores its index in<dir of compile_commands.json>/.cache/clangd/index/. - The
clangdchild ofhot-lsuses the same--compile-commands-dirby default. So it uses the same index directory,<dir of compile_commands.json>/.cache/clangd/index/, and the project is indexed once.clangdwrites a shard to a temporary file and renames it, so twoclangdprocesses can write the directory. The shadow files of the scripts are virtual and need no entry incompile_commands.json. The child writes nothing else in the directory of the database. - The option
--private-clangd-dir(orprivateClangdDirininitializationOptions) gives the child its own database directory.cache/hot/clangd/in the first workspace folder. The directory has a link to the realcompile_commands.json(a copy on Windows). So its index is in.cache/hot/clangd/.cache/clangd/index/, and the project is indexed twice. Use it if the shared index causes a problem.XDG_CACHE_HOMEdoes not move this index. - The symbol index of
hot-lsis in.cache/hot/index/. - The C++
clangddoes not get an open.hotdocument, andhot-lsdoes not get a C++ document. A diagnostic of one server never shows in the files of the other.
hot-ls supports many workspace folders. The index directory and the clangd directory are in the first folder that initialize gives. Each is one flat directory for all folders, so a shard of any folder is in it. The clangd child keeps its directory for the whole session. If initialize gives no folder, the first folder that the editor adds gives the index directory. The editor can add or remove a folder (workspace/didChangeWorkspaceFolders). The server then updates the folder list and reloads the host files. For a removed folder, it deletes the shards of the files of that folder. It keeps a shard of a file that another folder still holds.
A workspace symbol request also lists the classes and members of the .hot files of all folders. Cost rule: the request never analyzes a closed file. It lists a closed file only if the index has a valid shard for it. A shard comes from a rename or references request that scans the file, and from a document that closes with the text of the file on disk. An open document uses its own analysis. An empty query lists no script symbol.
Automatic setup (script hosts)
A script host tells hot-ls how the engine compiles its scripts. CMake writes the host files. So the user does not pass -I flags.
include(cmake/HotScripts.cmake)
# hot_add_scripts calls hot_script_host by themselves.
# Call it directly for a host without these functions:
hot_script_host(NAME engine TARGET engine
SCRIPT_DIRS ${CMAKE_SOURCE_DIR}/scripts
INCLUDES ${CMAKE_SOURCE_DIR}/engine)
- At generate time,
hot_script_hostwrites${HOT_HOSTS_DIR}/<NAME>.json. The default is<source dir>/.cache/hot/hosts. The file has the explicit include dirs, the script dirs and the scripts, with a full JSON escape, andsourceDir(the source directory of the call). The defines, options and include dirs ofTARGETgo to the side files<NAME>.defines,<NAME>.optionsand<NAME>.includes, one entry on each line. The JSON file names them indefinesFile,optionsFileandincludesFile. A relative path is relative tosourceDir, andhot-lsmakes it absolute and normalizes it.hot-lsdrops a repeated include dir. The order is the project include dir, the dirs ofTARGET, then the explicitINCLUDES. Every valid include path works: spaces, quotes, backslashes, non-ASCII characters,$,#, brackets,,,>,|,=, a leading-,.., a trailing slash, relative paths, and dirs from imported targets or generator expressions. Two cases do not work. A path with a newline cannot be in a one-line-per-entry file. A path with a semicolon is a CMake list separator, so CMake splits it in two entries beforehot_script_hostsees it. The server passes each flag toclangdas one item of an argument array, so no shell escape is needed. hot-lsreads the host files from<root>/.cache/hot/hosts. The argument--hosts-dir(init optionhostsDir) sets another directory.hot-lsgives the prelude files of the host (PRELUDESofhot_script_host) to each script of the host. A host without preludes uses the init optionpreludes, a list of files. An open prelude file does not get its own text as a prelude.hot-lsalso gives the user preludes. These are the files with the suffixPRELUDE_SUFFIXofhot_script_hostin the script dirs of the host. A host without a suffix uses the init optionpreludeSuffix, and then searches the workspace folders. The search runs at each analysis, so a new prelude file works at once. The user preludes come after the other preludes, sorted by path.- A script matches the host with the exact
scriptsentry. Else it matches the host with the longestscriptDirsprefix. A tie goes to the lower name. - The effective options are the host values, then the explicit
-Iand--cxxflagvalues. So the flags override the host. - A host is skipped when its
binaryDir/CMakeCache.txtis missing. The server checks it again later. - With no host, the old behavior stays. The server also shows an Information diagnostic at 0:0. It does not show it when
-Ior--compile-commands-diris set. - The server reloads the host files when the mtime of the hosts dir changes. The VS Code extension watches
**/*.hotand the host files. - Limits: many build dirs share one hosts dir, so the last configure wins. The server does not canonicalize symlinked workspace paths. Multi-Config generators are not tested.
How it works:
- The server owns the text of each open
.hotdocument. On each change it runshotc::analyze(). It gets the parse errors, the facts (classes, members), the generated C++ and the source map. - It starts a stock
clangdas a child process. It gives the generated C++ toclangdas the virtual file<script>.hot.cpp. The compile command comes fromhot::compile_command, throughworkspace/didChangeConfiguration. - It maps each position between the script and the generated C++ with the source map. A line without a segment uses the
#linedirectives. hotc::rewrite_generated_namesandhotc::rewrite_clang_messagechange generated names to script names in hover text, diagnostics and completion labels.- JSON comes from
nlohmann_json(CPM).
Features:
- Diagnostics: the parse errors of analyze, and the
clangderrors at the script position.- A rename warning (severity Warning) shows at the new name of a field. It shows when a field of the open text is gone and a new field has no
[alias]. The message isfield 'old' is renamed to 'new' without [alias]; the value is lost at hot reload. The fields atdidOpenare the baseline, and a save does not refresh them. A field with[alias("old")]removes the warning. A new field without a removed field gives no warning.
- A rename warning (severity Warning) shows at the new name of a field. It shows when a field of the open text is gone and a new field has no
- Completion works on any identifier prefix, with no trigger needed. The trigger characters are
.and::(the client sends:, and the server ignores a single:). The list has: locals and parameters in scope, module variables, script enums, classes and structs, the enumerators afterMode., the script members (own and inherited from script bases) afterthis.or after a variable of a script class, the native types, functions and members fromclangd, and the Hot++ keywords and builtin types from a fixed list. All names are in script form (std.string). The helpers of the translator (thunks,SLOT_,_Shell, plumbing) are not shown. The list is filtered by the typed prefix.- The script facts come from a token scan of the text. It works on a script with syntax errors. For
clangd, the server puts the typed prefix and the placeholder__hot_completein place of the word at the cursor. It closes the open brackets of an incomplete statement and translates again with the tolerant parse. Then it asksclangdat the placeholder. - If the translation fails, the list has the keywords and the script facts, without
clangd. hot/indexStatsalso givescompletions, the number of completion translations. A test uses it to check the reuse.- The server keeps the last
clangdanswer. While the user types more characters of the same word, and the rest of the text does not change, it filters the answer and does not translate again. A failed translation is kept the same way.
- The script facts come from a token scan of the text. It works on a script with syntax errors. For
- A
clangderror in the code of an imported script is published for the imported script, at its own line and column. The importing document does not get it. An open script keeps its own diagnostics. - Signature help (
(and,trigger) asksclangdat the generated call. The active parameter is the number of commas before the cursor. The labels have script names. - Formatting (
textDocument/formattingandtextDocument/rangeFormatting) is native. See “Formatter rules” below. The result is one edit, or none when the text is already formatted. - Code actions (quick fix): insert a missing
;for the errorexpected ';', and replace a word with the name from aclangddid you mean '...'message. - Hover, go-to-definition and references. A script symbol goes to the script. A native symbol goes to the native header.
- A name with no map segment (a type name, a base class, a name after
new, a field initializer) has no position in the generated C++. For hover, definition and references, the server then looks for the same word in the generated lines of the script line. It asksclangdat the first place that answers. A word in a comment or in a string gets no hover. - References of a native symbol give the uses in the script and the places in C++ files. A use with no map segment is found by the word in its script line: the k-th use on the generated line is the k-th use on the script line, so a parameter with the same name is skipped. The declaration and the definition in the header are added, because
clangdhas them only in its index.
- A name with no map segment (a type name, a base class, a name after
- Document symbols come from analyze. Semantic tokens come from a small lexer and the facts.
- Native names (types, functions, methods, fields, enumerators) get a token from
clangd(textDocument/semanticTokens/fullon the shadow document). A native name has no map segment, so the match is by the name and the script line: a token of the generated lines of a script line is a token for the same name in that script line. Only a name that the script lexer does not classify gets a token. The type maps to the stable legend: class, struct, enum, interface toclass; type alias totype; function and method tomethod; field and enumerator toproperty. A name thatclangdclassifies in two ways in those lines (a parameter with the name of a type) gets no token. A macro, a local and a word in a string or a comment get no token. The server keeps the answer ofclangdfor each version of the shadow document, so a second request for the same text does not askclangdagain. A request that has no answer for its version waits at most 300 ms. Then it gives the script tokens and the native tokens of the last answer, but only the tokens whose name is still at the same place. A late answer goes to the cache for the next request, so the server thread never waits for the full time. The worker call toclangdhas a limit of 3 seconds. The server reads at most 50000 tokens. Withoutclangd, or with the switch off, the tokens are the script tokens. The switch isnativeSemanticTokensininitializationOptions(defaulttrue) or the argument--no-native-tokens.
- Native names (types, functions, methods, fields, enumerators) get a token from
- Inlay hints (
textDocument/inlayHint) show the parameter name before each argument of a call of a script method or constructor. The kind is Parameter. The server finds the callee by name in the methods and constructors of the facts, and it reads the parameter names from the declaration. When more than one member has the name, a hint shows only if all members that fit the argument count have the same parameter name at that place. An argument that is the plain name of its parameter has no hint. The requestrangelimits the answer. A call of a native function, method or constructor gets hints fromclangd: see below. A call on a native object with the name of a script method can get a wrong hint.- A call with a name that no script method has asks
clangdfor the signature (textDocument/signatureHelp). The place is the end of the generated arguments, so the arguments choose the overload. When the generated call does not end on its line, the place is after the(. Then a hint shows only if all signatures with enough parameters have the same names. The server takes the parameter names of the signature thatclangdmarks active. A parameter with a type only, a name that starts with_, a call with more arguments than parameters, and a declaration have no hint. The same rules as for script calls apply to the argument. The native calls of one request share a time budget of 3 seconds and the limit of 64 calls, so a slowclangddoes not hold the editor. - Limit:
new X(...)of a native class with ahandle("std::shared_ptr<$T>")handle isstd::make_shared<X>(...)in the generated C++. Its signature has no parameter names, so this call has no hint.
- A call with a name that no script method has asks
- Code lens (
textDocument/codeLens) shows one lens above each script class, method and constructor of the open document. A native symbol has no declaration in a script, so it has no lens. Its callers are in the call hierarchy. The title isN references(1 referencefor one). The lens has no command, so the title is all. A destructor has no lens.- The count is a name scan of the open document: each identifier with the name, except the declarations. Comments and strings do not count. The scan does not use
clangdand does not read the other scripts, so the request is quick. A use in another script is not in the count. - The server does not know the type of an object. So the same name in two classes, or a local with the name of a method, counts too. The overloads of a method have the same count.
- The lens of a constructor counts every use of the class name (for example
new Tool(1)), as the lens of the class does. The name of a constructor or a destructor in its own declaration is not a use.
- The count is a name scan of the open document: each identifier with the name, except the declarations. Comments and strings do not count. The scan does not use
- Call hierarchy (
textDocument/prepareCallHierarchy,callHierarchy/incomingCalls,callHierarchy/outgoingCalls) works for the methods and constructors of the facts. It is name based. For script methods it works in one document. For native callables see the last point of this list.- An incoming call is a call with the name of the method in the body of a method or constructor of the same document. The calls are grouped by the method that holds them, and
fromRangeshas the places of the names. A recursive method is its own caller. - An outgoing call is a call in the body of the method with the name of a method or constructor of the document. A call to a native function or to a local with the same name can show as an outgoing call. The overloads of a method share their calls.
- A destructor is not an item, and the calls in its body are not listed. A call in a field initializer has no caller. A call in a comment or in a string is not a call.
- A request with a malformed item gets an empty list. A
.cppdocument gets no answer for these requests. - Native callables:
prepareCallHierarchyon a name that no script method has asksclangdat the generated place. The item is the native function, method or constructor, with its place in the C++ header. The item holds the item ofclangd, so the next requests can give it back.incomingCallsof a native item lists the script callers, then the C++ callers thatclangdhas in its index. The script callers are a name scan of all open.hotdocuments (a closed document is not scanned). A call outside a method or a constructor (a free function, a field initializer) has no caller item. A caller in a generated file is not listed.outgoingCallsof a script method also lists the native callees: the server asksclangdonce for each name that is not a script method (at most 16 names, 3 seconds).outgoingCallsof a native item is the answer ofclangd. The request toclangdforincomingCallsandoutgoingCallsof a native item waits at most 3 seconds. The match of the script callers is by name only: a document that has a script method or constructor with the name is not scanned for a native item with this name, and a local with the name of a native function counts as a call. The C++ callers depend on the index ofclangd: they can be missing until the index is ready.
- An incoming call is a call with the name of the method in the body of a method or constructor of the same document. The calls are grouped by the method that holds them, and
- Rename changes the script symbols (class, member, field) in all
.hotfiles of the workspace. The server scans the workspace folders frominitialize(it skips hidden folders andnode_modules). A class or a member with the same name in another script (for example the versions of a script) gets the new name there too. An open document uses its editor text. A file on disk goes toclangdonly for the request. A class name has no segment in the source map, so the semantic tokens find its places. References also look in the other.hotfiles. Rename of an imported C++ symbol (a native type, function, method, field or enumerator) goes throughclangd(textDocument/rename) at the place in the shadow document. The place has a map segment, or it comes from the word (see references). The answer is aWorkspaceEdit:- An edit in a shadow document maps back to a script range. An edit with no map segment uses the same k-th use rule as references. An edit in generated-only code (for example the copy of a parameter) is dropped. An edit in a real C++ file passes through unchanged. The declaration and the definition places in the workspace are added when
clangddoes not give them (a header that no indexed translation unit includes). Uses in C++ files thatclangddoes not index are not changed. - The server refuses with an error, and changes nothing, in these cases: a declaration or a definition is in a file outside the workspace folders (a system header, a library); an edit changes a script or a C++ file outside the workspace folders;
clangdgives an error or no edit; a changed range is not the old name (the server checks the text of each range in the generated code and in each C++ file); a changed range has no clear place in the script; a script line has more than one word with the name and the k-th use rule does not give the place (for exampleint count = c.count;with a type or a local of the same name that the rule cannot match); or two edits of one generated line map to one place in a script line with more than one such word. The declaration or definition place is not added when it overlaps an edit ofclangd. The calls share a time budget of 10 seconds. textDocument/prepareRenameagrees with rename: it gives the range of the word for a renameable symbol (script or native), else an error with the same message. For a native symbol it runs the check of rename with a probe name.
- An edit in a shadow document maps back to a script range. An edit with no map segment uses the same k-th use rule as references. An edit in generated-only code (for example the copy of a parameter) is dropped. An edit in a real C++ file passes through unchanged. The declaration and the definition places in the workspace are added when
- The shard digest covers the text of the file and the texts of the scripts that it imports (also the imports of the imports). A changed import makes the shard stale. The saved symbol index speeds up rename and references on closed
.hotfiles. There is no limit on the number of files. It has one shard for each.hotfile. All shards are flat in.cache/hot/index/in the first workspace folder.--index-dir dirsets another directory. The shard name is a hash of the file path, so a new shard replaces the old shard and the index does not grow when a file changes. A shard holds a format version, the content digest, the classes and members, and the places of each class. The places of a member come fromclangd. The server adds them to the shard at the first rename. The server loads a shard only if the version and the digest match. Else it analyzes the file again and writes the shard again. A write goes to a temporary file, then a rename puts it in place. Atinitializeand at each scan the server deletes the shards of missing files. The index is filled when a rename or a references request scans a closed file. It is not filled at startup. Add.cache/to the ignore list of the project. The requesthot/indexStatsgives the counts (analyzed,loaded,stored). - Workspace symbols come from the
clangdindex. Give--compile-commands-dirwith the output dir ofhotc --compile-commands. Thenclangdruns its background index on the generated files. A symbol of a generated file maps to its script member. The helpers of the translator (thunks, slot functions) are not shown.
The #line check: clangd reports the real line of the generated file, not the #line line. So the generated C++ keeps its #line directives. No translator option is necessary.
From C++ to the script:
- In a
.cppfile fromhotc --compile-commands, the#linedirectives name the.hotfile. A debugger and the compiler use them.clangddoes not use them for navigation. - The custom request
hot/toScript(params:textDocument,position) gives the script location of a position in a generated file. It uses the source map for an open script, and the#linedirectives for a file on disk. - The test
LanguageServer.GeneratedFileOnDiskMapsBackToTheScriptchecks it.
Limits:
- After a translation error,
clangdkeeps the last good generated C++. The server keeps the script text of that C++ and maps each position through the edit (common prefix and suffix). A position inside the edit has no mapping. The oldclangddiagnostics are dropped until the next good translation. clangddoes not publish again when a new version has the same diagnostics. The server keeps the last rawclangddiagnostics of each document. After each edit it maps them again to the new script text. A new publish ofclangdreplaces them.- A
#linefallback has the line only. The column is the column of the same word in the script line, or the first non-blank column. - Two open scripts that import the same script publish diagnostics for it. The last publish wins.
The tests are in tests/integration/ls/ (LanguageServer.*). They run the server on a thread with an LSP client over pipes. The clangd tests are skipped when clangd is missing. Set HOT_LS_CLANGD_LOG=file to get the clangd log of a test.
Formatter rules
The formatter of hot-ls works on a token stream. It keeps the comments and the literals as tokens. It does not parse the script. The code is src/ls/format.cpp. The function format_hot is pure. It is idempotent and it never changes a token: only white space changes. Tests check this on all example scripts.
- The line breaks stay as the author put them. The formatter does not move a brace and does not join or split a line.
- The indent follows the open
{,(and[. A line in open parentheses or brackets gets one more level. A line that continues an expression (after=,+,&&,.… or before&&,?,.…) gets one more level. The body of anif,for,while,elseordoon its own line gets one more level. Acaseordefaultlabel is one level inside theswitch. Its body is one more level. The width is thetabSizeof the request (default 4).insertSpaces: falsegives one tab for each level. - One space goes around a binary operator (
=,==,+,-,/,&&,||,=>,??and the compound assignments).*,&and&&get it when the author used the same spacing on both sides. A unary operator takes no space.<,>,?and:keep the spacing of the author, because they can be a generic, a nullable type or a label. - No space before
,and;. One space after them. No space inside( )and[ ]. No space before the(of a call. One space betweenif,for,foreach,while,switch,catch,using,lock,returnand the(. No space around.,?.and::. No space inoperator+(. - One space before
{and inside{ }on one line. - A run of blank lines becomes one blank line. The text has no leading blank line, no trailing white space and one new line at the end. A file with CRLF keeps CRLF.
- The text of a string, of a char, of a
${...}and of a block comment is byte exact, also the inner lines and their trailing white space. A line comment loses its trailing white space only. All comments keep their place. A line comment keeps the white space before it. - A token sequence that the rules do not know keeps the spacing of the author (one space for a run of spaces).
Range formatting runs the formatter on the whole text. The edit holds the new text of the lines in the range only. A line inside a string or a block comment does not change.