lsp-bjolang/Program.fs

425 lines
18 KiB
Forth
Raw Normal View History

/// 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 ()