Runs the compiler in-process against the Bjolang checkout beside it, and answers diagnostics, an outline, go to definition, hover and completion, plus a request of its own for macro expansion. Diagnostics come from the compiler's own check of the editor's text, saved or not: on open, on save, and 400 ms after typing pauses. The outline, definitions, hovers and completions read only as far as the reader, with no type checking, so they answer on every request: the top-level names of the file, its imports and the prelude, local names from the binding forms the parser knows, the declarations and docs as written, and the builtins with their types. emacs/bjolang-lsp.el shows a macro call's expansion, one step or all of it, in a buffer. test/smoke.py drives the server as an editor would. 🤖 Generated with [ECA](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
424 lines
18 KiB
FSharp
424 lines
18 KiB
FSharp
/// A language server for Bjolang. It answers diagnostics, from the compiler's
|
|
/// own frontend, when a file is opened or saved.
|
|
module BjolangLsp.Program
|
|
|
|
open System
|
|
open System.Reflection
|
|
open System.Text.Json.Nodes
|
|
|
|
/// Points the compiler at the Bjolang checkout this server was built against,
|
|
/// unless BJOLANG_HOME already names one. Done before anything touches
|
|
/// `Bjolang.Paths`, whose locations are computed once, when it is first used.
|
|
let private setBjolangHome () =
|
|
match Environment.GetEnvironmentVariable "BJOLANG_HOME" with
|
|
| null
|
|
| "" ->
|
|
Assembly.GetExecutingAssembly().GetCustomAttributes<AssemblyMetadataAttribute>()
|
|
|> Seq.tryFind (fun a -> a.Key = "BjolangHome")
|
|
|> Option.iter (fun a -> Environment.SetEnvironmentVariable("BJOLANG_HOME", a.Value))
|
|
| _ -> ()
|
|
|
|
/// Makes the checks the installed compiler's: staleness judged against
|
|
/// `bin/compiler/Bjolang.dll` rather than this server's copy of the compiler,
|
|
/// and a stale import built by running that compiler. Built here, an import's
|
|
/// build record would name this server, and the command line would build it
|
|
/// again.
|
|
///
|
|
/// The variable is only read while the backend is installed, and is cleared
|
|
/// again so that the compiler it starts builds its own imports as usual.
|
|
let private useInstalledCompiler () =
|
|
let installed = IO.Path.Combine(Bjolang.Paths.root, "bin", "compiler", "Bjolang.dll")
|
|
|
|
if IO.File.Exists installed then
|
|
Bjolang.Paths.compilerAssembly <- installed
|
|
Environment.SetEnvironmentVariable("BJOLANG_OUT_OF_PROCESS_DEPS", "1")
|
|
Bjolang.Build.installDependencyBackend ()
|
|
Environment.SetEnvironmentVariable("BJOLANG_OUT_OF_PROCESS_DEPS", null)
|
|
else
|
|
Rpc.log $"no compiler at %s{installed}; run build_compiler.sh there. Files that import a stale module will fail to check."
|
|
|
|
let private uriToPath (uri: string) = Uri(uri).LocalPath
|
|
|
|
let private str (node: JsonNode) (path: string list) =
|
|
path |> List.fold (fun (n: JsonNode) key -> if isNull n then null else n[key]) node
|
|
|> fun n -> if isNull n then None else Some(n.GetValue<string>())
|
|
|
|
/// The text of each open file as the editor has it, saved or not. Symbols,
|
|
/// definitions, completions and checks are all read from it, so that they
|
|
/// follow the buffer.
|
|
let private texts = Collections.Generic.Dictionary<string, string>()
|
|
|
|
let private publish (uri: string) =
|
|
let buffers =
|
|
texts |> Seq.map (fun kv -> IO.Path.GetFullPath(uriToPath kv.Key), kv.Value) |> Map.ofSeq
|
|
|
|
let found =
|
|
try
|
|
Check.file buffers (uriToPath uri)
|
|
with ex ->
|
|
Rpc.log $"check of %s{uri} failed: %O{ex}"
|
|
[]
|
|
|
|
Rpc.notify "textDocument/publishDiagnostics" (Check.publishParams uri found)
|
|
|
|
/// When each changed file is next to be checked. A check waits for typing to
|
|
/// pause, as one per keystroke would queue up behind each other.
|
|
let private dueChecks = Collections.Generic.Dictionary<string, DateTime>()
|
|
|
|
let private pause = TimeSpan.FromMilliseconds 400.0
|
|
|
|
let private textOf (uri: string) =
|
|
match texts.TryGetValue uri with
|
|
| true, text -> text
|
|
| _ -> IO.File.ReadAllText(uriToPath uri)
|
|
|
|
/// A compiler range as an LSP one. Lines are 1-based in the compiler and
|
|
/// 0-based in LSP; columns are 0-based in both.
|
|
let private range (r: Bjolang.Lexer.Range) : JsonNode =
|
|
let position (p: Bjolang.Lexer.Position) =
|
|
JsonObject(dict [ "line", JsonValue.Create(max 0 (p.Line - 1)) :> JsonNode; "character", JsonValue.Create p.Column ])
|
|
:> JsonNode
|
|
|
|
JsonObject(dict [ "start", position r.Start; "end", position r.End ])
|
|
|
|
let rec private documentSymbol (s: Symbols.Symbol) : JsonNode =
|
|
JsonObject(
|
|
dict
|
|
[ "name", JsonValue.Create s.Name :> JsonNode
|
|
"detail", JsonValue.Create s.Detail
|
|
"kind", JsonValue.Create(int s.Kind)
|
|
"range", range s.Range
|
|
"selectionRange", range s.NameRange
|
|
"children", JsonArray(s.Children |> List.map documentSymbol |> Array.ofList) ]
|
|
)
|
|
|
|
/// The last symbols each file read to, kept for while it does not read: a
|
|
/// half-typed form would otherwise empty the outline on every keystroke.
|
|
let private lastSymbols = Collections.Generic.Dictionary<string, Symbols.Symbol list>()
|
|
|
|
let private symbolsOf (uri: string) : JsonNode =
|
|
let symbols =
|
|
match Symbols.ofText (uriToPath uri) (textOf uri) with
|
|
| Some symbols ->
|
|
lastSymbols[uri] <- symbols
|
|
symbols
|
|
| None ->
|
|
match lastSymbols.TryGetValue uri with
|
|
| true, symbols -> symbols
|
|
| _ -> []
|
|
|
|
JsonArray(symbols |> List.map documentSymbol |> Array.ofList)
|
|
|
|
/// The name at an LSP position, where it is, and its definition.
|
|
let private lookup (uri: string) (line: int) (character: int) =
|
|
let path = uriToPath uri
|
|
let text = textOf uri
|
|
|
|
Symbols.nameAt path text (line + 1) character
|
|
|> Option.bind (fun (name, at) -> Symbols.definition path text name |> Option.map (fun found -> at, found))
|
|
|
|
/// The local binding the name at an LSP position refers to, if it is one, and
|
|
/// where the name is.
|
|
let private localAt (uri: string) (line: int) (character: int) : (Scope.Binder * Bjolang.Lexer.Range) option =
|
|
let path = uriToPath uri
|
|
let text = textOf uri
|
|
|
|
match Symbols.nameAt path text (line + 1) character, Symbols.forms path text with
|
|
| Some(name, at), Some forms -> Scope.find forms (line + 1) character name |> Option.map (fun b -> b, at)
|
|
| _ -> None
|
|
|
|
let private location (r: Bjolang.Lexer.Range) : JsonNode =
|
|
JsonObject(dict [ "uri", JsonValue.Create(Uri(r.File).AbsoluteUri) :> JsonNode; "range", range r ])
|
|
|
|
let private definitionAt (uri: string) (line: int) (character: int) : JsonNode =
|
|
match localAt uri line character with
|
|
| Some(b, _) -> location b.Range
|
|
| None ->
|
|
match lookup uri line character with
|
|
| None -> null
|
|
| Some(_, found) -> location found.Symbol.NameRange
|
|
|
|
let private markdownContent (text: string) : JsonNode =
|
|
JsonObject(dict [ "kind", JsonValue.Create "markdown" :> JsonNode; "value", JsonValue.Create text ])
|
|
|
|
/// What a hover says about `name`: of its definition, or of the builtin it
|
|
/// is.
|
|
let private describe (uri: string) (name: string) : string option =
|
|
match Symbols.definition (uriToPath uri) (textOf uri) name with
|
|
| Some found -> Some(Hover.text found)
|
|
| None -> Hover.builtin name
|
|
|
|
let private hoverAt (uri: string) (line: int) (character: int) : JsonNode =
|
|
let answer (text: string) (at: Bjolang.Lexer.Range) =
|
|
JsonObject(dict [ "contents", markdownContent text; "range", range at ]) :> JsonNode
|
|
|
|
match localAt uri line character with
|
|
| Some(b, at) -> answer (Hover.local b (Symbols.sliceOf (textOf uri) b.Shown)) at
|
|
| None ->
|
|
match Symbols.nameAt (uriToPath uri) (textOf uri) (line + 1) character with
|
|
| None -> null
|
|
| Some(name, at) ->
|
|
match describe uri name with
|
|
| None -> null
|
|
| Some text -> answer text at
|
|
|
|
/// The completions at a position. Each replaces the whole word before the
|
|
/// cursor. Only names that start with the word's first character are sent,
|
|
/// as the client filters the rest of the way itself, and every name at once
|
|
/// is two thousand of them.
|
|
let private completionAt (uri: string) (line: int) (character: int) : JsonNode =
|
|
let text = textOf uri
|
|
let lines = text.Replace("\r\n", "\n").Split('\n')
|
|
let lineText = if line < lines.Length then lines[line] else ""
|
|
let column = min character lineText.Length
|
|
let start = Completion.wordStart lineText column
|
|
let word = lineText.Substring(start, column - start)
|
|
let own = IO.Path.GetFileName(uriToPath uri)
|
|
|
|
let position (c: int) =
|
|
JsonObject(dict [ "line", JsonValue.Create line :> JsonNode; "character", JsonValue.Create c ]) :> JsonNode
|
|
|
|
let replace = JsonObject(dict [ "start", position start; "end", position column ])
|
|
|
|
let rank (item: Completion.Item) =
|
|
match item.From with
|
|
| "local" -> 0
|
|
| f when f = own -> 1
|
|
| "builtin" -> 3
|
|
| "special form" -> 4
|
|
| _ -> 2
|
|
|
|
// The locals in scope, innermost first and each name once: an inner
|
|
// binding hides an outer one, and both hide a top-level name.
|
|
let locals =
|
|
match Symbols.forms (uriToPath uri) text with
|
|
| Some forms ->
|
|
Scope.at forms (line + 1) column
|
|
|> List.rev
|
|
|> List.distinctBy (fun b -> b.Name)
|
|
// The word being typed is not yet a name in scope, unless it is
|
|
// the binder itself, which is not worth offering either.
|
|
|> List.filter (fun b -> not (b.Range.Start.Line = line + 1 && b.Range.Start.Column = start))
|
|
| None -> []
|
|
|
|
let localNames = locals |> List.map (fun b -> b.Name) |> Set.ofList
|
|
|
|
let candidates =
|
|
if Completion.inText lineText column then
|
|
[]
|
|
else
|
|
(locals |> List.map (fun b -> ({ Label = b.Name; Kind = Completion.ItemKind.Variable; From = "local" }: Completion.Item)))
|
|
@ (Completion.items (uriToPath uri) text |> List.filter (fun item -> not (localNames.Contains item.Label)))
|
|
|
|
let localDoc =
|
|
locals
|
|
|> List.map (fun b -> b.Name, Hover.local b (Symbols.sliceOf text b.Shown))
|
|
|> Map.ofList
|
|
|
|
let items =
|
|
candidates
|
|
|> List.filter (fun item -> word = "" || Char.ToLowerInvariant item.Label[0] = Char.ToLowerInvariant word[0])
|
|
|> List.map (fun item ->
|
|
let fields =
|
|
[ "label", JsonValue.Create item.Label :> JsonNode
|
|
"kind", JsonValue.Create(int item.Kind)
|
|
"labelDetails", JsonObject(dict [ "description", JsonValue.Create item.From :> JsonNode ])
|
|
"sortText", JsonValue.Create($"%d{rank item}%s{item.Label}")
|
|
"textEdit",
|
|
JsonObject(dict [ "range", replace.DeepClone(); "newText", JsonValue.Create item.Label :> JsonNode ]) ]
|
|
|
|
// A local's doc is at hand; anything else's is found when the
|
|
// client asks for it.
|
|
let more =
|
|
match item.From, Map.tryFind item.Label localDoc with
|
|
| "local", Some doc -> [ "documentation", markdownContent doc ]
|
|
| _ -> [ "data", JsonObject(dict [ "uri", JsonValue.Create uri :> JsonNode; "name", JsonValue.Create item.Label ]) :> JsonNode ]
|
|
|
|
JsonObject(dict (fields @ more)) :> JsonNode)
|
|
|
|
JsonObject(dict [ "isIncomplete", JsonValue.Create false :> JsonNode; "items", JsonArray(Array.ofList items) ])
|
|
|
|
/// A completion item with its doc, which is the hover of its name.
|
|
let private resolve (item: JsonNode) : JsonNode =
|
|
let data = item["data"]
|
|
|
|
if not (isNull data) then
|
|
let uri = data["uri"].GetValue<string>()
|
|
let name = data["name"].GetValue<string>()
|
|
|
|
match describe uri name with
|
|
| Some text -> item["documentation"] <- markdownContent text
|
|
| None -> ()
|
|
|
|
item.DeepClone()
|
|
|
|
let private capabilities () : JsonNode =
|
|
JsonNode.Parse
|
|
"""
|
|
{ "capabilities":
|
|
{ "textDocumentSync": { "openClose": true, "change": 1, "save": { "includeText": false } },
|
|
"documentSymbolProvider": true,
|
|
"definitionProvider": true,
|
|
"hoverProvider": true,
|
|
"completionProvider": { "resolveProvider": true, "triggerCharacters": [ "(" ] } },
|
|
"serverInfo": { "name": "bjolang-lsp", "version": "0.4.0" } }
|
|
"""
|
|
|
|
[<EntryPoint>]
|
|
let main _ =
|
|
setBjolangHome ()
|
|
useInstalledCompiler ()
|
|
let mutable shutdownAsked = false
|
|
|
|
// Messages are read on a thread of their own and handled on this one.
|
|
// Compiler work has to stay on one thread, as `Session` swaps the
|
|
// compiler's global state around each piece of it; reading apart from it
|
|
// is what lets this one notice that typing has paused.
|
|
let inbox = new Collections.Concurrent.BlockingCollection<JsonNode option>()
|
|
|
|
let reader =
|
|
Threading.Thread(
|
|
(fun () ->
|
|
let rec go () =
|
|
match Rpc.read () with
|
|
| Some m ->
|
|
inbox.Add(Some m)
|
|
go ()
|
|
| None -> inbox.Add None
|
|
|
|
try
|
|
go ()
|
|
with ex ->
|
|
Rpc.log $"reading failed: %O{ex}"
|
|
inbox.Add None),
|
|
IsBackground = true
|
|
)
|
|
|
|
reader.Start()
|
|
|
|
/// The next message, running every check that falls due while waiting
|
|
/// for it.
|
|
let rec next () : JsonNode option =
|
|
if dueChecks.Count = 0 then
|
|
inbox.Take()
|
|
else
|
|
let soonest = dueChecks.Values |> Seq.min
|
|
let untilDue = soonest - DateTime.UtcNow
|
|
let wait = if untilDue < TimeSpan.Zero then TimeSpan.Zero else untilDue
|
|
let mutable message: JsonNode option = None
|
|
|
|
if inbox.TryTake(&message, wait) then
|
|
message
|
|
else
|
|
let due =
|
|
dueChecks |> Seq.filter (fun kv -> kv.Value <= DateTime.UtcNow) |> Seq.map (fun kv -> kv.Key) |> List.ofSeq
|
|
|
|
for uri in due do
|
|
dueChecks.Remove uri |> ignore
|
|
publish uri
|
|
|
|
next ()
|
|
|
|
let rec serve () =
|
|
match next () with
|
|
| None -> if shutdownAsked then 0 else 1
|
|
| Some message ->
|
|
let id = message["id"]
|
|
let methodName = str message [ "method" ] |> Option.defaultValue ""
|
|
let uri () = str message [ "params"; "textDocument"; "uri" ]
|
|
|
|
match methodName with
|
|
| "exit" -> if shutdownAsked then 0 else 1
|
|
| _ ->
|
|
try
|
|
match methodName with
|
|
| "initialize" -> Rpc.respond id (capabilities ())
|
|
| "shutdown" ->
|
|
shutdownAsked <- true
|
|
Rpc.respond id null
|
|
| "textDocument/didOpen" ->
|
|
uri ()
|
|
|> Option.iter (fun u ->
|
|
str message [ "params"; "textDocument"; "text" ] |> Option.iter (fun t -> texts[u] <- t)
|
|
publish u)
|
|
// Full sync: the one change is the whole text.
|
|
| "textDocument/didChange" ->
|
|
match uri (), message["params"]["contentChanges"] with
|
|
| Some u, (:? JsonArray as changes) when changes.Count > 0 ->
|
|
let last = changes[changes.Count - 1]
|
|
texts[u] <- last["text"].GetValue<string>()
|
|
dueChecks[u] <- DateTime.UtcNow + pause
|
|
| _ -> ()
|
|
| "textDocument/didSave" ->
|
|
uri ()
|
|
|> Option.iter (fun u ->
|
|
dueChecks.Remove u |> ignore
|
|
publish u)
|
|
// A closed file's diagnostics are the client's to forget,
|
|
// and an empty list is how it is told to.
|
|
| "textDocument/didClose" ->
|
|
uri ()
|
|
|> Option.iter (fun u ->
|
|
texts.Remove u |> ignore
|
|
dueChecks.Remove u |> ignore
|
|
lastSymbols.Remove u |> ignore
|
|
Rpc.notify "textDocument/publishDiagnostics" (Check.publishParams u []))
|
|
| "textDocument/documentSymbol" ->
|
|
Rpc.respond id (uri () |> Option.map symbolsOf |> Option.defaultValue (JsonArray()))
|
|
| "textDocument/definition"
|
|
| "textDocument/hover" ->
|
|
let position = message["params"]["position"]
|
|
let line = position["line"].GetValue<int>()
|
|
let character = position["character"].GetValue<int>()
|
|
let answer = if methodName = "textDocument/hover" then hoverAt else definitionAt
|
|
|
|
Rpc.respond id (uri () |> Option.map (fun u -> answer u line character) |> Option.defaultValue null)
|
|
| "textDocument/completion" ->
|
|
let position = message["params"]["position"]
|
|
let line = position["line"].GetValue<int>()
|
|
let character = position["character"].GetValue<int>()
|
|
|
|
Rpc.respond id (uri () |> Option.map (fun u -> completionAt u line character) |> Option.defaultValue null)
|
|
| "completionItem/resolve" -> Rpc.respond id (resolve message["params"])
|
|
// Not part of LSP: the expansion of the macro call at a
|
|
// position, from the file's last check. A check that is
|
|
// due runs first, so that the expansion is of the text as
|
|
// it stands.
|
|
| "bjolang/expandMacro" ->
|
|
let p = message["params"]
|
|
let position = p["position"]
|
|
let flag (key: string) (otherwise: bool) =
|
|
match p[key] with
|
|
| null -> otherwise
|
|
| v -> v.GetValue<bool>()
|
|
|
|
let answer =
|
|
uri ()
|
|
|> Option.bind (fun u ->
|
|
if dueChecks.Remove u then publish u
|
|
|
|
Expansion.at
|
|
(uriToPath u)
|
|
(position["line"].GetValue<int>() + 1)
|
|
(position["character"].GetValue<int>())
|
|
(flag "full" true)
|
|
(flag "hygienic" false))
|
|
|> Option.map (fun (macro, text) ->
|
|
JsonObject(dict [ "macro", JsonValue.Create macro :> JsonNode; "text", JsonValue.Create text ])
|
|
:> JsonNode)
|
|
|> Option.defaultValue null
|
|
|
|
Rpc.respond id answer
|
|
| _ when not (isNull id) -> Rpc.respondError id -32601 $"%s{methodName} is not supported"
|
|
// Any other notification, `initialized` included, needs no
|
|
// answer.
|
|
| _ -> ()
|
|
with ex ->
|
|
Rpc.log $"%s{methodName} failed: %O{ex}"
|
|
|
|
if not (isNull id) then
|
|
Rpc.respondError id -32603 ex.Message
|
|
|
|
serve ()
|
|
|
|
serve ()
|