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)