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

View file

@ -13,6 +13,10 @@
here is in this project's bin. So the checkout's path is baked in,
and BJOLANG_HOME overrides it. -->
<BjolangHome>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../Bjolang'))</BjolangHome>
<!-- The build asks git about the repository for Source Link, and the
SDK's git reader refuses a repository whose objects are SHA-256,
as this one's are. Nothing here is published with Source Link. -->
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
</PropertyGroup>
<ItemGroup>
@ -21,6 +25,7 @@
<ItemGroup>
<Compile Include="Rpc.fs" />
<Compile Include="Project.fs" />
<Compile Include="Expansion.fs" />
<Compile Include="Check.fs" />
<Compile Include="Symbols.fs" />
@ -32,6 +37,13 @@
<ItemGroup>
<ProjectReference Include="$(BjolangHome)/Bjolang.fsproj" />
<!-- The compiler excludes this assembly's runtime copy, and the
exclusion does not pass through a project reference. A copy next
to the server is what its load context answers with when ASP.NET
Core asks for the assembly, and an older one makes every type that
needs it fail to load: a project that names WebApplicationBuilder
then fails to check. See the compiler's Bjolang.fsproj. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" ExcludeAssets="runtime" />
</ItemGroup>
</Project>

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 =

View file

@ -52,6 +52,19 @@ let private publish (uri: string) =
let buffers =
texts |> Seq.map (fun kv -> IO.Path.GetFullPath(uriToPath kv.Key), kv.Value) |> Map.ofSeq
// Before the check: the first file asked about decides which project's
// packages are installed, and they have to be before anything resolves.
let note: Check.Found list =
match Project.prepare (uriToPath uri) with
| Some message ->
[ { Line = 0
Column = 0
EndLine = 1
EndColumn = 0
IsError = false
Message = message } ]
| None -> []
let found =
try
Check.file buffers (uriToPath uri)
@ -59,7 +72,7 @@ let private publish (uri: string) =
Rpc.log $"check of %s{uri} failed: %O{ex}"
[]
Rpc.notify "textDocument/publishDiagnostics" (Check.publishParams uri found)
Rpc.notify "textDocument/publishDiagnostics" (Check.publishParams uri (note @ found))
/// When each changed file is next to be checked. A check waits for typing to
/// pause, as one per keystroke would queue up behind each other.

84
Project.fs Normal file
View file

@ -0,0 +1,84 @@
/// The project the server checks against: the packages `bjo` fetched for it,
/// which of them declared a .NET shared framework, and its NuGet packages.
///
/// `bjo` writes these under the project's `.bjo` and passes them to the
/// compiler as `--roots`, `--frameworks` and `--nuget`. A compiler process
/// serves one roots file, since every cache keyed by a path would answer from
/// a stale registry if the roots changed under it. So the server installs one
/// project's, that of the first file it is asked about, before anything is
/// resolved, and says so on a file it cannot serve as the command line would.
module BjolangLsp.Project
open System
open System.IO
/// The nearest directory at or above `dir` that holds a `manifest.bjodat`.
let rec private containing (dir: string) : string option =
if isNull dir then None
elif File.Exists(Path.Combine(dir, "manifest.bjodat")) then Some dir
else containing (Path.GetDirectoryName dir)
/// The project a file is in, if any.
let projectOf (path: string) : string option =
containing (Path.GetDirectoryName(Path.GetFullPath path))
let private rootsFile (project: string) = Path.Combine(project, ".bjo", "roots")
let private writtenAt (file: string) =
if File.Exists file then Some(File.GetLastWriteTimeUtc file) else None
type private State =
/// Nothing asked about yet.
| Undecided
/// The project whose inputs are installed, and when its roots file was
/// written then. `None` for a file in no project, or in a project whose
/// packages were not fetched: the standard library only.
| Serving of project: string option * roots: DateTime option
/// The inputs did not install, and why.
| Failed of project: string * message: string
let mutable private state = Undecided
/// Installs the inputs of the project `path` is in, the first time it is
/// called, and answers what the file's diagnostics should say about it: why
/// its packages are not seen as the command line sees them, or nothing.
let prepare (path: string) : string option =
let project = projectOf path
if state = Undecided then
state <-
match project with
| Some dir when File.Exists(rootsFile dir) ->
let frameworks = Path.Combine(dir, ".bjo", "frameworks")
let nuget = Path.Combine(dir, ".bjo", "nuget", "root")
let installed =
Bjolang.ProjectInputs.install
(Some(rootsFile dir))
(if File.Exists frameworks then Some frameworks else None)
[]
(if Directory.Exists nuget then Some nuget else None)
match installed with
| Ok() ->
Rpc.log $"checking against the packages of %s{dir}"
Serving(Some dir, writtenAt (rootsFile dir))
| Error message -> Failed(dir, message)
| Some _
| None -> Serving(None, None)
match state, project with
| Failed(dir, message), Some p when p = dir ->
Some $"The packages of this project did not load, so only the standard library is seen:\n%s{message}"
| Serving(Some dir, roots), Some p when p = dir ->
if writtenAt (rootsFile dir) <> roots then
Some "The project's packages changed after the language server started. Restart it to see them."
else
None
| Serving(None, _), Some p when not (File.Exists(rootsFile p)) ->
Some $"This project's packages are not fetched, so only the standard library is seen. Run `bjo fetch` in %s{p}, then restart the language server."
| Serving(None, _), Some p ->
Some $"The language server started on a file outside this project, and sees only the standard library. Restart it from a file in %s{p}."
| (Serving(Some dir, _) | Failed(dir, _)), Some p ->
Some $"The language server checks against the packages of %s{dir}. This file is in %s{p}: open it in a server of its own to see its packages."
| _ -> None

