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>
218 lines
8.3 KiB
FSharp
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)
|