LSP server
The Language server protocol is a standard protocol by Microsoft used for communicating between "language servers" which provide contextual language-specific information about files and "clients" or text editors. LSP client implementations exist for VSCode, Vim, Emacs among others so this makes a convenient common point for implementing HOL specific behaviors.
Usage
One server serves one theory script, and this is a contract rather
than a limitation awaiting work. The process binds to the first
*Script.sml it is given and never releases it, and it declines to
compile any other file: loading a theory seals it
(Theory.load_complete → KernelSig.sealed_ref, deliberately outside
the snapshot machinery as a soundness gate), so an ancestor loaded for
one file can be neither re-read nor withdrawn for a second.
What follows from that:
- Per-file state — buffers, versions, diagnostics — is isolated by
URI. HOL's state —
Context, the sealed set,Meta.loadedMods— is process-global and is not. Two files edited in two servers cannot affect each other's answers; two files in one process would corrupt each other, which is why the second is refused. - Dependencies are consumed as built artifacts, loaded at most once
per process. Rebuilding one does not reach a running server;
restart it. A dependency that was missing is re-read, which is
what
$/hol/retryCompileis for. $/setConfigis process-wide, and$/evalruns in the shared process state with no snapshot bracket, so it can perturb the file's compiles.
To start a server, use hol lsp. This should be run in the project root (containing the files you are editing).
It will communicate using the LSP format (on stdio). This covers most of the basic operations of a language server, in particular:
- Initialization/shutdown
- Opening, modifying, closing files
- Hover and go to definition
Initialization
The client sends a message describing the features it supports, and the LSP server responds by describing what features it has enabled.
This LSP server also supports the $/setConfig command for setting additional HOL-specific features.
File operations
When opening or modifying a file, the server will compile the file and report any warnings or errors in a Diagnostic notification. It is also responsible for caching intermediate states in order to minimize the amount of work needed to update the list of diagnostics.
Extensions
In addition to the usual LSP commands, the server supports the following extensions:
-
Notification
$/cancelRequest:This is a "standard" extension that allows cancelling a pending request. This is useful in particular for the
$/evalcommand to terminate a long-running user evaluation.Parameters:
id: integer | string- The request id to cancel
-
Request
$/setConfig: This sets additional global state for the server.Parameters:
-
elabOn?: ElabOnwhereenum ElabOn { None = 0, Change = 1, Save = 2 }(default:Change)This controls whether elaboration/compilation should be triggered after every modification to the file, on save, or not at all.
-
-
Request
$/eval: This runs a chunk of HOL text, to allow for a similar behavior ashol repl.Parameters:
uri: URI- The file (or virtual path) associated to this chunkcode: string- The HOL text to compileincr?: Incrwhereenum Incr { None = 0, Chunk = 1, Stream = 2 }(default:None)holdep?: HoldepKindwhereenum HoldepKind { None = 0, Quiet = 1, List = 2 }(default:Quiet) - controls whether it should first callholdepto collect and preload anyopens and other qualified names, and whether to print bindings (List) or not (Quiet).
Depending on the options set, it will send various notifications back, in the following order:
-
If
holdep = List, it will give a$/eval/holdepnotification:uri: URI- the original fileid: integer | string- the current requestfiles: [string]- The list of modules to load
-
It then loads the modules (if
holdep != None). -
If
holdep = List, it will give a$/eval/holdepCompletedwith the sameuri,idwhen loading is complete -
It then runs the code and reports results:
- If
incr = None, then the final response of the request is a[report]list containing all of the errors, warnings, etc. - If
incr = Chunk, then it returnsnullimmediately, but it compiles asynchronously, sending each report in a$/eval/Pnotification:id: integer | string- the current requestpos: Range- the chunk of the text that was evaluatedout: [Report]- the results from this chunk
- If
incr = Streamthen each report is sent as soon as possible in a$/eval/1notification:id: integer | string- the current requestout: Report- the report
- If
The
Reportobject is a possible output of the compiler:type Report = ErrorReport | CompilerOutReport | ToplevelOutReport | CompileProgressReport | { kind: "compileCompleted" } | { kind: "interrupted" }; interface ErrorReport { kind: "error"; hard: bool; // true = error, false = warning pos: Range; msg: string; } interface CompilerOutReport { kind: "compilerOut"; pos: Range; body: string; } interface ToplevelOutReport { kind: "toplevelOut"; pos: Range; body: string; } interface CompileProgressReport { kind: "compileProgress"; pos: Range; }An asynchronous compile is always terminated by
"compileCompleted"or"interrupted". -
The
$/compileProgressnotification is sent during an asynchronous compile caused by a file open or modification event.uri: URI- the file being compiledpos: Range- the chunk of the text that was evaluated
-
The
$/compileCompletednotification is sent when an asynchronous compile caused by a file open or modification event is completed.uri: URI- the file being compiled
-
The
$/compileInterruptednotification is sent when an asynchronous compile caused by a file open or modification event is interrupted.uri: URI- the file being compiled
-
Position encoding. The server's own offsets are bytes, so it asks for
utf-8when the client'sgeneral.positionEncodingsoffers it (eglot offers["utf-32", "utf-8", "utf-16"]) and answersutf-16otherwise, converting everycharacterit reads or writes. A client that offers nothing getsutf-16, which is what the spec says its silence means. Declaringutf-8regardless is not a way of getting it:vscode-languageclientadvertises onlyutf-16and throws on any other answer. -
$/setConfigcarries the settings a client knows and the server cannot work out:elabOn(when to compile),hoverWidth(columns to render hover text at, since only the client knows how wide its hover box is), andcheckProofs(whether to replay proofs rather than take them on trust).checkProofstakes effect immediately: switching it on re-elaborates every open script from byte 0, because the pass that already ran cheated its proofs and enqueued nothing; switching it off cancels the pool and clears its diagnostics. -
On startup the server loads the heap named by the working directory's
Holmakefile(HOLHEAP), elsebin/hol.state. If that heap cannot be loaded -- missing, or exported from a different executable, which everypolycproduces -- thelspsubcommand falls back tobin/hol.stateand reports it withwindow/showMessageafter the handshake, rather than exiting: a process that dies before answeringinitializecan only be reported as "died", and none of the server's own channels are available to say why.LSPServer.starttakes those messages as itswarningsargument. Other subcommands still die. -
The
$/compileBlockednotification is sent instead of compiling a file whose declared dependencies are not available. A file that names an ancestor or library it cannot get has nothing to be elaborated against, so the server compiles none of it, and answersnullto$/hol/goalStatefor it. Availability is decided by looking the declared structure up in the namespace, so it covers a dependency that loads without binding the name the header opens, and a retry that plans nothing because the module is still marked inMeta.loadedMods-- neither of which raises anything to catch. The failures themselves are published as diagnostics, positioned on the names in the header that asked for them; no$/compileProgressor$/compileCompletedfollows.The block is cleared by an edit that changes the file's declared dependency list -- the
AncestorsandLibsentries of itsTheoryheader, plus the identifiers of any leading top-levelopen-- or by the$/hol/retryCompilenotification below. Nothing else lifts it: no edit to the body can make a missing ancestor appear. The notification is re-sent on each later edit that leaves that list alone, so a client need not remember the state.uri: URI- the file that is not being compiledmodules: [string]- its declared dependencies, i.e. the list whose change clears the blockmessage: string- what went wrong, e.g.cannot load fooTheory: Cannot find file fooTheory.ui
-
textDocument/documentSymbollists a file's declarations, answered from the parser rather than from the last compile — so it works on a file that does not compile, or one blocked on an unloadable ancestor. A client advertisinghierarchicalDocumentSymbolSupportgets nestedDocumentSymbols, with the keyword and any attributes ([simp],[local]) asdetail; otherwise flatSymbolInformation. -
workspace/symbolsearches stored theorems: those of the theories this server loaded, plus any theory built in the project, read from itsTheory.datwithout loading it. The latter are marked(not an ancestor)incontainerName, since using one requires adding the theory toAncestorsfirst. Declarations of the open buffers are included. A query shorter than two characters answers nothing: the search matches substrings. -
textDocument/completionoffers the SML names in scope for the file and the theorem names HOL knows, with.as a trigger character. An empty prefix answers nothing, markedisIncomplete, rather than the whole namespace. -
There is no
textDocument/references, andreferencesProvideris not advertised. Poly records references only within the compilation unit it is compiling, and HOL has no index of which proofs cite a theorem —DB.revlookupgives where a theorem was stored, not who uses it. -
The
$/hol/retryCompilenotification asks the server to compile a file it has blocked, without waiting for the header to change. It is for the case where the missing ancestor has been built outside the editor.uri: URI- the file to compile
-
TODO unimplemented; this is an API proposal
$/getStaterequest to get the current goal view state:uri: URI- the file being compiledpos: Range- the selection
The response is either
nullif it is not in a proof, or an object containing:tactic: Rangegoals: [Goal]wherestruct Goal { asms: [string], concl: string }
goalscontains the list of goals that would be operated on by a tactic at this position.