lsp-bjolang/Check.fs
Linus Björnstam cc62a8a360 A project's packages, and diagnostics at their column
A file in a project is checked against the packages bjo fetched for it:
the server installs the project's .bjo/roots, .bjo/frameworks and
.bjo/nuget/root before its first check, through the compiler's
ProjectInputs, so imports from packages check and definition, hover and
completion reach them. A server serves one project, the first file's; a
warning on a file's first line says when it cannot serve one as bjo
would: packages not fetched, the roots changed since it started, or
another project.

The server no longer has the old Microsoft.Extensions.DependencyInjection
.Abstractions next to it. The compiler excludes it, the exclusion does
not pass through a project reference, and with it there every ASP.NET
type failed to load, so bjoweb did not check.

A diagnostic marks the form at the line and column the compiler names
now: an atom or a string whole, a bracketed form to its closing bracket,
and one over several lines as far as its head.

The build no longer asks git for Source Link, which fails on this
repository's SHA-256 objects.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-5-5)

Co-Authored-By: eca-agent <git@eca.dev>
2026-10-20 19:30:50 +02:00

284 lines
11 KiB
FSharp

/// Checking one file with the compiler's frontend, and turning what it
/// collected into LSP diagnostics.
module BjolangLsp.Check
open System
open System.IO
open System.Text.Json.Nodes
open System.Text.RegularExpressions
open Bjolang
/// A diagnostic as the client gets it: 0-based lines and columns, end
/// exclusive.
type Found =
{ Line: int
Column: int
EndLine: int
EndColumn: int
IsError: bool
Message: string }
/// `at file.bjo:12:3`, as `Lexer.formatPos` and `Lexer.formatAt` write a
/// location into a message, the column counted from 1, or `at file.bjo:12`.
let private location = Regex(@"\n?\s*\bat (\S+?):(\d+)(?::(\d+))?\s*$", RegexOptions.Multiline)
let private firstLocation = Regex(@"\bat (\S+?):(\d+)(?::(\d+))?")
/// The message with the location lines the compiler appends taken off, since
/// the client shows the location as the diagnostic's range.
let private withoutLocations (message: string) = location.Replace(message, "").TrimEnd()
let private wholeLine (line: int) =
{| Line = line; Column = 0; EndLine = line + 1; EndColumn = 0 |}
let private isBreak (c: char) =
Char.IsWhiteSpace c || "()[]{}\"';".Contains c
/// What to mark of the form that starts at `line` and `column`, both from 0:
/// a string or an atom whole, a bracketed form to its closing bracket. A form
/// over several lines is marked as far as its head, `(defun` or `(match`, so
/// that an error in a definition does not mark all of it. `None` where the
/// text has no form there.
let private formAt (text: string) (line: int) (column: int) =
let lines = text.Replace("\r\n", "\n").Split('\n')
if line >= lines.Length || column >= lines[line].Length then
None
else
let here = lines[line]
// The end of the atom that starts at `i` on line `l`.
let atomEnd (l: string) (i: int) =
let mutable j = i
while j < l.Length && not (isBreak l[j]) do
j <- j + 1
j
let single (endColumn: int) =
Some {| Line = line; Column = column; EndLine = line; EndColumn = max (column + 1) endColumn |}
match here[column] with
| '"' ->
// To the closing quote, past escapes; to the end of the line when
// the string goes on.
let mutable j = column + 1
while j < here.Length && here[j] <> '"' do
if here[j] = '\\' then j <- j + 1
j <- j + 1
single (min here.Length (j + 1))
| '('
| '['
| '{' ->
// The closing bracket, counting brackets outside strings, comments
// and character literals.
let mutable depth = 0
let mutable l = line
let mutable i = column
let mutable closedAt = None
while closedAt.IsNone && l < lines.Length do
let s = lines[l]
let mutable inString = false
while closedAt.IsNone && i < s.Length do
let c = s[i]
if inString then
if c = '\\' then i <- i + 1
elif c = '"' then inString <- false
elif c = '"' then
inString <- true
elif c = ';' then
i <- s.Length
elif c = '#' && i + 1 < s.Length && s[i + 1] = '\\' then
i <- i + 2
elif "([{".Contains c then
depth <- depth + 1
elif ")]}".Contains c then
depth <- depth - 1
if depth = 0 then closedAt <- Some(l, i + 1)
i <- i + 1
if closedAt.IsNone then
l <- l + 1
i <- 0
match closedAt with
| Some(endLine, endColumn) when endLine = line -> single endColumn
| _ ->
// Over several lines, or never closed: the bracket and the
// head after it.
let mutable j = column + 1
while j < here.Length && Char.IsWhiteSpace here[j] do
j <- j + 1
single (if j < here.Length && not (isBreak here[j]) then atomEnd here j else column + 1)
| c when Char.IsWhiteSpace c -> None
| _ -> single (atomEnd here column)
/// Where a diagnostic is, if it is in `file`. Anywhere else, such as in an
/// imported module, it is `None`, and the caller puts it at the top of `file`
/// with the location left in the message.
let private placeIn (text: string option) (file: string) (d: Diagnostics.Diagnostic) =
let markFrom (line: int) (column: int) =
text |> Option.bind (fun t -> formAt t line column)
let sameFile (other: string) =
// A range carries the full path; a location read from a message only
// the file name, which is all `Lexer.formatPos` writes.
if Path.IsPathRooted other then
Path.GetFullPath other = Path.GetFullPath file
else
other = Path.GetFileName file
match d.Where with
| Some r when sameFile r.File ->
// The lexer's lines are 1-based and its columns 0-based, as LSP's are.
let line = max 0 (r.Start.Line - 1)
let column = r.Start.Column
let endLine = max line (r.End.Line - 1)
let endColumn = r.End.Column
// A range with no width is still something to underline: the form
// there, else the rest of its line. A range over several lines, which
// is a whole declaration's, is marked as far as the form's head.
if endLine = line && endColumn <= column then
match markFrom line column with
| Some mark -> Some mark
| None -> Some {| Line = line; Column = column; EndLine = line + 1; EndColumn = 0 |}
elif endLine > line then
match markFrom line column with
| Some mark -> Some mark
| None -> Some {| Line = line; Column = column; EndLine = endLine; EndColumn = endColumn |}
else
Some {| Line = line; Column = column; EndLine = endLine; EndColumn = endColumn |}
| Some _ -> None
| None ->
let m = firstLocation.Match d.Message
if m.Success && sameFile m.Groups[1].Value then
let line = max 0 (int m.Groups[2].Value - 1)
// The column counts from 1, as `Lexer.formatPos` writes it.
if m.Groups[3].Success then
let column = max 0 (int m.Groups[3].Value - 1)
match markFrom line column with
| Some mark -> Some mark
| None -> Some {| Line = line; Column = column; EndLine = line + 1; EndColumn = 0 |}
else
Some(wholeLine line)
else
None
let private found (text: string option) (file: string) (d: Diagnostics.Diagnostic) : Found =
let isError = d.Severity = Diagnostics.Error
match placeIn text file d with
| Some p ->
{ Line = p.Line
Column = p.Column
EndLine = p.EndLine
EndColumn = p.EndColumn
IsError = isError
Message = withoutLocations d.Message }
| None ->
let where =
match d.Where with
| Some r -> $"\n at %s{Lexer.formatPos r}"
| None -> ""
let p = wholeLine 0
{ Line = p.Line
Column = p.Column
EndLine = p.EndLine
EndColumn = p.EndColumn
IsError = isError
Message = d.Message + where }
let private options: Build.Options = { Build.dependencyOptions with IsLibrary = false; Check = true }
/// Checks `file` as `bjolang --check` does, reading every file in `buffers`,
/// by full path, from there rather than from disk: the editor's unsaved text,
/// of the file and of any include or doc file that is open too.
///
/// In `Session.isolated`, so that one check leaves nothing behind for the
/// next, and in `Diagnostics.captured`, so that what the compiler prints goes
/// to a buffer rather than into the protocol. The buffer is only read when the
/// check failed with nothing collected: a failure the compiler printed rather
/// than recorded, such as a bug in it, which would otherwise fail silently.
let file (buffers: Map<string, string>) (path: string) : Found list =
Diagnostics.verbose <- false
Diagnostics.reset ()
Paths.sourceOverrides <- buffers
let status, printed =
try
Diagnostics.captured (fun () ->
try
Expansion.recording path (fun () -> Session.isolated (fun () -> Build.compile options path))
with ex ->
printfn "%s" (Diagnostics.humanize ex.Message)
1)
finally
Paths.sourceOverrides <- Map.empty
// With BJOLANG_TIMING set, where the check's time went, to the log.
if Timing.enabled then
let _, report = Diagnostics.captured Timing.report
Rpc.log report
// The text the check read, for what to mark at a column.
let text =
match buffers.TryFind(Path.GetFullPath path) with
| Some t -> Some t
| None when File.Exists path -> Some(File.ReadAllText path)
| None -> None
let collected = Diagnostics.snapshot () |> List.map (found text path)
if status <> 0 && not (collected |> List.exists (fun f -> f.IsError)) then
let message =
match printed.Trim() with
| "" -> "The check failed and said nothing about why."
| text -> text
collected
@ [ { Line = 0
Column = 0
EndLine = 1
EndColumn = 0
IsError = true
Message = message } ]
else
collected
/// The `textDocument/publishDiagnostics` parameters for `uri`.
let publishParams (uri: string) (found: Found list) : JsonNode =
let position (line: int) (character: int) =
JsonObject(dict [ "line", JsonValue.Create line :> JsonNode; "character", JsonValue.Create character ])
let diagnostic (f: Found) =
JsonObject(
dict
[ "range",
JsonObject(dict [ "start", position f.Line f.Column :> JsonNode; "end", position f.EndLine f.EndColumn ])
:> JsonNode
"severity", JsonValue.Create(if f.IsError then 1 else 2)
"source", JsonValue.Create "bjolang"
"message", JsonValue.Create f.Message ]
)
:> JsonNode
JsonObject(
dict
[ "uri", JsonValue.Create uri :> JsonNode
"diagnostics", JsonArray(found |> List.map diagnostic |> Array.ofList) ]
)