feat(compiler): list literals, typed compound assignment, file:line diagnostics
- `[a, b, c]` list literals (E_LIST → emit_list); static_type learns slice-element, `new T`, list, string and literal kinds - `x op= y` lowers through the same path as `x = x op y` (emit_bin_vals): fixed `*=`/`/=` use the Q16.16 64-bit paths, string `+=` concatenates, int→long widens; unary `-` keeps a fixed operand's type (arith_ty) - one `unescape()` table for "strings", 'chars' and `interpolation`; `'\''`, `'\\'`, `'\"'` no longer read as 0; unterminated char literals and unexpected characters are errors instead of silently skipped - every diagnostic is `file:line: error: msg` (g_parse_file / g_err_file, Node.file + Node.line set by node()); tok_desc() in expectation errors; duplicate `function` names and unknown `phase` names are reported in source terms (phase_id used to default unknown phases to Overlay) - interpolation holes skip braces inside string literals - hand-IR preludes move from the user `@fn_` prefix to `@lp_` so a user `is_ws` / `str_eq` / `path_join` no longer collides at link time - `@ClearColor(expr)` accepts any constant expression; `Os.pid()` added (docs page + inventory); `str_starts()` in support/str - main.ludic: `else if` flag ladder, char literals, stale script comments - examples/lang/operators.ludic covers all of the above; os.ludic covers Os.pid; docs pages for Os.pid and the Overlay phase; ten changesets - reseeded: selfhost/ludicc.seed.ll is the new compiler's own fixpoint Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ad548840c7
commit
647dfec334
88 changed files with 30081 additions and 29179 deletions
|
|
@ -1,107 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Create a Forgejo release and attach build artifacts.
|
||||
|
||||
forgejo_release.py <tag> <body-file> <assets-dir>
|
||||
|
||||
Reads the access token from $FORGEJO_TOKEN. `x release --publish` calls this
|
||||
after it has pushed the tag; it is the one piece of the release flow that speaks
|
||||
HTTP, which the Ludic task runner cannot do yet (a native Http client is issue
|
||||
#6). Python stdlib only — no third-party deps — matching the docgen tooling.
|
||||
|
||||
Idempotent-ish: if the release for <tag> already exists it is reused, and each
|
||||
asset in <assets-dir> is uploaded (a duplicate name is skipped, not fatal).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API = "https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic"
|
||||
|
||||
|
||||
def token():
|
||||
t = os.environ.get("FORGEJO_TOKEN", "").strip()
|
||||
if not t:
|
||||
sys.exit("forgejo_release: FORGEJO_TOKEN is not set")
|
||||
return t
|
||||
|
||||
|
||||
def api(method, path, tok, data=None, headers=None, raw=False):
|
||||
url = API + path
|
||||
h = {"Authorization": "token " + tok}
|
||||
body = None
|
||||
if data is not None and not raw:
|
||||
h["Content-Type"] = "application/json"
|
||||
body = json.dumps(data).encode()
|
||||
elif raw:
|
||||
body = data
|
||||
h.update(headers or {})
|
||||
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return r.getcode(), json.loads(r.read().decode() or "null")
|
||||
|
||||
|
||||
def get_release_by_tag(tag, tok):
|
||||
try:
|
||||
_, rel = api("GET", "/releases/tags/" + tag, tok)
|
||||
return rel
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
tag, body_file, assets_dir = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
tok = token()
|
||||
|
||||
body = ""
|
||||
if os.path.exists(body_file):
|
||||
with open(body_file, encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
|
||||
rel = get_release_by_tag(tag, tok)
|
||||
if rel is None:
|
||||
_, rel = api("POST", "/releases", tok, {
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": tag.startswith("v0."),
|
||||
})
|
||||
print("created release", tag, "id", rel["id"])
|
||||
else:
|
||||
print("release", tag, "already exists (id %s); uploading assets" % rel["id"])
|
||||
|
||||
rid = rel["id"]
|
||||
existing = {a["name"] for a in (rel.get("assets") or [])}
|
||||
if os.path.isdir(assets_dir):
|
||||
for name in sorted(os.listdir(assets_dir)):
|
||||
if name in existing:
|
||||
print(" skip (exists):", name)
|
||||
continue
|
||||
path = os.path.join(assets_dir, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path, "rb") as fh:
|
||||
blob = fh.read()
|
||||
boundary = "----ludicrelease7f3b2a"
|
||||
payload = (
|
||||
("--%s\r\n" % boundary)
|
||||
+ ('Content-Disposition: form-data; name="attachment"; filename="%s"\r\n' % name)
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + blob + ("\r\n--%s--\r\n" % boundary).encode()
|
||||
code, _ = api(
|
||||
"POST", "/releases/%s/assets?name=%s" % (rid, name), tok,
|
||||
data=payload, raw=True,
|
||||
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
|
||||
)
|
||||
print(" uploaded:", name, "(HTTP %d)" % code)
|
||||
print("done:", API + "/releases/tag/" + tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -217,6 +217,7 @@
|
|||
"phase-fixedupdate",
|
||||
"phase-input",
|
||||
"phase-lateupdate",
|
||||
"phase-overlay",
|
||||
"phase-render",
|
||||
"phase-start",
|
||||
"phase-update"
|
||||
|
|
@ -601,6 +602,7 @@
|
|||
"os-set_env",
|
||||
"os-unset_env",
|
||||
"os-exit",
|
||||
"os-pid",
|
||||
"os-platform",
|
||||
"os-arch",
|
||||
"os-stdout_write",
|
||||
|
|
|
|||
|
|
@ -1,301 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Drive build/ludic-lsp over real LSP traffic against the real source tree.
|
||||
|
||||
Every assertion here is about a feature an editor actually calls: if this
|
||||
passes, VS Code, JetBrains, Neovim and the rest are exercising code paths that
|
||||
have been checked, because they all speak to this one binary.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# the toolchain binaries live in bin/ (built by `x tools`); LUDIC_LSP overrides.
|
||||
SERVER = os.environ.get("LUDIC_LSP", os.path.join(ROOT, "bin", "ludic-lsp"))
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, got, want):
|
||||
global passed, failed
|
||||
if got == want:
|
||||
passed += 1
|
||||
print(f" ok {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" FAIL {name}\n expected {want!r}, got {got!r}")
|
||||
|
||||
|
||||
def check_true(name, cond, detail=""):
|
||||
check(name, bool(cond), True) if cond else check(name + (f" ({detail})" if detail else ""), False, True)
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, cwd):
|
||||
self.p = subprocess.Popen([SERVER], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, cwd=cwd)
|
||||
self.diagnostics = {}
|
||||
|
||||
def _send(self, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.p.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
|
||||
self.p.stdin.flush()
|
||||
|
||||
def _read(self):
|
||||
header = b""
|
||||
while not header.endswith(b"\r\n\r\n"):
|
||||
c = self.p.stdout.read(1)
|
||||
if not c:
|
||||
return None
|
||||
header += c
|
||||
length = int([l for l in header.decode().split("\r\n")
|
||||
if l.lower().startswith("content-length")][0].split(":")[1])
|
||||
return json.loads(self.p.stdout.read(length))
|
||||
|
||||
def request(self, method, params, _id=[0]):
|
||||
_id[0] += 1
|
||||
rid = _id[0]
|
||||
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||
while True:
|
||||
msg = self._read()
|
||||
if msg is None:
|
||||
raise RuntimeError(f"server closed during {method}")
|
||||
if msg.get("id") == rid:
|
||||
return msg.get("result")
|
||||
if msg.get("method") == "textDocument/publishDiagnostics":
|
||||
p = msg["params"]
|
||||
self.diagnostics[os.path.basename(p["uri"])] = [d["message"] for d in p["diagnostics"]]
|
||||
|
||||
def notify(self, method, params):
|
||||
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
||||
|
||||
def open(self, path, language_id="ludic"):
|
||||
with open(path) as fh:
|
||||
text = fh.read()
|
||||
self.notify("textDocument/didOpen", {"textDocument": {
|
||||
"uri": "file://" + path, "languageId": language_id, "version": 1, "text": text}})
|
||||
return text
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.request("shutdown", None)
|
||||
self.notify("exit", None)
|
||||
except Exception:
|
||||
pass
|
||||
self.p.kill()
|
||||
|
||||
|
||||
def position_of(text, needle, offset=0):
|
||||
for i, line in enumerate(text.split("\n")):
|
||||
if needle in line:
|
||||
return {"line": i, "character": line.index(needle) + offset}
|
||||
raise AssertionError(f"{needle!r} not found")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.access(SERVER, os.X_OK):
|
||||
print(" skip ludic-lsp missing")
|
||||
return 2
|
||||
|
||||
c = Client(ROOT)
|
||||
caps = c.request("initialize", {"rootUri": "file://" + ROOT, "capabilities": {}})["capabilities"]
|
||||
c.notify("initialized", {})
|
||||
|
||||
for cap in ["completionProvider", "hoverProvider", "definitionProvider",
|
||||
"referencesProvider", "renameProvider", "documentSymbolProvider",
|
||||
"workspaceSymbolProvider", "documentFormattingProvider",
|
||||
"semanticTokensProvider", "foldingRangeProvider",
|
||||
"signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]:
|
||||
check_true(f"advertises {cap}", cap in caps)
|
||||
|
||||
# ---- a fragment file: no `program` block, only reachable through an import --
|
||||
frag = os.path.join(ROOT, "examples/games/chronorift/combat.ludic")
|
||||
text = c.open(frag)
|
||||
uri = {"uri": "file://" + frag}
|
||||
|
||||
syms = c.request("textDocument/documentSymbol", {"textDocument": uri})
|
||||
check_true("document symbols in an imported fragment", len(syms) >= 3)
|
||||
|
||||
# a field reached through a query binding, declared in ANOTHER file
|
||||
pos = position_of(text, "st.guard", 3)
|
||||
hov = c.request("textDocument/hover", {"textDocument": uri, "position": pos})
|
||||
check_true("hover resolves a field through a query binding",
|
||||
hov and "Stats.guard: int" in hov["contents"]["value"])
|
||||
|
||||
d = c.request("textDocument/definition", {"textDocument": uri, "position": pos})
|
||||
check_true("go-to-definition crosses files", d and d["uri"].endswith("world.ludic"))
|
||||
|
||||
# a binding is not yet inside its own scope at the point it is written, so
|
||||
# resolving it has to go through the declaration table, not scope lookup
|
||||
qpos = position_of(text, "for (e) in query", 5)
|
||||
qhov = c.request("textDocument/hover", {"textDocument": uri, "position": qpos})
|
||||
check_true("hover works on a query binding at its declaration site",
|
||||
qhov and "query binding" in qhov["contents"]["value"],
|
||||
json.dumps(qhov)[:80])
|
||||
|
||||
comp = c.request("textDocument/completion", {"textDocument": uri,
|
||||
"position": position_of(text, "st.guard", 3)})
|
||||
labels = [i["label"] for i in comp["items"]]
|
||||
check_true("completion after '.' offers only that property's fields",
|
||||
"guard" in labels and "hp" in labels and "clear" not in labels)
|
||||
|
||||
# a query's term list admits propertys and models and nothing else —
|
||||
# not the loop variables, not builtins, not keywords
|
||||
qsrc = ('program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n'
|
||||
' handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
|
||||
qfile = "/tmp/ludic_lsp_test_query.ludic"
|
||||
with open(qfile, "w") as fh:
|
||||
fh.write(qsrc)
|
||||
c.open(qfile)
|
||||
quri = {"uri": "file://" + qfile}
|
||||
qcomp = c.request("textDocument/completion",
|
||||
{"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)})
|
||||
qlabels = sorted(i["label"] for i in qcomp["items"])
|
||||
check("completion inside query terms lists propertys only", qlabels, ["Pos", "Vel"])
|
||||
body = c.request("textDocument/completion",
|
||||
{"textDocument": quri, "position": position_of(qsrc, "] { }", 3)})
|
||||
blabels = [i["label"] for i in body["items"]]
|
||||
check_true("completion in the loop body still has locals and builtins",
|
||||
"a" in blabels and "clear" in blabels)
|
||||
os.unlink(qfile)
|
||||
|
||||
refs = c.request("textDocument/references", {"textDocument": uri,
|
||||
"position": position_of(text, "query [Stats", 7),
|
||||
"context": {"includeDeclaration": True}})
|
||||
files = {os.path.basename(r["uri"]) for r in refs}
|
||||
check_true("find-usages spans the compilation unit", len(files) >= 3, str(sorted(files)))
|
||||
|
||||
hints = c.request("textDocument/inlayHint", {"textDocument": uri,
|
||||
"range": {"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 9999, "character": 0}}})
|
||||
check_true("inlay hints name query binding types", any(": Stats" == h["label"] for h in hints))
|
||||
|
||||
# a namespaced call resolves to its underlying bare-builtin signature
|
||||
sig = c.request("textDocument/signatureHelp",
|
||||
{"textDocument": uri, "position": position_of(text, "Random.chance(", 14)})
|
||||
check_true("signature help for a namespaced builtin",
|
||||
sig and sig["signatures"][0]["label"].startswith("rng_chance("))
|
||||
|
||||
toks = c.request("textDocument/semanticTokens/full", {"textDocument": uri})
|
||||
check_true("semantic tokens produced", len(toks["data"]) // 5 > 100)
|
||||
check_true("semantic token stream is well formed", len(toks["data"]) % 5 == 0)
|
||||
|
||||
folds = c.request("textDocument/foldingRange", {"textDocument": uri})
|
||||
check_true("folding ranges produced", len(folds) > 5)
|
||||
|
||||
# ---- structural diagnostics arrive while typing --------------------------
|
||||
c.notify("textDocument/didChange", {"textDocument": {"uri": uri["uri"], "version": 2},
|
||||
"contentChanges": [{"text": text + "\n{ oops"}]})
|
||||
c.request("textDocument/documentSymbol", {"textDocument": uri})
|
||||
check_true("unbalanced brace reported while typing",
|
||||
any("unclosed" in m for m in c.diagnostics.get("combat.ludic", [])))
|
||||
|
||||
# ---- the LSP analyses opened files on its own ----------------------------
|
||||
# (Deep semantic diagnostics once came from `ludicc --fmt`; the compiler is
|
||||
# now written in Ludic and the LSP relies on its own parser. It still opens
|
||||
# any file and answers structural queries without a compiler present.)
|
||||
bad = "/tmp/ludic_lsp_test_bad.ludic"
|
||||
with open(bad, "w") as fh:
|
||||
fh.write("program Bad {\n property P { x: nosuchtype = 0 }\n}\n")
|
||||
c.open(bad)
|
||||
syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}})
|
||||
check_true("LSP opens and analyses a file with no compiler present",
|
||||
any(s["name"] == "Bad" for s in syms))
|
||||
os.unlink(bad)
|
||||
|
||||
# ---- markdown: ```ludic fences are real Ludic ---------------------------
|
||||
md = "/tmp/ludic_lsp_test.md"
|
||||
with open(md, "w") as fh:
|
||||
fh.write("# Title\n\nprose\n\n```ludic\nprogram D { property Pt { x: int = 0 } }\n```\n\n"
|
||||
"```python\nz = 1\n```\n")
|
||||
c.open(md, "markdown")
|
||||
mduri = {"uri": "file://" + md}
|
||||
mdsyms = c.request("textDocument/documentSymbol", {"textDocument": mduri})
|
||||
check_true("symbols found inside a markdown fence", [s["name"] for s in mdsyms] == ["D"])
|
||||
mdtoks = c.request("textDocument/semanticTokens/full", {"textDocument": mduri})
|
||||
check("markdown tokens start on the fence body line", mdtoks["data"][0], 5)
|
||||
# A fence is a snippet: elisions and half-shown blocks are the documentation
|
||||
# doing its job, so errors there would be noise. Highlighting still applies.
|
||||
check("no diagnostics are reported for markdown", c.diagnostics.get("ludic_lsp_test.md"), [])
|
||||
fmt = c.request("textDocument/formatting", {"textDocument": mduri,
|
||||
"options": {"tabSize": 2, "insertSpaces": True}})
|
||||
check_true("markdown formatting leaves prose and other fences alone",
|
||||
"prose" in fmt[0]["newText"] and "z = 1" in fmt[0]["newText"])
|
||||
os.unlink(md)
|
||||
|
||||
# the repository's own documentation must not light up with false errors
|
||||
for doc in ("LANGUAGE.md", "README.md", "COMPILING.md"):
|
||||
path = os.path.join(ROOT, doc)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
c.open(path, "markdown")
|
||||
c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + path}})
|
||||
check(f"{doc} opens with no diagnostics", c.diagnostics.get(doc, []), [])
|
||||
lang = os.path.join(ROOT, "LANGUAGE.md")
|
||||
if os.path.exists(lang):
|
||||
t = c.request("textDocument/semanticTokens/full", {"textDocument": {"uri": "file://" + lang}})
|
||||
check_true("LANGUAGE.md fences are highlighted", len(t["data"]) // 5 > 100)
|
||||
|
||||
# ---- rename has to be resolution-based, not textual ----------------------
|
||||
# `x` here is a field of Pos, a field of Vel, a local, and a parameter.
|
||||
# Renaming any one of them must leave the other three alone.
|
||||
amb = "/tmp/ludic_lsp_test_rename.ludic"
|
||||
with open(amb, "w") as fh:
|
||||
fh.write("program R {\n"
|
||||
" property Pos { x: int = 0, y: int = 0 }\n"
|
||||
" property Vel { x: int = 0 }\n"
|
||||
" function helper(x: int) -> int { return x + 1 }\n"
|
||||
" handler A phase Update {\n"
|
||||
" for (p) in query [Pos] {\n"
|
||||
" let x = 5\n"
|
||||
" p.x = p.x + x\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" handler B phase Update {\n"
|
||||
" for (v) in query [Vel] { v.x = v.x + 1 }\n"
|
||||
" }\n"
|
||||
"}\n")
|
||||
atext = c.open(amb)
|
||||
auri = {"uri": "file://" + amb}
|
||||
|
||||
def rename_at(pos):
|
||||
r = c.request("textDocument/rename",
|
||||
{"textDocument": auri, "position": pos, "newName": "ZZZ"})
|
||||
if not r or not r.get("changes"):
|
||||
return []
|
||||
return list(r["changes"].values())[0]
|
||||
|
||||
def lines_touched(edits):
|
||||
return sorted({e["range"]["start"]["line"] for e in edits})
|
||||
|
||||
field = rename_at(position_of(atext, "property Pos { x", 15))
|
||||
check("renaming Pos.x touches only its declaration and its uses",
|
||||
lines_touched(field), [1, 7])
|
||||
local = rename_at(position_of(atext, "let x = 5", 4))
|
||||
check("renaming a local touches only that local", lines_touched(local), [6, 7])
|
||||
param = rename_at(position_of(atext, "function helper(x", 16))
|
||||
check("renaming a parameter touches only that function", lines_touched(param), [3])
|
||||
check("renaming a parameter makes exactly two edits", len(param), 2)
|
||||
|
||||
# find-usages must be equally precise
|
||||
refs_x = c.request("textDocument/references",
|
||||
{"textDocument": auri,
|
||||
"position": position_of(atext, "property Vel { x", 15),
|
||||
"context": {"includeDeclaration": True}})
|
||||
check("find-usages of Vel.x does not pick up Pos.x",
|
||||
sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11])
|
||||
os.unlink(amb)
|
||||
|
||||
# ---- workspace-wide symbol search ---------------------------------------
|
||||
ws = c.request("workspace/symbol", {"query": "Stats"})
|
||||
check_true("workspace symbol search finds a property", any(s["name"] == "Stats" for s in ws))
|
||||
|
||||
c.close()
|
||||
print(f" {passed} passed, {failed} failed")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue