lsp-bjolang/Program.fs
Linus Björnstam cc62a8a360 A project's packages, and diagnostics at their column
A file in a project is checked against the packages bjo fetched for it:
the server installs the project's .bjo/roots, .bjo/frameworks and
.bjo/nuget/root before its first check, through the compiler's
ProjectInputs, so imports from packages check and definition, hover and
completion reach them. A server serves one project, the first file's; a
warning on a file's first line says when it cannot serve one as bjo
would: packages not fetched, the roots changed since it started, or
another project.

The server no longer has the old Microsoft.Extensions.DependencyInjection
.Abstractions next to it. The compiler excludes it, the exclusion does
not pass through a project reference, and with it there every ASP.NET
type failed to load, so bjoweb did not check.

A diagnostic marks the form at the line and column the compiler names
now: an atom or a string whole, a bracketed form to its closing bracket,
and one over several lines as far as its head.

The build no longer asks git for Source Link, which fails on this
repository's SHA-256 objects.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-5-5)

Co-Authored-By: eca-agent <git@eca.dev>
2026-10-20 19:30:50 +02:00

437 lines
19 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
// Before the check: the first file asked about decides which project's
// packages are installed, and they have to be before anything resolves.
let note: Check.Found list =
match Project.prepare (uriToPath uri) with
| Some message ->
[ { Line = 0
Column = 0
EndLine = 1
EndColumn = 0
IsError = false
Message = message } ]
| None -> []
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 (note @ 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 ()