From c78b940532d874ce4cd2b677099b348041ef9cf8 Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Thu, 13 Aug 2026 20:20:28 +0300 Subject: [PATCH 1/5] vscode: clean up the install the rename stranded, and find a sibling checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension appeared in the marketplace pane as `pol`, flagged "invalid extensions detected", offering to Install Locally a thing that was not there. An install made before "rename: pol becomes writ" is a symlink named pol.pol-0.1.0 pointing into a directory the rename took away. VS Code scans its extensions directory, cannot read a manifest through a dangling link, and reports the extension as invalid — naming `pol`, which the user cannot remove from the UI, because there is nothing behind it to uninstall. The entry in extensions.json outlives the directory and is what keeps re-reporting it. install.sh only ever removed writ.writ-0.1.0, so nothing cleaned this up: the rename shipped without a migration path, and every pre-rename install stayed broken and visible. It now purges legacy ids on INSTALL as well as uninstall — the person hitting this is installing the new extension, not remembering an old one — and tests `-e` OR `-L`, because `-e` is false for exactly the dangling case being cleaned. The removal is of the link, never through it, which would delete a live checkout. Underneath that sat a second failure the first one hid. candidates() joined the relative default onto workspace folders only, so the split into separate writ/ and writ-vscode/ repositories broke it: the natural thing to open is the directory holding both, and the default then resolves against a folder with no _build in it. The extension said "no language server" with a perfectly good one a single directory down — the same symptom as never having built it, which sends you off to check the wrong thing entirely. It now also tries checkouts one level below each workspace folder, identified by dune-project rather than by being named `writ`. One level, deliberately: this must not become a walk of the workspace, which on a large tree would cost more than it saves. install.sh had the same blind spot from the other side, looking only at $here/../.. for the engine — which in the split layout is the directory containing both repositories and never the engine itself. It now accepts $WRIT_REPO, ../.., or ../writ. Its verify step no longer pipes into an empty command name when there is no server to verify, which reported a shell error about "" rather than the thing that was actually true. TESTED, because all of this fails silently. test-resolve.js gains the split layout, exercised against a real directory tree: that a checkout one level down is found, that the workspace root still wins, that a sibling which is not a checkout is not guessed at, that the nested build still beats PATH, and that a workspace folder which does not exist is survivable rather than fatal at activation. The legacy purge was checked by recreating the dangling symlink and re-running the installer against it. Also: package-lock.json still called the package politics-constructor, and the run-path comments still named tooling/vscode/, which no longer exists. --- README.md | 21 ++++++-- extension.js | 37 +++++++++++++- install.sh | 121 +++++++++++++++++++++++++++++++++++++--------- package-lock.json | 4 +- test-resolve.js | 55 ++++++++++++++++++++- test-watcher.js | 2 +- 6 files changed, 206 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 5d3f2e7..ac4c08e 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,23 @@ installed. With no `writ-lsp` yet it warns and installs anyway: the server is resolved when the extension activates, so installing writ afterwards is a perfectly good order to do things in. -Dropped inside a checkout of [writ](https://github.com/writ-lang/writ) instead — -as `tooling/vscode/` — the same script builds the server from source first, and -that build wins over any installed one. That is what you want while changing -the language itself. +Given a checkout of [writ](https://github.com/writ-lang/writ) instead, the same +script builds the server from source first, and that build wins over any +installed one — which is what you want while changing the language itself. It +finds the checkout whether this extension sits *inside* it (as `tooling/vscode/`, +where it began) or *beside* it, the layout the split into two repositories +gives you: + +``` +projects/ + writ/ ← the engine; the server is built here + writ-vscode/ ← this extension +``` + +Open the directory holding both and the extension still finds that build: it +looks in each workspace folder and in the checkouts one level below it. If your +checkouts are somewhere else entirely, point `WRIT_REPO` at the engine when +installing, or set `writ.serverPath` to an absolute path. Then reload the window (Ctrl+Shift+P → *Developer: Reload Window*). VS Code only scans its extensions directory at startup, which is the one step a diff --git a/extension.js b/extension.js index 75fe710..29aa8ce 100644 --- a/extension.js +++ b/extension.js @@ -90,6 +90,34 @@ function onPath(exe) { return dirs.flatMap((d) => names.map((n) => path.join(d, n))); } +// A writ checkout, identified by the engine's own file rather than by being +// named `writ` — a checkout in a directory called something else is still one. +function isCheckout(dir) { + return fs.existsSync(path.join(dir, "dune-project")); +} + +// The checkouts one level below a workspace folder. +// +// WHY ONE LEVEL. Splitting writ and this extension into separate repositories +// means the natural thing to open is the directory that CONTAINS both, and then +// the relative default resolves against a folder that has no _build in it. The +// server is right there, one directory down, and the extension said "no language +// server" — the same symptom as not having built it, which is the wrong thing to +// go and check. One level covers that layout and stops; this must not become a +// walk of the workspace, which on a large tree would cost more than it saves. +function checkoutsBelow(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; // a folder that is gone or unreadable is not worth failing over + } + return entries + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => path.join(dir, e.name)) + .filter(isCheckout); +} + // Where to look for the server, in order. Returns every path tried, so a // failure can name them rather than say "not found". // @@ -110,7 +138,12 @@ function onPath(exe) { function candidates(configured) { if (path.isAbsolute(configured)) return [configured]; const folders = vscode.workspace.workspaceFolders || []; - const inWorkspace = folders.map((f) => path.join(f.uri.fsPath, configured)); + // The folder itself before anything under it: opening the checkout directly + // is the common case and must not be slowed down or second-guessed. + const inWorkspace = folders.flatMap((f) => [ + path.join(f.uri.fsPath, configured), + ...checkoutsBelow(f.uri.fsPath).map((d) => path.join(d, configured)), + ]); return [...inWorkspace, ...onPath(INSTALLED)]; } @@ -179,7 +212,7 @@ function deactivate() { module.exports = { activate, deactivate }; -// Exposed for tooling/vscode/test-watcher.js, which drives the watcher against a +// Exposed for test-watcher.js, which drives the watcher against a // real file on disk. The watcher is the one part of this glue with behaviour of // its own, and a watcher that silently never fires is the bug it exists to fix. module.exports.__test = { diff --git a/install.sh b/install.sh index 902a078..3dfd89c 100755 --- a/install.sh +++ b/install.sh @@ -3,8 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Install the .writ language support into VS Code. # -# ./tooling/vscode/install.sh # build, install, verify -# ./tooling/vscode/install.sh --uninstall # remove it again +# ./install.sh # build, install, verify +# ./install.sh --uninstall # remove it again # # Safe to re-run. Installs as a symlink into every VS Code extensions directory # it finds, so editing this repo updates the extension with no reinstall. @@ -13,14 +13,39 @@ set -euo pipefail here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) ext_id="writ.writ-0.1.0" -# INSIDE a writ checkout or not. This script ships with the extension, and the -# extension is usable both ways: from the writ repository, where the server is -# built from source two levels up, and on its own against a `writ-lsp` that -# an installed writ already put on PATH. Deciding by looking for the engine -# rather than by assuming, because `$here/../..` is a real directory either -# way — it is simply not the writ repo when the extension lives alone. -repo=$(cd "$here/../.." && pwd) -if [ -f "$repo/dune-project" ] && [ -d "$repo/core/stdlib" ]; then +# Extension ids this extension has been installed under BEFORE. The project was +# called `pol` (Partial Olog) and the extension `pol.pol`; see "rename: pol +# becomes writ". Kept so the rename can be migrated rather than left behind — +# purge_legacy below is the only thing that ever cleans these up. +legacy_globs=("pol.pol-*") + +# WHERE THE ENGINE IS. This script ships with the extension, and the extension is +# usable three ways, so the answer is looked up rather than assumed: +# +# 1. $WRIT_REPO — set it to say exactly which checkout to build. +# 2. $here/../.. — the extension living INSIDE a writ checkout, at +# tooling/vscode/, which is where it started. +# 3. $here/../writ — the extension as its OWN repository, checked out +# beside the engine. This is what the split into +# writ/ and writ-vscode/ actually produces, and +# looking only at `../..` missed it entirely: two +# levels up from a sibling checkout is the +# directory that CONTAINS both, never the engine. +# 4. no checkout at all — writ installed by opam pin or release tarball, +# where `writ-lsp` on PATH is the only server. +# +# Identified by the engine's own files, not by name, so a checkout in a directory +# called something else is still found. +is_repo() { [ -f "$1/dune-project" ] && [ -d "$1/core/stdlib" ]; } + +repo="" +for cand in "${WRIT_REPO:-}" "$here/../.." "$here/../writ"; do + [ -n "$cand" ] && [ -d "$cand" ] || continue + cand=$(cd "$cand" && pwd) + if is_repo "$cand"; then repo="$cand"; break; fi +done + +if [ -n "$repo" ]; then in_checkout=yes server="$repo/_build/default/tooling/lsp/bin/writ_lsp.exe" else @@ -52,8 +77,50 @@ mapfile -t ext_dirs < <(find_extension_dirs) [ -n "${ext_dirs[0]:-}" ] || die "no VS Code extensions directory found. Open this folder in VS Code once (which creates it), then re-run this script." +# Remove an install made under a name this extension no longer uses. +# +# WHY THIS EXISTS. An install from before the rename is a symlink named +# `pol.pol-0.1.0` pointing at a directory the rename took away. VS Code scans the +# extensions directory, cannot read a manifest through a dangling link, and +# reports "invalid extensions detected", naming `pol` — an extension the user +# CANNOT remove from the UI, because there is nothing behind it to uninstall. +# The marketplace pane then offers to "Install Locally" a ghost. Nothing in the +# renamed script removed it, so every pre-rename install stayed broken and +# visible. Run on install as well as uninstall: the person hitting this is +# installing the new extension, not uninstalling an old one they forgot about. +# +# `-e` is false for a dangling symlink, which is exactly the case to clean, so +# the test is `-e OR -L` and the removal is `rm -rf` on the link itself — never +# through it, which would delete a live checkout. +purge_legacy() { + local d g p + for d in "${ext_dirs[@]}"; do + for g in "${legacy_globs[@]}"; do + for p in "$d"/$g; do + [ -e "$p" ] || [ -L "$p" ] || continue + rm -rf "$p" + say "removed a pre-rename install: $p" + done + done + done + # VS Code also keeps a registry, and an entry there outlives the directory — + # it is what re-reports the extension as invalid. Only its own CLI can edit it + # safely, so ask, and stay quiet when the entry is already gone. + if command -v code >/dev/null 2>&1; then + local id + for g in "${legacy_globs[@]}"; do + id="${g%-*}" + if code --list-extensions 2>/dev/null | grep -qx "$id"; then + code --uninstall-extension "$id" >/dev/null 2>&1 && \ + say "deregistered $id from VS Code" + fi + done + fi +} + # ---------------------------------------------------------------- uninstall -- if [ "${1:-}" = "--uninstall" ]; then + purge_legacy for d in "${ext_dirs[@]}"; do if [ -e "$d/$ext_id" ] || [ -L "$d/$ext_id" ]; then rm -rf "$d/$ext_id" @@ -64,6 +131,8 @@ if [ "${1:-}" = "--uninstall" ]; then exit 0 fi +purge_legacy + # -------------------------------------------------------------------- build -- if [ "$in_checkout" = yes ]; then say "building the language server" @@ -104,20 +173,26 @@ done # ------------------------------------------------------------------- verify -- # Prove the server actually answers, rather than trusting that it built. A # handshake here is the difference between "installed" and "works". -say "verifying the server responds" -init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}' -exit_msg='{"jsonrpc":"2.0","method":"exit"}' -reply=$(printf 'Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s' \ - "${#init}" "$init" "${#exit_msg}" "$exit_msg" \ - | "$server" 2>/dev/null | tr -d '\r' || true) - -case "$reply" in - *documentSymbolProvider*completionProvider*|*completionProvider*documentSymbolProvider*) - say "server answered with its capabilities" ;; - *) - warn "the server did not answer a handshake as expected. The extension is +# +# Skipped, not attempted, when there is no server: the no-checkout path above +# already said why, and piping into an empty command name would report a shell +# error about "" instead of the thing that is actually true. +if [ -n "$server" ] && [ -x "$server" ]; then + say "verifying the server responds" + init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}' + exit_msg='{"jsonrpc":"2.0","method":"exit"}' + reply=$(printf 'Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s' \ + "${#init}" "$init" "${#exit_msg}" "$exit_msg" \ + | "$server" 2>/dev/null | tr -d '\r' || true) + + case "$reply" in + *documentSymbolProvider*completionProvider*|*completionProvider*documentSymbolProvider*) + say "server answered with its capabilities" ;; + *) + warn "the server did not answer a handshake as expected. The extension is installed, but check: $server" ;; -esac + esac +fi cat < 0); -const n = 10 - failed; +// 5. The workspace root is the PARENT of the checkout, not the checkout. +// +// This is what the split into separate writ/ and writ-vscode/ repositories +// produces: you open the directory that holds both, so the relative default +// resolves against a folder with no _build in it, and the extension reported +// "no language server" while a perfectly good one sat one level down. A +// checkout is identified by dune-project, so only a real one is added, and +// only immediate subdirectories are looked at — this must not become a walk +// of the whole workspace. +const os = require("os"); +const fs = require("fs"); +const root = fs.mkdtempSync(path.join(os.tmpdir(), "writ-resolve-")); +fs.mkdirSync(path.join(root, "writ")); +fs.writeFileSync(path.join(root, "writ", "dune-project"), "(lang dune 3.0)\n"); +fs.mkdirSync(path.join(root, "writ-vscode")); // a sibling that is NOT a checkout +fs.mkdirSync(path.join(root, "notes")); + +workspace = [folder(root)]; +c = candidates(DEFAULT); +check( + "a checkout one level below the workspace root is found", + c.includes(path.join(root, "writ", DEFAULT)) +); +check( + "the workspace root itself is still tried first", + c[0] === path.join(root, DEFAULT) +); +check( + "a subdirectory that is not a checkout is not guessed at", + !c.some((p) => p.startsWith(path.join(root, "notes") + path.sep)) +); +check( + "the nested checkout still beats anything on PATH", + c.indexOf(path.join(root, "writ", DEFAULT)) < + c.findIndex((p) => p.endsWith(path.sep + INSTALLED)) +); + +// An unreadable or absent folder must not throw — a workspace folder can name a +// disconnected drive, and resolution failing there would take the whole +// extension down at activation. +workspace = [folder(path.join(root, "gone"))]; +let threw = false; +try { + candidates(DEFAULT); +} catch { + threw = true; +} +check("a workspace folder that does not exist is survivable", !threw); + +fs.rmSync(root, { recursive: true, force: true }); + +const n = 15 - failed; console.log( failed === 0 ? `extension resolve tests: ${n} checks passed` diff --git a/test-watcher.js b/test-watcher.js index 3acffdc..1f41850 100644 --- a/test-watcher.js +++ b/test-watcher.js @@ -13,7 +13,7 @@ // are stubbed at the module loader. Nothing here needs the real ones: the // watcher only stats a path and calls `client.restart()`. // -// Run: node tooling/vscode/test-watcher.js +// Run: node test-watcher.js const Module = require("module"); const fs = require("fs"); From 974b2f17a4d5b3fb3f22bcad76858f1449063ab2 Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Fri, 14 Aug 2026 04:16:52 +0300 Subject: [PATCH 2/5] vscode: stop citing a checker that is not here, and keep dev files out of the .vsix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grammar's comments said .afk-flow/check-grammar.py asserts the guard, effect and rules-builtin alternations against the engine, and told the reader to keep them as whole ( | ) alternatives so that it could. No such script is in this repository, or in writ's. The word lists are still right — they match `guard_words` and `builtins` in core/syntax/rules_parser.ml, checked by hand here — but a comment promising a check that does not run is worse than no comment at all: it is exactly why nobody notices when the grammar drifts from the engine. The formatting rule stays, now with the reason that is actually true: so the sets can be diffed against that list by eye, and so a word added to the engine is visibly missing here. Only the "comment" fields changed. Every match regex is untouched, so nothing about the highlighting moves. .vscodeignore additionally keeps the tests, install.sh, the lockfile, .mailmap and .github/ out of a .vsix — shipping the installer inside the thing it installs is a loop that can only confuse, and the rest is repository plumbing that is invisible in the source tree and rides along unnoticed. docs/ stays in deliberately, because README.md embeds docs/images/writ-mark-200.png and the marketplace page loses its mark without it, as do LICENSE, NOTICE and AUTHORS, which ship with the work. VERIFIED with vsce rather than reasoned about, which is how .mailmap and .github/ were found at all — nothing else lists them. `vsce package` now yields 340 files, of which exactly fourteen are this extension's: the manifest, the client, the grammar, the language configuration, the icon, the README and its images, and the notices. The other 326 are node_modules and vsce's own two generated manifests. --- .vscodeignore | 20 ++++++++++++++++++++ syntaxes/writ.tmLanguage.json | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index 36da8fa..8e2f4ec 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -3,3 +3,23 @@ **/*.map **/.eslintrc.json node_modules/.bin/** + +# Development-only files. They belong in the repository and not in a .vsix: the +# tests drive the client against a real filesystem and cannot run inside an +# installed extension, and install.sh is the OTHER way to install this — shipping +# it inside the thing it installs is a loop that can only confuse. +test-*.js +install.sh +package-lock.json +CLA.md +CONTRIBUTING.md + +# Repository plumbing. `vsce ls` is the only thing that shows these — they are +# invisible in the source tree and would otherwise ride along into every .vsix. +.mailmap +.github/** + +# NOT ignored, deliberately: +# docs/** — README.md embeds docs/images/writ-mark-200.png, so the marketplace +# page loses its mark without it. 44K, and worth it. +# LICENSE, NOTICE, AUTHORS — AGPL-3.0-or-later; the notices ship with the work. diff --git a/syntaxes/writ.tmLanguage.json b/syntaxes/writ.tmLanguage.json index 542ef80..638d08f 100644 --- a/syntaxes/writ.tmLanguage.json +++ b/syntaxes/writ.tmLanguage.json @@ -73,12 +73,12 @@ "match": "(?<=\\()(arrow|to|fixed|vacatable|when|do|vacant)(?![-\\w])" }, "guard": { - "comment": "Guard connectives and leaf predicates (core/syntax/grammar.ml, guard): and/or/not/is/defined/some. This alternation is what .afk-flow/check-grammar.py asserts against the engine; keep the six as whole ( | ) alternatives. Head position only.", + "comment": "Guard connectives and leaf predicates (core/syntax/grammar.ml, guard): and/or/not/is/defined/some. These six are `guard_words` in core/syntax/rules_parser.ml; a word added there must be added here, or the editor silently paints it as a user relation. Kept as whole ( | ) alternatives so the set can be diffed against that list by eye. Head position only.", "name": "support.function.guard.writ", "match": "(?<=\\()(and|or|not|is|defined|some)(?![-\\w])" }, "effect": { - "comment": "Effect heads (core/syntax/grammar.ml, effect): set/vacate/gap. Also asserted by check-grammar.py as whole ( | ) alternatives. Head position only.", + "comment": "Effect heads (core/syntax/grammar.ml, effect): set/vacate/gap. Kept as whole ( | ) alternatives for the same reason as the guard set above. Head position only.", "name": "keyword.other.effect.writ", "match": "(?<=\\()(set|vacate|gap)(?![-\\w])" }, @@ -103,7 +103,7 @@ "match": "(?<=\\()=(?=\\s)" }, "rules-builtin": { - "comment": "The extension's seven BUILT-IN relations (core/syntax/rules_parser.ml, `builtins`): situation/init/edge/gap-edge/phase/phase-step/holds. Their arities and sorts are fixed, so a literal headed by one of these is never a user relation \u2014 which is exactly why they read as vocabulary and not as a name. Head position only. Kept as whole ( | ) alternatives for .afk-flow/check-grammar.py.", + "comment": "The extension's seven BUILT-IN relations (core/syntax/rules_parser.ml, `builtins`): situation/init/edge/gap-edge/phase/phase-step/holds. Their arities and sorts are fixed, so a literal headed by one of these is never a user relation \u2014 which is exactly why they read as vocabulary and not as a name. Head position only. Kept as whole ( | ) alternatives so they can be diffed against `builtins` by eye.", "name": "support.function.rules-builtin.writ", "match": "(?<=\\()(situation|init|edge|gap-edge|phase-step|phase|holds)(?![-\\w])" }, From f0e095979a6158227aaf5becace1871e3b7ff29c Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Sat, 5 Sep 2026 23:50:22 +0300 Subject: [PATCH 3/5] vscode: an Activity Bar panel, the verbs, and a version that stays in step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this extension could not do, and one of them was the reason the other two mattered. THE PANEL. The extension bundles no engine and downloads none: it resolves a `writ-lsp` at activation, from a checkout's `_build` or from PATH. Every way that can go wrong produces the same symptom — an editor that highlights and says nothing else — and the causes want different fixes. The extension said one of them once, in a toast, at activation, and then never again. Now there is a place to look: which server, which version, whether the two are in step, and the button for whichever thing is wrong. THE VERBS. writ is a command-line tool that answers questions; the extension exposed none of them. check, show, derive and compare, from the panel, the title bar, the context menus and the palette — each running the `writ` that belongs to the SAME install as the language server, derived from the resolved server rather than looked up on PATH. An editor whose "check this file" disagreed with its own squiggles would be worse than having no command at all. THE VERSION. It lived in three hand-kept copies here and had nothing to do with writ's, which dune generates. install.sh now derives its extension id from the manifest instead of holding one — installing under a stale id lands the new extension beside the old one, both manifests valid, and VS Code loads whichever it scans first. scripts/version.sh checks what is left, and `--from-writ` sets it from a checkout's dune-project. This is worth enforcing now that the panel shows a mismatch: a forgotten bump is a warning on somebody's working install. Staleness is the part no version comparison can reach. writ's version moves only when someone bumps it, so a server built before this morning's edit reports exactly the same version as a current one — the two agree, and the agreement is the misleading half. src/staleness.js compares times instead, and the build is a Task rather than a terminal because only a task reports whether it succeeded, and only a build that succeeded may clear the warning. extension.js is now the wiring; the behaviour is six modules under src/, none of which touches the vscode API outside its own register(). The two existing tests move to test/ and lose their module-loader stubs — nothing needed them once the workspace folders and the restart were arguments — and two new ones join them: the verdict (shown to users as a warning, so being wrong means telling someone their good install is broken) and the manifest (which nothing typechecks: a command contributed but not registered appears in the palette and does nothing). Co-Authored-By: Claude Opus 5 (1M context) --- .vscodeignore | 12 +- README.md | 103 ++++++++-- extension.js | 305 +++++++++++------------------- icons/writ-activitybar.svg | 23 +++ icons/writ-file-dark.svg | 19 ++ icons/writ-file-light.svg | 19 ++ install.sh | 9 +- package.json | 143 +++++++++++++- scripts/package-extension.sh | 45 +++++ scripts/test.sh | 20 ++ scripts/version.sh | 90 +++++++++ src/commands.js | 353 +++++++++++++++++++++++++++++++++++ src/engine.js | 155 +++++++++++++++ src/locate.js | 142 ++++++++++++++ src/panel.js | 245 ++++++++++++++++++++++++ src/staleness.js | 123 ++++++++++++ src/watcher.js | 74 ++++++++ test-resolve.js | 148 --------------- test-watcher.js | 94 ---------- test/harness.js | 33 ++++ test/manifest.test.js | 129 +++++++++++++ test/resolve.test.js | 151 +++++++++++++++ test/status.test.js | 164 ++++++++++++++++ test/watcher.test.js | 90 +++++++++ 24 files changed, 2233 insertions(+), 456 deletions(-) create mode 100644 icons/writ-activitybar.svg create mode 100644 icons/writ-file-dark.svg create mode 100644 icons/writ-file-light.svg create mode 100755 scripts/package-extension.sh create mode 100755 scripts/test.sh create mode 100755 scripts/version.sh create mode 100644 src/commands.js create mode 100644 src/engine.js create mode 100644 src/locate.js create mode 100644 src/panel.js create mode 100644 src/staleness.js create mode 100644 src/watcher.js delete mode 100644 test-resolve.js delete mode 100644 test-watcher.js create mode 100644 test/harness.js create mode 100644 test/manifest.test.js create mode 100644 test/resolve.test.js create mode 100644 test/status.test.js create mode 100644 test/watcher.test.js diff --git a/.vscodeignore b/.vscodeignore index 8e2f4ec..22beb62 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -7,8 +7,11 @@ node_modules/.bin/** # Development-only files. They belong in the repository and not in a .vsix: the # tests drive the client against a real filesystem and cannot run inside an # installed extension, and install.sh is the OTHER way to install this — shipping -# it inside the thing it installs is a loop that can only confuse. -test-*.js +# it inside the thing it installs is a loop that can only confuse. scripts/ is +# the same argument: version.sh edits this repository's files and +# package-extension.sh builds the very artifact it would be riding in. +test/** +scripts/** install.sh package-lock.json CLA.md @@ -22,4 +25,9 @@ CONTRIBUTING.md # NOT ignored, deliberately: # docs/** — README.md embeds docs/images/writ-mark-200.png, so the marketplace # page loses its mark without it. 44K, and worth it. +# icons/** — the activity bar container and the language's file icon are +# contributed by PATH; an extension whose icon 404s draws nothing +# at all, and nothing is exactly as visible as a wrong icon. +# src/** — the extension itself. `main` is extension.js and every line of +# behaviour is a require away from it. # LICENSE, NOTICE, AUTHORS — AGPL-3.0-or-later; the notices ship with the work. diff --git a/README.md b/README.md index ac4c08e..33d29ed 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,53 @@ real-world domains — over all three of its file types: Syntax highlighting, plus everything the language's own server provides: diagnostics from the real parser and type-checker, a document outline, hover -and completion. +and completion — and the verbs, from a panel in the Activity Bar. **The extension does not bundle a server and does not download one.** The point of an OCaml server is that the editor and the checker are the *same code*, so a diagnostic in the editor is one `writ check` would give you. It talks to a `writ-lsp` you already have. +## The panel + +Click the writ mark in the Activity Bar, or run **Writ: Open the Writ Panel** +from the Command Palette — the icon can end up unpinned, and then the palette is +the way in. + +It shows two things, and both are there because they are otherwise invisible. + +**Which writ you are actually talking to.** The extension and the engine are +separate installs; the panel prints both versions, the server's path, and +whether the two are in step. Every way that can be wrong produces the *same* +symptom — an editor that highlights and says nothing else — and the causes want +different fixes: + +| what it says | what happened | the button | +| --- | --- | --- | +| no language server | nothing was found at any candidate path | build, or update writ | +| older than `core/…` | the checkout has been edited since it was built | build the engine | +| the engine is *x*, the extension is *y* | one of the two was installed on its own | build, or update writ | +| the server did not say which writ it is | a writ from before the server reported one | update writ | + +The stale case is the one nothing else catches. writ's version only moves when +someone bumps it, so a build from before this morning's edit reports exactly the +same version as a current one — the two agree, and the agreement is the +misleading part. The panel compares *times*, and offers the build. + +**The verbs**, which the extension used to expose none of: + +| | | +| --- | --- | +| Check this file | `writ check`, with the sibling `.claims` attached if there is one. From a `.claims` file the roles swap and the model becomes the argument. Ctrl+K Ctrl+Enter, or the ▷ in the editor title bar. | +| Show a situation… | `writ show --at N` | +| Answer a relation… | `writ derive`, over a `.rules` file — prefix the question with `why ` for the derivation tree | +| Compare with another model… | `writ compare` against a model you pick | + +Each runs in a terminal, and each runs the `writ` belonging to *the same install +as the language server* — never whatever is first on `PATH`. An editor whose +"check this file" disagreed with its own squiggles would be worse than having no +command at all. + ## Install Two routes. The extension tries them in this order. @@ -101,11 +141,16 @@ and builds nothing. ### Packaging it instead ```console -$ npm install -$ npx @vscode/vsce package +$ scripts/package-extension.sh $ code --install-extension writ-0.1.0.vsix ``` +That fetches the dependencies if they are missing and **checks the version +first**: a `.vsix` is named after the version in the manifest and installs under +an id built from it, so packaging with the copies out of step produces an +artifact that is wrong in its filename *and* in where VS Code files it — and +neither is visible until someone has two of them installed. + Or open this repository in VS Code and press F5 for an Extension Development Host. @@ -155,20 +200,54 @@ Language Server"** shows which server it launched, and says so when it swaps: ## Development -`extension.js` is the whole client, and there is no compile step: every LSP -request is answered by the OCaml server, so a build pipeline would exist to -typecheck sixty lines of glue. +There is no compile step: every LSP request is answered by the OCaml server, so +a build pipeline would exist to typecheck glue. What the glue has instead is +structure — `extension.js` is the wiring, and one module per concern under +`src/`: + +| | | +| --- | --- | +| `locate.js` | where the engine is: the candidate order, the checkout a server came from, and the `writ` belonging to it | +| `watcher.js` | restart when the server binary is replaced | +| `staleness.js` | is the built server older than the sources it was built from | +| `engine.js` | what engine this editor is talking to, and the verdict on it | +| `panel.js` | the Activity Bar view | +| `commands.js` | the verbs | + +Only `extension.js` and each module's `register` touch the `vscode` API. The +rest is plain JavaScript that runs under node — which is what the tests +exercise, with nothing stubbed and nothing installed: + +```console +$ scripts/test.sh +``` + +Four files, and each one is there because its subject fails **silently**: + +| | | +| --- | --- | +| `test/watcher.test.js` | a watcher that never fires looks exactly like a server that is up to date | +| `test/resolve.test.js` | the wrong candidate order means "no language server" on a machine that has one | +| `test/status.test.js` | the verdict is shown to users as a warning; getting it wrong means telling someone with a good install that it is broken | +| `test/manifest.test.js` | nothing typechecks `package.json` — a command contributed but not registered appears in the palette and does nothing | + +### The version -Two behaviours have tests, because both fail **silently** when broken: +The extension and the engine are released together and live in separate +repositories, so nothing structural keeps them in step — and the panel now +*shows* a mismatch to the user, which makes a version that is merely forgotten +into a warning on somebody's working install. ```console -$ node test-watcher.js # the restart-on-rebuild watcher -$ node test-resolve.js # where the server is looked for +$ scripts/version.sh # print them, and fail if they disagree +$ scripts/version.sh 0.2.0 # set them +$ scripts/version.sh --from-writ # set them to what a writ checkout says ``` -Run them here: the editor client is its own repository now, so writ has no -target that runs them. `vscode` and `vscode-languageclient` are stubbed at the -module loader, so neither needs an editor. +It reads writ's own version from the `dune-project` of a checkout beside this +one, or of `$WRIT_REPO`. `install.sh` derives its extension id from the manifest +rather than holding a copy, so the only two places left are `package.json` and +the `.vsix` filename in this file. ## License diff --git a/extension.js b/extension.js index 29aa8ce..c6f405c 100644 --- a/extension.js +++ b/extension.js @@ -1,227 +1,142 @@ // Copyright (C) 2026 Alex Kunich // SPDX-License-Identifier: AGPL-3.0-or-later -// The VS Code client. It does one thing: find the server this repository built -// and hand it to vscode-languageclient over stdio. +// The VS Code client. // -// There is no compile step and no TypeScript here on purpose. Every LSP request -// the editor answers is answered by the OCaml server, so a build pipeline would -// exist to typecheck sixty lines of glue. +// It does one thing that matters: find the writ this workspace means and hand +// its language server to vscode-languageclient over stdio. Everything the +// editor answers — diagnostics, outline, hover, completion — is answered by the +// OCaml server, so that a squiggle here is one `writ check` would give. +// +// There is no compile step and no TypeScript, on purpose: a build pipeline +// would exist to typecheck glue. What the glue does have is structure, one +// module per concern under src/, and only this file and each module's +// [register] touch the vscode API — the rest is plain JavaScript that runs +// under node, which is what test/ exercises. -const fs = require("fs"); -const path = require("path"); const vscode = require("vscode"); const { LanguageClient, TransportKind } = require("vscode-languageclient/node"); -const SETTING = "writ.serverPath"; -const POLL_MS = 2000; - -let client; -let watcher; - -// Restart the server when its binary is replaced. -// -// WHY THIS EXISTS. `make build` writes a NEW inode over the server, and the -// process already running keeps the old one — `/proc/PID/exe` reads -// "(deleted)". Nothing surfaces that. The editor keeps answering, confidently, -// with a language that is one build out of date, and the symptom is a squiggle -// on code the CLI accepts. That has cost real debugging time more than once, -// and it is worst exactly when the language itself is being changed, because -// then the stale answer is *plausible*. -// -// Polling rather than [createFileSystemWatcher]: the default server path is -// under `_build`, which is routinely listed in `files.watcherExclude`, and a -// watcher that silently never fires would be a worse version of this same bug. -// One stat every two seconds is not a cost worth optimising. -function watchServerBinary(serverPath, log, pollMs = POLL_MS) { - let known = stampOf(serverPath); - let pending = null; - - const id = setInterval(async () => { - const now = stampOf(serverPath); - if (now === null) return; // mid-build: the file is briefly gone - if (now === known) { - pending = null; // nothing new, or a change that reverted - return; - } - if (pending !== now) { - // Seen a change, but act only once it has stopped moving, so a - // multi-second link step restarts the server once and not four times. - pending = now; - return; - } - known = now; - pending = null; - log(`server binary changed on disk — restarting (${serverPath})`); - try { - await client.restart(); - log("server restarted; diagnostics are from the current build"); - } catch (e) { - vscode.window.showErrorMessage( - `Writ: the language server changed on disk but would not restart: ${e}. ` + - "Reload the window to pick it up." - ); - } - }, pollMs); - - return { dispose: () => clearInterval(id) }; -} - -// Identity of the file as built, not merely its name: dune replaces the -// executable, so the inode changes even when a rebuild lands within the same -// mtime granularity. -function stampOf(p) { - try { - const s = fs.statSync(p); - return `${s.ino}:${s.mtimeMs}:${s.size}`; - } catch { - return null; - } -} - -// The name an installed writ puts on PATH (tooling/lsp/bin/dune's -// public_name). NOT the same as the in-checkout file name, which is -// `writ_lsp.exe` under _build. -const INSTALLED = "writ-lsp"; +const engine = require("./src/engine"); +const locate = require("./src/locate"); +const staleness = require("./src/staleness"); +const watcher = require("./src/watcher"); +const commands = require("./src/commands"); +const panel = require("./src/panel"); -function onPath(exe) { - const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean); - const names = process.platform === "win32" ? [exe + ".exe", exe] : [exe]; - return dirs.flatMap((d) => names.map((n) => path.join(d, n))); -} - -// A writ checkout, identified by the engine's own file rather than by being -// named `writ` — a checkout in a directory called something else is still one. -function isCheckout(dir) { - return fs.existsSync(path.join(dir, "dune-project")); -} - -// The checkouts one level below a workspace folder. -// -// WHY ONE LEVEL. Splitting writ and this extension into separate repositories -// means the natural thing to open is the directory that CONTAINS both, and then -// the relative default resolves against a folder that has no _build in it. The -// server is right there, one directory down, and the extension said "no language -// server" — the same symptom as not having built it, which is the wrong thing to -// go and check. One level covers that layout and stops; this must not become a -// walk of the workspace, which on a large tree would cost more than it saves. -function checkoutsBelow(dir) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; // a folder that is gone or unreadable is not worth failing over - } - return entries - .filter((e) => e.isDirectory() || e.isSymbolicLink()) - .map((e) => path.join(dir, e.name)) - .filter(isCheckout); -} +let watching; -// Where to look for the server, in order. Returns every path tried, so a -// failure can name them rather than say "not found". -// -// TWO AUDIENCES, and the order is which one gets served first. A CHECKOUT of -// this repository builds its own server, and that one must win — the whole -// point of an OCaml server is that the editor and the checker are the same -// code, so a developer changing the language wants the build in front of them -// and not whatever is installed. Everyone ELSE has installed writ (by pin, or -// unpacked a release) and has no checkout at all; for them the relative default -// resolves to nothing, and `writ-lsp` on PATH is the only server there is. +// Say what is missing, once, and offer the two things that fix it. // -// Serving only the first audience is what this did until now, which meant the -// extension could not be used with writ installed separately — the case -// tooling/lsp/bin/dune installs `writ-lsp` for in the first place. -// -// An ABSOLUTE setting is taken literally and nothing is guessed after it: an -// operator who names a server means that server. -function candidates(configured) { - if (path.isAbsolute(configured)) return [configured]; - const folders = vscode.workspace.workspaceFolders || []; - // The folder itself before anything under it: opening the checkout directly - // is the common case and must not be slowed down or second-guessed. - const inWorkspace = folders.flatMap((f) => [ - path.join(f.uri.fsPath, configured), - ...checkoutsBelow(f.uri.fsPath).map((d) => path.join(d, configured)), - ]); - return [...inWorkspace, ...onPath(INSTALLED)]; -} - -function activate(context) { - const configured = vscode.workspace - .getConfiguration() - .get(SETTING, "_build/default/tooling/lsp/bin/writ_lsp.exe"); - const tried = candidates(configured); - const found = tried.find((p) => fs.existsSync(p)); - - if (!found) { - // Silence here would look like a server that starts and answers nothing, - // which is the same symptom as a broken server and much harder to chase. - // - // The PATH candidates are summarised rather than listed: naming forty - // directories buries the one thing worth reading, which is that `writ-lsp` - // was not in any of them. - const inWorkspace = tried.filter((p) => !onPath(INSTALLED).includes(p)); - const where = inWorkspace.length - ? `not at ${inWorkspace.join(", ")}, and \`${INSTALLED}\` is not on PATH` - : `\`${INSTALLED}\` is not on PATH`; - vscode.window.showErrorMessage( +// Silence here would look like a server that starts and answers nothing, which +// is the same symptom as a broken server and much harder to chase. The PATH +// candidates are summarised rather than listed: naming forty directories buries +// the one thing worth reading, which is that `writ-lsp` was not in any of them. +function reportMissing(tried) { + const onPath = locate.onPath(locate.INSTALLED); + const inWorkspace = tried.filter((p) => !onPath.includes(p)); + const where = inWorkspace.length + ? `not at ${inWorkspace.join(", ")}, and \`${locate.INSTALLED}\` is not on PATH` + : `\`${locate.INSTALLED}\` is not on PATH`; + vscode.window + .showErrorMessage( `Writ: no language server — ${where}. ` + "In a checkout of the writ repository, run `make build`. Otherwise " + "install writ (`opam pin add writ git+https://github.com/writ-lang/writ.git`, " + "or a release tarball) so " + - `\`${INSTALLED}\` is on PATH — or set \`${SETTING}\` to an absolute ` + - "path to the server you want." - ); + `\`${locate.INSTALLED}\` is on PATH — or set \`${engine.SETTING}\` to an ` + + "absolute path to the server you want.", + "Open the Writ panel", + "Open settings" + ) + .then((choice) => { + if (choice === "Open the Writ panel") + vscode.commands.executeCommand("writ.openOverview"); + else if (choice === "Open settings") + vscode.commands.executeCommand("writ.openSettings"); + }); +} + +// Start the server, or record that there is none. Separated from [activate] so +// that a changed setting can redo exactly this and nothing else. +async function start(context) { + const state = engine.resolve(); + + // The panel and the commands are registered whether or not a server was + // found — the panel is where the absence is explained, so it must exist + // precisely when there is nothing to talk to. + if (!state.serverPath) { + reportMissing(state.tried); + panel.refresh(); return; } - const serverOptions = { - command: found, - args: [], - transport: TransportKind.stdio, - }; - - const clientOptions = { - documentSelector: [{ scheme: "file", language: "writ" }], - outputChannelName: "Writ Language Server", - }; - - client = new LanguageClient( + const client = new LanguageClient( "writ", "Writ Language Server", - serverOptions, - clientOptions + { command: state.serverPath, args: [], transport: TransportKind.stdio }, + { + documentSelector: [{ scheme: "file", language: "writ" }], + outputChannelName: "Writ Language Server", + } ); - + engine.attach(client); context.subscriptions.push(client); - const started = client.start(); + await client.start(); + + // Which writ answered. This is the whole reason the server reports serverInfo: + // the extension and the engine are separate installs and nothing else can tell + // them apart. See the panel, which is where the comparison is shown. + const version = engine.readVersion(client); const log = (m) => client.outputChannel.appendLine(`[client] ${m}`); - log(`server: ${found}`); - watcher = watchServerBinary(found, log); - context.subscriptions.push(watcher); + log(`server: ${state.serverPath}`); + log(`engine: ${version || "did not report a version"}`); + if (state.checkout) log(`checkout: ${state.checkout}`); + if (state.cli) log(`command line: ${state.cli}`); + + watching = watcher.watchServerBinary(state.serverPath, log, undefined, { + restart: () => engine.restart(), + onRestart: () => { + staleness.markBuilt(); + panel.refresh(); + }, + onFailure: (e) => + vscode.window.showErrorMessage( + `Writ: the language server changed on disk but would not restart: ${e}. ` + + "Reload the window to pick it up." + ), + }); + context.subscriptions.push(watching); + panel.refresh(); +} - return started; +function activate(context) { + panel.register(context); + commands.register(context); + + context.subscriptions.push( + // The server may have moved, so everything derived from it is suspect: which + // binary, which version, whether it is stale, and whether it is running at + // all. Reloading the window used to be the only way to pick up a corrected + // setting. + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (!event.affectsConfiguration("writ")) return; + if (watching) watching.dispose(); + const client = engine.current().client; + if (client) await client.stop().catch(() => {}); + engine.attach(null); + await start(context); + }) + ); + + return start(context); } function deactivate() { - if (watcher) watcher.dispose(); + if (watching) watching.dispose(); + const client = engine.current().client; return client ? client.stop() : undefined; } module.exports = { activate, deactivate }; - -// Exposed for test-watcher.js, which drives the watcher against a -// real file on disk. The watcher is the one part of this glue with behaviour of -// its own, and a watcher that silently never fires is the bug it exists to fix. -module.exports.__test = { - watchServerBinary, - stampOf, - candidates, - onPath, - INSTALLED, - setClient: (c) => { - client = c; - }, -}; diff --git a/icons/writ-activitybar.svg b/icons/writ-activitybar.svg new file mode 100644 index 0000000..ab27424 --- /dev/null +++ b/icons/writ-activitybar.svg @@ -0,0 +1,23 @@ + +writ + + + + + + + + diff --git a/icons/writ-file-dark.svg b/icons/writ-file-dark.svg new file mode 100644 index 0000000..08c90db --- /dev/null +++ b/icons/writ-file-dark.svg @@ -0,0 +1,19 @@ + +writ + + + + + + + + diff --git a/icons/writ-file-light.svg b/icons/writ-file-light.svg new file mode 100644 index 0000000..be0c5ca --- /dev/null +++ b/icons/writ-file-light.svg @@ -0,0 +1,19 @@ + +writ + + + + + + + + diff --git a/install.sh b/install.sh index 3dfd89c..8c36f19 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,14 @@ set -euo pipefail here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -ext_id="writ.writ-0.1.0" +# The extension id VS Code files this under: publisher.name-version, from the +# manifest. READ rather than written down — a hardcoded copy here was a fourth +# place the version lived, and the one nobody thinks of at bump time: install +# under a stale id and the new extension lands BESIDE the old one, both +# manifests valid, and VS Code loads whichever it scans first. See +# scripts/version.sh for the copies that remain. +field() { sed -n "s/^ \"$1\": \"\(.*\)\",\?$/\1/p" "$here/package.json" | head -1; } +ext_id="$(field publisher).$(field name)-$(field version)" # Extension ids this extension has been installed under BEFORE. The project was # called `pol` (Partial Olog) and the extension `pol.pol`; see "rename: pol diff --git a/package.json b/package.json index b00bb42..2092199 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "writ", "displayName": "Writ", - "description": "Authoring support for Writ \u2014 model a rule-governed world; get every consequence back, with the route \u2014 over .writ models, .claims question files and .rules derivations: diagnostics, outline, hover and completion, served by the language's own OCaml server.", + "description": "Authoring support for Writ — model a rule-governed world; get every consequence back, with the route — over .writ models, .claims question files and .rules derivations: diagnostics, outline, hover and completion, served by the language's own OCaml server.", "version": "0.1.0", "publisher": "writ", "private": true, @@ -15,6 +15,11 @@ "workspace" ], "main": "./extension.js", + "scripts": { + "test": "scripts/test.sh", + "package": "scripts/package-extension.sh", + "version:check": "scripts/version.sh" + }, "contributes": { "languages": [ { @@ -28,7 +33,11 @@ ".claims", ".rules" ], - "configuration": "./language-configuration.json" + "configuration": "./language-configuration.json", + "icon": { + "light": "./icons/writ-file-light.svg", + "dark": "./icons/writ-file-dark.svg" + } } ], "grammars": [ @@ -38,13 +47,135 @@ "path": "./syntaxes/writ.tmLanguage.json" } ], + "viewsContainers": { + "activitybar": [ + { + "id": "writ", + "title": "Writ", + "icon": "icons/writ-activitybar.svg" + } + ] + }, + "views": { + "writ": [ + { + "type": "webview", + "id": "writ.home", + "name": "Overview" + } + ] + }, + "commands": [ + { + "command": "writ.check", + "title": "Writ: Check This File", + "icon": "$(play)" + }, + { + "command": "writ.show", + "title": "Writ: Show a Situation…" + }, + { + "command": "writ.derive", + "title": "Writ: Answer a Relation…" + }, + { + "command": "writ.compare", + "title": "Writ: Compare With Another Model…" + }, + { + "command": "writ.restartServer", + "title": "Writ: Restart the Language Server", + "icon": "$(debug-restart)" + }, + { + "command": "writ.showOutput", + "title": "Writ: Show the Server Log" + }, + { + "command": "writ.build", + "title": "Writ: Build the Engine", + "icon": "$(tools)" + }, + { + "command": "writ.update", + "title": "Writ: Update writ…" + }, + { + "command": "writ.openOverview", + "title": "Writ: Open the Writ Panel" + }, + { + "command": "writ.openSettings", + "title": "Writ: Settings" + } + ], + "menus": { + "editor/title": [ + { + "command": "writ.check", + "when": "resourceLangId == writ", + "group": "navigation" + } + ], + "editor/context": [ + { + "command": "writ.check", + "when": "resourceLangId == writ", + "group": "writ@1" + }, + { + "command": "writ.show", + "when": "resourceLangId == writ", + "group": "writ@2" + }, + { + "command": "writ.derive", + "when": "resourceLangId == writ", + "group": "writ@3" + } + ], + "explorer/context": [ + { + "command": "writ.check", + "when": "resourceExtname == .writ || resourceExtname == .claims || resourceExtname == .rules", + "group": "writ" + } + ], + "commandPalette": [ + { + "command": "writ.check", + "when": "resourceLangId == writ" + }, + { + "command": "writ.show", + "when": "resourceLangId == writ" + }, + { + "command": "writ.derive", + "when": "resourceLangId == writ" + }, + { + "command": "writ.compare", + "when": "resourceExtname == .writ" + } + ] + }, + "keybindings": [ + { + "command": "writ.check", + "key": "ctrl+k ctrl+enter", + "mac": "cmd+k cmd+enter", + "when": "editorTextFocus && editorLangId == writ" + } + ], "configuration": { "title": "Writ", "properties": { "writ.serverPath": { "type": "string", "default": "_build/default/tooling/lsp/bin/writ_lsp.exe", - "markdownDescription": "Path to the `writ-lsp` language server executable.\n\nA **relative** path is resolved against each open workspace folder, so the default finds the server built by `make build` in a checkout of the writ repository. If no workspace folder has it, `writ-lsp` is looked up on `PATH` \u2014 which is what an opam pin of writ or a release tarball provides, and is how this extension works without a checkout.\n\nAn **absolute** path is used exactly as given and nothing else is tried." + "markdownDescription": "Path to the `writ-lsp` language server executable.\n\nA **relative** path is resolved against each open workspace folder, so the default finds the server built by `make build` in a checkout of the writ repository. If no workspace folder has it, `writ-lsp` is looked up on `PATH` — which is what an opam pin of writ or a release tarball provides, and is how this extension works without a checkout.\n\nAn **absolute** path is used exactly as given and nothing else is tried." } } } @@ -64,5 +195,9 @@ "formal methods", "state machine" ], - "icon": "icon.png" + "icon": "icon.png", + "activationEvents": [ + "onLanguage:writ", + "onView:writ.home" + ] } diff --git a/scripts/package-extension.sh b/scripts/package-extension.sh new file mode 100755 index 0000000..0195e97 --- /dev/null +++ b/scripts/package-extension.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Copyright (C) 2026 Alex Kunich +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Build the .vsix. Needs @vscode/vsce, which npx will fetch. +# +# scripts/package-extension.sh +# +# The version is CHECKED first, not assumed. A .vsix is named after the version +# in the manifest and installs under an id built from it, so packaging with the +# copies out of step produces an artifact that is wrong in its filename and in +# where VS Code files it — and neither is visible until someone has both +# installed. scripts/version.sh is the check; this is the one place that cannot +# skip it. +set -e +cd "$(dirname "$0")/.." + +command -v npx >/dev/null 2>&1 || { echo "npx is not installed" >&2; exit 1; } + +scripts/version.sh >/dev/null || { + echo "the version is inconsistent — see scripts/version.sh" >&2 + exit 1 +} +version=$(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json) + +# The dependencies are SHIPPED: vscode-languageclient is the one thing this +# extension does not get from the engine, and a .vsix without node_modules +# activates to a module-not-found. +if [ ! -d node_modules/vscode-languageclient ]; then + echo "==> installing dependencies" + if [ -f package-lock.json ]; then + npm ci --no-audit --no-fund + else + npm install --no-audit --no-fund + fi +fi + +npx --yes @vscode/vsce package --out "writ-$version.vsix" + +echo +echo "built writ-$version.vsix" +echo "install it with: code --install-extension writ-$version.vsix" +echo +echo "it needs a writ-lsp to talk to; see scripts/version.sh for whether this" +echo "version matches the engine beside it." diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..bef713c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Copyright (C) 2026 Alex Kunich +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Every test, in plain node. No runner and nothing to install: this repository's +# one dependency is vscode-languageclient, and a test framework would be the +# second — for four files that each print a line. +set -e +cd "$(dirname "$0")/.." + +failed=0 +for t in test/*.test.js; do + node "$t" || failed=1 +done + +# The version is not a test file, but it is checked the same way and by the same +# people at the same moment. +scripts/version.sh >/dev/null || failed=1 + +[ "$failed" = 0 ] || { echo; echo "some checks FAILED" >&2; exit 1; } diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 0000000..a2c15dd --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Copyright (C) 2026 Alex Kunich +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# The version, in the two places here that have to agree — and against the +# engine, which is the version that decides. +# +# scripts/version.sh print them, and fail if they disagree +# scripts/version.sh 0.2.0 set them +# scripts/version.sh --from-writ set them to what a writ checkout says +# +# WHY THIS EXISTS. The extension and the engine ship together and are separate +# repositories, so nothing structural keeps them in step: writ's version lives +# in its dune-project and is generated into the binary and the opam package, +# while this repository writes it into package.json by hand. +# +# install.sh used to hold a third copy, as the extension id — the one nobody +# thinks of at bump time, and the one with teeth: install under a stale id and +# the new extension lands BESIDE the old one, both manifests valid, and VS Code +# loads whichever it scans first. It now reads the manifest instead, which is +# why it is not listed below. The README's .vsix filename cannot be derived the +# same way, so it stays here. +# +# The panel compares the extension's version against the engine's at runtime and +# reports a mismatch to the user, which is what makes this worth enforcing here: +# a version that is wrong is not a cosmetic problem any more, it is a warning +# shown to someone whose install is fine. +set -e +cd "$(dirname "$0")/.." + +pkg=$(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json) +doc=$(sed -n 's/.*code --install-extension writ-\(.*\)\.vsix.*/\1/p' README.md) + +# The engine's version, if a checkout is at hand. Read from dune-project rather +# than by running the binary: this must work in a checkout that has not been +# built, and dune-project is the source `writ --version` is generated from. +writ_version() { + for cand in "${WRIT_REPO:-}" ../writ ..; do + [ -n "$cand" ] && [ -f "$cand/dune-project" ] || continue + sed -n 's/^(version \(.*\))$/\1/p' "$cand/dune-project" | head -1 + return + done +} + +set_all() { + sed -i "s/^ \"version\": \".*\",$/ \"version\": \"$1\",/" package.json + sed -i "s/code --install-extension writ-.*\.vsix/code --install-extension writ-$1.vsix/" README.md +} + +if [ "${1:-}" = "--from-writ" ]; then + engine=$(writ_version) + [ -n "$engine" ] || { + echo "no writ checkout found — set WRIT_REPO, or pass the version" >&2 + exit 1 + } + set_all "$engine" + echo "$pkg -> $engine (from the engine)" + exit 0 +fi + +if [ -n "${1:-}" ]; then + set_all "$1" + echo "$pkg -> $1" + exit 0 +fi + +printf 'package.json %s\n' "$pkg" +printf 'README.md %s\n' "$doc" + +engine=$(writ_version) +if [ -n "$engine" ]; then + printf 'writ %s (the engine, from its dune-project)\n' "$engine" +else + printf 'writ — (no checkout beside this one; set WRIT_REPO to compare)\n' +fi + +if [ "$pkg" != "$doc" ]; then + echo "they disagree; run: scripts/version.sh $pkg" >&2 + exit 1 +fi + +# A version behind the engine is a real state — the extension is released when +# it changes, and it does not change every time writ does — so this is reported +# and not failed. What is failed above is the copies here contradicting each +# other, which is never anything but a mistake. +if [ -n "$engine" ] && [ "$pkg" != "$engine" ]; then + echo "note: the extension is $pkg and the engine is $engine." >&2 + echo " the panel will show that as a mismatch to anyone with both." >&2 + echo " run: scripts/version.sh --from-writ" >&2 +fi diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..0994bde --- /dev/null +++ b/src/commands.js @@ -0,0 +1,353 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The commands. +// +// Every one of them runs the SAME writ the language server is: `engine.current().cli` +// is derived from the resolved server, never looked up on its own. That is the +// whole point — an editor whose "check this file" disagrees with its own +// squiggles would be worse than having no command at all. +// +// The verbs run in a terminal, not a task and not an output channel: `writ +// check` prints a report meant to be read, `writ derive --why` prints a tree, +// and both are things you scroll back through and re-run with an edited +// argument. A task would file them under the Problems view, which is where the +// server's diagnostics already live and where a report is not a diagnostic. +// +// [build] is the exception, and for the opposite reason: nobody reads a build +// log that succeeded, and the one thing that has to be known about it is +// whether it did. Only a task reports an exit code. + +const fs = require("fs"); +const path = require("path"); +const engine = require("./engine"); +const staleness = require("./staleness"); +const panel = require("./panel"); + +let terminal = null; + +function shell() { + const vscode = require("vscode"); + if (!terminal || terminal.exitStatus !== undefined) { + terminal = vscode.window.createTerminal("writ"); + } + terminal.show(true); + return terminal; +} + +// Quote for a shell, because these are typed into a terminal. +function quote(s) { + return /[^\w@%+=:,./-]/.test(s) ? "'" + s.replace(/'/g, "'\\''") + "'" : s; +} + +// The CLI, or a complaint that says what to do about not having one. Every verb +// goes through this, so the message is written once. +function cli() { + const vscode = require("vscode"); + const found = engine.current().cli; + if (found) return found; + vscode.window + .showWarningMessage( + "Writ: no `writ` command line to run. The language server answers " + + "diagnostics, but the verbs need the CLI, which ships beside it.", + "Open the Writ panel" + ) + .then((c) => { + if (c) vscode.commands.executeCommand("writ.openOverview"); + }); + return null; +} + +function run(args) { + const found = cli(); + if (!found) return; + shell().sendText([quote(found), ...args.map(quote)].join(" ")); +} + +// The file a command acts on: the one the explorer offered, else the active +// editor's. Saved first when it is dirty, because the CLI reads the disk and +// checking the previous save is a confusing kind of wrong answer. +async function target(uri) { + const vscode = require("vscode"); + const editor = vscode.window.activeTextEditor; + const chosen = uri && uri.fsPath ? uri : editor ? editor.document.uri : null; + if (!chosen) { + vscode.window.showInformationMessage("Writ: no file to act on."); + return null; + } + if ( + editor && + editor.document.uri.toString() === chosen.toString() && + editor.document.isDirty + ) + await editor.document.save(); + return chosen.fsPath; +} + +// The model a .claims or .rules file belongs to. +// +// A sibling of the same name first — the convention writ's own help states +// ("A model's questions live beside it in MODEL.claims") — and a pick over the +// workspace's models when there is none, rather than an error. Splitting +// questions from models is deliberate in this language; one question suite is +// meant to be asked of several models, and a command that could only ever ask +// the sibling would be arguing with the design. +async function modelFor(file) { + const vscode = require("vscode"); + const sibling = file.replace(/\.[^.]+$/, ".writ"); + if (sibling !== file && fs.existsSync(sibling)) return sibling; + const found = await vscode.workspace.findFiles("**/*.writ", "**/_build/**", 200); + if (!found.length) { + vscode.window.showInformationMessage( + "Writ: no model to ask. A .claims or .rules file is a question about a " + + "model, and there is no .writ file in this workspace to ask it of." + ); + return null; + } + const pick = await vscode.window.showQuickPick( + found.map((u) => ({ + label: path.basename(u.fsPath), + description: vscode.workspace.asRelativePath(u), + path: u.fsPath, + })), + { title: "Which model?", placeHolder: path.basename(file) + " asks about…" } + ); + return pick ? pick.path : null; +} + +// --- the verbs ------------------------------------------------------------- + +// `writ check` on whatever is in front of you, with the questions attached. +// +// The two file types swap roles and the command must not: from a model, the +// sibling .claims is the optional extra; from a .claims, the model is the +// argument and the buffer is the extra. Asking the user which way round it goes +// would be asking them to restate something the extension can see. +async function check(uri) { + const file = await target(uri); + if (!file) return; + if (file.endsWith(".claims")) { + const model = await modelFor(file); + if (!model) return; + run(["check", model, "--claims", file]); + return; + } + if (file.endsWith(".rules")) { + // `check` has nothing to say about a .rules file; `derive` is the verb. + await derive(uri); + return; + } + const claims = file.replace(/\.writ$/, ".claims"); + run(claims !== file && fs.existsSync(claims) + ? ["check", file, "--claims", claims] + : ["check", file]); +} + +async function show(uri) { + const vscode = require("vscode"); + const file = await target(uri); + if (!file) return; + const model = file.endsWith(".writ") ? file : await modelFor(file); + if (!model) return; + const at = await vscode.window.showInputBox({ + title: "Show a situation", + prompt: + "The state index to print. Empty shows the initial situation. " + + "Several, space-separated, print several.", + placeHolder: "0", + }); + if (at === undefined) return; // escaped, as opposed to entered empty + const indices = at.trim().split(/\s+/).filter(Boolean); + run(["show", model, ...indices.flatMap((i) => ["--at", i])]); +} + +// `writ derive`, which needs three things: the model, the rules, and the +// question. Two of them are on screen whenever this is worth running. +async function derive(uri) { + const vscode = require("vscode"); + const file = await target(uri); + if (!file) return; + let rules = file; + if (!file.endsWith(".rules")) { + const found = await vscode.workspace.findFiles("**/*.rules", "**/_build/**", 200); + if (!found.length) { + vscode.window.showInformationMessage( + "Writ: no .rules file in this workspace to derive from." + ); + return; + } + const pick = await vscode.window.showQuickPick( + found.map((u) => ({ + label: path.basename(u.fsPath), + description: vscode.workspace.asRelativePath(u), + path: u.fsPath, + })), + { title: "Which rules?" } + ); + if (!pick) return; + rules = pick.path; + } + // The model, which is `file` only when `file` IS one. Reached here from a + // .claims buffer as well as a .rules one — the palette entry is on the + // language, and all three extensions are the same language — and passing a + // .claims file where the model belongs would be a parse error blamed on the + // wrong file. + const model = file.endsWith(".writ") ? file : await modelFor(file); + if (!model) return; + const question = await vscode.window.showInputBox({ + title: "Answer a relation", + prompt: + "A bare RELATION prints every row; \"(RELATION ARG…)\" keeps the matching " + + "ones, ALL-CAPS being a free variable. Prefix with `why ` for the " + + "derivation tree of one fact.", + placeHolder: "subordinate or (subordinate nabu X) or why (subordinate nabu cabinet)", + }); + if (question === undefined || !question.trim()) return; + const q = question.trim(); + const why = /^why\s+/i.test(q); + run([ + "derive", + model, + rules, + ...(why ? ["--why", q.replace(/^why\s+/i, "")] : [q]), + ]); +} + +async function compare(uri) { + const vscode = require("vscode"); + const file = await target(uri); + if (!file || !file.endsWith(".writ")) { + vscode.window.showInformationMessage( + "Writ: compare takes two models — open a .writ file first." + ); + return; + } + const found = ( + await vscode.workspace.findFiles("**/*.writ", "**/_build/**", 200) + ).filter((u) => u.fsPath !== file); + if (!found.length) { + vscode.window.showInformationMessage( + "Writ: there is no second model in this workspace to compare with." + ); + return; + } + const pick = await vscode.window.showQuickPick( + found.map((u) => ({ + label: path.basename(u.fsPath), + description: vscode.workspace.asRelativePath(u), + path: u.fsPath, + })), + { + title: "Compare with", + placeHolder: + "The NEW model. " + path.basename(file) + " is the old one, and its " + + "sibling .claims supplies the properties.", + } + ); + if (!pick) return; + run(["compare", file, pick.path]); +} + +// --- the install itself ---------------------------------------------------- + +// Build the engine, in the checkout the server came from. +// +// `make build` and not `dune build`: the Makefile is what writ's own README +// tells you to run, and it is the target that knows about the opam switch. +// +// A TASK and not a plain terminal, for one reason: the staleness verdict has to +// be cleared by a build that SUCCEEDED, and a terminal cannot say whether one +// did. `sendText` types into a shell, and the shell's own exit status is what +// closing the terminal reports — zero whether or not make failed. A task +// reports the process's code. +// +// Clearing it needs saying at all because dune is content-addressed: a build +// that changes nothing leaves the binary's mtime exactly where it was, so the +// watcher next door never fires and "older than core/…/reader.ml" would stand +// for ever after a build that did the right thing and had nothing to do. +function build() { + const vscode = require("vscode"); + const root = engine.current().checkout; + if (!root) { + vscode.window.showInformationMessage( + "Writ: nothing to build here — the server in use is an installed one, " + + "not a checkout’s. Use “Update writ” instead." + ); + return; + } + // Which folder the task belongs to, matched on a path boundary: a plain + // prefix test would put a build of `writ` under a workspace folder called + // `writ-vscode`, which is exactly the pair of names this project has. + const folder = (vscode.workspace.workspaceFolders || []).find( + (f) => root === f.uri.fsPath || root.startsWith(f.uri.fsPath + path.sep) + ); + const task = new vscode.Task( + { type: "shell", task: "writ: build the engine" }, + folder || vscode.TaskScope.Workspace, + "build the engine", + "writ", + new vscode.ShellExecution("make build", { cwd: root }) + ); + const ended = vscode.tasks.onDidEndTaskProcess((e) => { + if (e.execution.task !== task) return; + ended.dispose(); + if (e.exitCode !== 0) return; + staleness.markBuilt(); + panel.refresh(); + // The watcher restarts the server when the binary is replaced. When it was + // not — the content-addressed case above — nothing is running that is + // stale, so there is nothing else to do. + }); + vscode.tasks.executeTask(task); +} + +const PIN = "opam pin add writ git+https://github.com/writ-lang/writ.git"; + +// Update an installed writ. +// +// Re-running the pin is the update: opam re-fetches the branch and rebuilds, +// which is exactly what `opam upgrade writ` would do for a pinned package and +// works whether or not one is pinned already. Typed into a terminal rather than +// spawned, because it takes minutes, prints as it goes, and may ask a question. +function update() { + const vscode = require("vscode"); + if (engine.current().checkout) { + build(); + return; + } + const t = vscode.window.createTerminal("writ update"); + t.show(true); + t.sendText(PIN, false); // NOT executed: an install command is the user's to press enter on +} + +function register(context) { + const vscode = require("vscode"); + const on = (name, fn) => + context.subscriptions.push(vscode.commands.registerCommand(name, fn)); + + on("writ.check", check); + on("writ.show", show); + on("writ.derive", derive); + on("writ.compare", compare); + on("writ.build", build); + on("writ.update", update); + + on("writ.restartServer", async () => { + await engine.restart(); + panel.refresh(); + vscode.window.setStatusBarMessage("Writ: language server restarted", 3000); + }); + on("writ.showOutput", () => { + const client = engine.current().client; + if (client && client.outputChannel) client.outputChannel.show(true); + else + vscode.window.showInformationMessage( + "Writ: no server log — the language server never started." + ); + }); + on("writ.openSettings", () => + vscode.commands.executeCommand("workbench.action.openSettings", engine.SETTING) + ); +} + +module.exports = { register, quote, PIN }; diff --git a/src/engine.js b/src/engine.js new file mode 100644 index 0000000..bca6c12 --- /dev/null +++ b/src/engine.js @@ -0,0 +1,155 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// What engine this editor is talking to, and whether it is the right one. +// +// The extension and the engine are SEPARATE INSTALLS — a .vsix from one place, +// a `writ-lsp` from an opam pin or a checkout's `_build` from another — and +// nothing has ever made them agree. When they disagree the editor does not +// break; it answers, confidently, with a different writ than the one you think +// you have, and the symptom is a diagnostic `writ check` will not reproduce. +// +// This module is the one place that knows the answer, so the panel, the +// commands and the activation warning all report the same thing. + +const locate = require("./locate"); +const staleness = require("./staleness"); + +const SETTING = "writ.serverPath"; + +const state = { + serverPath: null, // the server that was resolved, or null + tried: [], // every candidate, so a failure can name them + checkout: null, // the checkout it was built in, or null if installed + cli: null, // the `writ` command line belonging to that same install + version: null, // what its `initialize` reported, or null + client: null, +}; + +function configured() { + const vscode = require("vscode"); + return vscode.workspace.getConfiguration().get(SETTING, locate.DEFAULT_SERVER); +} + +function folders() { + const vscode = require("vscode"); + return (vscode.workspace.workspaceFolders || []).map((f) => f.uri.fsPath); +} + +// Look for the server and remember what was found. Returns the state so a +// caller does not have to fetch it separately. +function resolve() { + const fs = require("fs"); + state.tried = locate.candidates(configured(), folders()); + state.serverPath = state.tried.find((p) => fs.existsSync(p)) || null; + state.checkout = state.serverPath ? locate.checkoutOf(state.serverPath) : null; + state.cli = state.serverPath ? locate.cliFor(state.serverPath) : null; + state.version = null; + staleness.forget(); + return state; +} + +function current() { + return state; +} + +// The version the server named itself with, from `initialize`. +// +// A server that reports none is not an error: it is a writ from before the LSP +// grew serverInfo. Saying so is worth a line in the panel, because "unknown" and +// "mismatched" want different things done about them and look identical if both +// are drawn as a warning. +function readVersion(client) { + const info = + client && client.initializeResult && client.initializeResult.serverInfo; + state.version = (info && info.version) || null; + return state.version; +} + +function attach(client) { + state.client = client; +} + +function stale() { + if (!state.serverPath) return null; + return staleness.stale(state.checkout, state.serverPath); +} + +// The verdict, as one fact about the install. +// +// Pure — extension version, engine version, staleness and whether there is a +// checkout, in; a level and a sentence, out — so every case can be checked +// under plain node. Null means "nothing to say", which the panel draws as the +// reassuring line rather than as no line at all: an empty status box is +// indistinguishable from a panel that failed to render. +// +// ORDER MATTERS. Staleness is asked BEFORE the versions are compared, because a +// server that is merely out of date reports the same version as a current one — +// the two agree, and the agreement is exactly the misleading part. +function verdict({ extension, engine, stale, checkout, serverPath }) { + if (!serverPath) { + return { + level: "bad", + message: + "No language server. Syntax highlighting still works — it is a grammar " + + "file — but there are no diagnostics, no outline, no hover and no " + + "completion, because all four are answered by the engine.", + action: checkout ? "build" : "install", + }; + } + if (stale) { + return { + level: "warn", + message: + "The server is older than " + + stale.path + + ". Until it is rebuilt, every answer here comes from a build that " + + "predates your change — and it reports the same version as a current " + + "one, so nothing else would tell you.", + action: "build", + }; + } + if (engine === null || engine === undefined) { + return { + level: "warn", + message: + "The server did not say which writ it is. That means a writ from " + + "before the language server reported one, so this extension cannot " + + "tell whether the two are in step. Updating writ restores the check.", + action: checkout ? "build" : "install", + }; + } + if (String(engine).trim() !== String(extension).trim()) { + return { + level: "warn", + message: + "The extension is " + + extension + + " and the engine is " + + String(engine).trim() + + ". They are released together; a mismatch means one of them was " + + "installed on its own, and a diagnostic here may not be one " + + "`writ check` would give.", + action: checkout ? "build" : "install", + }; + } + return null; +} + +// Restart the running server. Used by the command and by the watcher. +function restart() { + if (!state.client) return Promise.resolve(); + return state.client.restart().then(() => readVersion(state.client)); +} + +module.exports = { + SETTING, + resolve, + current, + readVersion, + attach, + stale, + verdict, + restart, + configured, +}; diff --git a/src/locate.js b/src/locate.js new file mode 100644 index 0000000..9a27280 --- /dev/null +++ b/src/locate.js @@ -0,0 +1,142 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Where the engine is. +// +// Everything else in this extension needs an answer to that and must not have +// its own: the server the client talks to, the `writ` the commands run, and the +// checkout the build command builds all have to be the SAME install, or the +// panel reports a version from one and the diagnostics come from another. + +const fs = require("fs"); +const path = require("path"); + +// The name an installed writ puts on PATH (tooling/lsp/bin/dune's +// public_name). NOT the same as the in-checkout file name, which is +// `writ_lsp.exe` under _build. +const INSTALLED = "writ-lsp"; + +// The command line, likewise: `writ` installed, `writ.exe` under _build. +const CLI = "writ"; + +const DEFAULT_SERVER = "_build/default/tooling/lsp/bin/writ_lsp.exe"; + +// The CLI's path inside a checkout, relative to the root, and the server's. +// Kept together because the pair is what lets a resolved server name a +// checkout and a checkout name its CLI. +const IN_CHECKOUT = { + server: DEFAULT_SERVER, + cli: "_build/default/tooling/cli/writ.exe", +}; + +function onPath(exe) { + const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean); + const names = process.platform === "win32" ? [exe + ".exe", exe] : [exe]; + return dirs.flatMap((d) => names.map((n) => path.join(d, n))); +} + +// A writ checkout, identified by the engine's own file rather than by being +// named `writ` — a checkout in a directory called something else is still one. +function isCheckout(dir) { + return fs.existsSync(path.join(dir, "dune-project")); +} + +// The checkouts one level below a workspace folder. +// +// WHY ONE LEVEL. Splitting writ and this extension into separate repositories +// means the natural thing to open is the directory that CONTAINS both, and then +// the relative default resolves against a folder that has no _build in it. The +// server is right there, one directory down, and the extension said "no language +// server" — the same symptom as not having built it, which is the wrong thing to +// go and check. One level covers that layout and stops; this must not become a +// walk of the workspace, which on a large tree would cost more than it saves. +function checkoutsBelow(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; // a folder that is gone or unreadable is not worth failing over + } + return entries + .filter((e) => e.isDirectory() || e.isSymbolicLink()) + .map((e) => path.join(dir, e.name)) + .filter(isCheckout); +} + +// Where to look for the server, in order. Returns every path tried, so a +// failure can name them rather than say "not found". +// +// TWO AUDIENCES, and the order is which one gets served first. A CHECKOUT of +// this repository builds its own server, and that one must win — the whole +// point of an OCaml server is that the editor and the checker are the same +// code, so a developer changing the language wants the build in front of them +// and not whatever is installed. Everyone ELSE has installed writ (by pin, or +// unpacked a release) and has no checkout at all; for them the relative default +// resolves to nothing, and `writ-lsp` on PATH is the only server there is. +// +// An ABSOLUTE setting is taken literally and nothing is guessed after it: an +// operator who names a server means that server. +// +// `folders` is passed in rather than read from `vscode` so the order can be +// checked under plain node, which is what test/resolve.test.js does. +function candidates(configured, folders) { + if (path.isAbsolute(configured)) return [configured]; + // The folder itself before anything under it: opening the checkout directly + // is the common case and must not be slowed down or second-guessed. + const inWorkspace = (folders || []).flatMap((f) => [ + path.join(f, configured), + ...checkoutsBelow(f).map((d) => path.join(d, configured)), + ]); + return [...inWorkspace, ...onPath(INSTALLED)]; +} + +// The checkout a resolved server came out of, or null for an installed one. +// +// Pure string work, deliberately: asking the filesystem "is there a +// dune-project above this?" would also say yes for `~/.opam/…/bin/writ-lsp` +// sitting inside some unrelated tree, and then the build command would offer to +// rebuild a checkout that has nothing to do with the server in use. A server is +// a checkout's only when it is at the exact path a checkout builds it to. +function checkoutOf(serverPath) { + const suffix = path.join(...IN_CHECKOUT.server.split("/")); + if (!serverPath.endsWith(suffix)) return null; + const root = serverPath.slice(0, serverPath.length - suffix.length); + const trimmed = root.replace(/[\\/]+$/, ""); + return trimmed && isCheckout(trimmed) ? trimmed : null; +} + +// The `writ` command line that belongs to a given server. +// +// It must be the SAME install, which is why this is derived from the server +// rather than looked up independently: a checkout's server with the installed +// `writ` on PATH is exactly the mismatch the panel exists to catch, and running +// commands through the wrong one would hide it. +function cliFor(serverPath) { + const root = checkoutOf(serverPath); + if (root) { + const built = path.join(root, ...IN_CHECKOUT.cli.split("/")); + if (fs.existsSync(built)) return built; + return null; // a checkout that built the server but not the CLI + } + // An installed server has the CLI beside it: opam and the release tarball + // both put `writ` and `writ-lsp` in the same bin directory. + const beside = path.join( + path.dirname(serverPath), + process.platform === "win32" ? CLI + ".exe" : CLI + ); + if (fs.existsSync(beside)) return beside; + return onPath(CLI).find((p) => fs.existsSync(p)) || null; +} + +module.exports = { + INSTALLED, + CLI, + DEFAULT_SERVER, + IN_CHECKOUT, + onPath, + isCheckout, + checkoutsBelow, + candidates, + checkoutOf, + cliFor, +}; diff --git a/src/panel.js b/src/panel.js new file mode 100644 index 0000000..b491698 --- /dev/null +++ b/src/panel.js @@ -0,0 +1,245 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The Activity Bar panel. +// +// DELIBERATELY NOT THE README. VS Code already shows the readme and the version +// if you click the extension in the Extensions sidebar, so nothing here is +// information the editor lacks — this is about WHERE SOMEONE LOOKS when the +// thing they are looking at is wrong. +// +// Two things earn their place in a 250px strip. +// +// The INSTALL STATUS, because this extension bundles no engine and downloads +// none: it talks to a `writ-lsp` you already have, resolved at activation from a +// checkout's `_build` or from PATH. Every way that can go wrong produces the +// same symptom — an editor that highlights and says nothing else — and the three +// causes want three different things done about them. No server, a server older +// than the sources it was built from, and a server that is a different writ than +// this extension: the first needs an install, the second a build, the third +// either. Until now the extension said one of these once, in a toast, at +// activation, and then never again. +// +// And the VERBS, because writ is a command-line tool that answers questions and +// the extension exposed none of them. `check` on the file in front of you is +// two clicks from here rather than a remembered invocation with two paths in it. +// +// The file-type table is three lines and stays because the split — models here, +// questions there, derivations somewhere else — is the first thing about this +// language a person has to hold in their head, and the panel is already open. + +const engine = require("./engine"); + +const GUIDE = "https://github.com/writ-lang/writ-vscode#readme"; +const LANGUAGE = "https://github.com/writ-lang/writ#readme"; + +const FILE_TYPES = [ + [".writ", "models and libraries — schema, instance, transitions"], + [".claims", "the questions asked of a model, kept as their own document"], + [".rules", "Datalog-style derivations over a model's situation space"], +]; + +function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c] + ); +} + +// The action button under the status line, if the verdict asks for one. +// Named here rather than in [engine.verdict] so that the verdict stays a +// statement about the install and not a piece of user interface. +function actionButton(action, checkout) { + if (action === "build") + return ''; + if (action === "install") + return ( + '" + ); + return ""; +} + +// Pure, so the whole panel can be rendered and inspected under plain node — +// which test/panel.test.js does, because a webview is otherwise only checkable +// by looking at it. +function renderHtml({ extension, engineVersion, serverPath, checkout, stale }) { + const v = engine.verdict({ + extension, + engine: engineVersion, + stale, + checkout, + serverPath, + }); + const status = v + ? escapeHtml(v.message) + : "The extension and the engine are in step."; + const cls = v ? v.level : "ok"; + const shownEngine = + engineVersion === null || engineVersion === undefined + ? serverPath + ? "not reported" + : "not found" + : escapeHtml(String(engineVersion).trim()); + + const types = FILE_TYPES.map( + ([ext, what]) => + '' + + escapeHtml(ext) + + "" + + escapeHtml(what) + + "" + ).join("\n"); + + return ` + + + + +

Ask something

+ + + + + +

Three kinds of file

+ ${types}
+ +

The engine

+
+
Extension
${escapeHtml(extension)}
+
Engine
${shownEngine}
+
+
${escapeHtml(serverPath || "no language server")}
+

${status}

+ ${v ? actionButton(v.action, checkout) : ""} + + + + +

Help

+ + + + +`; +} + +// The view, once it exists, so that anything which changes what the panel says +// can ask it to say it again. Rendering once and never again would be a bug of +// its own: you build the engine, the warning stays, and the panel is the only +// thing left in the editor still claiming you are out of date. +let live = null; + +function refresh() { + if (live) live(); +} + +function register(context) { + const vscode = require("vscode"); + + const provider = { + resolveWebviewView(view) { + view.webview.options = { enableScripts: true }; + const mine = + (context.extension && + context.extension.packageJSON && + context.extension.packageJSON.version) || + "0.0.0"; + + const paint = () => { + const s = engine.current(); + view.webview.html = renderHtml({ + extension: mine, + engineVersion: s.version, + serverPath: s.serverPath, + checkout: s.checkout, + stale: engine.stale(), + }); + }; + live = paint; + paint(); + + view.onDidChangeVisibility(() => { + if (view.visible) paint(); + }); + view.onDidDispose(() => { + if (live === paint) live = null; + }); + + view.webview.onDidReceiveMessage((m) => { + if (m.command) vscode.commands.executeCommand(m.command); + else if (m.open) vscode.env.openExternal(vscode.Uri.parse(m.open)); + }); + }, + }; + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("writ.home", provider), + + // A Command Palette route to the panel, because the Activity Bar entry can + // be invisible through no fault of the manifest: VS Code stores per-user + // workbench state in workbench.activity.pinnedViewlets2, and on a crowded + // bar a newly contributed container can land there pinned but not visible — + // registered, ordered, and never drawn. "Click the icon" is then a dead end + // with nothing to search for. VS Code generates .focus for a + // contributed view; this wraps it under a name a person can find by typing + // "writ". + vscode.commands.registerCommand("writ.openOverview", () => + vscode.commands.executeCommand("writ.home.focus") + ) + ); +} + +module.exports = { register, refresh, renderHtml, escapeHtml, FILE_TYPES }; diff --git a/src/staleness.js b/src/staleness.js new file mode 100644 index 0000000..cfb4a08 --- /dev/null +++ b/src/staleness.js @@ -0,0 +1,123 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Is the built server older than the language it was built from? +// +// The version comparison in the panel cannot answer this. dune-project's +// version moves only when someone bumps it deliberately, so a checkout built +// before this morning's edit reports exactly the same version as one built +// after it — the two agree, the panel says so, and the editor goes on answering +// with a server that predates the change being made. +// +// The watcher next door handles the other half: a server that is rebuilt WHILE +// the editor runs. This is the half it cannot see, because nothing happened — +// the build was never run at all. +// +// Times catch what versions cannot. + +const fs = require("fs"); +const path = require("path"); + +// Only the engine and the tools built from it. Not tests/ (a changed test does +// not make the server wrong) and not core/stdlib (.writ files, read at runtime +// from disk, never compiled in). +const SOURCE_DIRS = ["core", "tooling"]; +const SOURCE_EXT = [".ml", ".mli"]; + +// Pure, so it can be checked without a filesystem. +// +// [builtAt] is when a build last finished, and it matters because dune is +// content-addressed: touch a file without changing it and dune rebuilds +// nothing, so the binary keeps an mtime older than the source for ever. A +// completed build means the binary reflects the sources whether or not any +// bytes moved, and without this the warning could never be cleared by doing the +// thing the warning asks for. +function staleness(binaryMtime, sources, builtAt) { + if (binaryMtime === null || binaryMtime === undefined) return null; + const floor = Math.max(binaryMtime, builtAt || 0); + let newest = null; + for (const source of sources) { + if (source.mtime > floor && (!newest || source.mtime > newest.mtime)) + newest = source; + } + return newest + ? { path: newest.path, mtime: newest.mtime, binary: binaryMtime } + : null; +} + +// The engine's sources under a checkout. Recursive, because writ's are nested +// (tooling/lsp/lib/feature/…) where a flat listing would see none of them — +// and bounded to SOURCE_DIRS, which are 61 files, so this is a walk of the +// engine and never of the workspace. +function sourcesIn(root) { + const out = []; + const walk = (dir, shown) => { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + const label = shown + "/" + e.name; + if (e.isDirectory()) { + if (e.name === "_build" || e.name === ".git") continue; + walk(full, label); + } else if (SOURCE_EXT.includes(path.extname(e.name))) { + try { + out.push({ path: label, mtime: fs.statSync(full).mtimeMs }); + } catch { + // it went away between the listing and the stat + } + } + } + }; + for (const dir of SOURCE_DIRS) walk(path.join(root, dir), dir); + return out; +} + +// Said by whoever ran the build, when it succeeded. +let builtAt = 0; +let lastLook = 0; +let lastAnswer = null; + +function markBuilt(when) { + builtAt = when === undefined ? Date.now() : when; + lastLook = 0; + lastAnswer = null; +} + +function forget() { + lastLook = 0; + lastAnswer = null; +} + +// Cached for a second: the panel repaints on every visibility change, and a +// 61-file walk on each one would be paid for nothing. +// +// Returns null for an INSTALLED server as well as for an up-to-date one. That +// is not a gap: there are no sources to be older than, and "your opam pin is +// behind the repository" is a different question with a different answer, which +// is the update command and not a rebuild. +function stale(checkout, serverPath, now) { + const t = now === undefined ? Date.now() : now; + if (now === undefined && t - lastLook < 1000) return lastAnswer; + const answer = (() => { + if (!checkout) return null; + let binaryMtime; + try { + binaryMtime = fs.statSync(serverPath).mtimeMs; + } catch { + return null; // no server at all is a different complaint + } + return staleness(binaryMtime, sourcesIn(checkout), builtAt); + })(); + if (now === undefined) { + lastLook = t; + lastAnswer = answer; + } + return answer; +} + +module.exports = { staleness, sourcesIn, stale, markBuilt, forget, SOURCE_DIRS }; diff --git a/src/watcher.js b/src/watcher.js new file mode 100644 index 0000000..dd6245d --- /dev/null +++ b/src/watcher.js @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Restart the server when its binary is replaced. +// +// WHY THIS EXISTS. `make build` writes a NEW inode over the server, and the +// process already running keeps the old one — `/proc/PID/exe` reads +// "(deleted)". Nothing surfaces that. The editor keeps answering, confidently, +// with a language that is one build out of date, and the symptom is a squiggle +// on code the CLI accepts. That has cost real debugging time more than once, +// and it is worst exactly when the language itself is being changed, because +// then the stale answer is *plausible*. +// +// Polling rather than [createFileSystemWatcher]: the default server path is +// under `_build`, which is routinely listed in `files.watcherExclude`, and a +// watcher that silently never fires would be a worse version of this same bug. +// One stat every two seconds is not a cost worth optimising. + +const fs = require("fs"); + +const POLL_MS = 2000; + +// Identity of the file as built, not merely its name: dune replaces the +// executable, so the inode changes even when a rebuild lands within the same +// mtime granularity. +function stampOf(p) { + try { + const s = fs.statSync(p); + return `${s.ino}:${s.mtimeMs}:${s.size}`; + } catch { + return null; + } +} + +// `restart` and `onRestart` are injected rather than reached for, so this can +// be driven against a real file with no editor — which test/watcher.test.js +// does, because a watcher that silently never fires is the bug it exists to +// fix and nothing else would catch it. +function watchServerBinary(serverPath, log, pollMs = POLL_MS, hooks = {}) { + const restart = hooks.restart || (() => Promise.resolve()); + const onRestart = hooks.onRestart || (() => {}); + const onFailure = hooks.onFailure || (() => {}); + let known = stampOf(serverPath); + let pending = null; + + const id = setInterval(async () => { + const now = stampOf(serverPath); + if (now === null) return; // mid-build: the file is briefly gone + if (now === known) { + pending = null; // nothing new, or a change that reverted + return; + } + if (pending !== now) { + // Seen a change, but act only once it has stopped moving, so a + // multi-second link step restarts the server once and not four times. + pending = now; + return; + } + known = now; + pending = null; + log(`server binary changed on disk — restarting (${serverPath})`); + try { + await restart(); + log("server restarted; diagnostics are from the current build"); + onRestart(); + } catch (e) { + onFailure(e); + } + }, pollMs); + + return { dispose: () => clearInterval(id) }; +} + +module.exports = { watchServerBinary, stampOf, POLL_MS }; diff --git a/test-resolve.js b/test-resolve.js deleted file mode 100644 index 4505eab..0000000 --- a/test-resolve.js +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (C) 2026 Alex Kunich -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Where the client looks for the language server, and in what order. -// -// This is the whole of "the extension works with writ installed separately". -// Until it was written, the only candidate was a path under `_build`, which -// exists ONLY inside a checkout — so on a machine that had installed writ -// separately the extension found nothing, even though tooling/lsp/bin/dune installs -// `writ-lsp` precisely so that case works. -// -// The order matters as much as the set: a checkout's own build must win, because -// an OCaml server exists so the editor and the checker are the same code, and -// someone changing the language wants the build in front of them. -// -// Run: node test-resolve.js - -const Module = require("module"); -const path = require("path"); - -let workspace = []; -const origLoad = Module._load; -Module._load = function (req) { - if (req === "vscode") - return { - workspace: { - get workspaceFolders() { - return workspace; - }, - getConfiguration: () => ({ get: (_k, d) => d }), - }, - window: { showErrorMessage: () => {} }, - }; - if (req === "vscode-languageclient/node") - return { LanguageClient: class {}, TransportKind: { stdio: 0 } }; - return origLoad.apply(this, arguments); -}; - -const { candidates, onPath, INSTALLED } = require( - path.join(__dirname, "extension.js") -).__test; - -let failed = 0; -function check(name, cond) { - if (!cond) { - console.log(`FAIL: ${name}`); - failed++; - } -} - -const DEFAULT = "_build/default/tooling/lsp/bin/writ_lsp.exe"; -const folder = (p) => ({ uri: { fsPath: p } }); - -// 1. In a checkout: the build comes first, the installed server after it. -workspace = [folder("/w/writ")]; -let c = candidates(DEFAULT); -check("a checkout's own build is tried first", c[0] === path.join("/w/writ", DEFAULT)); -check("and the installed writ-lsp is still a fallback", c.some((p) => p.endsWith(path.sep + INSTALLED))); -check( - "the build is tried BEFORE anything on PATH", - c.indexOf(path.join("/w/writ", DEFAULT)) < - c.findIndex((p) => p.endsWith(path.sep + INSTALLED)) -); - -// 2. No workspace at all — a lone .writ file, or writ installed without a -// checkout. This is the case that used to yield an empty list. -workspace = []; -c = candidates(DEFAULT); -check("with no workspace there is still somewhere to look", c.length > 0); -check( - "and it is writ-lsp on PATH", - c.every((p) => p.endsWith(path.sep + INSTALLED)) -); - -// 3. An absolute setting is taken literally: an operator naming a server means -// that server, and nothing is guessed after it. -workspace = [folder("/w/writ")]; -c = candidates("/opt/writ/bin/writ-lsp"); -check("an absolute setting is the only candidate", c.length === 1); -check("and it is exactly what was set", c[0] === "/opt/writ/bin/writ-lsp"); - -// 4. The PATH search uses the INSTALLED name, not the in-checkout file name. -// an installed writ produces `writ-lsp`; `_build` produces `writ_lsp.exe`. -check("the installed name is writ-lsp", INSTALLED === "writ-lsp"); -check( - "every PATH candidate ends in it", - onPath(INSTALLED).every((p) => path.basename(p).startsWith(INSTALLED)) -); -check("PATH is actually searched", onPath(INSTALLED).length > 0); - -// 5. The workspace root is the PARENT of the checkout, not the checkout. -// -// This is what the split into separate writ/ and writ-vscode/ repositories -// produces: you open the directory that holds both, so the relative default -// resolves against a folder with no _build in it, and the extension reported -// "no language server" while a perfectly good one sat one level down. A -// checkout is identified by dune-project, so only a real one is added, and -// only immediate subdirectories are looked at — this must not become a walk -// of the whole workspace. -const os = require("os"); -const fs = require("fs"); -const root = fs.mkdtempSync(path.join(os.tmpdir(), "writ-resolve-")); -fs.mkdirSync(path.join(root, "writ")); -fs.writeFileSync(path.join(root, "writ", "dune-project"), "(lang dune 3.0)\n"); -fs.mkdirSync(path.join(root, "writ-vscode")); // a sibling that is NOT a checkout -fs.mkdirSync(path.join(root, "notes")); - -workspace = [folder(root)]; -c = candidates(DEFAULT); -check( - "a checkout one level below the workspace root is found", - c.includes(path.join(root, "writ", DEFAULT)) -); -check( - "the workspace root itself is still tried first", - c[0] === path.join(root, DEFAULT) -); -check( - "a subdirectory that is not a checkout is not guessed at", - !c.some((p) => p.startsWith(path.join(root, "notes") + path.sep)) -); -check( - "the nested checkout still beats anything on PATH", - c.indexOf(path.join(root, "writ", DEFAULT)) < - c.findIndex((p) => p.endsWith(path.sep + INSTALLED)) -); - -// An unreadable or absent folder must not throw — a workspace folder can name a -// disconnected drive, and resolution failing there would take the whole -// extension down at activation. -workspace = [folder(path.join(root, "gone"))]; -let threw = false; -try { - candidates(DEFAULT); -} catch { - threw = true; -} -check("a workspace folder that does not exist is survivable", !threw); - -fs.rmSync(root, { recursive: true, force: true }); - -const n = 15 - failed; -console.log( - failed === 0 - ? `extension resolve tests: ${n} checks passed` - : `extension resolve tests: ${failed} FAILED` -); -process.exit(failed === 0 ? 0 : 1); diff --git a/test-watcher.js b/test-watcher.js deleted file mode 100644 index 1f41850..0000000 --- a/test-watcher.js +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (C) 2026 Alex Kunich -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Drives extension.js's server-binary watcher against a real file on disk. -// -// The watcher is the only part of the client with behaviour of its own, and its -// failure mode is SILENCE — it either fires or it does not, and a watcher that -// never fires looks exactly like a server that is simply up to date. That is -// the bug it exists to fix, so it does not get to go untested. The first -// version of it never fired at all; this file is what caught that. -// -// `vscode` and `vscode-languageclient` do not exist outside the editor, so both -// are stubbed at the module loader. Nothing here needs the real ones: the -// watcher only stats a path and calls `client.restart()`. -// -// Run: node test-watcher.js - -const Module = require("module"); -const fs = require("fs"); -const path = require("path"); -const os = require("os"); - -const errors = []; -const origLoad = Module._load; -Module._load = function (req) { - if (req === "vscode") - return { - workspace: { - workspaceFolders: [], - getConfiguration: () => ({ get: (_k, d) => d }), - }, - window: { showErrorMessage: (m) => errors.push(m) }, - }; - if (req === "vscode-languageclient/node") - return { LanguageClient: class {}, TransportKind: { stdio: 0 } }; - return origLoad.apply(this, arguments); -}; - -const ext = require(path.join(__dirname, "extension.js")); - -const POLL = 40; // fast enough to gate; the shipped default is 2000 -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -let failed = 0; -function check(name, cond) { - if (!cond) { - console.log(`FAIL: ${name}`); - failed++; - } -} - -(async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "writ-watch-")); - const bin = path.join(dir, "writ_lsp.exe"); - fs.writeFileSync(bin, "v1"); - - let restarts = 0; - const logs = []; - ext.__test.setClient({ - restart: async () => { - restarts++; - }, - }); - const w = ext.__test.watchServerBinary(bin, (m) => logs.push(m), POLL); - - await sleep(POLL * 6); - check("an unchanged binary does not restart the server", restarts === 0); - - // Replaced, not edited in place — which is what dune does, and why the stamp - // carries the inode and not just the mtime. - fs.rmSync(bin); - fs.writeFileSync(bin, "v2-different-length"); - await sleep(POLL * 8); - check("a replaced binary restarts the server", restarts === 1); - check("and says so in the output channel", logs.length === 2); - - await sleep(POLL * 6); - check("a settled binary restarts it once, not repeatedly", restarts === 1); - - // A vanished file is a build in progress, not a reason to do anything. - fs.rmSync(bin); - await sleep(POLL * 6); - check("a missing binary is not a restart", restarts === 1); - check("nothing was reported to the user", errors.length === 0); - - w.dispose(); - const n = 6 - failed; - console.log( - failed === 0 - ? `extension watcher tests: ${n} checks passed` - : `extension watcher tests: ${failed} FAILED` - ); - process.exit(failed === 0 ? 0 : 1); -})(); diff --git a/test/harness.js b/test/harness.js new file mode 100644 index 0000000..f46b56f --- /dev/null +++ b/test/harness.js @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// What the four test files share: counting, and a name for a failure. +// +// Deliberately not a framework. Each file is `node test/NAME.test.js` and +// nothing has to be installed to run one — which matters because this +// repository's only dependency is vscode-languageclient, and a test runner +// would be the second. + +function suite(label) { + let failed = 0; + let passed = 0; + return { + check(name, cond) { + if (cond) passed++; + else { + console.log(`FAIL: ${name}`); + failed++; + } + }, + done() { + console.log( + failed === 0 + ? `${label}: ${passed} checks passed` + : `${label}: ${failed} FAILED of ${failed + passed}` + ); + process.exit(failed === 0 ? 0 : 1); + }, + }; +} + +module.exports = { suite }; diff --git a/test/manifest.test.js b/test/manifest.test.js new file mode 100644 index 0000000..24877ed --- /dev/null +++ b/test/manifest.test.js @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The manifest against the code. +// +// package.json is the half of an extension that nothing typechecks. A command +// contributed but never registered shows up in the palette and does nothing +// when picked; a command registered but not contributed is unreachable except +// from a button; a menu or a webview button naming a command that is neither is +// a click that silently fails. All three are invisible until someone tries the +// one path that hits them, and none of them is a crash — which is why they get +// a test rather than a review. +// +// The panel in particular is HTML in a string: the buttons carry command ids +// that no tool anywhere resolves. +// +// Run: node test/manifest.test.js + +const fs = require("fs"); +const path = require("path"); +const { suite } = require("./harness"); + +const { check, done } = suite("manifest tests"); + +const root = path.join(__dirname, ".."); +const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const c = pkg.contributes; +const read = (p) => fs.readFileSync(path.join(root, p), "utf8"); + +// Every `writ.…` id the source registers, wherever it registers it. +const source = ["src/commands.js", "src/panel.js"].map(read).join("\n"); +const registered = new Set( + [...source.matchAll(/(?:registerCommand\(|\bon\()\s*"(writ\.[\w.]+)"/g)].map((m) => m[1]) +); + +const contributed = new Set(c.commands.map((x) => x.command)); + +// --- contributed against registered ---------------------------------------- + +for (const id of contributed) + check(`${id} is contributed and registered`, registered.has(id)); + +// The reverse is not an error in general — a command can exist for a button +// alone — but it is worth knowing about, because an id that is registered and +// contributed nowhere is one nobody can reach from the palette either. +for (const id of registered) + check(`${id} is registered and contributed`, contributed.has(id)); + +// --- everything that NAMES a command --------------------------------------- + +const referenced = new Set(); +for (const group of Object.values(c.menus || {})) + for (const item of group) referenced.add(item.command); +for (const b of c.keybindings || []) referenced.add(b.command); +// The panel's buttons, which are command ids inside an HTML string. +for (const m of read("src/panel.js").matchAll(/(?:data-cmd="|command: ')(writ\.[\w.]+)/g)) + referenced.add(m[1]); +// And the ones the activation error offers. +for (const m of read("extension.js").matchAll(/executeCommand\("(writ\.[\w.]+)"/g)) + referenced.add(m[1]); + +for (const id of referenced) + check(`${id}, named in a menu or a button, exists`, registered.has(id)); + +check("the panel does name commands, so the check above is not vacuous", referenced.size > 5); + +// --- the activity bar ------------------------------------------------------ + +const container = c.viewsContainers.activitybar[0]; +check("there is an activity bar container", !!container); +check("its views are keyed by its id", Array.isArray(c.views[container.id])); + +const view = c.views[container.id][0]; +check("the view is a webview, which is what the panel renders", view.type === "webview"); +check( + "and the extension activates when it is opened — otherwise the panel that " + + "explains a missing server would itself need a .writ file open to appear", + (pkg.activationEvents || []).includes("onView:" + view.id) +); +check( + "the panel registers a provider for exactly that id", + read("src/panel.js").includes('registerWebviewViewProvider("' + view.id + '"') +); +check( + "and the palette route focuses that same view — the activity bar icon can " + + "be unpinned, and then it is the only way in", + read("src/panel.js").includes('executeCommand("' + view.id + '.focus")') +); + +// --- files the manifest promises ------------------------------------------- + +const icons = [ + container.icon, + ...Object.values((c.languages[0] || {}).icon || {}), + pkg.icon, +].filter(Boolean); +for (const i of icons) + check(`${i} exists`, fs.existsSync(path.join(root, i.replace(/^\.\//, "")))); +check("the language icons are declared for both themes", icons.length >= 4); + +check( + "the grammar file exists", + fs.existsSync(path.join(root, c.grammars[0].path.replace(/^\.\//, ""))) +); +check("main points at a real file", fs.existsSync(path.join(root, pkg.main.replace(/^\.\//, "")))); + +// --- the setting the code actually reads ----------------------------------- + +const setting = Object.keys(c.configuration.properties)[0]; +check( + "the contributed setting is the one src/engine.js reads", + read("src/engine.js").includes('SETTING = "' + setting + '"') +); +check( + "and its default is the path src/locate.js defaults to", + c.configuration.properties[setting].default === + read("src/locate.js").match(/DEFAULT_SERVER = "([^"]+)"/)[1] +); + +// --- version ----------------------------------------------------------------- +// +// Cheap here, and the README is the copy that gets forgotten. scripts/version.sh +// is the thing that fixes it; this is the thing that notices. +check( + "the README's .vsix filename carries the manifest's version", + read("README.md").includes("writ-" + pkg.version + ".vsix") +); + +done(); diff --git a/test/resolve.test.js b/test/resolve.test.js new file mode 100644 index 0000000..daeca38 --- /dev/null +++ b/test/resolve.test.js @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Where the client looks for the engine, and in what order. +// +// This is the whole of "the extension works with writ installed separately". +// Until it was written, the only candidate was a path under `_build`, which +// exists ONLY inside a checkout — so on a machine that had installed writ +// separately the extension found nothing, even though tooling/lsp/bin/dune +// installs `writ-lsp` precisely so that case works. +// +// The order matters as much as the set: a checkout's own build must win, +// because an OCaml server exists so the editor and the checker are the same +// code, and someone changing the language wants the build in front of them. +// +// Nothing is stubbed here any more. [candidates] takes its workspace folders as +// an argument rather than reaching into `vscode`, which is what makes the +// module plain node — and the reason the resolution order is the one part of +// this extension that has never needed an editor to check. +// +// Run: node test/resolve.test.js + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { suite } = require("./harness"); +const locate = require("../src/locate"); + +const { check, done } = suite("resolve tests"); +const { candidates, onPath, checkoutOf, cliFor, INSTALLED, DEFAULT_SERVER: DEFAULT } = locate; + +// 1. In a checkout: the build comes first, the installed server after it. +let c = candidates(DEFAULT, ["/w/writ"]); +check("a checkout's own build is tried first", c[0] === path.join("/w/writ", DEFAULT)); +check( + "and the installed writ-lsp is still a fallback", + c.some((p) => p.endsWith(path.sep + INSTALLED)) +); +check( + "the build is tried BEFORE anything on PATH", + c.indexOf(path.join("/w/writ", DEFAULT)) < + c.findIndex((p) => p.endsWith(path.sep + INSTALLED)) +); + +// 2. No workspace at all — a lone .writ file, or writ installed without a +// checkout. This is the case that used to yield an empty list. +c = candidates(DEFAULT, []); +check("with no workspace there is still somewhere to look", c.length > 0); +check("and it is writ-lsp on PATH", c.every((p) => p.endsWith(path.sep + INSTALLED))); + +// 3. An absolute setting is taken literally: an operator naming a server means +// that server, and nothing is guessed after it. +c = candidates("/opt/writ/bin/writ-lsp", ["/w/writ"]); +check("an absolute setting is the only candidate", c.length === 1); +check("and it is exactly what was set", c[0] === "/opt/writ/bin/writ-lsp"); + +// 4. The PATH search uses the INSTALLED name, not the in-checkout file name: +// an installed writ produces `writ-lsp`; `_build` produces `writ_lsp.exe`. +check("the installed name is writ-lsp", INSTALLED === "writ-lsp"); +check( + "every PATH candidate ends in it", + onPath(INSTALLED).every((p) => path.basename(p).startsWith(INSTALLED)) +); +check("PATH is actually searched", onPath(INSTALLED).length > 0); + +// 5. The workspace root is the PARENT of the checkout, not the checkout. +// +// This is what the split into separate writ/ and writ-vscode/ repositories +// produces: you open the directory that holds both, so the relative default +// resolves against a folder with no _build in it, and the extension reported +// "no language server" while a perfectly good one sat one level down. +const root = fs.mkdtempSync(path.join(os.tmpdir(), "writ-resolve-")); +fs.mkdirSync(path.join(root, "writ")); +fs.writeFileSync(path.join(root, "writ", "dune-project"), "(lang dune 3.0)\n"); +fs.mkdirSync(path.join(root, "writ-vscode")); // a sibling that is NOT a checkout +fs.mkdirSync(path.join(root, "notes")); + +c = candidates(DEFAULT, [root]); +check( + "a checkout one level below the workspace root is found", + c.includes(path.join(root, "writ", DEFAULT)) +); +check("the workspace root itself is still tried first", c[0] === path.join(root, DEFAULT)); +check( + "a subdirectory that is not a checkout is not guessed at", + !c.some((p) => p.startsWith(path.join(root, "notes") + path.sep)) +); +check( + "the nested checkout still beats anything on PATH", + c.indexOf(path.join(root, "writ", DEFAULT)) < + c.findIndex((p) => p.endsWith(path.sep + INSTALLED)) +); + +// An unreadable or absent folder must not throw — a workspace folder can name a +// disconnected drive, and resolution failing there would take the whole +// extension down at activation. +let threw = false; +try { + candidates(DEFAULT, [path.join(root, "gone")]); +} catch { + threw = true; +} +check("a workspace folder that does not exist is survivable", !threw); + +// 6. Which checkout a resolved server belongs to. +// +// The panel's build button and the "older than your sources" warning both +// depend on this being the checkout that produced THIS server, and not any +// checkout that happens to be above it on disk. An installed server inside +// somebody's source tree is the case that makes the difference: it must +// report no checkout, or the panel would offer to rebuild a repository that +// has nothing to do with the server in use. +const built = path.join(root, "writ", DEFAULT); +check("a server under a checkout names that checkout", checkoutOf(built) === path.join(root, "writ")); +check( + "a server merely sitting inside a checkout names none", + checkoutOf(path.join(root, "writ", "some", "bin", "writ-lsp")) === null +); +check( + "and neither does one at the right path under a directory that is no checkout", + checkoutOf(path.join(root, "notes", DEFAULT)) === null +); + +// 7. The command line that belongs to the SAME install as the server. +// +// Looking `writ` up on PATH independently is exactly the mismatch the panel +// exists to catch: a checkout's server answering the squiggles while the +// installed `writ` answers "Check this file" would make the two disagree by +// construction. +const cliPath = path.join(root, "writ", ...locate.IN_CHECKOUT.cli.split("/")); +fs.mkdirSync(path.dirname(cliPath), { recursive: true }); +fs.writeFileSync(cliPath, ""); +check("a checkout's server is paired with that checkout's CLI", cliFor(built) === cliPath); + +fs.rmSync(cliPath); +check("a checkout that built no CLI reports none, rather than falling back to PATH", cliFor(built) === null); + +// An installed server's CLI is its neighbour: opam and the release tarball both +// put `writ` and `writ-lsp` in the same bin directory. +const bin = path.join(root, "bin"); +fs.mkdirSync(bin); +fs.writeFileSync(path.join(bin, "writ-lsp"), ""); +fs.writeFileSync(path.join(bin, process.platform === "win32" ? "writ.exe" : "writ"), ""); +check( + "an installed server is paired with the writ beside it", + cliFor(path.join(bin, "writ-lsp")) === + path.join(bin, process.platform === "win32" ? "writ.exe" : "writ") +); + +fs.rmSync(root, { recursive: true, force: true }); +done(); diff --git a/test/status.test.js b/test/status.test.js new file mode 100644 index 0000000..fa05816 --- /dev/null +++ b/test/status.test.js @@ -0,0 +1,164 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// What the panel says about the install, and why it says it. +// +// The verdict is the extension's one piece of judgement. Everything else here +// either finds a path or forwards a message the server wrote; this decides, +// from four facts, which of four different things has gone wrong — and it is +// shown to a user as a warning, so getting it wrong means telling someone with +// a perfectly good install that it is broken. +// +// The ORDER of the cases is the part worth pinning. A stale build reports the +// same version as a current one, so if the versions were compared first, the +// most misleading state this extension can be in would report as healthy. +// +// Run: node test/status.test.js + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { suite } = require("./harness"); +const { verdict } = require("../src/engine"); +const { staleness, sourcesIn } = require("../src/staleness"); +const { renderHtml, escapeHtml } = require("../src/panel"); + +const { check, done } = suite("status tests"); + +const base = { + extension: "0.1.0", + engine: "0.1.0", + stale: null, + checkout: "/w/writ", + serverPath: "/w/writ/_build/default/tooling/lsp/bin/writ_lsp.exe", +}; +const v = (over) => verdict({ ...base, ...over }); + +// --- the four states ------------------------------------------------------- + +check("matched versions, fresh build: nothing to say", v({}) === null); + +check("no server at all is the bad one", v({ serverPath: null }).level === "bad"); +check( + "and it says what still works, because highlighting keeps working and that " + + "is exactly what makes the failure hard to spot", + /highlighting/.test(v({ serverPath: null }).message) +); + +const mismatch = v({ engine: "0.2.0" }); +check("a version mismatch is a warning, not an error", mismatch.level === "warn"); +check("and it names both versions", /0\.1\.0/.test(mismatch.message) && /0\.2\.0/.test(mismatch.message)); + +const old = v({ stale: { path: "core/syntax/reader.ml" } }); +check("a stale build is a warning", old.level === "warn"); +check("and it names the file that outran it", /reader\.ml/.test(old.message)); + +const silent = v({ engine: null }); +check("a server that reports no version is its own case", silent.level === "warn"); +check( + "and is not reported as a mismatch, which would be a lie about a version " + + "nobody has", + !/mismatch/.test(silent.message) +); + +// --- the order ------------------------------------------------------------- +// +// This is the whole reason staleness exists as a separate signal. +check( + "a stale build is reported even though the versions agree", + v({ engine: "0.1.0", stale: { path: "core/data/kind.ml" } }) !== null +); +check( + "and staleness wins over a version mismatch, because rebuilding is the step " + + "that has to happen first either way", + /older than/.test(v({ engine: "0.9.9", stale: { path: "core/data/kind.ml" } }).message) +); + +// --- what the verdict asks for --------------------------------------------- + +check("in a checkout, the fix offered is a build", v({ serverPath: null }).action === "build"); +check( + "with no checkout, it is an install", + v({ serverPath: null, checkout: null }).action === "install" +); + +// --- the clock ------------------------------------------------------------- + +check("nothing is stale when there is no binary", staleness(null, [{ path: "a", mtime: 9 }], 0) === null); +check("nothing is stale when every source is older", staleness(100, [{ path: "a", mtime: 9 }], 0) === null); +check( + "a newer source makes it stale, and names the newest one", + staleness(100, [{ path: "a", mtime: 101 }, { path: "b", mtime: 900 }], 0).path === "b" +); +// dune is content-addressed: a build that changes nothing leaves the binary's +// mtime where it was, so without a record of the build HAVING RUN the warning +// could never be cleared by doing the thing it asks for. +check( + "a completed build clears it even though the binary did not move", + staleness(100, [{ path: "a", mtime: 900 }], 1000) === null +); + +// A real walk, over a real tree, because the failure that matters is finding +// nothing: writ's sources are nested three deep and a flat listing sees none. +const root = fs.mkdtempSync(path.join(os.tmpdir(), "writ-stale-")); +fs.mkdirSync(path.join(root, "tooling", "lsp", "lib", "feature"), { recursive: true }); +fs.writeFileSync(path.join(root, "tooling", "lsp", "lib", "feature", "hover.ml"), ""); +fs.mkdirSync(path.join(root, "core", "data"), { recursive: true }); +fs.writeFileSync(path.join(root, "core", "data", "kind.ml"), ""); +fs.writeFileSync(path.join(root, "core", "data", "kind.mli"), ""); +fs.mkdirSync(path.join(root, "tooling", "lsp", "_build"), { recursive: true }); +fs.writeFileSync(path.join(root, "tooling", "lsp", "_build", "stale.ml"), ""); +fs.mkdirSync(path.join(root, "tests"), { recursive: true }); +fs.writeFileSync(path.join(root, "tests", "test_lsp.ml"), ""); +fs.mkdirSync(path.join(root, "core", "stdlib"), { recursive: true }); +fs.writeFileSync(path.join(root, "core", "stdlib", "stdlib.writ"), ""); + +const found = sourcesIn(root).map((s) => s.path).sort(); +check("a source nested three deep is found", found.includes("tooling/lsp/lib/feature/hover.ml")); +check("interfaces count as sources", found.includes("core/data/kind.mli")); +check("_build is not walked", !found.some((p) => p.includes("_build"))); +check( + "tests are not sources: a changed test does not make the server wrong", + !found.some((p) => p.startsWith("tests/")) +); +check( + "and neither is the .writ standard library, which is read at runtime and " + + "never compiled in", + !found.some((p) => p.endsWith(".writ")) +); +fs.rmSync(root, { recursive: true, force: true }); + +// --- the page -------------------------------------------------------------- + +check("a version is escaped before it reaches the page", escapeHtml('&"') === "<b>&""); + +const page = renderHtml({ + extension: "0.1.0", + engineVersion: "0.1.0", + serverPath: "/w/writ/_build/default/tooling/lsp/bin/writ_lsp.exe", + checkout: "/w/writ", + stale: null, +}); +check("the panel offers the verbs", /writ\.check/.test(page) && /writ\.derive/.test(page)); +check("and a way back to the server log", /writ\.showOutput/.test(page)); +check("a healthy install draws the reassuring status, not an empty box", /status ok/.test(page)); + +const broken = renderHtml({ + extension: "0.1.0", + engineVersion: null, + serverPath: null, + checkout: null, + stale: null, +}); +check("no server draws the bad status", /status bad/.test(broken)); +check("and the button offered is the update, not a build of a checkout that is not there", /writ\.update/.test(broken)); +check("the engine version reads as not found rather than blank", /not found/.test(broken)); + +const nover = renderHtml({ ...base, engineVersion: null, engine: undefined }); +check( + "a server that reported no version reads as not reported, which is a " + + "different thing from not found", + /not reported/.test(nover) +); + +done(); diff --git a/test/watcher.test.js b/test/watcher.test.js new file mode 100644 index 0000000..830169a --- /dev/null +++ b/test/watcher.test.js @@ -0,0 +1,90 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Drives the server-binary watcher against a real file on disk. +// +// The watcher's failure mode is SILENCE — it either fires or it does not, and +// one that never fires looks exactly like a server that is simply up to date. +// That is the bug it exists to fix, so it does not get to go untested. The +// first version of it never fired at all; this file is what caught that. +// +// Nothing is stubbed. The watcher takes its restart as an argument rather than +// reaching for a client, so it only ever stats a path and calls a function — +// which is the whole reason it can be checked with no editor in the room. +// +// Run: node test/watcher.test.js + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { suite } = require("./harness"); +const { watchServerBinary } = require("../src/watcher"); + +const POLL = 40; // fast enough to gate; the shipped default is 2000 +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const { check, done } = suite("watcher tests"); + +(async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "writ-watch-")); + const bin = path.join(dir, "writ_lsp.exe"); + fs.writeFileSync(bin, "v1"); + + let restarts = 0; + let rebuilt = 0; + const logs = []; + const failures = []; + const w = watchServerBinary(bin, (m) => logs.push(m), POLL, { + restart: async () => { + restarts++; + }, + onRestart: () => rebuilt++, + onFailure: (e) => failures.push(e), + }); + + await sleep(POLL * 6); + check("an unchanged binary does not restart the server", restarts === 0); + + // Replaced, not edited in place — which is what dune does, and why the stamp + // carries the inode and not just the mtime. + fs.rmSync(bin); + fs.writeFileSync(bin, "v2-different-length"); + await sleep(POLL * 8); + check("a replaced binary restarts the server", restarts === 1); + check("and says so in the output channel", logs.length === 2); + // The panel has to be told, or it goes on showing the staleness warning that + // the very build just cleared — the one place in the editor still claiming + // you are out of date. + check("and the rest of the extension is told the build landed", rebuilt === 1); + + await sleep(POLL * 6); + check("a settled binary restarts it once, not repeatedly", restarts === 1); + + // A vanished file is a build in progress, not a reason to do anything. + fs.rmSync(bin); + await sleep(POLL * 6); + check("a missing binary is not a restart", restarts === 1); + check("nothing was reported to the user", failures.length === 0); + + w.dispose(); + + // A restart that throws must reach the user, not the void: the server on disk + // has already changed, so staying quiet leaves the editor answering from a + // binary that is gone. + const bin2 = path.join(dir, "two.exe"); + fs.writeFileSync(bin2, "a"); + const seen = []; + const w2 = watchServerBinary(bin2, () => {}, POLL, { + restart: async () => { + throw new Error("no"); + }, + onFailure: (e) => seen.push(e), + }); + fs.rmSync(bin2); + fs.writeFileSync(bin2, "bbbbbb"); + await sleep(POLL * 8); + check("a restart that fails is reported", seen.length === 1); + w2.dispose(); + + fs.rmSync(dir, { recursive: true, force: true }); + done(); +})(); From b4fde3e03a5f70319695f27c67db20b41b577d42 Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Tue, 22 Sep 2026 06:04:13 +0300 Subject: [PATCH 4/5] README in the Marketplace layout; developer notes move to DEVELOPING.md --- DEVELOPING.md | 53 ++++++ README.md | 341 ++++++++++++---------------------- docs/images/river-example.png | Bin 0 -> 92693 bytes docs/images/river-example.svg | 16 ++ 4 files changed, 192 insertions(+), 218 deletions(-) create mode 100644 DEVELOPING.md create mode 100644 docs/images/river-example.png create mode 100644 docs/images/river-example.svg diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 0000000..6cf611a --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,53 @@ +# Working on the Writ extension + +This is for people changing the extension. To use it, the [README](README.md) is enough; to send a patch, see [CONTRIBUTING.md](CONTRIBUTING.md) first. + +There is no compile step: every LSP request is answered by the OCaml server, so +a build pipeline would exist to typecheck glue. What the glue has instead is +structure — `extension.js` is the wiring, and one module per concern under +`src/`: + +| | | +| --- | --- | +| `locate.js` | where the engine is: the candidate order, the checkout a server came from, and the `writ` belonging to it | +| `watcher.js` | restart when the server binary is replaced | +| `staleness.js` | is the built server older than the sources it was built from | +| `engine.js` | what engine this editor is talking to, and the verdict on it | +| `panel.js` | the Activity Bar view | +| `commands.js` | the verbs | + +Only `extension.js` and each module's `register` touch the `vscode` API. The +rest is plain JavaScript that runs under node — which is what the tests +exercise, with nothing stubbed and nothing installed: + +```console +$ scripts/test.sh +``` + +Four files, and each one is there because its subject fails **silently**: + +| | | +| --- | --- | +| `test/watcher.test.js` | a watcher that never fires looks exactly like a server that is up to date | +| `test/resolve.test.js` | the wrong candidate order means "no language server" on a machine that has one | +| `test/status.test.js` | the verdict is shown to users as a warning; getting it wrong means telling someone with a good install that it is broken | +| `test/manifest.test.js` | nothing typechecks `package.json` — a command contributed but not registered appears in the palette and does nothing | + +## The version + +The extension and the engine are released together and live in separate +repositories, so nothing structural keeps them in step — and the panel now +*shows* a mismatch to the user, which makes a version that is merely forgotten +into a warning on somebody's working install. + +```console +$ scripts/version.sh # print them, and fail if they disagree +$ scripts/version.sh 0.2.0 # set them +$ scripts/version.sh --from-writ # set them to what a writ checkout says +``` + +It reads writ's own version from the `dune-project` of a checkout beside this +one, or of `$WRIT_REPO`. `install.sh` derives its extension id from the manifest +rather than holding a copy, so the only two places left are `package.json` and +the `.vsix` filename in the README. + diff --git a/README.md b/README.md index 33d29ed..72bddfc 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,161 @@ -# Writ language support for VS Code - -writ - -*The editor client for [writ](https://github.com/writ-lang/writ). The language, -the checker and the language server live there; this repository is the VS Code -front end and nothing else.* - -Editor support for **Writ** — *Partial Olog*, an abstract language for modelling -real-world domains — over all three of its file types: - -| | | -| --- | --- | -| `.writ` | models and libraries — schema, instance, transitions | -| `.claims` | the questions asked of a model, kept as their own document | -| `.rules` | Datalog-style derivations over a model's situation space | - -Syntax highlighting, plus everything the language's own server provides: -diagnostics from the real parser and type-checker, a document outline, hover -and completion — and the verbs, from a panel in the Activity Bar. - -**The extension does not bundle a server and does not download one.** The point -of an OCaml server is that the editor and the checker are the *same code*, so a -diagnostic in the editor is one `writ check` would give you. It talks to a -`writ-lsp` you already have. - -## The panel - -Click the writ mark in the Activity Bar, or run **Writ: Open the Writ Panel** -from the Command Palette — the icon can end up unpinned, and then the palette is -the way in. - -It shows two things, and both are there because they are otherwise invisible. - -**Which writ you are actually talking to.** The extension and the engine are -separate installs; the panel prints both versions, the server's path, and -whether the two are in step. Every way that can be wrong produces the *same* -symptom — an editor that highlights and says nothing else — and the causes want -different fixes: - -| what it says | what happened | the button | -| --- | --- | --- | -| no language server | nothing was found at any candidate path | build, or update writ | -| older than `core/…` | the checkout has been edited since it was built | build the engine | -| the engine is *x*, the extension is *y* | one of the two was installed on its own | build, or update writ | -| the server did not say which writ it is | a writ from before the server reported one | update writ | - -The stale case is the one nothing else catches. writ's version only moves when -someone bumps it, so a build from before this morning's edit reports exactly the -same version as a current one — the two agree, and the agreement is the -misleading part. The panel compares *times*, and offers the build. - -**The verbs**, which the extension used to expose none of: - -| | | -| --- | --- | -| Check this file | `writ check`, with the sibling `.claims` attached if there is one. From a `.claims` file the roles swap and the model becomes the argument. Ctrl+K Ctrl+Enter, or the ▷ in the editor title bar. | -| Show a situation… | `writ show --at N` | -| Answer a relation… | `writ derive`, over a `.rules` file — prefix the question with `why ` for the derivation tree | -| Compare with another model… | `writ compare` against a model you pick | - -Each runs in a terminal, and each runs the `writ` belonging to *the same install -as the language server* — never whatever is first on `PATH`. An editor whose -"check this file" disagreed with its own squiggles would be worse than having no -command at all. - -## Install - -Two routes. The extension tries them in this order. - -### With writ installed, and no checkout - -The ordinary case. The extension needs `writ-lsp` on your `PATH`: +# Writ extension for Visual Studio Code + +[![AGPL-3.0 License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) + +Writ is a language for modelling a rule-governed world and getting every +consequence back, with the route. This extension is its editor: highlighting +for all three file types, and diagnostics, outline, hover and completion +served by the language's own OCaml server, so what the editor says is what +`writ check` says. + +![A writ model and a claim about it](https://raw.githubusercontent.com/writ-lang/writ-vscode/main/docs/images/river-example.png) + +## Features + +- Syntax highlighting for `.writ` models, `.claims` question files and + `.rules` derivations +- Diagnostics from the real parser and type-checker, as you type +- Document outline, hover and completion, all from the server +- Check this file, with the sibling `.claims` attached; from a `.claims` file + the roles swap and the model becomes the argument +- Show a situation, answer a relation, compare with another model, each in a + terminal, each with the `writ` that belongs to the same install as the + server, never whatever is first on `PATH` +- A panel in the Activity Bar that shows which writ you are actually talking + to: both versions, the server's path, and whether they are in step +- The server restarts itself when its binary changes on disk, so a fresh + `make build` is what answers, not the process from before it + +**The extension bundles no server and downloads none.** The point of an +OCaml server is that the editor and the checker are the same code. It talks +to a `writ-lsp` you already have. + +## Supported + +| Piece | Where it comes from | +| ------------- | ----------------------------------------------------------------------------------- | +| `writ-lsp` | [writ](https://github.com/writ-lang/writ): an opam pin, a release tarball, or a checkout built with `make build` | +| VS Code | 1.82 or later | +| Remote windows| The extension runs on the workspace side, next to the server | + +### Feature Contributions + +- **Language:** Writ (`.writ`, `.claims`, `.rules`), with grammar +- **View:** a writ mark in the Activity Bar with an Overview panel +- **Keybinding:** `Ctrl+K Ctrl+Enter` checks this file +- **Commands** in the Command Palette under **Writ**, and in the editor and + explorer context menus: Check This File; Show a Situation…; Answer a + Relation…; Compare With Another Model…; Open the Writ Panel; Restart the + Language Server; Show the Server Log; Build the Engine; Update writ…; + Settings +- **Activation:** on opening a Writ file, or the panel + +## Getting Started + +**You need writ too.** The ordinary route needs `writ-lsp` on your `PATH`: ```console $ opam pin add writ git+https://github.com/writ-lang/writ.git # or unpack a release tarball -$ command -v writ-lsp # confirm it is on PATH +$ command -v writ-lsp /home/you/.opam/default/bin/writ-lsp ``` -`writ` is not in opam-repository, so there is no `opam install writ` to run — the -pin is the no-checkout route, and opam does the cloning. The other ways to get -one (a release tarball, `make install-writ` from a checkout) are in -[writ's README](https://github.com/writ-lang/writ#install); any of them puts -`writ-lsp` on your `PATH`, which is all this extension needs. +`writ` is not in opam-repository, so the pin is the no-checkout route. The +other ways to get one are in [writ's README](https://github.com/writ-lang/writ#install); +any of them puts `writ-lsp` on your `PATH`, which is all this extension needs. -Then install the extension — from a `.vsix`, or by linking it (below) — and -open a `.writ` file. Nothing to configure. - -### From this repository +Then install the extension. From this repository: ```console $ ./install.sh ``` -That fetches the extension's dependencies, links the extension into every VS -Code extensions directory it finds, and **verifies the server answers a -handshake** — so a success message means it works, not merely that it -installed. With no `writ-lsp` yet it warns and installs anyway: the server is -resolved when the extension activates, so installing writ afterwards is a -perfectly good order to do things in. +That fetches the dependencies, links the extension into every VS Code +extensions directory it finds, and verifies the server answers a handshake, +so a success message means it works. With no `writ-lsp` yet it warns and +installs anyway; installing writ afterwards is a fine order. Given a checkout +of writ inside or beside this one, it builds the server from source first, +and that build wins over an installed one. Then reload the window +(Developer: Reload Window); VS Code scans its extensions directory only at +startup. -Given a checkout of [writ](https://github.com/writ-lang/writ) instead, the same -script builds the server from source first, and that build wins over any -installed one — which is what you want while changing the language itself. It -finds the checkout whether this extension sits *inside* it (as `tooling/vscode/`, -where it began) or *beside* it, the layout the split into two repositories -gives you: - -``` -projects/ - writ/ ← the engine; the server is built here - writ-vscode/ ← this extension -``` - -Open the directory holding both and the extension still finds that build: it -looks in each workspace folder and in the checkouts one level below it. If your -checkouts are somewhere else entirely, point `WRIT_REPO` at the engine when -installing, or set `writ.serverPath` to an absolute path. - -Then reload the window (Ctrl+Shift+P → *Developer: Reload Window*). -VS Code only scans its extensions directory at startup, which is the one step a -script cannot do for you. - -Re-running is safe. To remove it: - -```console -$ ./install.sh --uninstall -``` - -The install is a **symlink** into the extensions directory, so editing this repo -updates the extension with no reinstall — and moving or deleting the clone -breaks it, which is the trade. - -### Requirements - -`npm`, for `vscode-languageclient`. Everything else is the server, which comes -from [writ](https://github.com/writ-lang/writ) — this repository contains no OCaml -and builds nothing. - -### Packaging it instead +Or package a `.vsix`: ```console $ scripts/package-extension.sh $ code --install-extension writ-0.1.0.vsix ``` -That fetches the dependencies if they are missing and **checks the version -first**: a `.vsix` is named after the version in the manifest and installs under -an id built from it, so packaging with the copies out of step produces an -artifact that is wrong in its filename *and* in where VS Code files it — and -neither is visible until someone has two of them installed. - -Or open this repository in VS Code and press F5 for an Extension -Development Host. +Open a `.writ` file. Nothing to configure. -## The setting +## Usage -**`writ.serverPath`** — default -`_build/default/tooling/lsp/bin/writ_lsp.exe`. +**Check.** `Ctrl+K Ctrl+Enter`, or the ▷ in the editor title, runs +`writ check` on the file with its sibling `.claims` attached if there is one. +Squiggles while you type come from the same checker, so the two never +disagree unless the server is stale, and the panel tells you when it is. -- A **relative** path is resolved against each open workspace folder, so the - default finds a checkout's own build with nobody editing a setting. -- If no workspace folder has it, **`writ-lsp` is looked up on `PATH`** — which is - what an opam pin or a release tarball provides, and is how this extension - works with no checkout at all. -- An **absolute** path is used exactly as given and nothing else is tried: if - you name a server, you mean that server. +**The verbs**, from the Command Palette or the panel: -If none exists, the extension says so and names what it looked for. Silence -would be worse — a server that starts and answers nothing looks identical to a -broken one, and is much harder to chase. +| Command | Runs | +| ------------------------------- | -------------------------------------------------------------------- | +| **Show a Situation…** | `writ show --at N` | +| **Answer a Relation…** | `writ derive` over a `.rules` file; prefix the question with `why ` for the derivation tree | +| **Compare With Another Model…** | `writ compare` against a model you pick | -## It restarts itself when the server changes +**The panel.** Click the writ mark in the Activity Bar, or run **Writ: Open +the Writ Panel** if the icon ended up unpinned. It prints both versions, the +server's path, and a verdict. Every way this can be wrong produces the same +symptom, an editor that highlights and says nothing else, and the causes +want different fixes: -`make build` writes a *new* file over the server; a process already running -keeps the old one. Without this the editor goes on answering with a language one -build out of date, and the symptom is a squiggle on code the CLI accepts — at -its most confusing exactly when you are changing the language. +| It says | What happened | The button | +| ----------------------------------------- | ----------------------------------------------- | -------------------- | +| no language server | nothing was found at any candidate path | build, or update writ| +| older than `core/…` | the checkout was edited since it was built | build the engine | +| the engine is *x*, the extension is *y* | one of the two was installed on its own | build, or update writ| +| the server did not say which writ it is | a writ from before the server reported one | update writ | -The client watches the binary and restarts on its own. **View → Output → "Writ -Language Server"** shows which server it launched, and says so when it swaps: - -``` -[client] server: /home/you/writ/_build/default/tooling/lsp/bin/writ_lsp.exe -[client] server binary changed on disk — restarting (…) -[client] server restarted; diagnostics are from the current build -``` +The stale case is the one nothing else catches: a build from before this +morning's edit reports the same version as a current one. The panel compares +times, and offers the build. -## If something looks wrong - -- **A diagnostic you disagree with** — `writ check` is the authority; same code, - no process to go stale. If they differ, the server is stale, and the output - channel above shows whether it restarted. -- **No diagnostics at all** — check that channel for which server was launched, - or whether the extension reported finding none. -- **A `(load …)` resolving to the wrong file** — `WRIT_TRACE_LOADS=1 writ check - FILE` prints what each load actually resolved to. A `stdlib.writ` sitting - beside your model replaces the installed one, by design and silently. - -## Development - -There is no compile step: every LSP request is answered by the OCaml server, so -a build pipeline would exist to typecheck glue. What the glue has instead is -structure — `extension.js` is the wiring, and one module per concern under -`src/`: - -| | | -| --- | --- | -| `locate.js` | where the engine is: the candidate order, the checkout a server came from, and the `writ` belonging to it | -| `watcher.js` | restart when the server binary is replaced | -| `staleness.js` | is the built server older than the sources it was built from | -| `engine.js` | what engine this editor is talking to, and the verdict on it | -| `panel.js` | the Activity Bar view | -| `commands.js` | the verbs | - -Only `extension.js` and each module's `register` touch the `vscode` API. The -rest is plain JavaScript that runs under node — which is what the tests -exercise, with nothing stubbed and nothing installed: +**The server log.** View, Output, "Writ Language Server" shows which server +was launched and says so when it swaps after a rebuild. -```console -$ scripts/test.sh -``` +## Configuration -Four files, and each one is there because its subject fails **silently**: +| Setting | Default | Description | +| ----------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `writ.serverPath` | `_build/default/tooling/lsp/bin/writ_lsp.exe` | A relative path is resolved against each open workspace folder, so the default finds a checkout's own build. If none has it, `writ-lsp` is looked up on `PATH`. An absolute path is used exactly as given. | -| | | -| --- | --- | -| `test/watcher.test.js` | a watcher that never fires looks exactly like a server that is up to date | -| `test/resolve.test.js` | the wrong candidate order means "no language server" on a machine that has one | -| `test/status.test.js` | the verdict is shown to users as a warning; getting it wrong means telling someone with a good install that it is broken | -| `test/manifest.test.js` | nothing typechecks `package.json` — a command contributed but not registered appears in the palette and does nothing | +If nothing exists at any of those, the extension says so and names what it +looked for. -### The version +## Troubleshooting -The extension and the engine are released together and live in separate -repositories, so nothing structural keeps them in step — and the panel now -*shows* a mismatch to the user, which makes a version that is merely forgotten -into a warning on somebody's working install. +- **A diagnostic you disagree with.** `writ check` is the authority. If they + differ, the server is stale; the output channel shows whether it restarted. +- **No diagnostics at all.** Check that channel for which server was + launched, or whether the extension reported finding none. +- **A `(load …)` resolving to the wrong file.** `WRIT_TRACE_LOADS=1 writ check + FILE` prints what each load resolved to. A `stdlib.writ` beside your model + replaces the installed one, by design and silently. +- **Removing a linked install:** `./install.sh --uninstall`. The install is a + symlink into the extensions directory, so moving the clone breaks it. -```console -$ scripts/version.sh # print them, and fail if they disagree -$ scripts/version.sh 0.2.0 # set them -$ scripts/version.sh --from-writ # set them to what a writ checkout says -``` +## Developing the Extension -It reads writ's own version from the `dune-project` of a checkout beside this -one, or of `$WRIT_REPO`. `install.sh` derives its extension id from the manifest -rather than holding a copy, so the only two places left are `package.json` and -the `.vsix` filename in this file. +- Open this repository in VS Code and press F5 for an Extension Development + Host. There is no compile step; every request is answered by the server. +- `scripts/test.sh` runs the tests under plain node, nothing stubbed. +- `scripts/version.sh` keeps the extension's version in step with writ's. +- [DEVELOPING.md](DEVELOPING.md) has the module map, what each test guards + against, and the version scheme. Patches need the CLA in + [CONTRIBUTING.md](CONTRIBUTING.md). ## License -Copyright (C) 2026 Alex Kunich. **GNU Affero General Public License, version 3 -or later** — the same as [writ](https://github.com/writ-lang/writ) itself. See +Copyright (C) 2026 Alex Kunich. GNU Affero General Public License, version 3 +or later, the same as [writ](https://github.com/writ-lang/writ) itself. See [LICENSE](LICENSE). diff --git a/docs/images/river-example.png b/docs/images/river-example.png new file mode 100644 index 0000000000000000000000000000000000000000..dd3a42177c631bd75451dd4dcc1a2ca0ba92e55f GIT binary patch literal 92693 zcmdqIcUV(f`z^|L+Yqb>2nuYrAkvX4T}6R_fb>p8r1uVifLK7fgeEmoBfa+$5s(hj zO9+T`Lg=9S3W`(J=g)#8l5vy!;NRI7$_kGu$R~fF8c@*`6n{}b9zWFbN?yf!``#kJ$(wtt z?D&;No-PTy@hy8(Gdr_4_e2%0DGK~xV`Kf}5oH=X+q)RLD&cZ=`}1t;ii2x~xg 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

McvQ%{W>at(4xLJeY38dR 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 + +;; river.writ, the farmer, the wolf, the goat and the cabbage +(schema river + (type bank (left right)) + (type thing (arrow at (to bank)))) + +(transition cross-goat-LR + (when (and (is farmer.at left) (is goat.at left))) + (do (set farmer.at right) (set goat.at right))) + +;; river.claims, the questions live beside the model +(property solvable "everything can reach the right bank intact" + (possible (and (is farmer.at right) (is wolf.at right) + (is goat.at right) (is cabbage.at right)))) + \ No newline at end of file From 69b50d80be5d5ad1e67dab6b9c0938a6794aac11 Mon Sep 17 00:00:00 2001 From: Alex Kunich Date: Sat, 3 Oct 2026 05:18:57 +0300 Subject: [PATCH 5/5] test: the extension against a real engine The other test files check this repository's JavaScript, and none of them would notice the engine changing underneath it. That coupling is the one most likely to break silently: a capability writ-lsp stops advertising, or a flag the CLI renames, leaves the extension running and doing nothing. test/engine.test.js spawns the writ-lsp the extension would find and speaks LSP to it over vscode-jsonrpc (already installed as the client's transport): initialize and its serverInfo, full sync, hover, outline and completion advertised, diagnostics on a good and a broken model, the outline, hover, completion, and shutdown. It then runs every command-line shape src/commands.js builds - check, check --claims, show --at, derive, derive --why, compare - and asserts the CLI accepted each one (exit 0 or 1, never 2). With no writ-lsp, or no node_modules, it prints a skip and passes, so scripts/test.sh still needs nothing installed. WRIT_E2E_REQUIRED=1 turns a skip into a failure; writ's downstream image sets it. Against writ main the two serverInfo checks fail: the server does not send it yet, though engine.js and the panel read it. Co-Authored-By: Claude Opus 5.5 --- DEVELOPING.md | 3 +- test/engine.test.js | 229 ++++++++++++++++++++++++++++++++++++++++++++ test/harness.js | 2 +- 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 test/engine.test.js diff --git a/DEVELOPING.md b/DEVELOPING.md index 6cf611a..e7ef1a7 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -24,7 +24,7 @@ exercise, with nothing stubbed and nothing installed: $ scripts/test.sh ``` -Four files, and each one is there because its subject fails **silently**: +Five files, and each one is there because its subject fails **silently**: | | | | --- | --- | @@ -32,6 +32,7 @@ Four files, and each one is there because its subject fails **silently**: | `test/resolve.test.js` | the wrong candidate order means "no language server" on a machine that has one | | `test/status.test.js` | the verdict is shown to users as a warning; getting it wrong means telling someone with a good install that it is broken | | `test/manifest.test.js` | nothing typechecks `package.json` — a command contributed but not registered appears in the palette and does nothing | +| `test/engine.test.js` | the engine is a separate install — a capability `writ-lsp` stops advertising, or a flag the CLI renames, leaves the extension running and doing nothing. It drives the real server and runs each command line the verbs build; with no `writ-lsp` on PATH it skips, and `WRIT_E2E_REQUIRED=1` makes that a failure | ## The version diff --git a/test/engine.test.js b/test/engine.test.js new file mode 100644 index 0000000..d8a699e --- /dev/null +++ b/test/engine.test.js @@ -0,0 +1,229 @@ +// Copyright (C) 2026 Alex Kunich +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The extension against a REAL engine: what it asks of `writ-lsp`, and the +// command lines it hands to `writ`. +// +// The other test files check this repository's JavaScript, and nothing in them +// would notice the engine changing underneath it. That coupling is the one most +// likely to break silently: a capability the server stops advertising, or a +// flag the CLI renames, leaves the extension running and doing nothing. The +// verbs are the worst case, because they are typed into a terminal and nobody +// reads the usage error that comes back. +// +// So this spawns the server the extension would find, speaks LSP to it over +// vscode-jsonrpc (already installed, as vscode-languageclient's transport), +// and runs each command-line SHAPE src/commands.js builds. A new verb or flag +// there belongs here too. +// +// No engine, no test: with no `writ-lsp`, or no node_modules to speak LSP +// with, it prints a skip and passes, so this stays as installable as the rest. Set WRIT_E2E_REQUIRED=1 to make a missing +// engine a failure, which is what writ's downstream image does. WRIT_LSP names +// a server explicitly. +// +// Run: node test/engine.test.js + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawn, spawnSync } = require("child_process"); +const { suite } = require("./harness"); +const locate = require("../src/locate"); + +const { check, done } = suite("engine tests"); + +// Found the way the extension finds an installed one, and its CLI derived from +// it the way the extension derives it — beside the server, not off PATH. +function skip(why) { + if (process.env.WRIT_E2E_REQUIRED === "1") { + console.log(`FAIL: ${why}, and WRIT_E2E_REQUIRED=1`); + process.exit(1); + } + console.log(`engine tests: SKIP — ${why}`); + process.exit(0); +} + +const server = + process.env.WRIT_LSP || + locate.onPath(locate.INSTALLED).find((p) => fs.existsSync(p)); +if (!server) skip("no writ-lsp on PATH (set WRIT_LSP=)"); + +// Required here rather than at the top: the other test files need nothing +// installed, and a checkout without `npm ci` should skip this, not crash. +let rpc; +try { + rpc = require("vscode-jsonrpc/node"); +} catch { + skip("vscode-jsonrpc is not installed (run npm ci)"); +} +const cli = locate.cliFor(server); + +// A model small enough to read, using the stdlib so the load path is exercised +// from a directory that is not the install prefix. +const MODEL = [ + '(load "stdlib.writ")', + "(schema s (type v (lo hi)) (type box (arrow f (to v))))", + "(instance i s (box b (f lo)))", + "(use s)", + "(initial i)", + "(transition raise (when (is b.f lo)) (do (set b.f hi)))", + "", +].join("\n"); +const CLAIMS = '(property raised "b can go high" (possible (is b.f hi)))\n'; +const RULES = '(load "ct.rules")\n'; +// An unbalanced form: whatever else changes, the reader must reject it. +const BROKEN = "(schema s (type v (lo hi))\n"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "writ-e2e-")); +const file = (name, text) => { + const p = path.join(dir, name); + fs.writeFileSync(p, text); + return p; +}; +const model = file("m.writ", MODEL); +const claims = file("m.claims", CLAIMS); +const rules = file("m.rules", RULES); +const other = file("other.writ", MODEL); +const broken = file("broken.writ", BROKEN); +const uri = (p) => "file://" + p; + +// ── the command lines ──────────────────────────────────────────────────────── +// Exit 0 and 1 are verdicts (1 means "has findings"); 2 is how the CLI says it +// could not run the command at all, which for these inputs means the shape the +// extension builds is no longer one the CLI accepts. +function cliChecks() { + check("a `writ` CLI is found beside the server", !!cli); + if (!cli) return; + const shapes = [ + ["check", model], + ["check", model, "--claims", claims], + ["show", model, "--at", "0"], + ["show", model, "--at", "0", "--at", "1"], + ["derive", model, rules, "reach"], + ["derive", model, rules, "(reach X Y)"], + ["derive", model, rules, "--why", "(reach 0 1)"], + ["compare", model, other], + ]; + for (const args of shapes) { + const r = spawnSync(cli, args, { cwd: dir, encoding: "utf8" }); + const ok = r.status === 0 || r.status === 1; + if (!ok) console.log(` writ ${args.join(" ")}\n ${r.stderr.trim()}`); + const shown = args.map((a) => (a.startsWith(dir) ? path.basename(a) : a)); + check(`writ ${shown.join(" ")} is accepted`, ok); + } +} + +// ── the language server ───────────────────────────────────────────────────── +async function lspChecks() { + const proc = spawn(server, [], { stdio: ["pipe", "pipe", "inherit"] }); + const conn = rpc.createMessageConnection( + new rpc.StreamMessageReader(proc.stdout), + new rpc.StreamMessageWriter(proc.stdin) + ); + const diagnostics = new Map(); + const waiting = new Map(); + conn.onNotification("textDocument/publishDiagnostics", (p) => { + diagnostics.set(p.uri, p.diagnostics); + const w = waiting.get(p.uri); + if (w) w(p.diagnostics); + }); + conn.listen(); + const diagnosticsFor = (u) => + diagnostics.has(u) + ? Promise.resolve(diagnostics.get(u)) + : new Promise((resolve) => { + waiting.set(u, resolve); + setTimeout(() => resolve(null), 10000); + }); + const open = (p) => + conn.sendNotification("textDocument/didOpen", { + textDocument: { + uri: uri(p), + languageId: "writ", + version: 1, + text: fs.readFileSync(p, "utf8"), + }, + }); + + const init = await conn.sendRequest("initialize", { + processId: process.pid, + rootUri: uri(dir), + capabilities: {}, + }); + conn.sendNotification("initialized", {}); + const caps = init.capabilities || {}; + const info = init.serverInfo || {}; + check("initialize names the server", !!info.name); + // engine.js shows this version in the panel and compares it with the + // extension's; a server that stops sending it reads as "unknown engine". + check("initialize reports a version", !!info.version); + check("full-document sync, which is what the client sends", caps.textDocumentSync === 1); + check("hover is advertised", caps.hoverProvider === true); + check("the outline is advertised", caps.documentSymbolProvider === true); + check("completion is advertised", !!caps.completionProvider); + + open(model); + const clean = await diagnosticsFor(uri(model)); + check("a good model gets diagnostics published", Array.isArray(clean)); + check( + "and none of them is an error", + Array.isArray(clean) && !clean.some((d) => d.severity === 1) + ); + + open(broken); + const bad = await diagnosticsFor(uri(broken)); + check( + "a broken model gets an error, with a range to underline", + Array.isArray(bad) && + bad.some((d) => d.severity === 1 && d.range && d.range.start) + ); + + const symbols = await conn.sendRequest("textDocument/documentSymbol", { + textDocument: { uri: uri(model) }, + }); + check("the outline is not empty", Array.isArray(symbols) && symbols.length > 0); + check( + "and it names the transition", + JSON.stringify(symbols || []).includes("raise") + ); + + // On the `initial` keyword: the server documents the language's forms, and + // completion offers names at the same place. + const at = { line: 4, character: 2 }; + const hover = await conn.sendRequest("textDocument/hover", { + textDocument: { uri: uri(model) }, + position: at, + }); + check( + "hover documents a form", + JSON.stringify((hover && hover.contents) || "").includes("initial") + ); + const completion = await conn.sendRequest("textDocument/completion", { + textDocument: { uri: uri(model) }, + position: at, + }); + const items = Array.isArray(completion) ? completion : (completion || {}).items; + check("completion offers something", Array.isArray(items) && items.length > 0); + + await conn.sendRequest("shutdown"); + conn.sendNotification("exit"); + const exited = await new Promise((resolve) => { + proc.on("exit", () => resolve(true)); + setTimeout(() => resolve(false), 5000); + }); + check("shutdown then exit stops the server", exited); + if (!exited) proc.kill(); + conn.dispose(); +} + +(async () => { + console.log(`engine tests: ${server}`); + cliChecks(); + try { + await lspChecks(); + } catch (e) { + check(`the LSP session completes (${e.message})`, false); + } + fs.rmSync(dir, { recursive: true, force: true }); + done(); +})(); diff --git a/test/harness.js b/test/harness.js index f46b56f..334e9f6 100644 --- a/test/harness.js +++ b/test/harness.js @@ -1,7 +1,7 @@ // Copyright (C) 2026 Alex Kunich // SPDX-License-Identifier: AGPL-3.0-or-later -// What the four test files share: counting, and a name for a failure. +// What the test files share: counting, and a name for a failure. // // Deliberately not a framework. Each file is `node test/NAME.test.js` and // nothing has to be installed to run one — which matters because this