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

218 lines
8.3 KiB
FSharp

/// The macro expansions of the last check of each file, and the expansion of
/// one call, printed: one step, or all the way down.
///
/// The compiler hands each expansion to `Macro.observe` while it checks. A
/// call is found again by its range, which is its call site; a template's own
/// nodes carry that range too, so a call whose expansion is another macro
/// call expands again at the same site, and the form expanded is what tells
/// those steps apart.
module BjolangLsp.Expansion
open System
open System.Text
open Bjolang
open Bjolang.Lexer
open Bjolang.Ast
type Record =
{ Macro: string
Site: Range
Input: SExpr
Output: SExpr }
// ---------------------------------------------------------------------------
// Printing
// ---------------------------------------------------------------------------
let private punctuation =
Map.ofList
[ Colon, ":"
Dot, "."
Comma, ","
CommaAt, ",@"
Quote, "'"
SynQuote, "#'"
Spread, "..."
Hash, "#" ]
let private escaped (s: string) =
let b = StringBuilder("\"")
for c in s do
match c with
| '"' -> b.Append "\\\"" |> ignore
| '\\' -> b.Append "\\\\" |> ignore
| '\n' -> b.Append "\\n" |> ignore
| '\t' -> b.Append "\\t" |> ignore
| c -> b.Append c |> ignore
b.Append('"').ToString()
let private character (c: int) =
match c with
| 32 -> @"#\space"
| 10 -> @"#\newline"
| 9 -> @"#\tab"
| c -> @"#\" + Char.ConvertFromUtf32 c
/// An atom as source. `name` spells a symbol, so that a hygiene renaming can
/// be shown as the template wrote it. A keyword at the head of a list is a
/// clause's, `:for`; anywhere else an argument's, `#:when`.
let private atom (name: string -> string) (atHead: bool) (t: Token) =
match t with
| Symbol s
| ResolvedSymbol s -> name s
| Keyword k -> if atHead then ":" + k else "#:" + k
| QuotedSymbol s -> "'" + s
| TypeVar v -> "%" + v.TrimStart '%'
| NumberLit n -> n
| StringLit s -> escaped s
| CharLit c -> character c
| BoolLit b -> if b then "#t" else "#f"
| other -> Map.tryFind other punctuation |> Option.defaultValue "?"
/// A form on one line.
let rec private flat (name: string -> string) (atHead: bool) (s: SExpr) : string =
match s with
| SAtom t -> atom name atHead t.Token
| SList(items, _) -> "(" + (items |> List.mapi (fun i x -> flat name (i = 0) x) |> String.concat " ") + ")"
/// The heads whose first argument stays on their line and whose body is
/// indented by two: a binding form or a definition, as Emacs indents them.
let private bodyForms =
set
[ "let"; "let*"; "letrec"; "let/mono"; "when"; "unless"; "fun"; "bjoroutine"; "defun"; "defbjo"
"def"; "match"; "case"; "with-open"; "with-return"; "loop"; "seql"; "if"; "begin"; "parameterize"
"try"; "def/macro" ]
let private width = 78
/// A form, indented as a reader of Lisp expects: on one line if it fits, else
/// its head and first argument on the first line and the rest beneath, two in
/// for a body form and under the first argument for a call.
let rec private pretty (name: string -> string) (indent: int) (atHead: bool) (s: SExpr) : string =
let one = flat name atHead s
match s with
| SList(head :: args, _) when indent + one.Length > width && not args.IsEmpty ->
let headText = flat name true head
// A template's `match` arrives as `match__12`, which is still a match.
let isBody =
match head with
| SAtom { Token = Symbol h } -> bodyForms.Contains(RegularExpressions.Regex.Replace(h, @"__\d+$", ""))
| _ -> false
let first = pretty name (indent + headText.Length + 2) false args.Head
let restIndent =
match head with
| SAtom _ when not isBody -> indent + headText.Length + 2
| _ -> indent + 2
let rest =
args.Tail
|> List.map (fun a -> "\n" + String(' ', restIndent) + pretty name restIndent false a)
|> String.concat ""
// A list headed by a list, such as a `let`'s bindings or a `match`
// arm, keeps its items in a column.
match head with
| SList _ ->
let inner = indent + 1
"("
+ pretty name inner true head
+ (args |> List.map (fun a -> "\n" + String(' ', inner) + pretty name inner false a) |> String.concat "")
+ ")"
| _ -> "(" + headText + " " + first + rest + ")"
| _ -> one
// ---------------------------------------------------------------------------
// What the last checks expanded
// ---------------------------------------------------------------------------
/// Each file's expansions, from its last check, in the order they were made.
let private byFile = Collections.Generic.Dictionary<string, Record list>()
/// Each name hygiene made, and the spelling the template wrote.
let private renamed = Collections.Generic.Dictionary<string, string>()
/// Runs `check` with the compiler reporting its expansions, and keeps those
/// made in `path` as the file's.
let recording (path: string) (check: unit -> 'a) : 'a =
let seen = ResizeArray<Record>()
Macro.observe <-
Some(fun macro site input output names ->
for KeyValue(fresh, original) in names do
renamed[fresh] <- original
seen.Add
{ Macro = macro
Site = site
Input = input
Output = output })
try
check ()
finally
Macro.observe <- None
let full = IO.Path.GetFullPath path
// A form can be expanded more than once in one check, as a
// declaration is checked again in a copy; the first is kept.
byFile[full] <-
seen
|> Seq.filter (fun r -> r.Site.File <> "" && IO.Path.GetFullPath r.Site.File = full)
|> Seq.distinctBy (fun r -> r.Site, flat id false r.Input)
|> List.ofSeq
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 size (r: Range) = (r.End.Line - r.Start.Line) * 100000 + (r.End.Column - r.Start.Column)
/// The expansion of the innermost macro call at `line` (1-based) and
/// `column` (0-based) of `path`, from its last check: the macro's name and
/// the expansion as text. One step is what the macro made; `full` expands
/// every macro call in that too, as the compiler went on to.
///
/// `hygienic` keeps the names hygiene made, `tmp__37`, rather than showing
/// them as the template wrote them, which is what tells a template's name
/// from the caller's of the same spelling.
let at (path: string) (line: int) (column: int) (full: bool) (hygienic: bool) : (string * string) option =
match byFile.TryGetValue(IO.Path.GetFullPath path) with
| false, _ -> None
| true, records ->
let calls = records |> List.filter (fun r -> holds line column r.Site)
match calls |> List.sortBy (fun r -> size r.Site) |> List.tryHead with
| None -> None
| Some innermost ->
// The first expansion at a site is of the call as written; any
// after it are of what that made.
let call = records |> List.find (fun r -> r.Site = innermost.Site)
let name (s: string) =
if hygienic then s
else
match renamed.TryGetValue s with
| true, original -> original
| _ -> s
/// Every macro call in `s` replaced by its expansion, again and
/// again, as far as the check went.
let rec expand (depth: int) (s: SExpr) : SExpr =
match s with
| SList(items, r) when depth < 200 ->
let key = flat id false s
match records |> List.tryFind (fun x -> x.Site = r && flat id false x.Input = key) with
| Some x -> expand (depth + 1) x.Output
| None -> SList(items |> List.map (expand depth), r)
| _ -> s
let result = if full then expand 0 call.Output else call.Output
Some(call.Macro, pretty name 0 false result)