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>
273 lines
9.2 KiB
FSharp
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"
|