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>
This commit is contained in:
Linus Björnstam 2026-10-20 19:30:50 +02:00
parent 77b8a92e03
commit cc62a8a360
6 changed files with 362 additions and 15 deletions

132
Check.fs
View file

@ -18,8 +18,8 @@ type Found =
IsError: bool
Message: string }
/// `at file.bjo:12` or `at file.bjo:12:3`, as `Lexer.formatPos` and
/// `Lexer.formatAt` write a location into a message.
/// `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+))?")
@ -31,10 +31,104 @@ let private withoutLocations (message: string) = location.Replace(message, "").T
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 (file: string) (d: Diagnostics.Diagnostic) =
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.
@ -51,10 +145,17 @@ let private placeIn (file: string) (d: Diagnostics.Diagnostic) =
let endLine = max line (r.End.Line - 1)
let endColumn = r.End.Column
// A range with no width is still something to underline: the rest of
// its line.
// 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
Some {| Line = line; Column = column; EndLine = line + 1; EndColumn = 0 |}
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
@ -64,18 +165,22 @@ let private placeIn (file: string) (d: Diagnostics.Diagnostic) =
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)
Some {| Line = line; Column = column; EndLine = line + 1; EndColumn = 0 |}
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 (file: string) (d: Diagnostics.Diagnostic) : Found =
let private found (text: string option) (file: string) (d: Diagnostics.Diagnostic) : Found =
let isError = d.Severity = Diagnostics.Error
match placeIn file d with
match placeIn text file d with
| Some p ->
{ Line = p.Line
Column = p.Column
@ -130,7 +235,14 @@ let file (buffers: Map<string, string>) (path: string) : Found list =
let _, report = Diagnostics.captured Timing.report
Rpc.log report
let collected = Diagnostics.snapshot () |> List.map (found path)
// 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 =