lsp-bjolang/Program.fs
Linus Björnstam c6f357c6f8 A language server for Bjolang
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>
2026-10-15 22:38:22 +02:00

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