View file

@ -1,12 +1,16 @@
# BjolangLsp
A language server for Bjolang. It does five things:
A language server for Bjolang. It checks a file against its project's
packages (see "Projects"), and it does five things:
- Diagnostics: it checks a file with the compiler's frontend, as
`bjolang --check` does, and sends the errors and warnings to the editor.
It checks the editor's text, saved or not: when a file is opened or
saved, and 400 ms after typing pauses. Every open file is read from its
buffer, so an unsaved include or `.bjodoc` counts too.
buffer, so an unsaved include or `.bjodoc` counts too. A diagnostic marks
the form at the line and column the compiler names: an atom or a string
whole, a bracketed form to its closing bracket, and one over several lines
as far as its head, `(defun` or `(match`.
- An outline (`textDocument/documentSymbol`): the file's top-level
definitions, with a type's constructors or fields, a trait's methods and an
`impl`'s functions under them.
@ -66,6 +70,27 @@ The server is `bin/Release/net10.0/BjolangLsp.dll`, started as
`dotnet bin/Release/net10.0/BjolangLsp.dll`. It talks LSP on stdin and
stdout, and logs to stderr.
## Projects
A file in a project, a directory with a `manifest.bjodat` at or above it, is
checked against the project's packages, as `bjo` compiles it: the roots file
`.bjo/roots` that says where each package is, `.bjo/frameworks` for the .NET
shared frameworks the packages declared, and `.bjo/nuget/root` for restored
NuGet packages. `bjo` writes them; `bjo fetch` is enough. Imports from the
packages then check, and go to definition, hover and completion reach them.
A compiler process serves one roots file, so a server serves the project of
the first file it is asked about, from start to exit. A warning on the first
line of a file says when it cannot serve that file as the command line
would:
- the project's packages are not fetched yet: run `bjo fetch`, then restart
the server;
- the roots file changed after the server started, as `bjo fetch` after a
change to the manifest does: restart the server;
- the file is in another project than the one the server serves: open it in
a server of its own, which lsp-mode and eglot do per project root.
## How it finds things
- The standard library and the runtime: through the checkout path baked in
@ -116,7 +141,9 @@ With eglot instead:
## What it does not do yet
- Columns. Most diagnostics carry only a line, and the whole line is marked.
- Exact ends. The compiler says where a diagnostic starts, and the form that
starts there is marked, as far as its head when it spans several lines.
That is the form the message is about in most cases, not in all.
- A check of a large file waits for the requests before it, and they for it:
everything that touches the compiler runs on one thread, because the
compiler's state is global.
@ -136,6 +163,7 @@ With eglot instead:
`import`, or of keyword arguments. A string that began on an earlier line
is not seen as one, so a multi-line string gets completions.
- Find references, rename.
- Packages: no `--roots` is passed, so only the standard library resolves.
- More than one project. A server serves the project of the first file it is
asked about; see "Projects" above.
- Stale imports are built by the installed compiler, which was tested only
for the standard library being current, not for a stale import.

