lsp-bjolang/Hover.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

273 lines
9.2 KiB
FSharp

/// What a hover shows for a name: its signature as written, and its doc from
/// the module's `(:doc ...)` forms, the `.bjodoc` files included, as Markdown.
module BjolangLsp.Hover
open System
open System.Text
open Bjolang
open Bjolang.Lexer
open Bjolang.Ast
open BjolangLsp.Symbols
/// A doc text's markup as Markdown. The texts use `@code{...}`, `@strong`,
/// `@em`, `@sup` and `@itemlist{@item{...} ...}`, which nest; anything else
/// after an `@` is left as it is.
let markdown (text: string) : string =
let out = StringBuilder()
// The index after the brace that closes the one at `start`.
let closing (start: int) =
let mutable depth = 0
let mutable i = start
let mutable found = -1
while found < 0 && i < text.Length do
match text[i] with
| '{' -> depth <- depth + 1
| '}' ->
depth <- depth - 1
if depth = 0 then found <- i
| _ -> ()
i <- i + 1
found
let rec go (from: int) (until: int) =
let mutable i = from
while i < until do
let c = text[i]
if c = '@' then
let mutable j = i + 1
while j < until && Char.IsLetter text[j] do
j <- j + 1
let command = text.Substring(i + 1, j - i - 1)
if j < until && text[j] = '{' && command <> "" then
let close = closing j
if close < 0 then
out.Append c |> ignore
i <- i + 1
else
let wrap (before: string) (after: string) =
out.Append before |> ignore
go (j + 1) close
out.Append after |> ignore
match command with
| "code" -> wrap "`" "`"
| "strong" -> wrap "**" "**"
| "em" -> wrap "*" "*"
| "sup" -> wrap "^" ""
| "item" -> wrap "\n- " ""
| "itemlist" -> wrap "" "\n"
| _ -> wrap ("@" + command + "{") "}"
i <- close + 1
else
out.Append c |> ignore
i <- i + 1
else
out.Append c |> ignore
i <- i + 1
go 0 text.Length
// The space between two `@item`s is left at the end of a line.
RegularExpressions.Regex.Replace(out.ToString(), " +\n", "\n")
/// The `(: name Type)` declaration of `name` among `forms`, if it has one.
let rec private declaration (name: string) (forms: SExpr list) : SExpr option =
forms
|> List.tryPick (fun form ->
match form with
| SList(SAtom { Token = Colon } :: SAtom { Token = Symbol n } :: _, _) when n = name -> Some form
| SList(SAtom { Token = Symbol("begin" | "module") } :: rest, _) -> declaration name rest
| _ -> None)
/// The `(:doc ...)` entries among `forms`. One that does not read is left
/// out: the compiler reports it, and a hover has nothing to say about it.
let private entries (forms: SExpr list) : Docs.Entry list =
forms
|> List.filter Docs.isDocForm
|> List.choose (fun f ->
try
Some(Docs.parseEntry f)
with _ ->
None)
let private longest = 20
/// At most `longest` lines of `text`.
let private clip (text: string) =
let lines = text.Split('\n')
if lines.Length <= longest then
text
else
String.Join("\n", Array.truncate longest lines) + "\n…"
let private fence (code: string) = $"```lisp\n%s{code}\n```"
/// The signature: the `(: name Type)` declaration if there is one, and the
/// form's head with its parameters, as written.
let private signature (found: Found) : string =
let s = found.Symbol
let m = found.Module
let declared = declaration s.Name m.Forms |> Option.map (fun d -> m.Slice(getRange d))
let shape = m.Slice s.Signature
let head =
match s.Kind with
| Kind.Function when s.Detail.StartsWith "def" -> $"(%s{s.Detail} %s{shape})"
| Kind.Variable -> $"(%s{s.Detail} %s{shape})"
| _ -> shape
[ yield! Option.toList declared; yield clip head ] |> String.concat "\n" |> fence
let private docText (entry: Docs.Entry) (member': string option) : string =
let clauses = entry.Clauses |> List.map fst
let section (title: string) (items: string list) =
if items.IsEmpty then [] else [ $"**%s{title}**\n\n" + String.concat "\n" items ]
let item (name: string) (text: string) = $"- `%s{name}`: %s{markdown text}"
[ for c in clauses do
match c with
| Docs.Summary t -> yield markdown t
| _ -> ()
// The member hovered over, documented in its type's doc.
match member' with
| Some name ->
for c in clauses do
match c with
| Docs.Case(n, t)
| Docs.Field(n, t) when n = name -> yield $"`%s{name}`: %s{markdown t}"
| _ -> ()
| None -> ()
yield!
section
"Parameters"
(clauses
|> List.choose (function
| Docs.Arg(n, t) -> Some(item n t)
| Docs.Rest(n, t) -> Some(item $". %s{n}" t)
| Docs.Key(n, t) -> Some(item $"#:%s{n}" t)
| Docs.TParam(n, t) -> Some(item $"%%%s{n}" t)
| _ -> None))
if member'.IsNone then
yield!
section
"Cases"
(clauses
|> List.choose (function
| Docs.Case(n, t) -> Some(item n t)
| _ -> None))
yield!
section
"Fields"
(clauses
|> List.choose (function
| Docs.Field(n, t) -> Some(item n t)
| _ -> None))
for c in clauses do
match c with
| Docs.Returns t -> yield $"**Returns** %s{markdown t}"
| _ -> ()
yield!
section
"Raises"
(clauses
|> List.choose (function
| Docs.Raises(n, t) -> Some(item n t)
| _ -> None))
for c in clauses do
match c with
| Docs.Example t -> yield fence (t.Trim())
| _ -> ()
for c in clauses do
match c with
| Docs.Reference t -> yield markdown t
| _ -> ()
for c in clauses do
match c with
| Docs.See names -> yield "See " + (names |> List.map (fun n -> $"`%s{n}`") |> String.concat ", ") + "."
| _ -> () ]
|> String.concat "\n\n"
/// The doc of what `found` is: its own, or for a constructor or field, its
/// type's, with the member's line picked out.
let private doc (found: Found) : string option =
let all = entries found.Module.Forms
let named (n: string) = all |> List.tryFind (fun e -> e.Subject = Docs.Named n)
match named found.Symbol.Name with
| Some entry -> Some(docText entry None)
| None ->
match found.Symbol.Kind with
| Kind.EnumMember
| Kind.Field ->
named found.Symbol.Detail
|> Option.map (fun entry -> docText entry (Some found.Symbol.Name))
| _ -> None
/// The hover for a local name: its binding as written, and what binds it.
let local (b: Scope.Binder) (shown: string) : string =
let what = b.Description
let sentence = string (Char.ToUpperInvariant what[0]) + what.Substring 1
[ fence (clip shown); $"*%s{sentence}.*" ]
|> String.concat "\n\n"
/// The type of a builtin, one the compiler defines rather than a module, as a
/// declaration: `(: seq-unfold (-> ...))`. `None` for a name that is not one.
let builtinDeclaration (name: string) : string option =
Prelude.prelude.Bindings
|> Map.tryFind name
|> Option.map (fun b ->
let (TypedAST.Scheme(_, _, t)) = b.Scheme
$"(: %s{name} %s{DotNetInterop.showType t})")
/// The hover for a builtin: its type, and its doc, which the prelude's docs
/// hold although the prelude does not define it.
let builtin (name: string) : string option =
builtinDeclaration name
|> Option.map (fun declared ->
let entry =
Symbols.prelude ()
|> Option.bind (fun m -> entries m.Forms |> List.tryFind (fun e -> e.Subject = Docs.Named name))
[ yield fence declared
yield! entry |> Option.map (fun e -> docText e None) |> Option.toList
yield "*Built into the compiler.*" ]
|> String.concat "\n\n")
/// The hover for `found`: its signature, where it is, and its doc.
let text (found: Found) : string =
let where =
let file = IO.Path.GetFileName found.Symbol.NameRange.File
match found.Symbol.Kind with
| Kind.EnumMember -> $"A case of `%s{found.Symbol.Detail}`, in %s{file}."
| Kind.Field -> $"A field of `%s{found.Symbol.Detail}`, in %s{file}."
| Kind.Method -> $"A method of the trait `%s{found.Symbol.Detail}`, in %s{file}."
| _ -> $"In %s{file}."
[ yield signature found
yield! Option.toList (doc found)
yield $"*%s{where}*" ]
|> String.concat "\n\n"