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

216 lines
10 KiB
FSharp

/// The local names in scope at a position: parameters, `let` bindings,
/// pattern variables, loop variables, and definitions inside a body.
///
/// Read off the forms as the reader gives them, as `Symbols` reads the
/// top-level names, with the scoping rules of the forms the parser knows. A
/// macro's binding forms are not known, so a name a macro binds is not seen;
/// the parser's own forms cover most of what is written.
module BjolangLsp.Scope
open System
open Bjolang.Lexer
open Bjolang.Ast
type Binder =
{ Name: string
/// Where the name is written in its binding form.
Range: Range
/// What a hover shows of the binding: the parameter with its type, the
/// `let` binding with its value, the pattern.
Shown: Range
/// What binds it, for a hover: "a parameter of `f`", "bound by `let`".
Description: string }
/// Whether `r` holds the position. The end is included, so a cursor right
/// after a name is in it, as it is while the name is being typed.
let private holds (line: int) (column: int) (r: Range) =
(line > r.Start.Line || (line = r.Start.Line && column >= r.Start.Column))
&& (line < r.End.Line || (line = r.End.Line && column <= r.End.Column))
let private binder name range shown description =
{ Name = name
Range = range
Shown = shown
Description = description }
/// The names a pattern binds: every lowercase symbol that is not the head of
/// a list, as the head is the constructor.
let rec private patternNames (description: string) (shown: Range) (s: SExpr) : Binder list =
match s with
| SAtom { Token = Symbol n; Range = r } when n <> "_" && n <> "else" && not (Char.IsUpper n[0]) ->
[ binder n r shown description ]
| SList(SAtom { Token = Symbol _ } :: args, _) -> args |> List.collect (patternNames description shown)
| SList(items, _) -> items |> List.collect (patternNames description shown)
| _ -> []
/// The parameters of an argument list: `x`, `(: x Type)`, `#:rest xs`, and a
/// keyword parameter `(#:key default)` or `#:key default`, which binds `key`.
let private parameters (owner: string) (args: SExpr list) : Binder list =
let description = if owner = "" then "a parameter" else $"a parameter of `%s{owner}`"
let rec go (args: SExpr list) =
match args with
| [] -> []
| SAtom { Token = Symbol n; Range = r } :: rest -> binder n r r description :: go rest
| (SList([ SAtom { Token = Colon }; SAtom { Token = Symbol n; Range = r }; _ ], whole)) :: rest ->
binder n r whole description :: go rest
| SList([ SAtom { Token = Keyword k; Range = r }; _ ], whole) :: rest ->
binder k r whole (description + ", by keyword") :: go rest
| SAtom { Token = Keyword "rest" } :: SAtom { Token = Symbol n; Range = r } :: rest ->
binder n r r (description + ", the rest of the arguments") :: go rest
| SAtom { Token = Keyword k; Range = r } :: value :: rest ->
binder k r (getRange value) (description + ", by keyword") :: go rest
| _ :: rest -> go rest
go args
/// What a body form defines for the forms after it: `(def x ...)`,
/// `(def (: x T) ...)`, `(defun (f ...) ...)` and their kin.
let private definedBy (form: SExpr) : Binder list =
match form with
| SList(SAtom { Token = Symbol("def" | "def/mutable") } :: target :: _, whole) ->
match target with
| SAtom { Token = Symbol n; Range = r } -> [ binder n r whole "defined in this body" ]
| SList(SAtom { Token = Colon } :: SAtom { Token = Symbol n; Range = r } :: _, _) ->
[ binder n r whole "defined in this body" ]
| SList(names, _) -> names |> List.collect (patternNames "defined in this body" whole)
| _ -> []
| SList(SAtom { Token = Symbol head } :: SList(SAtom { Token = Symbol n; Range = r } :: _, sig') :: _, _) when
head = "defun" || head = "defbjo" || head = "defbjouble"
->
[ binder n r sig' $"a `%s{head}` in this body" ]
| _ -> []
/// The bindings of a `let`'s list, each as its names and its value.
let private letBindings (description: string) (bindings: SExpr list) : (Binder list * SExpr option) list =
bindings
|> List.map (fun b ->
match b with
| SList([ SAtom { Token = Symbol n; Range = r }; value ], whole) -> [ binder n r whole description ], Some value
| SList([ SList(SAtom { Token = Colon } :: SAtom { Token = Symbol n; Range = r } :: _, _); value ], whole) ->
[ binder n r whole description ], Some value
| SList([ pattern; value ], whole) -> patternNames description whole pattern, Some value
| other -> [], Some other)
/// The names a loop's clauses bind, which its other clauses, its `=>` and,
/// in a comprehension, the expression before them see. A loop variable is in
/// scope across the whole form rather than only after its clause; that is
/// more than the loop allows, and enough to find a name by.
let rec private loopNames (items: SExpr list) : Binder list =
items
|> List.collect (fun item ->
match item with
| SList(SAtom { Token = Keyword("for" | "with" | "let" | "when-let" | "finish-let" | "break-let" as k) }
:: pattern
:: _,
whole) -> patternNames $"bound by a `:%s{k}` clause" whole pattern
| SList(SAtom { Token = Keyword "acc" } :: SAtom { Token = Symbol n; Range = r } :: _, whole) ->
[ binder n r whole "a loop accumulator" ]
| SList(SAtom { Token = Keyword "subloop" } :: inner, _) -> loopNames inner
| SList([ SAtom { Token = Symbol n; Range = r }; _ ], whole) when
// A `:subloop`'s `=> (name expr)`, the only bare list among clauses.
not (Char.IsUpper n[0])
->
[ binder n r whole "a subloop's result" ]
| _ -> [])
/// The names in scope at the position, innermost last, inside `s`; `None`
/// if the position is not in it.
let rec private inForm (line: int) (column: int) (env: Binder list) (s: SExpr) : Binder list option =
if not (holds line column (getRange s)) then
None
else
match s with
| SAtom _ -> Some env
| SList(items, _) -> Some(inList line column env items)
and private inList (line: int) (column: int) (env: Binder list) (items: SExpr list) : Binder list =
let body env forms = inBody line column env forms
match items with
| SAtom { Token = Symbol head } :: SList(SAtom { Token = Symbol name } :: args, _) :: _ when
head.StartsWith "def" && head <> "def" && head <> "def/mutable"
->
// `(defun (f x y) body...)`: the parameters over the whole form, the
// head included, so that a parameter is found where it is written.
body (env @ parameters name args) items
| SAtom { Token = Symbol("fun" | "bjoroutine") } :: SList(args, _) :: _ -> body (env @ parameters "" args) items
| SAtom { Token = Symbol("let" | "let*" | "letrec" | "let/mono" | "with-open" as head) } :: rest ->
let named, bindingList, bodyForms =
match rest with
| SAtom { Token = Symbol n; Range = r } :: SList(bs, _) :: forms ->
[ binder n r r "the name of a named `let`, called to loop" ], bs, forms
| SList(bs, _) :: forms -> [], bs, forms
| forms -> [], [], forms
let bindings = letBindings $"bound by `%s{head}`" bindingList
let all = named @ (bindings |> List.collect fst)
// A value sees what is around the `let`, and in a `let*` or a
// `letrec` the bindings before it, or all of them.
let inValue =
bindings
|> List.indexed
|> List.tryPick (fun (i, (_, value)) ->
value
|> Option.bind (fun v ->
let seen =
match head with
| "letrec" -> env @ all
| "let*" -> env @ named @ (bindings |> List.take i |> List.collect fst)
| _ -> env
inForm line column seen v))
match inValue with
| Some found -> found
| None ->
let inBinders = all |> List.exists (fun b -> holds line column b.Range)
if inBinders then env @ all else body (env @ all) bodyForms
| SAtom { Token = Symbol "with-return" } :: SAtom { Token = Symbol n; Range = r } :: forms ->
body (env @ [ binder n r r "the escape of a `with-return`, called to return" ]) forms
| SAtom { Token = Symbol "match" } :: scrutinee :: arms ->
match inForm line column env scrutinee with
| Some found -> found
| None ->
arms
|> List.tryPick (fun arm ->
match arm with
| SList(pattern :: _, _) ->
let bound = patternNames "bound by a `match` pattern" (getRange pattern) pattern
inForm line column (env @ bound) arm
| _ -> inForm line column env arm)
|> Option.defaultValue env
| SAtom { Token = Symbol("loop" | "seql" | "comprehension") } :: rest -> body (env @ loopNames rest) items
| _ -> body env items
/// The names in scope in a body at the position: a definition scopes over
/// the forms after it.
and private inBody (line: int) (column: int) (env: Binder list) (forms: SExpr list) : Binder list =
let rec go (env: Binder list) (forms: SExpr list) =
match forms with
| [] -> env
| form :: rest ->
match inForm line column env form with
| Some found -> found
| None -> go (env @ definedBy form) rest
go env forms
/// The local names in scope at `line` (1-based) and `column` (0-based) among
/// a file's top-level forms, innermost last. A top-level definition is not
/// among them: `Symbols` has those.
let at (forms: SExpr list) (line: int) (column: int) : Binder list =
forms |> List.tryPick (inForm line column []) |> Option.defaultValue []
/// The binding `name` refers to at the position, if it is a local one: the
/// innermost of that name.
let find (forms: SExpr list) (line: int) (column: int) (name: string) : Binder option =
at forms line column |> List.tryFindBack (fun b -> b.Name = name)