View file

@ -566,6 +566,91 @@ def expanding(server, tmp):
server.diagnostics_after("textDocument/didClose", uri)
GREET = """(import (std prelude))
(export greet)
;; Says hello.
(: greet (-> string string))
(defun (greet name) (string-append "hello, " name))
"""
USES_GREET = """(import (std prelude))
(import (mylib greet))
(: main (-> (Vec string) int))
(defun (main args)
(println (greet "you"))
0)
"""
def packages():
"""A project's packages, as `bjo` wrote them into its `.bjo/roots`. Each
case has a server of its own, as one serves the project of the first file
it is asked about."""
def started():
server = Server()
server.send("initialize", {"processId": None, "rootUri": None, "capabilities": {}}, request=True)
server.send("initialized")
return server
def stopped(server):
server.send("shutdown", request=True)
server.send("exit")
server.proc.wait(timeout=10)
with tempfile.TemporaryDirectory() as tmp:
lib = os.path.join(tmp, "mylib", "src")
project = os.path.join(tmp, "app")
os.makedirs(lib)
os.makedirs(os.path.join(project, "src"))
os.makedirs(os.path.join(project, ".bjo"))
with open(os.path.join(lib, "greet.bjo"), "w") as f:
f.write(GREET)
with open(os.path.join(project, "manifest.bjodat"), "w") as f:
f.write('(package (name (app)) (version "0.1.0"))\n')
with open(os.path.join(project, ".bjo", "roots"), "w") as f:
f.write(f'(roots (root (mylib) "{lib}"))\n')
path = os.path.join(project, "src", "main.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(USES_GREET)
server = started()
found = server.diagnostics_after("textDocument/didOpen", uri, USES_GREET)
expect("packages: an import from the project's packages checks", found == [], json.dumps(found, indent=1))
at = position_of(USES_GREET, '(greet "you")')
d = server.send(
"textDocument/definition", {"textDocument": {"uri": uri}, "position": at}, request=True
)["result"]
expect(
"packages: go to definition reaches the package",
d and d["uri"] == "file://" + os.path.join(lib, "greet.bjo"),
d,
)
items = server.send(
"textDocument/completion",
{"textDocument": {"uri": uri}, "position": position_of(USES_GREET, '(greet "you")', 0, 4)},
request=True,
)["result"]["items"]
expect("packages: completion offers the package's names", any(i["label"] == "greet" for i in items))
stopped(server)
# The same project before `bjo fetch`: the server says what to do.
os.remove(os.path.join(project, ".bjo", "roots"))
server = started()
found = server.diagnostics_after("textDocument/didOpen", uri, USES_GREET)
expect(
"packages: unfetched, a warning says to run bjo fetch",
any(d["severity"] == 2 and "bjo fetch" in d["message"] for d in found),
json.dumps(found, indent=1),
)
stopped(server)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "smoke.bjo")
uri = "file://" + path
@ -584,6 +669,17 @@ with tempfile.TemporaryDirectory() as tmp:
any(d["range"]["start"]["line"] == 4 for d in found),
json.dumps(found, indent=1),
)
# `(+ 1 "two")`: the compiler says the column of the `1`, and the form
# there is what is marked.
expect(
"at its column, marking the form there",
any(
d["range"]["start"] == {"line": 4, "character": 5}
and d["range"]["end"] == {"line": 4, "character": 6}
for d in found
),
json.dumps(found, indent=1),
)
for d in found:
print(" ", d["range"]["start"], d["message"].splitlines()[0])
@ -626,4 +722,6 @@ with tempfile.TemporaryDirectory() as tmp:
server.send("exit")
expect("exit ends the process with 0", server.proc.wait(timeout=10) == 0)
packages()
sys.exit(1 if failures else 0)