lsp-bjolang/test/smoke.py

630 lines
22 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Talks to the server as an editor would, over stdin and stdout, and checks
what it answers. One server process for every case, so that a second check
in the same process is tested too.
test/smoke.py # builds nothing; run `dotnet build -c Release` first
"""
import json
import os
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SERVER = os.path.join(ROOT, "bin", "Release", "net10.0", "BjolangLsp.dll")
def frame(message):
body = json.dumps(message).encode()
return b"Content-Length: %d\r\n\r\n" % len(body) + body
def read(stream):
length = None
while True:
line = stream.readline()
if not line:
raise EOFError("server closed stdout")
line = line.strip()
if not line:
break
name, _, value = line.decode().partition(":")
if name.lower() == "content-length":
length = int(value)
return json.loads(stream.read(length))
class Server:
def __init__(self):
self.proc = subprocess.Popen(
["dotnet", SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
self.next_id = 0
# Notifications read while waiting for something else: a check that
# fell due can publish between a request and its answer.
self.pending = []
def send(self, method, params=None, request=False):
message = {"jsonrpc": "2.0", "method": method, "params": params or {}}
if request:
self.next_id += 1
message["id"] = self.next_id
self.proc.stdin.write(frame(message))
self.proc.stdin.flush()
if not request:
return None
while True:
answer = read(self.proc.stdout)
if answer.get("id") == self.next_id:
return answer
self.pending.append(answer)
def change(self, uri, text, version):
self.send(
"textDocument/didChange",
{"textDocument": {"uri": uri, "version": version}, "contentChanges": [{"text": text}]},
)
def published(self, uri):
"""The diagnostics for `uri` among the notifications already read, as
a list of their diagnostics lists, and takes them out."""
mine = [
m for m in self.pending
if m.get("method") == "textDocument/publishDiagnostics" and m["params"]["uri"] == uri
]
self.pending = [m for m in self.pending if m not in mine]
return [m["params"]["diagnostics"] for m in mine]
def next_diagnostics(self, uri):
"""The next diagnostics published for `uri`, waiting for them if
none has come yet."""
earlier = self.published(uri)
if earlier:
return earlier[0]
while True:
message = read(self.proc.stdout)
if message.get("method") == "textDocument/publishDiagnostics" and message["params"]["uri"] == uri:
return message["params"]["diagnostics"]
self.pending.append(message)
def diagnostics_after(self, method, uri, text=None):
doc = {"uri": uri}
if text is not None:
doc.update(languageId="bjolang", version=1, text=text)
self.published(uri) # anything older is not the answer to this
self.send(method, {"textDocument": doc})
return self.next_diagnostics(uri)
failures = 0
def expect(name, ok, detail=""):
global failures
print(("ok: " if ok else "FAIL: ") + name + ("" if ok else f"\n {detail}"))
if not ok:
failures += 1
BROKEN = """(import (std prelude))
(: main (-> (Vec string) int))
(defun (main args)
(+ 1 "two"))
"""
FIXED = """(import (std prelude))
(: main (-> (Vec string) int))
(defun (main args)
(+ 1 2))
"""
UNCLOSED = """(import (std prelude))
(defun (main args)
(+ 1 2)
"""
SYMBOLS = """(import (std prelude))
(type (: Shape (Union (Circle int) (Square int) Empty)))
(type (: Point (Record (: x int) (: y int))))
(def/trait (Area %a)
(: area (-> %a int)))
(impl (Area Shape)
(defun (area s)
(match s
((Circle r) (* 3 (* r r)))
((Square a) (* a a))
(Empty 0))))
(def unit-size 1)
(: double (-> int int))
(defun (double n) (* 2 n))
(: main (-> (Vec string) int))
(defun (main args)
(double (area (Circle unit-size)))
(list-map double (list 1 2 3))
0)
"""
def position_of(text, needle, nth=0, offset=1):
"""The LSP position `offset` characters into the `nth` occurrence of
`needle` in `text`."""
at = -1
for _ in range(nth + 1):
at = text.index(needle, at + 1)
at += offset
line = text.count("\n", 0, at)
return {"line": line, "character": at - (text.rfind("\n", 0, at) + 1)}
def line_of(text, needle):
return text.count("\n", 0, text.index(needle))
def names(symbols):
return [(s["name"], names(s.get("children", []))) for s in symbols]
def symbols_and_definitions(server, tmp):
path = os.path.join(tmp, "symbols.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(SYMBOLS)
server.diagnostics_after("textDocument/didOpen", uri, SYMBOLS)
found = server.send("textDocument/documentSymbol", {"textDocument": {"uri": uri}}, request=True)["result"]
expect(
"the outline",
names(found)
== [
("Shape", [("Circle", []), ("Square", []), ("Empty", [])]),
("Point", [("x", []), ("y", [])]),
("Area", [("area", [])]),
("impl (Area Shape)", [("area", [])]),
("unit-size", []),
("double", []),
("main", []),
],
names(found),
)
def definition(text, needle, offset=1):
answer = server.send(
"textDocument/definition",
{"textDocument": {"uri": uri}, "position": position_of(text, needle, 0, offset)},
request=True,
)
return answer["result"]
d = definition(SYMBOLS, "(double (area")
expect(
"a name defined in the file",
d and d["uri"] == uri and d["range"]["start"]["line"] == line_of(SYMBOLS, "(defun (double"),
d,
)
d = definition(SYMBOLS, "(Circle unit-size")
expect(
"a constructor",
d and d["range"]["start"]["line"] == line_of(SYMBOLS, "(type (: Shape"),
d,
)
d = definition(SYMBOLS, "(list-map")
expect("a name from the prelude", d and d["uri"].endswith("/lib/std/prelude.bjo"), d)
# `n` is a parameter: it goes to where the parameter is written.
d = definition(SYMBOLS, "(* 2 n)", 5)
expect(
"a parameter goes to the parameter",
d and d["range"]["start"] == position_of(SYMBOLS, "(defun (double n)", 0, 15),
d,
)
def hover(text, needle, offset=1):
answer = server.send(
"textDocument/hover",
{"textDocument": {"uri": uri}, "position": position_of(text, needle, 0, offset)},
request=True,
)
result = answer["result"]
return result["contents"]["value"] if result else None
h = hover(SYMBOLS, "(double (area")
expect(
"hover: the declaration and the head",
h and "(: double (-> int int))" in h and "(defun (double n))" in h,
h,
)
h = hover(SYMBOLS, "(Circle unit-size")
expect("hover: a constructor's case and type", h and "(Circle int)" in h and "A case of `Shape`" in h, h)
h = hover(SYMBOLS, "(list-map")
expect(
"hover: the prelude's doc",
h and "Applies a function to each element of a list." in h and "**Parameters**" in h,
h,
)
h = hover(SYMBOLS, "(* 2 n)", 5)
expect("hover: a parameter says whose", h and "*A parameter of `double`.*" in h, h)
# Unsaved: the buffer gains a definition the file on disk does not have.
changed = SYMBOLS + "\n(defun (triple n) (* 3 n))\n(def nine (triple 3))\n"
server.send(
"textDocument/didChange",
{"textDocument": {"uri": uri, "version": 2}, "contentChanges": [{"text": changed}]},
)
d = definition(changed, "(triple 3)")
expect(
"the buffer, not the file",
d and d["range"]["start"]["line"] == line_of(changed, "(defun (triple"),
d,
)
found = server.send("textDocument/documentSymbol", {"textDocument": {"uri": uri}}, request=True)["result"]
expect("the outline follows the buffer", [n for n, _ in names(found)][-2:] == ["triple", "nine"], names(found))
# Half typed: the outline stays as it was rather than emptying.
server.send(
"textDocument/didChange",
{"textDocument": {"uri": uri, "version": 3}, "contentChanges": [{"text": changed + "(defun (half"}]},
)
found = server.send("textDocument/documentSymbol", {"textDocument": {"uri": uri}}, request=True)["result"]
expect(
"a half-typed form is read as if closed",
[n for n, _ in names(found)][-3:] == ["triple", "nine", "half"],
names(found),
)
# A stray closer cannot be mended by closing: the last outline stays.
server.send(
"textDocument/didChange",
{"textDocument": {"uri": uri, "version": 4}, "contentChanges": [{"text": changed + ")"}]},
)
found = server.send("textDocument/documentSymbol", {"textDocument": {"uri": uri}}, request=True)["result"]
expect(
"text that does not read keeps the last outline",
[n for n, _ in names(found)][-3:] == ["triple", "nine", "half"],
names(found),
)
server.diagnostics_after("textDocument/didClose", uri)
DOCUMENTED = """(import (std prelude))
(:doc #:file "documented.bjodoc")
(: squeeze (-> int int int int))
(defun (squeeze lo hi x) (max lo (min hi x)))
(: main (-> (Vec string) int))
(defun (main args) (squeeze 0 10 12))
"""
DOCUMENTED_DOC = '''(:doc squeeze
(summary "@code{x}, kept @strong{between} @em{lo} and hi.")
(arg lo "The least.")
(arg hi "The most.")
(arg x "The value.")
(returns "One of: @itemlist{@item{@code{lo}} @item{@code{hi}} @item{x}}")
(example """
(squeeze 0 10 12) ; 10
"""))
'''
def documented(server, tmp):
path = os.path.join(tmp, "documented.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(DOCUMENTED)
with open(os.path.join(tmp, "documented.bjodoc"), "w") as f:
f.write(DOCUMENTED_DOC)
found = server.diagnostics_after("textDocument/didOpen", uri, DOCUMENTED)
expect("the documented file checks", found == [], found)
answer = server.send(
"textDocument/hover",
{"textDocument": {"uri": uri}, "position": position_of(DOCUMENTED, "(squeeze 0")},
request=True,
)
h = answer["result"]["contents"]["value"] if answer["result"] else None
expect(
"hover: a doc from a .bjodoc, its markup as Markdown",
h
and "`x`, kept **between** *lo* and hi." in h
and "- `lo`: The least." in h
and "**Returns** One of:\n- `lo`\n- `hi`\n- x" in h
and "(squeeze 0 10 12) ; 10" in h,
h,
)
server.diagnostics_after("textDocument/didClose", uri)
COMPLETING = """(import (std prelude))
(defun (list-unwind xs) xs)
(: main (-> (Vec string) int))
(defun (main args)
;; list-u
(list-u
"""
def completing(server, tmp):
path = os.path.join(tmp, "completing.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(COMPLETING)
server.diagnostics_after("textDocument/didOpen", uri, COMPLETING)
def complete(needle, nth=0):
at = position_of(COMPLETING, needle, nth, len(needle))
answer = server.send(
"textDocument/completion", {"textDocument": {"uri": uri}, "position": at}, request=True
)
return at, answer["result"]["items"]
at, items = complete("(list-u", 0)
labels = {i["label"]: i for i in items}
expect(
"completion: the file's own name and the prelude's",
"list-unwind" in labels and "list-unfold" in labels,
sorted(labels)[:20],
)
expect(
"completion: only names starting with the word's first letter, in either case",
all(l[0] in "lL" for l in labels),
sorted(labels)[:20],
)
own = labels.get("list-unwind", {})
expect(
"completion: the item replaces the whole word",
own.get("textEdit", {}).get("range", {}).get("start") == {"line": at["line"], "character": at["character"] - 6},
own,
)
expect(
"completion: the file's own names sort first",
own.get("sortText", "").startswith("1") and labels["list-unfold"]["sortText"].startswith("2"),
(own.get("sortText"), labels.get("list-unfold", {}).get("sortText")),
)
resolved = server.send("completionItem/resolve", labels["list-unfold"], request=True)["result"]
doc = resolved.get("documentation", {}).get("value", "")
expect("completion: resolve adds the doc", "A list generated from a state." in doc, doc)
_, items = complete(";; list-u")
expect("completion: none in a comment", items == [], [i["label"] for i in items][:10])
# A builtin: no source to define it, but a type and a doc.
seq_text = COMPLETING.replace("(list-u\n", "(seq-unfold\n")
server.send(
"textDocument/didChange",
{"textDocument": {"uri": uri, "version": 2}, "contentChanges": [{"text": seq_text}]},
)
answer = server.send(
"textDocument/hover",
{"textDocument": {"uri": uri}, "position": position_of(seq_text, "(seq-unfold")},
request=True,
)
h = answer["result"]["contents"]["value"] if answer["result"] else None
expect(
"hover: a builtin's type and doc",
h and "(: seq-unfold (->" in h and "A sequence generated from a state, lazily." in h,
h,
)
server.diagnostics_after("textDocument/didClose", uri)
LOCALS = """(import (std prelude))
(: total (-> (List int) int int))
(defun (total xs (: bonus int))
(def base 10)
(let ((doubled (list-map (fun (x) (* 2 x)) xs))
(count 0))
(match doubled
((Cons first rest) (+ first bonus base count))
(Nil (loop (:for y xs)
(:acc sum (summing (+ y bonus)))
=> sum)))))
(: shadow (-> int int))
(defun (shadow v)
(let ((v (+ v 1)))
v))
(: typing (-> int int))
(defun (typing amount)
(let ((amplified (* amount 2)))
(+ am
"""
def locals_(server, tmp):
path = os.path.join(tmp, "locals.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(LOCALS)
server.diagnostics_after("textDocument/didOpen", uri, LOCALS)
def ask(method, needle, offset=1, nth=0):
at = position_of(LOCALS, needle, nth, offset)
return server.send(method, {"textDocument": {"uri": uri}, "position": at}, request=True)["result"]
def goes_to(needle, target, target_offset, offset=1, nth=0):
d = ask("textDocument/definition", needle, offset, nth)
return d and d["range"]["start"] == position_of(LOCALS, target, 0, target_offset), d
cases = [
("a parameter", "bonus base", "(: bonus int)", 3),
("a def in the body", "base count", "(def base", 5),
("a let binding", "doubled\n", "(doubled (list-map", 1),
("a fun's parameter", "x)) xs", "(fun (x)", 6),
("a match pattern's variable", "first bonus", "(Cons first", 6),
("a loop variable, from its :acc", "y bonus", "(:for y", 6),
("an accumulator, from =>", "sum)))))", "(:acc sum", 6),
("the inner of two of a name", "v))\n", "((v (+ v 1)))", 2),
("the outer, in the inner's value", "v 1)", "(shadow v)", 8),
]
for name, needle, target, target_offset in cases:
ok, d = goes_to(needle, target, target_offset, 0)
expect("local definition: " + name, ok, d)
h = ask("textDocument/hover", "bonus base", 0)
h = h and h["contents"]["value"]
expect("local hover: a typed parameter shows its type", h and "(: bonus int)" in h and "A parameter of `total`" in h, h)
h = ask("textDocument/hover", "first bonus", 0)
h = h and h["contents"]["value"]
expect("local hover: a pattern shows the pattern", h and "(Cons first rest)" in h and "`match` pattern" in h, h)
# Completion while the form is being typed: the locals of `typing`, and
# nothing of `total`'s.
at = position_of(LOCALS, "(+ am", 0, 5)
items = server.send("textDocument/completion", {"textDocument": {"uri": uri}, "position": at}, request=True)["result"]["items"]
labels = {i["label"]: i for i in items}
expect(
"local completion: what is in scope, first",
labels.get("amount", {}).get("sortText", "").startswith("0")
and labels.get("amplified", {}).get("sortText", "").startswith("0"),
sorted((l, i["sortText"]) for l, i in labels.items())[:10],
)
expect("local completion: another function's locals are not", "doubled" not in labels and "bonus" not in labels, sorted(labels)[:10])
doc = labels.get("amplified", {}).get("documentation", {}).get("value", "")
expect("local completion: its doc comes along", "(amplified (* amount 2))" in doc, doc)
server.diagnostics_after("textDocument/didClose", uri)
CHAIN_MACROS = """;; Two macros, one expanding to the other.
(import (std syntax-match))
(def/macro (twice form inject compare)
(syntax-match form
((_ e) #'(both ,e ,e))))
(def/macro (both form inject compare)
(syntax-match form
((_ a b) #'(let ((tmp ,a)) (+ tmp ,b)))))
"""
CHAIN = """(import "chain-macros.bjo")
(: main (-> (Vec string) int))
(defun (main args)
(twice 21))
"""
def expanding(server, tmp):
with open(os.path.join(tmp, "chain-macros.bjo"), "w") as f:
f.write(CHAIN_MACROS)
path = os.path.join(tmp, "chain.bjo")
uri = "file://" + path
with open(path, "w") as f:
f.write(CHAIN)
found = server.diagnostics_after("textDocument/didOpen", uri, CHAIN)
expect("expansion: a file with an imported macro module checks", found == [], found)
expect(
"expansion: the import was built by the installed compiler",
os.path.exists(os.path.join(tmp, "chain-macros.dll")),
os.listdir(tmp),
)
def expand(needle, **flags):
at = position_of(CHAIN, needle, 0, 2)
answer = server.send(
"bjolang/expandMacro", dict(textDocument={"uri": uri}, position=at, **flags), request=True
)
return answer["result"]
r = expand("(twice", full=False)
expect("expansion: one step", r and r["macro"] == "twice" and r["text"] == "(both 21 21)", r)
r = expand("(twice")
expect("expansion: all the way", r and r["text"] == "(let ((tmp 21)) (+ tmp 21))", r)
r = expand("(twice", hygienic=True)
expect("expansion: hygienic names kept", r and "tmp__" in r["text"], r)
r = expand("(defun (main", full=False)
expect("expansion: none where there is no macro call", r is None, r)
# An unsaved change is expanded as it stands, without waiting for the pause.
changed = CHAIN.replace("(twice 21)", "(twice (twice 1))")
server.change(uri, changed, 2)
at = position_of(changed, "(twice (twice", 0, 2)
r = server.send("bjolang/expandMacro", {"textDocument": {"uri": uri}, "position": at}, request=True)["result"]
expect(
"expansion: of the buffer, nested calls expanded too",
r and r["text"].count("(+ tmp") == 3 and "twice" not in r["text"] and "both" not in r["text"],
r,
)
server.diagnostics_after("textDocument/didClose", uri)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "smoke.bjo")
uri = "file://" + path
server = Server()
answer = server.send("initialize", {"processId": None, "rootUri": None, "capabilities": {}}, request=True)
expect("initialize answers capabilities", "capabilities" in answer.get("result", {}), answer)
server.send("initialized")
with open(path, "w") as f:
f.write(BROKEN)
found = server.diagnostics_after("textDocument/didOpen", uri, BROKEN)
expect("a type error is reported", len(found) >= 1 and found[0]["severity"] == 1, found)
expect(
"on the line it is on",
any(d["range"]["start"]["line"] == 4 for d in found),
json.dumps(found, indent=1),
)
for d in found:
print(" ", d["range"]["start"], d["message"].splitlines()[0])
# Saved: the editor sends the change, writes the file, and says so.
server.change(uri, FIXED, 2)
with open(path, "w") as f:
f.write(FIXED)
found = server.diagnostics_after("textDocument/didSave", uri)
expect("the fixed file has none", found == [], json.dumps(found, indent=1))
# Not saved: the check follows the buffer once typing pauses, while the
# file on disk stays fixed.
server.change(uri, UNCLOSED, 3)
found = server.next_diagnostics(uri)
expect("a parse error in an unsaved buffer is reported", len(found) >= 1, found)
for d in found:
print(" ", d["range"]["start"], d["message"].splitlines()[0])
# A burst of typing is checked once, after it, not once per change.
for version in range(4, 9):
server.change(uri, FIXED if version % 2 else BROKEN, version)
found = server.next_diagnostics(uri)
expect("a burst is checked as its last text", len(found) >= 1, found)
import time
time.sleep(1.0)
server.send("textDocument/documentSymbol", {"textDocument": {"uri": uri}}, request=True)
expect("and only once", server.published(uri) == [], "more than one check published")
found = server.diagnostics_after("textDocument/didClose", uri)
expect("closing clears them", found == [], found)
symbols_and_definitions(server, tmp)
documented(server, tmp)
completing(server, tmp)
locals_(server, tmp)
expanding(server, tmp)
answer = server.send("shutdown", request=True)
expect("shutdown answers", "result" in answer, answer)
server.send("exit")
expect("exit ends the process with 0", server.proc.wait(timeout=10) == 0)
sys.exit(1 if failures else 0)