diff --git a/BjolangLsp.fsproj b/BjolangLsp.fsproj
index 152ef5a..0f37b68 100644
--- a/BjolangLsp.fsproj
+++ b/BjolangLsp.fsproj
@@ -13,6 +13,10 @@
here is in this project's bin. So the checkout's path is baked in,
and BJOLANG_HOME overrides it. -->
$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../Bjolang'))
+
+ false
@@ -21,6 +25,7 @@
+
@@ -32,6 +37,13 @@
+
+
diff --git a/Check.fs b/Check.fs
index 33f1226..502b037 100644
--- a/Check.fs
+++ b/Check.fs
@@ -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) (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 =
diff --git a/Program.fs b/Program.fs
index bb78fbc..5d0e64c 100644
--- a/Program.fs
+++ b/Program.fs
@@ -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.
diff --git a/Project.fs b/Project.fs
new file mode 100644
index 0000000..8291e8d
--- /dev/null
+++ b/Project.fs
@@ -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
diff --git a/README.md b/README.md
index 130bd84..71a46f6 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/test/smoke.py b/test/smoke.py
index 74485c6..a11162e 100755
--- a/test/smoke.py
+++ b/test/smoke.py
@@ -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)